Recode Log

  • tech-blog
  • device
  • features
Copyright © [WebKBS]. All rights reserved.
  • tech-blog
  • device
  • features
  1. Home
  2. blog
  3. NestJS에서 Zod와 Swagger 함께 사용하기 (Zod v4)

NestJS에서 Zod와 Swagger 함께 사용하기 (Zod v4)

NestJS에서 Zod(nestjs-zod)와 Swagger를 함께 사용하는 방법에 대해 알아봅니다.

  • nestjs 11.0.1
  • zod ^4.1.5
  • nestjs-zod ^5.0.1
  • nestjs/swagger ^11.2.0
2025년 9월 13일

NestJS에서 Zod와 Swagger를 함께 사용하는 방법에 대해 알아봅니다.

NestJS는 기본적으로 class-validator와 class-transformer를 사용하여 DTO를 정의하고 유효성 검사를 수행합니다.

그러나 Zod는 더 간결하고 강력한 스키마 정의 및 유효성 검사 라이브러리로, 많은 개발자들이 선호합니다. 이 글에서는 nestjs-zod 패키지를 사용하여 Zod와 Swagger를 통합하는 방법을 설명합니다.

zod가 class-validator보다 좋은 점은 다음과 같습니다.

  • 프론트엔드와 백엔드에서 모두 사용 가능하여 코드 중복을 줄일 수 있습니다.
  • 더 직관적이고 간결한 API를 제공합니다.
  • 타입스크립트와의 호환성이 뛰어나 타입 안전성을 보장합니다.

Zod v4와 v3의 주요 차이점은 다음과 같습니다.

  • Zod v4는 더 나은 TypeScript 지원과 향상된 성능을 제공합니다.
  • Zod v4는 새로운 기능과 개선된 API를 도입하여 더 직관적인 스키마 정의가 가능합니다.
  • Zod v4는 더 엄격한 타입 검사를 제공하여, 런타임 오류를 줄이는 데 도움이 됩니다.

간단한 주요 변경점에 대한 차이에 대한 예시를 보여드리겠습니다.

import * as z from "zod";
const schema = z.object({
  name: z.string().min(1, { message: "Name is required" }),
  age: z.number().min(0, { message: "Age must be non-negative" }),
  email: z.string().email({ message: "Invalid email address" }),
});
import { z } from "zod";
const schema = z.object({
  name: z.string().min(1, { errors: "Name is required" }),
  age: z.number().min(0, { errors: "Age must be non-negative" }),
  email: z.email({ errors: "Invalid email address" }),
});

message 옵션이 errors로 변경되었습니다.

email 메서드가 z.string().email()에서 z.email()로 변경되었습니다.

위 내용에 대한 자세한 내용은 `Zod v4 마이그레이션 가이드`를 참고하시기 바랍니다.

NestJS 프로젝트 설정

먼저, NestJS 프로젝트를 생성합니다. 이미 프로젝트가 있다면 이 단계를 건너뛰어도 됩니다.

nest js가 설치되지 않았다면 아래 명령어로 설치합니다.

bash
npm install -g @nestjs/cli

nest new 명령어로 새로운 프로젝트를 생성합니다.

bash
nest new my-nestjs-project

필요한 패키지 설치 및 기본 API 생성

다음으로, nestjs-zod, zod, @nestjs/swagger 패키지를 설치합니다.

bash
npm install nestjs-zod zod @nestjs/swagger

이제 간단한 User api를 만들어보겠습니다.

user 모듈, 컨트롤러와 서비스를 생성합니다.

bash
nest generate module user --no-spec
bash
nest generate controller user --no-spec
bash
nest generate service user --no-spec

--no-spec 옵션은 테스트 파일을 생성하지 않도록 합니다.

우선 api 테스트용으로 user 컨트롤러를 생성하여 간단한 GET 엔드포인트를 만들어보겠습니다.

src/user/user.controller.ts
import { Controller, Get } from "@nestjs/common";
 
@Controller("users")
export class UserController {
  @Get()
  getUser() {
    return { message: "User endpoint" };
  }
}

브라우저를 열고 http://localhost:3000/users로 접속하여 "User endpoint" 메시지가 표시되는지 확인합니다.

message 속성만 있는 간단한 응답을 반환합니다.

Swagger 설정

이제 Swagger를 설정해보겠습니다. main.ts 파일을 열고 Swagger 설정 코드를 추가합니다.

