#NestJS#TypeScript#Zod#Validation#DTO#TIL

NestJS에서 API 사용자에게 친절한 에러 메시지 보내기

들어가며: API 사용자에게 뭐가 잘못됐는지 어떻게 알려주지?

API를 배포하고 나면 여러 가지 질문이 생깁니다.

  • 사용자가 정의되지 않은 필드를 보내면 어떻게 알려주지?
  • 빈 요청을 보내면 클라이언트 실수일 수 있는데, 어떻게 안내하지?
  • 서버에서 원하는 타입(구조체)으로 요청을 받으려면 어떻게 해야 하지?
  • URL 파라미터를 문자열이 아닌 숫자로 받으려면?

이 글에서는 NestJS + Zod 조합으로 이 질문들을 하나씩 해결해봅니다.

왜 Zod인가?

NestJS 공식 문서는 class-validator를 권장하지만, 저는 Zod를 선택했습니다. golang ozzo-validation 패키지를 사용해본 경험이 있어 익숙했습니다.

TypeScript
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을 숫자로 받고 싶습니다. 그런데 문제가 있습니다.

TypeScript
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' === 1false니까 DB 조회도 실패합니다.

해결: ParseIntPipe로 변환 + 에러 메시지

TypeScript
1import { ParseIntPipe } from '@nestjs/common';
2
3@Get(':id')
4async findOne(@Param('id', ParseIntPipe) id: number) {
5 // id는 이제 진짜 number
6 return this.postsService.findOne(id);
7}

사용자가 잘못된 값을 보내면 자동으로 명확한 에러를 반환합니다.

Bash
1GET /posts/abc
2
3# 응답 (400 Bad Request)
4{
5 "message": "Validation failed (numeric string is expected)",
6 "error": "Bad Request",
7 "statusCode": 400
8}

사용자는 "아, 숫자를 보내야 하는구나"를 바로 알 수 있습니다.


질문 2: 정의되지 않은 필드를 보내면 어떻게 알려주지?

사용자가 API 문서에 없는 필드를 보내는 경우가 있습니다.

Bash
1PUT /posts/1
2{
3 "title": "새 제목",
4 "hackerField": "이상한 값" # API에 없는 필드
5}

기본적으로 Zod는 정의되지 않은 필드를 무시합니다. 이러면 사용자는 자기가 보낸 필드가 적용됐다고 착각할 수 있습니다.

해결: .strict()로 알려주기

TypeScript
1export const UpdatePostSchema = CreatePostSchema.partial().strict();

이제 사용자에게 명확한 에러 메시지가 갑니다.

JSON
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이면, 빈 객체도 유효한 요청이 됩니다.

Bash
1PUT /posts/1
2{} # 아무것도 수정 안 함

하지만 이건 대부분 클라이언트 실수입니다. 알려줘야 합니다.

해결: .refine()으로 커스텀 검증

TypeScript
1export const UpdatePostSchema = CreatePostSchema
2 .partial()
3 .strict()
4 .refine(
5 (data) => Object.keys(data).length > 0,
6 { message: 'Please provide at least one field to update.' }
7 );
JSON
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 스키마를 정의했다고 끝이 아닙니다. 실제로 검증을 적용해야 합니다.

TypeScript
1// 🚨 이렇게만 하면 검증이 안 됨!
2@Put(':id')
3async update(@Body() dto: UpdatePostDto) {
4 return this.postsService.update(dto);
5}

TypeScript 타입은 런타임에 존재하지 않습니다. Zod 스키마를 실제로 실행하는 Pipe가 필요합니다.

해결: ZodValidationPipe 생성

TypeScript
1// zod-validation.pipe.ts
2import { 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}

사용법: 컨트롤러에 적용

TypeScript
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}

이제 잘못된 요청이 오면 자동으로 에러 응답이 갑니다.

Bash
1POST /posts
2{ "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 검증

모든 질문의 해결책을 종합한 전체 코드입니다.

TypeScript
1// posts/post.controller.ts
2import { 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) { }
TypeScript
1// posts.controller.ts
2
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 = 0
48
49 for (const [id, post] of posts) {
50 if (id > nextid) {
51 nextid = id
52 }
53 }
54
55 const post: Post = {
56 id: nextid + 1,
57 author: author,
58 title: title,
59 content: content,
60 likes: 0,
61 comments: 0
62 }
63
64 posts.set(nextid + 1, post)
65 return nextid + 1
66 }
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: any
75 ): 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 updatedPost
90 }
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 문서를 다시 찾아볼 필요가 없습니다.

Comments