Claude Code와 Zod 검증 실전 가이드
Claude Code와 Zod로 폼, API, 환경 변수, Webhook, 테스트까지 안전하게 검증하는 방법.
런타임 경계에서 Zod가 필요한 이유
TypeScript 타입은 코드를 작성할 때 강력하지만, 실행 중에 들어오는 데이터까지 자동으로 검사하지는 않습니다. 브라우저 폼, API 요청, Webhook payload, process.env, 데이터베이스 저장 직전 값은 모두 외부 입력입니다. 런타임 검증은 프로그램이 실행되는 순간 그 값의 형태를 확인하는 작업입니다. Zod는 schema, 즉 데이터 규칙표를 만들고, 그 schema에서 TypeScript 타입을 추론할 수 있게 해 줍니다.
Claude Code는 Zod 구현에 잘 맞습니다. 검증 코드는 필드명, 타입, 길이, enum, 에러 메시지, 사용 위치를 꼼꼼히 맞추는 작업이기 때문입니다. 단순히 “검증 코드 만들어줘”라고 하면 얕은 코드가 나오기 쉽습니다. “폼, API request/response, 환경 변수, Webhook payload, DB 입력 전 검증, 테스트까지 작성해줘”라고 경계를 지정하면 훨씬 실무적인 결과를 얻습니다. 세부 동작은 Zod 공식 문서와 Next.js Route Handler 공식 문서를 함께 확인하세요.
unknown input
-> Zod schema
-> safeParse
-> typed data
-> business logic
-> response schema
-> client
핵심은 외부 입력을 처음부터 믿지 않는 것입니다. as SomeType으로 캐스팅하는 대신 unknown으로 받고, Zod 검증이 성공한 뒤에만 타입이 있는 데이터로 사용합니다.
Claude Code에 전달할 사용 사례
입력 경계마다 필요한 엄격함이 다릅니다. 폼은 사용자에게 보여 줄 메시지가 중요하고, API는 400 응답이 중요하며, 환경 변수는 시작 시점에 실패해야 합니다. Webhook은 payload 검증 전에 서명 검증도 필요합니다.
| 사용 사례 | 진입점 | Zod가 보호할 것 |
|---|---|---|
| 폼 | 브라우저 입력 | 빈 문자열, 이메일 형식, 길이, 동의 체크 |
| API request/response | request.json()과 응답 JSON | 잘못된 payload, 응답 계약, 상태 enum |
| 환경 변수 | process.env | 누락된 secret, URL 형식, 포트 범위 |
| Webhook payload | 외부 서비스 POST | 이벤트 타입, 객체 ID, 금액, 서명 흐름 |
| DB 저장 전 | 앱 내부 변환 후 값 | 저장 가능한 필드, enum, 필수 ID |
이 표를 Claude Code 프롬프트에 넣으면 결과가 좋아집니다. 하나의 schema를 모든 곳에서 공유하려고 하면 폼 전용 필드와 DB 전용 필드가 섞입니다. emailSchema, idSchema처럼 작은 부품은 공유하고, 폼 schema, API schema, DB insert schema는 필요에 따라 나누는 편이 안전합니다. 폼 연동은 React Hook Form 가이드를, API 타입 안정성은 tRPC 개발 가이드를 같이 보면 좋습니다.
기본 Zod schema 만들기
아래는 문의 폼을 검증하는 복사 가능한 예제입니다. Claude Code에 만들게 할 때는 오류 메시지 언어, 필수 여부, 길이 제한, 선택지를 명확히 적고, id나 createdAt 같은 DB 필드는 넣지 말라고 지시하세요.
// src/lib/schemas/contact.ts
import { z } from "zod";
export const contactFormSchema = z.object({
name: z
.string()
.trim()
.min(1, "이름을 입력해 주세요")
.max(80, "이름은 80자 이하여야 합니다"),
email: z
.string()
.trim()
.email("올바른 이메일 주소를 입력해 주세요"),
plan: z.enum(["trial", "team", "enterprise"]),
message: z
.string()
.trim()
.min(10, "문의 내용은 10자 이상이어야 합니다")
.max(2000, "문의 내용은 2000자 이하여야 합니다"),
agreedToPolicy: z
.boolean()
.refine((value) => value, "개인정보 처리방침 동의가 필요합니다"),
});
export type ContactFormInput = z.infer<typeof contactFormSchema>;
trim()은 공백만 입력한 값을 막습니다. z.enum은 문자열을 허용된 선택지로 좁힙니다. z.infer는 schema에서 타입을 뽑아내므로, 별도 interface를 수동으로 맞추는 일을 줄입니다.
safeParse로 API 에러 만들기
parse는 실패하면 예외를 던집니다. 환경 변수처럼 실패하면 앱을 멈춰야 하는 곳에 적합합니다. safeParse는 성공과 실패를 객체로 반환하므로, 폼과 API에서 400 응답을 만들기 좋습니다.
// src/lib/validation.ts
import { z } from "zod";
export type ValidationProblem = {
path: string;
message: string;
};
export function validateInput<TSchema extends z.ZodTypeAny>(
schema: TSchema,
input: unknown,
):
| { ok: true; data: z.infer<TSchema> }
| { ok: false; status: 400; errors: ValidationProblem[] } {
const result = schema.safeParse(input);
if (!result.success) {
return {
ok: false,
status: 400,
errors: result.error.issues.map((issue) => ({
path: issue.path.join(".") || "_root",
message: issue.message,
})),
};
}
return { ok: true, data: result.data };
}
이 helper를 사용하면 Route Handler마다 같은 방식으로 오류를 반환할 수 있습니다. 다국어 제품이라면 여기서 message 대신 message key를 반환하도록 확장할 수 있습니다.
Next.js Route Handler에서 request와 response 검증
API는 입력만이 아니라 출력도 검증할 수 있습니다. 응답 schema는 내부 상태명이 실수로 바뀌었을 때 빠르게 문제를 드러냅니다.
// app/api/contact/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
import {
contactFormSchema,
type ContactFormInput,
} from "@/lib/schemas/contact";
import { validateInput } from "@/lib/validation";
const contactResponseSchema = z.object({
id: z.string().min(1),
status: z.enum(["queued"]),
});
async function saveContact(input: ContactFormInput) {
// Replace this with your database insert.
return {
id: `contact_${Date.now()}`,
status: "queued" as const,
email: input.email,
};
}
export async function POST(request: Request) {
const body: unknown = await request.json();
const validated = validateInput(contactFormSchema, body);
if (!validated.ok) {
return NextResponse.json(
{ message: "입력 내용을 확인해 주세요", errors: validated.errors },
{ status: validated.status },
);
}
const saved = await saveContact(validated.data);
const response = contactResponseSchema.parse(saved);
return NextResponse.json(response, { status: 201 });
}
Webhook에서는 순서를 지켜야 합니다. 먼저 서명을 검증하고, 그 다음 payload schema를 통과시키고, 마지막으로 이벤트를 처리합니다. Claude Code에는 verifySignature, webhookPayloadSchema, handleWebhookEvent를 분리하라고 지시하면 리뷰가 쉬워집니다.
환경 변수는 시작 시점에 검증하기
환경 변수는 문자열 또는 undefined입니다. DATABASE_URL이 없으면 첫 요청 때 오류가 나기보다 앱 시작 시점에 실패하는 편이 낫습니다.
// src/env.ts
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
DATABASE_URL: z.string().url("DATABASE_URL must be a valid URL"),
NEXT_PUBLIC_APP_URL: z.string().url("NEXT_PUBLIC_APP_URL must be a valid URL"),
WEBHOOK_SECRET: z.string().min(32, "WEBHOOK_SECRET must be at least 32 chars"),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error(
"Invalid environment variables",
parsed.error.flatten().fieldErrors,
);
throw new Error("Invalid environment variables");
}
export const env = parsed.data;
z.coerce.number()는 편리하지만 남용하면 위험합니다. 환경 변수나 URL query처럼 문자열로 들어온다는 사실이 분명한 곳에만 제한해서 쓰는 것이 좋습니다.
react-hook-form과 연결하기
클라이언트 검증은 사용자 경험을 개선합니다. 하지만 서버 검증을 대체하지 않습니다. 브라우저는 우회할 수 있기 때문에 Route Handler의 Zod 검증은 반드시 유지해야 합니다.
// src/components/contact-form.tsx
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import {
contactFormSchema,
type ContactFormInput,
} from "@/lib/schemas/contact";
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ContactFormInput>({
resolver: zodResolver(contactFormSchema),
defaultValues: {
name: "",
email: "",
plan: "trial",
message: "",
agreedToPolicy: false,
},
});
async function onSubmit(values: ContactFormInput) {
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!response.ok) {
throw new Error("Failed to send contact request");
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name")} aria-invalid={Boolean(errors.name)} />
{errors.name && <p>{errors.name.message}</p>}
<input {...register("email")} aria-invalid={Boolean(errors.email)} />
{errors.email && <p>{errors.email.message}</p>}
<select {...register("plan")}>
<option value="trial">Trial</option>
<option value="team">Team</option>
<option value="enterprise">Enterprise</option>
</select>
<textarea {...register("message")} />
{errors.message && <p>{errors.message.message}</p>}
<label>
<input type="checkbox" {...register("agreedToPolicy")} />
I agree to the privacy policy
</label>
{errors.agreedToPolicy && <p>{errors.agreedToPolicy.message}</p>}
<button type="submit" disabled={isSubmitting}>
Send
</button>
</form>
);
}
Claude Code가 폼을 수정할 때 “서버 측 schema 검증을 제거하지 말 것”을 명시하세요. 화면은 좋아졌지만 API 경계가 약해지는 회귀를 자주 막을 수 있습니다.
Claude Code 리뷰 프롬프트
구현 후에는 Claude Code를 리뷰어로 사용하세요. 범위를 좁혀야 좋은 지적이 나옵니다.
Review only the Zod validation design in these files.
Check:
1. Every external input is treated as unknown until safeParse or parse succeeds.
2. Route Handlers return 400 with field-level errors for invalid requests.
3. Environment variables fail fast at startup.
4. coerce and transform are used only where the input source is clear.
5. Form schemas, API schemas, and database insert schemas are not over-shared.
6. Error messages are user-facing and ready for localization.
Do not refactor unrelated business logic.
Return findings with file paths, risk level, and a minimal patch suggestion.
이 프롬프트는 TypeScript 타입만 믿는 문제, parse와 safeParse 혼동, 과도한 coerce, transform 안의 부작용, 오류 문구 현지화 누락, schema 재사용 과잉을 점검합니다.
테스트로 계약 고정하기
schema는 제품 계약입니다. 정상 사례 하나와 실패 사례 여러 개를 테스트로 남기면, 나중에 사람이나 Claude Code가 수정해도 규칙이 쉽게 약해지지 않습니다.
// src/lib/schemas/contact.test.ts
import { describe, expect, it } from "vitest";
import { contactFormSchema } from "./contact";
describe("contactFormSchema", () => {
it("accepts a valid contact request", () => {
const result = contactFormSchema.safeParse({
name: "Masa",
email: "masa@example.com",
plan: "team",
message: "I want to introduce Claude Code to my team.",
agreedToPolicy: true,
});
expect(result.success).toBe(true);
});
it("rejects invalid email and short message", () => {
const result = contactFormSchema.safeParse({
name: "Masa",
email: "not-an-email",
plan: "team",
message: "short",
agreedToPolicy: true,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.path.join("."))).toEqual(
expect.arrayContaining(["email", "message"]),
);
}
});
});
DB 저장 전 검증도 테스트하세요. 저장 직전 객체를 schema에 통과시키면 필드명 변경, enum 변경, 변환 로직 실수를 빨리 잡을 수 있습니다.
자주 나오는 함정
첫째, TypeScript 타입만으로는 런타임 검증이 되지 않습니다. request.json() as ContactFormInput은 컴파일러에게 믿어 달라고 말하는 것일 뿐입니다.
둘째, parse와 safeParse를 구분해야 합니다. 사용자 입력과 API 요청은 보통 safeParse로 400을 반환하고, 환경 변수는 실패 시 앱을 멈춥니다.
셋째, coerce를 과하게 쓰지 마세요. 문자열 입력이 명확한 환경 변수와 query에는 유용하지만, 모든 JSON body에 적용하면 지저분한 입력을 조용히 통과시킬 수 있습니다.
넷째, transform에는 부작용을 넣지 마세요. DB 쓰기, 이메일 전송, 분석 이벤트는 검증 뒤의 업무 로직에 있어야 합니다.
다섯째, 오류 문구와 로컬라이즈를 미루지 마세요. 다국어 제품은 message key 또는 API 계층의 변환 규칙이 필요합니다.
여섯째, schema를 너무 많이 공유하지 마세요. 폼, API, Webhook, DB insert는 비슷해도 같은 계약은 아닙니다.
상담과 검증 메모
이미 폼과 Webhook이 많거나, 환경 변수와 DB 검증이 여러 파일에 흩어져 있다면 Claude Code Lab의 영문 상담 및 트레이닝 페이지를 통해 프로젝트 단위로 schema 분리, 리뷰 프롬프트, 테스트 전략을 정리할 수 있습니다.
이 글의 예제는 2026-06-02 기준 Zod 공식 문서와 Next.js Route Handler 문서를 바탕으로 정리했습니다. 코드는 zod, react-hook-form, @hookform/resolvers, vitest가 설치된 TypeScript 프로젝트를 전제로 합니다. 실제 작업에서 Masa는 인증, CSRF 또는 Webhook 서명 검증, DB 제약, 외부 입력별 실패 테스트를 추가한 뒤 공개합니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.