이 글은 누구를 위한 것인가
- API 요청/응답을 안전하게 검증하고 싶은 팀
- 환경변수 타입 안전성을 Zod로 보장하려는 개발자
- 프론트엔드 폼 검증과 백엔드 API 검증에 같은 스키마를 쓰려는 팀
들어가며
TypeScript는 컴파일 시점 타입 안전성을 제공하지만 런타임에는 검증하지 않는다. Zod는 런타임에 데이터를 검증하고 TypeScript 타입을 동시에 생성한다. 하나의 스키마로 폼 검증, API 검증, 환경변수 검증을 모두 처리한다.
이 글은 bluefoxdev.kr의 Zod 풀스택 검증 가이드 를 참고하여 작성했습니다.
1. Zod 고급 패턴
[Zod 핵심 메서드]
.parse(): 성공 시 값 반환, 실패 시 throw
.safeParse(): { success: true, data } 또는 { success: false, error }
.parseAsync(): 비동기 검증
.shape: 객체 내부 스키마 접근
.partial(): 모든 필드를 optional로
.required(): 모든 필드를 required로
.pick({ field: true }): 일부 필드만 선택
.omit({ field: true }): 일부 필드 제외
.extend({ newField: z.string() }): 필드 추가
.merge(otherSchema): 두 스키마 합치기
.transform(): 값 변환 (타입 변경 가능)
.preprocess(): 검증 전 전처리
.refine(): 커스텀 검증 (타입 변경 없음)
.superRefine(): 복잡한 커스텀 검증
[Zod 추론 패턴]
z.infer<typeof schema>: 스키마에서 타입 추론
z.input<typeof schema>: input 타입 (transform 전)
z.output<typeof schema>: output 타입 (transform 후)
2. Zod 풀스택 구현 패턴
// 공유 스키마 (프론트/백엔드 공통 패키지)
// packages/schemas/src/product.ts
import { z } from 'zod';
// 기본 스키마
export const productSchema = z.object({
id: z.string().cuid().optional(),
name: z.string()
.min(2, { message: '상품명은 2자 이상 입력해주세요' })
.max(100, { message: '상품명은 100자 이하로 입력해주세요' })
.trim(), // 앞뒤 공백 제거
// 문자열 입력 → 숫자로 변환
price: z.coerce.number()
.positive({ message: '가격은 0보다 커야 합니다' })
.transform(val => Math.round(val)), // 소수점 제거
// 빈 문자열을 undefined로 처리
description: z.string().trim()
.transform(val => val || undefined)
.optional(),
category: z.enum(['electronics', 'clothing', 'food', 'books'], {
errorMap: () => ({ message: '유효하지 않은 카테고리입니다' }),
}),
// 태그: 쉼표 구분 문자열 → 배열 변환
tags: z.preprocess(
(val) => typeof val === 'string' ? val.split(',').map(t => t.trim()).filter(Boolean) : val,
z.array(z.string().min(1)).max(10)
),
stock: z.coerce.number().int().min(0).default(0),
isPublished: z.boolean().default(false),
// 날짜: 문자열 → Date 변환
createdAt: z.coerce.date().optional(),
});
// 생성용 스키마 (id, createdAt 제외)
export const createProductSchema = productSchema.omit({ id: true, createdAt: true });
// 수정용 스키마 (모든 필드 optional)
export const updateProductSchema = createProductSchema.partial();
// 쿼리 파라미터 스키마
export const productQuerySchema = z.object({
category: z.enum(['electronics', 'clothing', 'food', 'books']).optional(),
search: z.string().min(1).optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sortBy: z.enum(['name', 'price', 'createdAt']).default('createdAt'),
order: z.enum(['asc', 'desc']).default('desc'),
});
// 타입 추론
export type Product = z.infer<typeof productSchema>;
export type CreateProduct = z.infer<typeof createProductSchema>;
export type ProductQuery = z.infer<typeof productQuerySchema>;
// 백엔드 API 검증
// app/api/products/route.ts
import { NextRequest } from 'next/server';
import { productQuerySchema, createProductSchema } from '@myapp/schemas';
export async function GET(request: NextRequest) {
// URL 쿼리 파라미터 검증
const rawParams = Object.fromEntries(request.nextUrl.searchParams);
const result = productQuerySchema.safeParse(rawParams);
if (!result.success) {
return Response.json(
{ error: '잘못된 쿼리 파라미터', details: result.error.flatten() },
{ status: 400 }
);
}
const { page, limit, category, search, sortBy, order } = result.data;
const products = await db.product.findMany({
where: {
...(category && { category }),
...(search && { name: { contains: search } }),
},
orderBy: { [sortBy]: order },
skip: (page - 1) * limit,
take: limit,
});
return Response.json({ products, page, limit });
}
export async function POST(request: NextRequest) {
const body = await request.json();
const result = createProductSchema.safeParse(body);
if (!result.success) {
// 필드별 에러 메시지 구조화
return Response.json(
{
error: '입력값 검증 실패',
fieldErrors: result.error.flatten().fieldErrors,
},
{ status: 422 }
);
}
const product = await db.product.create({ data: result.data });
return Response.json(product, { status: 201 });
}
// 환경변수 검증 (빌드 시점에 실패!)
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
DATABASE_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(32, '최소 32자 필요'),
NEXTAUTH_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
REDIS_URL: z.string().url().optional(),
// 숫자 파싱
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(100),
SESSION_MAX_AGE: z.coerce.number().int().positive().default(30 * 24 * 60 * 60),
});
// 서버 시작 시 즉시 검증
export const env = envSchema.parse(process.env);
// 사용: env.DATABASE_URL (타입 안전!)
// 에러 메시지 국제화
import { z, ZodError } from 'zod';
const koreanErrors: Record<string, string> = {
invalid_type: '유효하지 않은 타입입니다',
too_small: '값이 너무 작습니다',
too_big: '값이 너무 큽니다',
invalid_string: '유효하지 않은 문자열입니다',
invalid_enum_value: '허용되지 않는 값입니다',
};
function formatZodErrors(error: ZodError): Record<string, string> {
return error.issues.reduce((acc, issue) => {
const field = issue.path.join('.');
acc[field] = issue.message;
return acc;
}, {} as Record<string, string>);
}
// API 응답에서 에러 처리
async function createProduct(data: unknown) {
const result = createProductSchema.safeParse(data);
if (!result.success) {
const fieldErrors = formatZodErrors(result.error);
throw new ValidationError(fieldErrors);
}
return result.data;
}
class ValidationError extends Error {
constructor(public fieldErrors: Record<string, string>) {
super('Validation failed');
}
}
// 중첩 오브젝트 스키마
const addressSchema = z.object({
street: z.string().min(1),
city: z.string().min(1),
postalCode: z.string().regex(/^\d{5}$/, '우편번호는 5자리 숫자'),
country: z.string().length(2).toUpperCase(),
});
const userSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
address: addressSchema.optional(),
// 유니온 타입
role: z.discriminatedUnion('type', [
z.object({ type: z.literal('admin'), permissions: z.array(z.string()) }),
z.object({ type: z.literal('user'), tier: z.enum(['free', 'pro']) }),
]),
// 상호 의존 검증
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(
data => data.password === data.confirmPassword,
{ message: '비밀번호가 일치하지 않습니다', path: ['confirmPassword'] }
);
const db = { product: { findMany: async (q: any) => [], create: async (d: any) => d } };
마무리
Zod의 핵심 가치는 "하나의 스키마, 두 가지 결과 (타입 + 런타임 검증)"다. createProductSchema.partial()로 수정용 스키마를, .omit()으로 생성용 스키마를 파생하면 중복 없이 일관된 검증을 유지한다. 환경변수 검증은 z.object().parse(process.env)를 앱 시작 시 실행해서 잘못된 환경변수로 인한 런타임 오류를 배포 시점에 잡는다. 프론트엔드와 백엔드가 같은 스키마 패키지를 공유하면 API 계약이 자동으로 강제된다.