Zod

seongha_h·2024년 12월 30일

NestJS

목록 보기
2/3

zod

Zod는 데이터 유효성 검사를 위한 TypeScript-first 라이브러리입니다.

TypeScript 의 한계를 보완하기 위해 사용합니다.
TypeScript 는 컴파일 타임에만 타입 유효성 검사를 진행합니다. 런타임 환경에서는 JavaScript로 동작하기 때문에 실제로 데이터를 검증할 수 없습니다.
또한, 유효성 검사와 타입 변환을 위해서는 별도의 로직을 작성해야만 합니다. 이러한 단점을 보완하기 위해 zod를 도입하였습니다.

Zod는 런타임에서도 데이터를 검증할 수 있고, Pipe를 이용하여 유효성 검사를 간편하게 사용할 수 있다는 장점이 있습니다. 또한, 스키마를 이용하여 FE,BE에서 공통으로 참조할 수 있고, 에러 메세지 또한 상세하게 제공할 수 있습니다.

Schema

간단한 문자열부터 복잡한 중첩 객체에 이르기까지 모든 데이터 유형을 광범위하게 지칭하기 위해 스키마라는 용어를 사용합니다.

아래와 같이 객체의 정보를 정의하고, 유효성 검사까지 진행할 수 있습니다.

import { z } from 'zod';

export const MyZodSchema = z.object({
  title: z.string().min(1, '제목은 1글자 이상입니다.'),
  content: z.string().min(1, '내용은 1글자 이상입니다.'),
  age: z.number().optional(),
});

//입력 
zodInput = {
    "title": "zod title",
    "content": "zod content"
}
//유효성 검사
MyZodSchema.parse(zodInput);

또다른 유효성 검사 예제

const stringSchema = z.string();

stringSchema.parse("fish"); // => returns "fish"
stringSchema.parse(12); // throws error

이렇게 zod를 이용하여 유효성 검사를 진행할 수 있습니다.

하지만, parse 라는 구문이 아래 코드처럼 controller 안에서 작성되고 있습니다. 이 parse 구문은 controller 의 관심사가 아닌 입력에 대한 유효성 검사입니다. 따라서 controller 에서 분리한다면 이 controller 가 어떤 역할을 하는지 읽기 쉬운 코드가 될 것입니다.
이것을 관심사 분리라고 볼 수 있습니다.

@Controller('zod')
export class ZodController {
  @Post('create')
  async createZod(@Body() zodDto: ZodDto) {
   try{
    MyZodSchema.parse(zodDto);
   } catch(error){
	console.error(error.errors); 
   }
    return zodDto;
  }
}

이러한 관심사 분리를 적용하기 위해서 NestJS의 요청 생명주기에 해당하는 Pipe 를 사용하였습니다.Pipe는 Nest에서 제공하는 기능중 하나로 유효성 검사와 데이터 타입변환을 담당합니다.
Pipe로 관심사를 분리하고 zod를 이용하여 런타임 환경에서의 유효성 검사와 상세한 에러 메세지를 반환하고자 하였습니다.

NestJS 요청 생명주기에 대한 글은 아래 링크에 정리하였습니다.
NestJS 요청 생명주기

적용 단계

ZodValidationPipe

Pipe를 이용한 유효성 검사를 위해 PipeTransfrom 이라는 인터페이스를 구현하는 ZodValidationPipe를 만들었습니다. 이 Pipe는 NestJS의 요청 생명주기 중 데이터 검증 단계에서 zod 스키마를 사용해 값을 검사합니다.

초기 구현

아래는 기본적인 ZodValidationPipe의 구현입니다. 이 Pipe는 zod 스키마를 사용해 데이터를 검증하고, 에러가 발생하면 HttpException으로 변환해 클라이언트에 반환합니다.

