왜 Zod인가
NestJS의 기본 검증 스택은 class-validator + class-transformer입니다. 데코레이터 기반으로 직관적이지만, 런타임 타입과 TypeScript 타입이 별개라는 근본적 문제가 있습니다. DTO 클래스의 프로퍼티 타입을 변경해도 데코레이터를 동기화하지 않으면 런타임 검증이 깨집니다.
Zod는 “스키마 우선(schema-first)” 접근으로 이 문제를 해결합니다. Zod 스키마에서 TypeScript 타입을 자동 추론(z.infer)하므로, 런타임 검증과 컴파일 타임 타입이 항상 동기화됩니다.
설치 및 기본 설정
npm install zod nestjs-zod
// app.module.ts
import { APP_PIPE } from '@nestjs/core';
import { ZodValidationPipe } from 'nestjs-zod';
@Module({
providers: [
{
provide: APP_PIPE,
useClass: ZodValidationPipe, // 글로벌 Zod 검증 파이프
},
],
})
export class AppModule {}
스키마 정의와 타입 추론
Zod 스키마에서 DTO 클래스를 생성합니다:
import { createZodDto } from 'nestjs-zod';
import { z } from 'zod';
// 스키마 정의 — 단일 소스 오브 트루스(Single Source of Truth)
const CreateUserSchema = z.object({
email: z.string().email('유효한 이메일을 입력하세요'),
name: z.string().min(2, '이름은 2자 이상').max(50, '이름은 50자 이하'),
password: z.string()
.min(8, '비밀번호는 8자 이상')
.regex(/[A-Z]/, '대문자 1개 이상 포함')
.regex(/[0-9]/, '숫자 1개 이상 포함')
.regex(/[^A-Za-z0-9]/, '특수문자 1개 이상 포함'),
age: z.number().int().min(14, '14세 이상만 가입 가능').optional(),
role: z.enum(['user', 'admin', 'moderator']).default('user'),
tags: z.array(z.string()).max(10, '태그는 최대 10개').default([]),
});
// DTO 클래스 자동 생성 — TypeScript 타입이 스키마에서 추론됨
export class CreateUserDto extends createZodDto(CreateUserSchema) {}
// 타입 추출도 가능
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
컨트롤러에서 바로 사용합니다:
@Controller('users')
export class UserController {
@Post()
create(@Body() dto: CreateUserDto) {
// dto는 이미 검증·변환 완료
// dto.email → string (보장됨)
// dto.role → 'user' | 'admin' | 'moderator' (보장됨)
// dto.tags → string[] (기본값 [])
return this.userService.create(dto);
}
}
고급 스키마 패턴
중첩 객체와 배열
const AddressSchema = z.object({
street: z.string().min(1),
city: z.string().min(1),
zipCode: z.string().regex(/^d{5}$/, '우편번호 5자리'),
country: z.string().length(2, '국가 코드 2자리'),
});
const OrderItemSchema = z.object({
productId: z.string().uuid(),
quantity: z.number().int().min(1).max(999),
price: z.number().positive(),
});
const CreateOrderSchema = z.object({
items: z.array(OrderItemSchema)
.min(1, '최소 1개 상품 필요')
.max(50, '최대 50개 상품'),
shippingAddress: AddressSchema,
billingAddress: AddressSchema.optional(),
couponCode: z.string().optional(),
note: z.string().max(500).optional(),
});
export class CreateOrderDto extends createZodDto(CreateOrderSchema) {}
조건부 검증: refine·superRefine
const UpdatePasswordSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8),
confirmPassword: z.string().min(8),
}).refine(
(data) => data.newPassword === data.confirmPassword,
{
message: '새 비밀번호가 일치하지 않습니다',
path: ['confirmPassword'],
},
).refine(
(data) => data.currentPassword !== data.newPassword,
{
message: '현재 비밀번호와 다른 비밀번호를 입력하세요',
path: ['newPassword'],
},
);
// superRefine — 여러 에러를 한 번에 추가
const TransferSchema = z.object({
fromAccount: z.string(),
toAccount: z.string(),
amount: z.number().positive(),
currency: z.enum(['KRW', 'USD', 'EUR']),
}).superRefine((data, ctx) => {
if (data.fromAccount === data.toAccount) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '같은 계좌로 이체할 수 없습니다',
path: ['toAccount'],
});
}
if (data.currency === 'KRW' && data.amount < 1000) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '원화 최소 이체 금액은 1,000원입니다',
path: ['amount'],
});
}
});
transform: 입력 변환
const SearchQuerySchema = z.object({
q: z.string().trim().toLowerCase(),
page: z.string().transform(Number).pipe(z.number().int().min(1)).default('1'),
limit: z.string().transform(Number).pipe(z.number().int().min(1).max(100)).default('20'),
sort: z.enum(['latest', 'popular', 'price']).default('latest'),
tags: z.string()
.transform(s => s.split(',').map(t => t.trim()).filter(Boolean))
.pipe(z.array(z.string()))
.optional(),
});
export class SearchQueryDto extends createZodDto(SearchQuerySchema) {}
// GET /products?q=phone&page=2&limit=10&tags=electronics,sale
@Get()
search(@Query() dto: SearchQueryDto) {
// dto.q → "phone" (trim + lowercase)
// dto.page → 2 (string → number 변환)
// dto.tags → ["electronics", "sale"] (문자열 → 배열 변환)
}
커스텀 에러 응답 포매팅
Zod의 에러를 API 응답 형식에 맞게 변환합니다:
import { ZodValidationException } from 'nestjs-zod';
import { Catch, ExceptionFilter, ArgumentsHost } from '@nestjs/common';
@Catch(ZodValidationException)
export class ZodExceptionFilter implements ExceptionFilter {
catch(exception: ZodValidationException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const zodError = exception.getZodError();
const errors = zodError.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
code: err.code,
}));
response.status(422).json({
statusCode: 422,
error: 'Validation Error',
message: '입력값 검증에 실패했습니다',
errors,
});
}
}
응답 예시:
{
"statusCode": 422,
"error": "Validation Error",
"message": "입력값 검증에 실패했습니다",
"errors": [
{ "field": "email", "message": "유효한 이메일을 입력하세요", "code": "invalid_string" },
{ "field": "password", "message": "대문자 1개 이상 포함", "code": "invalid_string" }
]
}
Swagger 연동
nestjs-zod는 Zod 스키마에서 Swagger 문서를 자동 생성합니다:
import { patchNestjsSwagger } from 'nestjs-zod';
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
patchNestjsSwagger(); // Zod 스키마 → Swagger 자동 변환
const config = new DocumentBuilder()
.setTitle('API')
.setVersion('1.0')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);
await app.listen(3000);
}
createZodDto로 만든 DTO는 별도 Swagger 데코레이터 없이도 OpenAPI 스펙에 자동 반영됩니다.
스키마 재사용과 합성
Zod의 .pick(), .omit(), .partial(), .merge()로 스키마를 합성합니다:
// 기본 User 스키마
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(2),
password: z.string().min(8),
role: z.enum(['user', 'admin']),
createdAt: z.date(),
});
// Create → id, createdAt 제외
const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
// Update → 모든 필드 optional, id 제외
const UpdateUserSchema = UserSchema.omit({ id: true, createdAt: true }).partial();
// Response → password 제외
const UserResponseSchema = UserSchema.omit({ password: true });
// 검색 필터 — pick으로 필요한 필드만
const UserFilterSchema = UserSchema.pick({ role: true, name: true }).partial();
export class CreateUserDto extends createZodDto(CreateUserSchema) {}
export class UpdateUserDto extends createZodDto(UpdateUserSchema) {}
export class UserResponseDto extends createZodDto(UserResponseSchema) {}
export class UserFilterDto extends createZodDto(UserFilterSchema) {}
Drizzle ORM과 통합
Drizzle의 createInsertSchema로 DB 스키마에서 Zod 검증 스키마를 자동 생성합니다:
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
import { users } from './schema';
// DB 스키마 → Zod 스키마 자동 생성
const insertUserSchema = createInsertSchema(users, {
email: (schema) => schema.email.email('유효한 이메일을 입력하세요'),
name: (schema) => schema.name.min(2).max(50),
});
const selectUserSchema = createSelectSchema(users);
export class CreateUserDto extends createZodDto(insertUserSchema) {}
export type UserSelect = z.infer<typeof selectUserSchema>;
class-validator 대비 장점
| 비교 항목 | class-validator | Zod |
|---|---|---|
| 타입 동기화 | 수동 (데코레이터↔타입 별개) | 자동 (z.infer) |
| 런타임 변환 | class-transformer 별도 필요 | .transform() 내장 |
| 조건부 검증 | 복잡 (커스텀 데코레이터) | refine/superRefine |
| 스키마 합성 | 상속/mixin | pick/omit/merge/partial |
| 프론트엔드 공유 | 불가 (데코레이터 의존) | 가능 (순수 JS 라이브러리) |
정리
Zod는 스키마 하나로 런타임 검증, TypeScript 타입, Swagger 문서, 입력 변환을 모두 해결합니다. nestjs-zod와 결합하면 기존 NestJS 파이프라인에 자연스럽게 통합되며, Drizzle ORM의 drizzle-zod로 DB 스키마까지 연결하면 엔드투엔드 타입 안전성이 완성됩니다.