NestJS에서 Zod(nestjs-zod)와 Swagger를 함께 사용하는 방법에 대해 알아봅니다.
NestJS에서 Zod
와 Swagger
를 함께 사용하는 방법에 대해 알아봅니다.
NestJS는 기본적으로 class-validator
와 class-transformer
를 사용하여 DTO를 정의하고 유효성 검사를 수행합니다.
그러나 Zod
는 더 간결하고 강력한 스키마 정의 및 유효성 검사 라이브러리로, 많은 개발자들이 선호합니다. 이 글에서는 nestjs-zod
패키지를 사용하여 Zod와 Swagger를 통합하는 방법을 설명합니다.
zod
가 class-validator
보다 좋은 점은 다음과 같습니다.
Zod v4와 v3의 주요 차이점은 다음과 같습니다.
간단한 주요 변경점에 대한 차이에 대한 예시를 보여드리겠습니다.
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 프로젝트를 생성합니다. 이미 프로젝트가 있다면 이 단계를 건너뛰어도 됩니다.
nest js가 설치되지 않았다면 아래 명령어로 설치합니다.
npm install -g @nestjs/cli
nest new 명령어로 새로운 프로젝트를 생성합니다.
nest new my-nestjs-project
다음으로, nestjs-zod
, zod
, @nestjs/swagger
패키지를 설치합니다.
npm install nestjs-zod zod @nestjs/swagger
이제 간단한 User
api를 만들어보겠습니다.
user
모듈, 컨트롤러와 서비스를 생성합니다.
nest generate module user --no-spec
nest generate controller user --no-spec
nest generate service user --no-spec
--no-spec 옵션은 테스트 파일을 생성하지 않도록 합니다.
우선 api 테스트용으로 user
컨트롤러를 생성하여 간단한 GET 엔드포인트를 만들어보겠습니다.
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를 설정해보겠습니다. main.ts
파일을 열고 Swagger 설정 코드를 추가합니다.
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 UI가 표시된다면 성공입니다.
이제 zod 스키마를 정의하고, Post 엔드포인트를 만들어보겠습니다.
이제 User
엔드포인트에 대한 Zod 스키마를 정의해보겠습니다.
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
: 속성의 형식을 지정합니다. 예를 들어, 이메일 형식을 지정할 때 사용합니다.이제 UserController
에 POST 엔드포인트를 추가하여 사용자 생성을 처리해보겠습니다.
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
를 추출합니다.
이제 http://localhost:3000/api-docs
로 접속하여 Swagger UI에서 POST 엔드포인트가 정상적으로 표시되는지 확인합니다.
Swagger Post Endpoint
위 이미지와 같이 @ApiProperty 데코레이터에 정의한 설명과 예시가 Swagger UI에 잘 나타나는 것을 확인할 수 있습니다.
Post 우측 상단의 "Try it out" 버튼을 클릭하여 테스트할 수 있습니다.
Excute
버튼을 클릭하여 테스트를 실행하면, 유효성 검사에 따라 성공 또는 오류 응답이 반환됩니다.
다음은 작성한 schema의 유효성을 검증하기위해 @UsePipes
를 추가해 봅시다.
// ... 생략 ...
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에서 잘못된 데이터를 입력하면 유효성 검사 오류 메시지가 반환됩니다.
이제 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
파일을 생성합니다.
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
을 잡아내어 첫 번째 오류 메시지만 추출하여 클라이언트에 반환합니다.
이제 이 필터를 글로벌로 적용해 봅시다.
// ... 생략 ...
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": "사용자 이름은 필수입니다."
}
위와 같이 첫 번째 오류 메시지만 반환되어 사용자에게 더 친절한 메시지를 제공할 수 있습니다.
이전에는 컨트롤러 메서드에 @UsePipes(ZodValidationPipe)
를 추가하여 유효성 검사를 수행했습니다.
그러나, 모든 컨트롤러에서 일일이 추가하는 것은 번거롭거나 실수로 누락되어 유효성 검사가 적용되지 않을 수 있습니다.
이를 해결하기 위해, app.module.ts
파일에서 ZodValidationPipe를 글로벌로 설정할 수 있습니다.
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를 활용하여 효율적인 백엔드 개발을 이어가시길 바랍니다.