import { PipeTransform, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { ZodSchema } from 'zod';

export class ZodValidationPipe implements PipeTransform {
  constructor(private schema: ZodSchema) {}

  transform(value: unknown, metadata: ArgumentMetadata) {
    try {
      const parsedValue = this.schema.parse(value);
      return parsedValue;
    } catch (error) {
      throw new BadRequestException('Validation failed');
    }
  }
}

문제

위 코드처럼 구현한다면 Zod에서 발생한 에러의 구체적인 정보를 잃어버립니다.
zod 라이브러리에서 발생시킨 에러를 catch 하여 HttpException 으로 되던지고 있기 때문입니다.
이렇게 되면 클라이언트는 어떤 데이터가 잘못되었는지 알 수 없게 됩니다.
이를 해결하기 위해 Zod 에서 제공하는 에러 객체를 활용하여 구체적인 정보를 제공할 수 있습니다.

여러개의 Zod 에러 반환하기

Zod의 에러 객체를 통해 알맞은 에러 메세지를 반환할 수 있도록 수정하였습니다.
발생한 error 가 ZodError 라는 클래스의 인스턴스라면 발생한 속성의 이름과 어떤 점이 잘못되었는지 메세지로 반환하도록 구현했습니다.
이때, 어떤점이 잘못 되었는지는 zod 스키마를 정의할 때 메세지를 설정할 수 있습니다.

const CreateUserSchema = z.object({
  speakerEmail: z.string().min(1, '이메일은 필수입니다.').email('올바른 이메일 형식이 아닙니다.'),
  tags: z.array(z.string()).max(4, '태그는 최대 4개까지 가능합니다.'),
});

또한, 하나의 유효성 검사에서 zodError 는 여러개 발생할 수 있습니다. 객체 schema에 적용한 유효성 검사가 여러개이므로 가능합니다.
예를들어 이메일이 필수 값인데 이메일 형식도 아니고, 빈칸일 경우 2가지 에러가 함께 발생됩니다.

import { PipeTransform, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { ZodSchema, ZodError } from 'zod';

export class ZodValidationPipe implements PipeTransform {
  constructor(private schema: ZodSchema) {}

  transform(value: unknown, metadata: ArgumentMetadata) {
    try {
      const parsedValue = this.schema.parse(value);
      return parsedValue;
    } catch (error) {
      if (error instanceof ZodError) {
        const errorMessages = error.errors.map((err) => ({
          field: err.path.join('.'),
          message: err.message,
        }));

        throw new BadRequestException({
          message: 'Validation failed',
          errors: errorMessages,
        });
      }
      throw error;
    }
  }
}
===
//응답
{
    "message": "Validation failed",
    "errors": [
        {
            "field": "speakerEmail",
            "message": "올바른 이메일 형식이 아닙니다"
        },
        {
            "field": "speakerEmail",
            "message": "올바른 이메일 형식이 아닙니다"
        },
        {
            "field": "tags",
            "message": "태그는 최대 4개까지 가능합니다"
        }
    ]
}

에러를 하나만 반환하기

사용자에게 유효성 검사 결과 발생하는 에러 여러개를 모두 보여주지 않는 것이 일반적이라고 생각했습니다.
그렇기에 위와 같이 이를 여러개 담아서 보낼 수도 있지만, client 측에서 보여주는 것으로는 1개로 제한하는 것이 에러를 수정하는데 더 효과적이라고 생각했습니다.
아래와 같이 여러개의 에러가 발생하더라도 에러 메세지를 1개만 전달하도록 ZodValidationPipe 를 수정했습니다.

import { PipeTransform, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { ZodSchema, ZodError } from 'zod';

export class ZodValidationPipe implements PipeTransform {
  constructor(private schema: ZodSchema) {}

  transform(value: unknown, metadata: ArgumentMetadata) {
    try {
      const parsedValue = this.schema.parse(value);
      return parsedValue;
    } catch (error) {
      if (error instanceof ZodError) {
        const errorMessage = error.errors[0].message;
        throw new BadRequestException(errorMessage);
      }
      throw error;
    }
  }
}

Pipe 사용시 에러 해결

Pipe 를 통한 유효성 검사를 검증이 필요한 메서드에만 적용하기 위해 컨트롤러의 메서드에 usePipes를 적용했습니다. 그러나 예상치 못한 문제가 발생했습니다. Guard에서 가져온 userId 또한 유효성 검사 대상이 되는 것이었습니다. (이 userId는 Guard를 통해 토큰 인증이되고 인증된 사용자의 아이디가 이 메서드로 전달됩니다.)

usePipes를 메서드 레벨 데코레이터로 사용할 경우, 해당 메서드의 모든 매개변수에 지정된 Pipe가 적용됩니다. 아래 코드에서는 userIdcreateTicleDto 모두가 검사 대상이 됩니다. 하지만 우리의 목적은 Request Body인 createTicleDto만 유효성 검사를 진행하는 것이므로, 이를 해결할 방법이 필요했습니다.

@Post()
  @UseGuards(JwtAuthGuard)
  @UsePipes(new ZodValidationPipe(CreateTicleSchema))
  async createTicle(@GetUserId() userId: number, @Body() createTicleDto: CreateTicleDto) {
    const newTicle = await this.ticleService.createTicle(createTicleDto, userId);
    return { ticleId: newTicle.id };
  }

해결

특정 매개변수에만 Pipe를 적용하여 해결했습니다.

usePipes를 메서드 레벨에서 사용하지 않고, 매개변수 데코레이터에 Pipe를 개별적으로 적용하는 방식을 사용했습니다. 이렇게 하면 createTicleDto에만 Zod Pipe를 적용할 수 있습니다.

  @Post()
  @UseGuards(JwtAuthGuard)
  async createTicle(
    @GetUserId() userId: number,
    @Body(new ZodValidationPipe(CreateTicleSchema)) createTicleDto: CreateTicleDto
  ) {
    console.log(userId);
    const newTicle = await this.ticleService.createTicle(createTicleDto, userId);
    return { ticleId: newTicle.id };
  }

참고

https://zod.dev

profile
https://github.com/Fixtar

0개의 댓글