들어가며: API 사용자에게 뭐가 잘못됐는지 어떻게 알려주지?
API를 배포하고 나면 여러 가지 질문이 생깁니다.
- 사용자가 정의되지 않은 필드를 보내면 어떻게 알려주지?
- 빈 요청을 보내면 클라이언트 실수일 수 있는데, 어떻게 안내하지?
- 서버에서 원하는 타입(구조체)으로 요청을 받으려면 어떻게 해야 하지?
- URL 파라미터를 문자열이 아닌 숫자로 받으려면?
이 글에서는 NestJS + Zod 조합으로 이 질문들을 하나씩 해결해봅니다.
왜 Zod인가?
NestJS 공식 문서는 class-validator를 권장하지만, 저는 Zod를 선택했습니다. golang ozzo-validation 패키지를 사용해본 경험이 있어 익숙했습니다.
// class-validator: 데코레이터 기반export class CreatePostDto { @IsString() @MinLength(1) title: string;}
// Zod: 스키마 기반 - 타입 추론이 자동export const CreatePostSchema = z.object({ title: z.string().min(1),});export type CreatePostDto = z.infer<typeof CreatePostSchema>; // 타입 자동 생성Zod는 스키마 하나로 검증 로직과 TypeScript 타입을 동시에 정의할 수 있습니다. 중복이 없어서 유지보수가 편합니다.
질문 1: URL 파라미터를 숫자로 받으려면?
GET /posts/1에서 1을 숫자로 받고 싶습니다. 그런데 문제가 있습니다.
@Get(':id')async findOne(@Param('id') id: number) { console.log(typeof id); // 'string' 😱 return this.postsService.findOne(id);}TypeScript에서 id: number라고 선언해도, 런타임에는 문자열입니다. HTTP URL의 모든 파라미터는 문자열이기 때문입니다. '1' === 1은 false니까 DB 조회도 실패합니다.
해결: ParseIntPipe로 변환 + 에러 메시지
import { ParseIntPipe } from '@nestjs/common';
@Get(':id')async findOne(@Param('id', ParseIntPipe) id: number) { // id는 이제 진짜 number return this.postsService.findOne(id);}사용자가 잘못된 값을 보내면 자동으로 명확한 에러를 반환합니다.
GET /posts/abc
# 응답 (400 Bad Request){ "message": "Validation failed (numeric string is expected)", "error": "Bad Request", "statusCode": 400}사용자는 "아, 숫자를 보내야 하는구나"를 바로 알 수 있습니다.
질문 2: 정의되지 않은 필드를 보내면 어떻게 알려주지?
사용자가 API 문서에 없는 필드를 보내는 경우가 있습니다.
PUT /posts/1{ "title": "새 제목", "hackerField": "이상한 값" # API에 없는 필드}기본적으로 Zod는 정의되지 않은 필드를 무시합니다. 이러면 사용자는 자기가 보낸 필드가 적용됐다고 착각할 수 있습니다.
해결: .strict()로 알려주기
export const UpdatePostSchema = CreatePostSchema.partial().strict();이제 사용자에게 명확한 에러 메시지가 갑니다.
# 응답 (400 Bad Request){ "statusCode": 400, "message": "Validation failed", "errors": [ { "code": "unrecognized_keys", "keys": [ "hackerField" ], "path": [], "message": "Unrecognized key: \"hackerField\"" } ]}"이 필드는 API에서 지원하지 않습니다"를 사용자가 바로 알 수 있습니다.
질문 3: 빈 요청을 보내면 어떻게 알려주지?
부분 업데이트 API에서 모든 필드가 optional이면, 빈 객체도 유효한 요청이 됩니다.
PUT /posts/1{} # 아무것도 수정 안 함하지만 이건 대부분 클라이언트 실수입니다. 알려줘야 합니다.
해결: .refine()으로 커스텀 검증
export const UpdatePostSchema = CreatePostSchema .partial() .strict() .refine( (data) => Object.keys(data).length > 0, { message: 'Please provide at least one field to update.' } );# 응답 (400 Bad Request){ "statusCode": 400, "message": "Validation failed", "errors": [ { "code": "custom", "path": [], "message": "Please provide at least one field to update." } ]}사용자는 "아, 뭔가 빠뜨렸구나"를 알 수 있습니다.
질문 4: 요청을 구조체(DTO)로 받으려면?
Zod 스키마를 정의했다고 끝이 아닙니다. 실제로 검증을 적용해야 합니다.
// 🚨 이렇게만 하면 검증이 안 됨!@Put(':id')async update(@Body() dto: UpdatePostDto) { return this.postsService.update(dto);}TypeScript 타입은 런타임에 존재하지 않습니다. Zod 스키마를 실제로 실행하는 Pipe가 필요합니다.
해결: ZodValidationPipe 생성
// zod-validation.pipe.tsimport { PipeTransform, BadRequestException } from '@nestjs/common';import { ZodSchema } from 'zod';
export class ZodValidationPipe implements PipeTransform { constructor(private schema: ZodSchema) {}
transform(value: unknown) { const result = this.schema.safeParse(value); if (!result.success) { throw new BadRequestException(result.error.format()); } return result.data; // 검증된 데이터만 반환 }}사용법: 컨트롤러에 적용
@Put(':id')async update( @Param('id', ParseIntPipe) id: number, @Body(new ZodValidationPipe(UpdatePostSchema)) dto: UpdatePostDto,) { // dto는 이제 검증 완료된 구조체 return this.postsService.update(id, dto);}이제 잘못된 요청이 오면 자동으로 에러 응답이 갑니다.
POST /posts{ "title": "" } # 빈 문자열
# 응답 (400 Bad Request){ "statusCode": 400, "message": "Validation failed", "errors": [ { "origin": "string", "code": "too_small", "minimum": 1, "inclusive": true, "path": [ "title" ], "message": "Too small: expected string to have >=1 characters" } ]}전체 코드: 사용자 친화적 API 검증
모든 질문의 해결책을 종합한 전체 코드입니다.
// posts/post.controller.tsimport { createZodDto } from 'class-validator-zod';import { z } from 'zod';
const UpdatePostSchema = z.object({ author: z.string().optional(), title: z.string().min(1).optional(), content: z.string().optional(),}) .strict() .refine((data) => Object.keys(data).length > 0, { // 2. 데이터가 비어있으면(키가 0개면) 에러 발생 message: "Please provide at least one field to update.", });
class UpdatePost extends createZodDto(UpdatePostSchema) { }// posts.controller.ts
@Injectable()export class ForbidBodyPipe implements PipeTransform { transform(value: any) { // 값이 존재하고, 키 개수가 0보다 크면 (빈 객체가 아니면) 에러 if (value && Object.keys(value).length > 0) { throw new BadRequestException( 'This API does not accept body data. Please use query parameters.' ); } return value; }}
@Controller('posts')export class PostsController { constructor(private readonly postsService: PostsService) { }
// getPosts 모든 포스트 조회 // getPost/:id 특정 포스트 조회 // POST /posts 포스트 작성 // PUT /posts/:id 포스트 수정 // DELETE /posts/:id 포스트 삭제
@Get() getPosts(): Post[] { return Array.from(posts.values()); }
// 입력에 구조체를 받아서 하는 방법은? @Get(':id') getPost(@Param('id', ParseIntPipe) id: number): Post | {} { const post = posts.get(id) // if (!post) { // throw new NotFoundException(`Post with ID "${params.id}" not found`); // } return post ?? {}; }
@Post() create( @Body('author') author: string, @Body('title') title: string, @Body('content') content: string, ): number { let nextid = 0
for (const [id, post] of posts) { if (id > nextid) { nextid = id } }
const post: Post = { id: nextid + 1, author: author, title: title, content: content, likes: 0, comments: 0 }
posts.set(nextid + 1, post) return nextid + 1 }
@Put(':id') @UsePipes(ZodValidationPipe) // <--- 여기에 파이프 명시 update( @Param('id', ParseIntPipe) id: number, @Query() updatePost: UpdatePost, @Body(new ForbidBodyPipe()) bodyForbidden: any ): Post | {} { const post = posts.get(id) if (!post) { throw new NotFoundException(`Post with ID "${id}" not found`); }
const updatedPost: Post = { ...post, ...updatePost, }
console.log(updatedPost)
posts.set(id, updatedPost) return updatedPost }
@Delete(':id') delete(@Param('id', ParseIntPipe) id: number): { id: number } { const post = posts.get(id) if (!post) { throw new NotFoundException(`Post with ID "${id}" not found`); } posts.delete(id) return { id } }}사용자가 받게 되는 에러 메시지들
| 잘못된 요청 | 응답 |
|---|---|
GET /posts/abc | "Validation failed (numeric string is expected)" |
PUT /posts/1 + {} | "최소 하나의 필드가 필요합니다." |
PUT /posts/1 + {"unknown": "값"} | "Unrecognized key(s) in object: 'unknown'" |
POST /posts + {"title": ""} | "제목은 1자 이상이어야 합니다." |
PUT /posts/1 + {"title": "값"} | "This API does not accept body data. Please use query parameters." |
모든 에러 메시지가 무엇이 잘못됐는지 명확하게 알려줍니다.
정리
| 질문 | 해결책 |
|---|---|
| URL 파라미터를 숫자로 받으려면? | ParseIntPipe |
| 정의되지 않은 필드 알려주려면? | .strict() |
| 빈 요청 알려주려면? | .refine() |
| Body를 구조체로 받으려면? | ZodValidationPipe |
핵심은 사용자가 잘못 사용했을 때, 무엇이 잘못됐는지 바로 알 수 있게 하는 것입니다. 에러 메시지가 친절하면 API 문서를 다시 찾아볼 필요가 없습니다.