src/main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { cleanupOpenApiDoc } from "nestjs-zod";
 
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
 
  const config = new DocumentBuilder()
    // setTitle - API 문서의 제목을 설정합니다.
    .setTitle("API Documentation")
    // setDescription - API 문서의 설명을 설정합니다.
    .setDescription("API description")
    // setVersion - API 문서의 버전을 설정합니다.
    .setVersion("1.0")
    // addServer - API 서버의 기본 URL을 추가합니다.
    .addServer("http://localhost:3000")
    // addTag - API 문서를 그룹화하는 태그를 추가합니다.
    .addTag("users")
    .build();
 
  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup("api-docs", app, cleanupOpenApiDoc(document));
 
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

http://localhost:3000/api-docs로 접속하여 Swagger UI가 정상적으로 표시되는지 확인합니다.

Swagger Home

Swagger Home

위 이미지와 같이 Swagger UI가 표시된다면 성공입니다.

이제 zod 스키마를 정의하고, Post 엔드포인트를 만들어보겠습니다.

Zod 스키마 정의

이제 User 엔드포인트에 대한 Zod 스키마를 정의해보겠습니다. user 디렉토리에 dto 폴더를 만들고, 그 안에 user.dto.ts 파일을 생성합니다.

src/user/dto/user.dto.ts
import { z } from "zod";
import { createZodDto } from "nestjs-zod";
import { ApiProperty } from "@nestjs/swagger";
 
export const createUserSchema = z.object({
  userName: z.string().min(1, { error: "사용자 이름은 필수입니다." }),
  email: z.email({ error: "유효한 이메일 주소여야 합니다." }),
  password: z
    .string()
    .min(6, { error: "비밀번호는 최소 6자 이상이어야 합니다." })
    .regex(/[A-Z]/, {
      error: "비밀번호에는 최소 하나의 대문자가 포함되어야 합니다.",
    }),
});
 
export class CreateUserDto extends createZodDto(createUserSchema) {
  @ApiProperty({
    description: "사용자 이름",
    example: "John",
    minLength: 1,
    type: String,
  })
  userName: string;
 
  @ApiProperty({
    description: "이메일 주소",
    example: "user@example.com",
    type: String,
    format: "email",
  })
  email: string;
 
  @ApiProperty({
    description: "비밀번호",
    example: "Password123",
    minLength: 6,
    type: String,
  })
  password: string;
}

위 코드에서는 createUserSchema라는 Zod 스키마를 정의하고, 이를 기반으로 CreateUserDto 클래스를 생성합니다. 각 속성에는 Swagger 문서화를 위한 @ApiProperty 데코레이터를 추가했습니다.

각 옵션에 대한 내용은 다음과 같습니다.

  • description: 속성에 대한 설명을 제공합니다.
  • example: 속성의 예시 값을 제공합니다.
  • minLength: 문자열의 최소 길이를 지정합니다.
  • type: 속성의 데이터 타입을 지정합니다.
  • format: 속성의 형식을 지정합니다. 예를 들어, 이메일 형식을 지정할 때 사용합니다.

POST 엔드포인트 생성

이제 UserController에 POST 엔드포인트를 추가하여 사용자 생성을 처리해보겠습니다.

src/user/user.controller.ts
import { Body, Controller, Get, Post } from "@nestjs/common";
import { CreateUserDto } from "./dto/user.dto";
import { UserService } from "./user.service";
import { ApiTags } from "@nestjs/swagger";
@ApiTags("users")
@Controller("users")
export class UserController {
  constructor(private readonly userService: UserService) {}
  @Get()
  getUser() {
    return { message: "User endpoint" };
  }
 
  @Post()
  createUser(@Body() createUserDto: CreateUserDto) {
    const { userName, email, password } = createUserDto;
    return `User created: ${userName}, ${email}, ${password}`;
  }
}

위 코드에서는 @Post() 데코레이터를 사용하여 POST 엔드포인트를 생성하고, @Body() 데코레이터를 사용하여 요청 본문에서 CreateUserDto를 추출합니다.

Swagger UI에서 확인

이제 http://localhost:3000/api-docs로 접속하여 Swagger UI에서 POST 엔드포인트가 정상적으로 표시되는지 확인합니다.

Swagger Post Endpoint

Swagger Post Endpoint

위 이미지와 같이 @ApiProperty 데코레이터에 정의한 설명과 예시가 Swagger UI에 잘 나타나는 것을 확인할 수 있습니다.

Post 우측 상단의 "Try it out" 버튼을 클릭하여 테스트할 수 있습니다.

Excute 버튼을 클릭하여 테스트를 실행하면, 유효성 검사에 따라 성공 또는 오류 응답이 반환됩니다.

다음은 작성한 schema의 유효성을 검증하기위해 @UsePipes를 추가해 봅시다.

src/user/user.controller.ts
// ... 생략 ...
import { ZodValidationPipe } from "nestjs-zod";
 
// ... 생략 ...
  @Post()
  @UsePipes(ZodValidationPipe)
  createUser(@Body() createUserDto: CreateUserDto) {
    const { userName, email, password } = createUserDto;
    return `User created: ${userName}, ${email}, ${password}`;
  }

위 코드에서는 @UsePipes(ZodValidationPipe)를 사용하여 Zod 스키마에 기반한 유효성 검사를 수행합니다. 이제 Swagger UI에서 잘못된 데이터를 입력하면 유효성 검사 오류 메시지가 반환됩니다.

nestjs-zod 사용시 @UsePipes에서는 new 키워드를 사용하지 않습니다. ZodValidationPipe에서 자동으로 Dto를 검사합니다.

이제 Swagger UI에서 POST 엔드포인트를 테스트할 수 있습니다.

이전과 같이 "Try it out" 버튼을 클릭하고, 요청 본문에 데이터를 입력한 후 "Execute" 버튼을 클릭합니다.

정상적인 데이터를 입력하면 성공 응답이 반환되고, 잘못된 데이터를 입력하면 유효성 검사 오류 메시지가 반환됩니다.

임의로 잘못된 데이터를 추가해 봅시다.

{
  "userName": "",
  "email": "invalid-email",
  "password": "short"
}

잘못된 데이터를 입력하면 다음과 같은 오류 응답이 반환됩니다.

{
  "statusCode": 400,
  "message": "Validation failed",
  "errors": [
    {
      "origin": "string",
      "code": "too_small",
      "minimum": 1,
      "inclusive": true,
      "path": ["userName"],
      "message": "사용자 이름은 필수입니다."
    },
    {
      "origin": "string",
      "code": "invalid_format",
      "format": "email",
      "pattern": "/^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$/",
      "path": ["email"],
      "message": "유효한 이메일 주소여야 합니다."
    },
    {
      "origin": "string",
      "code": "too_small",
      "minimum": 6,
      "inclusive": true,
      "path": ["password"],
      "message": "비밀번호는 최소 6자 이상이어야 합니다."
    },
    {
      "origin": "string",
      "code": "invalid_format",
      "format": "regex",
      "pattern": "/[A-Z]/",
      "path": ["password"],
      "message": "비밀번호에는 최소 하나의 대문자가 포함되어야 합니다."
    }
  ]
}

위 데이터는 zod 스키마에 정의한 유효성 검사 규칙을 위반하므로, 각 필드에 대한 구체적인 오류 메시지가 반환됩니다.

이 오류 메세지는 nestjs-zod 패키지에서 자동으로 생성되며, 각 오류 항목에는 다음과 같은 정보가 포함됩니다.

  • origin: 오류가 발생한 데이터 타입 (예: string, number 등)
  • code: 오류 코드 (예: too_small, invalid_format 등)
  • minimum: 최소값 (해당되는 경우)
  • inclusive: 최소값 포함 여부 (해당되는 경우)
  • path: 오류가 발생한 필드의 경로 (예: ["userName"])
  • message: 사용자에게 표시할 오류 메시지

하지만 이 메세지 전체를 사용자에게 보여주는 것은 바람직하지 않습니다. 따라서, 실제 서비스에서는 이 오류 메시지를 가공하여 사용자에게 더 친절한 메시지를 제공하는 것이 좋습니다. 이를 위해, 글로벌 예외 필터를 만들어 봅시다.

글로벌 예외 필터 생성

먼저, common 디렉토리를 만들고, 그 안에 filters 폴더를 생성합니다. 그런 다음, zod-exceptions.filter.ts 파일을 생성합니다.

src/common/filters/zod-exceptions.filter.ts
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpStatus,
} from "@nestjs/common";
import { Response } from "express";
import { ZodValidationException } from "nestjs-zod";
 
