NestJS Zod 스키마 검증 심화

왜 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 ORMdrizzle-zod로 DB 스키마까지 연결하면 엔드투엔드 타입 안전성이 완성됩니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux