NestJS에서 API 사용자에게 친절한 에러 메시지 보내기
들어가며: API 사용자에게 뭐가 잘못됐는지 어떻게 알려주지?
API를 배포하고 나면 여러 가지 질문이 생깁니다.
- 사용자가 정의되지 않은 필드를 보내면 어떻게 알려주지?
- 빈 요청을 보내면 클라이언트 실수일 수 있는데, 어떻게 안내하지?
- 서버에서 원하는 타입(구조체)으로 요청을 받으려면 어떻게 해야 하지?
- URL 파라미터를 문자열이 아닌 숫자로 받으려면?
이 글에서는 NestJS + Zod 조합으로 이 질문들을 하나씩 해결해봅니다.
왜 Zod인가?
NestJS 공식 문서는 class-validator를 권장하지만, 저는 Zod를 선택했습니다. golang ozzo-validation 패키지를 사용해본 경험이 있어 익숙했습니다.
1// class-validator: 데코레이터 기반2export class CreatePostDto {3 @IsString()4 @MinLength(1)5 title: string;6}7
8// Zod: 스키마 기반 - 타입 추론이 자동9export const CreatePostSchema = z.object({10 title: z.string().min(1),11});12export type CreatePostDto = z.infer<typeof CreatePostSchema>; // 타입 자동 생성Zod는 스키마 하나로 검증 로직과 TypeScript 타입을 동시에 정의할 수 있습니다. 중복이 없어서 유지보수가 편합니다.
질문 1: URL 파라미터를 숫자로 받으려면?
GET /posts/1에서 1을 숫자로 받고 싶습니다. 그런데 문제가 있습니다.
1@Get(':id')2async findOne(@Param('id') id: number) {3 console.log(typeof id); // 'string' 😱4 return this.postsService.findOne(id);5}TypeScript에서 id: number라고 선언해도, 런타임에는 문자열입니다. HTTP URL의 모든 파라미터는 문자열이기 때문입니다. '1' === 1은 false니까 DB 조회도 실패합니다.
해결: ParseIntPipe로 변환 + 에러 메시지
1import { ParseIntPipe } from '@nestjs/common';2
3@Get(':id')4async findOne(@Param('id', ParseIntPipe) id: number) {5 // id는 이제 진짜 number6 return this.postsService.findOne(id);7}사용자가 잘못된 값을 보내면 자동으로 명확한 에러를 반환합니다.
1GET /posts/abc2
3# 응답 (400 Bad Request)4{5 "message": "Validation failed (numeric string is expected)",6 "error": "Bad Request",7 "statusCode": 4008}사용자는 "아, 숫자를 보내야 하는구나"를 바로 알 수 있습니다.
질문 2: 정의되지 않은 필드를 보내면 어떻게 알려주지?
사용자가 API 문서에 없는 필드를 보내는 경우가 있습니다.
1PUT /posts/12{3 "title": "새 제목",4 "hackerField": "이상한 값" # API에 없는 필드5}기본적으로 Zod는 정의되지 않은 필드를 무시합니다. 이러면 사용자는 자기가 보낸 필드가 적용됐다고 착각할 수 있습니다.
해결: .strict()로 알려주기
1export const UpdatePostSchema = CreatePostSchema.partial().strict();이제 사용자에게 명확한 에러 메시지가 갑니다.
1# 응답 (400 Bad Request)2{3 "statusCode": 400,4 "message": "Validation failed",5 "errors": [6 {7 "code": "unrecognized_keys",8 "keys": [9 "hackerField"10 ],11 "path": [],12 "message": "Unrecognized key: \"hackerField\""13 }14 ]15}"이 필드는 API에서 지원하지 않습니다"를 사용자가 바로 알 수 있습니다.
질문 3: 빈 요청을 보내면 어떻게 알려주지?
부분 업데이트 API에서 모든 필드가 optional이면, 빈 객체도 유효한 요청이 됩니다.
1PUT /posts/12{} # 아무것도 수정 안 함하지만 이건 대부분 클라이언트 실수입니다. 알려줘야 합니다.
해결: .refine()으로 커스텀 검증
1export const UpdatePostSchema = CreatePostSchema2 .partial()3 .strict()4 .refine(5 (data) => Object.keys(data).length > 0,6 { message: 'Please provide at least one field to update.' }7 );1# 응답 (400 Bad Request)2{3 "statusCode": 400,4 "message": "Validation failed",5 "errors": [6 {7 "code": "custom",8 "path": [],9 "message": "Please provide at least one field to update."10 }11 ]12}사용자는 "아, 뭔가 빠뜨렸구나"를 알 수 있습니다.
질문 4: 요청을 구조체(DTO)로 받으려면?
Zod 스키마를 정의했다고 끝이 아닙니다. 실제로 검증을 적용해야 합니다.
1// 🚨 이렇게만 하면 검증이 안 됨!2@Put(':id')3async update(@Body() dto: UpdatePostDto) {4 return this.postsService.update(dto);5}TypeScript 타입은 런타임에 존재하지 않습니다. Zod 스키마를 실제로 실행하는 Pipe가 필요합니다.
해결: ZodValidationPipe 생성
1// zod-validation.pipe.ts2import { PipeTransform, BadRequestException } from '@nestjs/common';3import { ZodSchema } from 'zod';4
5export class ZodValidationPipe implements PipeTransform {6 constructor(private schema: ZodSchema) {}7
8 transform(value: unknown) {9 const result = this.schema.safeParse(value);10 if (!result.success) {11 throw new BadRequestException(result.error.format());12 }13 return result.data; // 검증된 데이터만 반환14 }15}사용법: 컨트롤러에 적용
1@Put(':id')2async update(3 @Param('id', ParseIntPipe) id: number,4 @Body(new ZodValidationPipe(UpdatePostSchema)) dto: UpdatePostDto,5) {6 // dto는 이제 검증 완료된 구조체7 return this.postsService.update(id, dto);8}이제 잘못된 요청이 오면 자동으로 에러 응답이 갑니다.
1POST /posts2{ "title": "" } # 빈 문자열3
4# 응답 (400 Bad Request)5{6 "statusCode": 400,7 "message": "Validation failed",8 "errors": [9 {10 "origin": "string",11 "code": "too_small",12 "minimum": 1,13 "inclusive": true,14 "path": [15 "title"16 ],17 "message": "Too small: expected string to have >=1 characters"18 }19 ]20}전체 코드: 사용자 친화적 API 검증
모든 질문의 해결책을 종합한 전체 코드입니다.
1// posts/post.controller.ts2import { createZodDto } from 'class-validator-zod';3import { z } from 'zod';4
5const UpdatePostSchema = z.object({6 author: z.string().optional(),7 title: z.string().min(1).optional(),8 content: z.string().optional(),9})10 .strict()11 .refine((data) => Object.keys(data).length > 0, {12 // 2. 데이터가 비어있으면(키가 0개면) 에러 발생13 message: "Please provide at least one field to update.",14 });15
16class UpdatePost extends createZodDto(UpdatePostSchema) { }1// posts.controller.ts2
3@Injectable()4export class ForbidBodyPipe implements PipeTransform {5 transform(value: any) {6 // 값이 존재하고, 키 개수가 0보다 크면 (빈 객체가 아니면) 에러7 if (value && Object.keys(value).length > 0) {8 throw new BadRequestException(9 'This API does not accept body data. Please use query parameters.'10 );11 }12 return value;13 }14}15
16@Controller('posts')17export class PostsController {18 constructor(private readonly postsService: PostsService) { }19
20 // getPosts 모든 포스트 조회21 // getPost/:id 특정 포스트 조회22 // POST /posts 포스트 작성23 // PUT /posts/:id 포스트 수정24 // DELETE /posts/:id 포스트 삭제25
26 @Get()27 getPosts(): Post[] {28 return Array.from(posts.values());29 }30
31 // 입력에 구조체를 받아서 하는 방법은?32 @Get(':id')33 getPost(@Param('id', ParseIntPipe) id: number): Post | {} {34 const post = posts.get(id)35 // if (!post) {36 // throw new NotFoundException(`Post with ID "${params.id}" not found`);37 // }38 return post ?? {};39 }40
41 @Post()42 create(43 @Body('author') author: string,44 @Body('title') title: string,45 @Body('content') content: string,46 ): number {47 let nextid = 048
49 for (const [id, post] of posts) {50 if (id > nextid) {51 nextid = id52 }53 }54
55 const post: Post = {56 id: nextid + 1,57 author: author,58 title: title,59 content: content,60 likes: 0,61 comments: 062 }63
64 posts.set(nextid + 1, post)65 return nextid + 166 }67
68
69 @Put(':id')70 @UsePipes(ZodValidationPipe) // <--- 여기에 파이프 명시71 update(72 @Param('id', ParseIntPipe) id: number,73 @Query() updatePost: UpdatePost,74 @Body(new ForbidBodyPipe()) bodyForbidden: any75 ): Post | {} {76 const post = posts.get(id)77 if (!post) {78 throw new NotFoundException(`Post with ID "${id}" not found`);79 }80
81 const updatedPost: Post = {82 ...post,83 ...updatePost,84 }85
86 console.log(updatedPost)87
88 posts.set(id, updatedPost)89 return updatedPost90 }91
92 @Delete(':id')93 delete(@Param('id', ParseIntPipe) id: number): { id: number } {94 const post = posts.get(id)95 if (!post) {96 throw new NotFoundException(`Post with ID "${id}" not found`);97 }98 posts.delete(id)99 return { id }100 }101}사용자가 받게 되는 에러 메시지들
| 잘못된 요청 | 응답 |
|------------|------|
| 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 문서를 다시 찾아볼 필요가 없습니다.