@Catch(ZodValidationException)
export class FirstMessageZodExceptionFilter implements ExceptionFilter {
  catch(exception: ZodValidationException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
 
    const zodError = exception.getZodError() as any;
    const firstMessage = zodError.issues[0]?.message || "Validation failed";
 
    response.status(HttpStatus.BAD_REQUEST).json({
      statusCode: HttpStatus.BAD_REQUEST,
      message: firstMessage, // 첫 번째 메시지만 반환
    });
  }
}

위 코드에서는 ZodValidationException을 잡아내어 첫 번째 오류 메시지만 추출하여 클라이언트에 반환합니다. 이제 이 필터를 글로벌로 적용해 봅시다.

src/main.ts
// ... 생략 ...
import { FirstMessageZodExceptionFilter } from "./common/filters/zod-exceptions.filter";
// ... 생략 ...
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // 글로벌 예외 필터 등록
  app.useGlobalFilters(new FirstMessageZodExceptionFilter());
}

이제 다시 잘못된 데이터를 입력하여 테스트해 봅시다.

{
  "userName": "",
  "email": "invalid-email",
  "password": "short"
}

잘못된 데이터를 입력하면 다음과 같은 간단한 오류 응답이 반환됩니다.

{
  "statusCode": 400,
  "message": "사용자 이름은 필수입니다."
}

위와 같이 첫 번째 오류 메시지만 반환되어 사용자에게 더 친절한 메시지를 제공할 수 있습니다.

Zod 전역 Pipe 설정

이전에는 컨트롤러 메서드에 @UsePipes(ZodValidationPipe)를 추가하여 유효성 검사를 수행했습니다.

그러나, 모든 컨트롤러에서 일일이 추가하는 것은 번거롭거나 실수로 누락되어 유효성 검사가 적용되지 않을 수 있습니다.

이를 해결하기 위해, app.module.ts 파일에서 ZodValidationPipe를 글로벌로 설정할 수 있습니다.

src/app.module.ts
import { Module } from "@nestjs/common";
import { APP_PIPE } from "@nestjs/core";
import { ZodValidationPipe } from "nestjs-zod";
import { UserModule } from "./user/user.module";
@Module({
  imports: [UserModule],
  providers: [
    {
      provide: APP_PIPE,
      useClass: ZodValidationPipe,
    },
  ],
})
export class AppModule {}

위 코드에서는 APP_PIPE 토큰을 사용하여 ZodValidationPipe를 글로벌 파이프로 등록합니다. 이제 모든 컨트롤러에서 자동으로 Zod 유효성 검사가 적용됩니다.

이전의 @UsePipes(ZodValidationPipe) 데코레이터는 더 이상 필요하지 않습니다.

@UsePipes 데코레이터를 제거한 후, 다시 잘못된 데이터를 입력하여 테스트해 봅시다.

{
  "userName": "",
  "email": "invalid-email",
  "password": "short"
}

잘못된 데이터를 입력하면 이전과 동일하게 다음과 같은 간단한 오류 응답이 반환됩니다.

{
  "statusCode": 400,
  "message": "사용자 이름은 필수입니다."
}

위와 같이 글로벌 파이프로 설정하면, 모든 컨트롤러에서 일관된 유효성 검사를 적용할 수 있어 코드의 중복을 줄이고 유지보수를 용이하게 합니다.

마무리

이 글에서는 NestJS에서 Zod와 Swagger를 함께 사용하는 방법에 대해 알아보았습니다.

class-validator 대신 프론트엔드 및 백엔드에서 모두 사용 가능한 zod를 사용하여 DTO를 정의하고 유효성 검사를 수행하는 방법을 설명했습니다.

또한, nestjs-zod 패키지를 사용하여 Zod 스키마를 기반으로 Swagger 문서를 자동으로 생성하는 방법도 다루었습니다.

마지막으로, 글로벌 예외 필터를 만들어 사용자에게 더 친절한 오류 메시지를 제공하는 방법과, ZodValidationPipe를 글로벌 파이프로 설정하여 모든 컨트롤러에서 일관된 유효성 검사를 적용하는 방법도 설명했습니다.

Zod와 Swagger를 함께 사용하면, 더 간결하고 강력한 API를 구축할 수 있습니다. 앞으로도 NestJS와 Zod를 활용하여 효율적인 백엔드 개발을 이어가시길 바랍니다.


다음글

npm 최신 버전으로 업데이트 하는 방법


관련 태그

  • nestjs
  • zod
  • swagger
  • api