NestJS - Pipe, Validation


이 포스트는 NestJS 의 Pipe 를 통한 유효성 검사에 대해 알아본다.

소스는 example, user-service 에 있습니다.


1. Pipe

Pipe 는 요청이 라우터 핸들러로 전달되기 전에 요청 객체를 변환하거나 검사할 수 있도록 한다.
미들웨어와 비슷하지만 미들웨어를 현재 요청이 어떤 핸들러에서 수행되고 어떤 매개변수를 갖는지에 대한 실행 context 를 알지 못하므로 모든 context 에서 사용이 불가하다.

라우트 핸들러
요청을 처리하는 엔드포인트마다 동작을 수행하는 컴포넌트
라우트 핸들러가 요청 경로와 컨트롤러 매핑

Pipe 는 보통 아래 두 가지 목적으로 사용된다.

  • transformation
    • 입력 데이터를 원하는 형식으로 변환
    • ex) user/1 경로 매개변수 문자열 1을 정수로 변환
  • validation
    • 입력 데이터가 유효하지 않은 경우 예외 처리

@nest/common 패키지에 아래와 같은 Pipe 가 있다.

  • ParseIntPipe
    • 전달된 인수 타입 검사
  • ParseBoolPipe
    • 전달된 인수 타입 검사
  • ParseArrayPipe
    • 전달된 인수 타입 검사
  • ParseUUIDPipe
    • 전달된 인수 타입 검사
  • DefaultValuePipe
    • 인수에 기본값 설정
  • ValidationPipe

예를 들어 /user/:id 에서 경로 매개변수 id 는 문자열 타입이고, 내부에선 정수로 사용할 때 이를 매번 정수형으로 변경해서 사용하는 것은 코드 중복이다. 이럴 때 @Param 데커레이터의 두 번째 인수로 Pipe 를 넘겨 현재 ExecutionContext 에 바인딩할 수 있다.

@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
  return id + 2;
}
$ nest new ch07

...

$ curl --location 'http://localhost:3000/2aa' | jq
{
  "statusCode": 400,
  "message": "Validation failed (numeric string is expected)",
  "error": "Bad Request"
}

유효성 검사 에러가 발생하며 요청이 컨트롤러까지 전달되지 않는다.

클래스를 전달하는 것이 아니라 Pipe 객체를 직접 생성해서 전달할 수도 있는데 생성할 Pipe 객체의 동작을 원하는대로 변경하고자 할 때 사용한다.

아래는 에러 상태 코드를 변경하는 예시이다.

  @Get(':id')
  findOne(
    @Param('id', new ParseIntPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE })) id: number) {
    return id + 2;
  }
$ curl --location 'http://localhost:3000/2aa' | jq
{
  "statusCode": 406,
  "message": "Validation failed (numeric string is expected)",
  "error": "Not Acceptable"
}

DefaultValuePipe 는 아래와 같이 사용한다.

@Get('user/all')
findAll(
  @Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number,
  @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
) {
  console.log(offset, limit);
  return offset;
}
$ curl --location 'http://localhost:3000/user/all'
0%
$ curl --location 'http://localhost:3000/user/all?offset=2'
2%

2. Pipe 내부 구현

2. Pipe 내부 구현3. 유효성 검사 Pipe 만들기 에서 ValidationPipe 를 직접 만들어보긴 하지만 원리만 파악하고 실제로는 Nest 가 제공하는 ValidationPipe 를 쓰는 것이 좋다.

4. 유저 서비스 유효성 검사 적용 에서 Nest 가 제공하는 ValidationPipe 를 사용하는 예시가 있습니다.

@nest/common 패키지에 있는 Pipe 중 ValidationPipe 가 있는데 Nest 가 이미 제공하는 것을 활용하여 직접 ValidationPipe 를 만들어본다. (=Custom Pipe)

Custom Pipe 는 PipeTransform 인터페이스를 상속받은 클래스에 @Injectable() 데커레이션을 붙여주면 된다.

validation.pipe.ts

import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common';

@Injectable()
export class ValidationPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata): any {
    return undefined;
  }
}

PipeTransform 의 시그니처는 아래와 같다.

export interface PipeTransform<T = any, R = any> {
    /**
     * Method to implement a custom pipe.  Called with two parameters
     *
     * @param value argument before it is received by route handler method
     * @param metadata contains metadata about the value
     */
    transform(value: T, metadata: ArgumentMetadata): R;
}
  • value
    • 현재 pipe 에 전달된 인수
  • metadata
    • 현재 pipe 에 전달된 인수의 메타데이터

ArgumentMetadata 의 시그니처는 아래와 같다.

export interface ArgumentMetadata {
    /**
     * Indicates whether argument is a body, query, param, or custom parameter
     */
    readonly type: Paramtype;
    /**
     * Underlying base type (e.g., `String`) of the parameter, based on the type
     * definition in the route handler.
     */
    readonly metatype?: Type<any> | undefined;
    /**
     * String passed as an argument to the decorator.
     * Example: `@Body('userId')` would yield `userId`
     */
    readonly data?: string | undefined;
}
  • type
    • pipe 에 전달된 인수가 본문인지 쿼리인지 경로 매개변수인지 커스텀 매개변수인지 여부
  • metatype
    • 라우트 핸들러에 정의된 인수의 타입
    • 생략하거나 바닐라 자바스크립트 사용 시 undefined 가 됨
  • data
    • 데커레이터에 전달된 문자열 (=매개변수명)

예를 들어 아래와 같은 라우트 핸들러가 있을 때 value 와 metadata 는 아래와 같다.

@Get(':id')
findOne(@Param('id', ValidationPipe) id: number) {
  return id;
}
$ curl --location 'http://localhost:3000/1'

value:  1
metadata:  { metatype: [Function: Number], type: 'param', data: 'id' }

3. 유효성 검사 Pipe 만들기

NestJS 공식 문서에는 @UsePipes 데커레이터와 joi 라이브러리를 이용해서 Custom Pipe 를 바인딩하는 방법을 설명하고 있다.

joi 는 Schema 라고 부르는 유효성 검사 규칙을 가진 객체를 만든 후 이 Schema 에 검사하려는 객체를 전달하여 validate 하는 방식인데 joiclass-validator (바로 뒤에 나옴) 와 비교했을 때 Schema 를 적용하는 문법이 불편하다.
하여 여기선 class-validator 를 이용하여 유효성 검사를 진행해 볼 예정이다.

$ npm i class-validator class-transformer
$ nest g resource Users                  
? What transport layer do you use? REST API
? Would you like to generate CRUD entry points? Yes
CREATE src/users/users.controller.spec.ts (566 bytes)
CREATE src/users/users.controller.ts (894 bytes)
CREATE src/users/users.module.ts (247 bytes)
CREATE src/users/users.service.spec.ts (453 bytes)
CREATE src/users/users.service.ts (609 bytes)
CREATE src/users/dto/create-user.dto.ts (30 bytes)
CREATE src/users/dto/update-user.dto.ts (169 bytes)
CREATE src/users/entities/user.entity.ts (21 bytes)
UPDATE package.json (2037 bytes)
UPDATE src/app.module.ts (312 bytes)

그럼 이제 신규 유저 생성 시 body 유효성 검사를 해본다.

/users/dto/create-user.dto.ts

import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator';

export class CreateUserDto {
  @IsString()
  @MinLength(1)
  @MaxLength(20)
  readonly name: string;

  @IsEmail()
  email: string;
}

class-validator 가 지원하는 데커레이터들은 class-validator 공식 문서 에서 확인하세요.

위에서 정의한 것과 같은 dto 객체를 받아 유효성 검사를 하는 Pipe(ValidationPipe) 를 직접 구현해본다.

validation.pipe.ts

import {
  ArgumentMetadata,
  BadRequestException,
  Injectable,
  PipeTransform,
} from '@nestjs/common';
import { plainToClass } from 'class-transformer';
import { validate } from 'class-validator';

@Injectable()
export class ValidationPipe implements PipeTransform {
  // transform(value: any, metadata: ArgumentMetadata): any {
  //   console.log('value: ', value);
  //   console.log('metadata: ', metadata);
  //   return undefined;
  // }
  async transform(value: any, { metatype }: ArgumentMetadata) {
    // metatype 이 Pipe 가 지원하는 타입인지 검사
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    }
    // 순수 자바스크립트 객체를 클래스 객체로 변경
    // (네트워크를 통해 들어온 데이터는 역직렬화 과정에서 body 의 객체가 아무런 타입 정보도 없기 때문에 타입을 지정하는 변환 과정)
    const object = plainToClass(metatype, value);
    const errors = await validate(object);
    if (errors.length > 0) {
      throw new BadRequestException('Validation failed~');
    }
    return value; // 유효성 검사 통과했다면 원래의 값 그대로 전달
  }

  // eslint-disable-next-line @typescript-eslint/ban-types
  private toValidate(metatype: Function): boolean {
    // eslint-disable-next-line @typescript-eslint/ban-types
    const types: Function[] = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}

이제 위에서 만든 ValidationPipe 를 적용해본다.

users.controller.ts

import { ValidationPipe } from '../validation.pipe';

...

@Controller('users')
export class UsersController {
  @Post()
  create(@Body(ValidationPipe) createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto);
  }
}
$ curl --location 'http://localhost:3000/users' \
--header 'Content-Type: application/json' \
--data '{
    "name": "ss",
    "email": "testtest.com"
}' | jq

{
  "statusCode": 400,
  "message": "Validation failed~",
  "error": "Bad Request"
}

ValidationPipe 를 핸들러마다 지정하는 것이 아니라 전역으로 설정하려면 부트스트랩 과정에서 적용하면 된다.

main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from './validation.pipe';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(3000);
}
bootstrap();

4. 유저 서비스 유효성 검사 적용

Nest 에서 제공하는 class-validator 를 적용하고, class-validator 에서 제공하지 않는 유효성 검사 기능을 직접 만들어보도록 한다.


4.1. 유저 생성 body 유효성 검사

$ npm i class-validator class-transformer

Nest 에서 제공하는 ValidationPipe 를 전역으로 적용하고, class-transformer 도 적용한다. (=transform 속성 true)

main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(
    new ValidationPipe({
      transform: true,
    }),
  );
  await app.listen(3000);
}
bootstrap();

기존 CreateUserDto 는 아래와 같은데 여기에 class-validator 를 이용하여 몇 가지 규칙을 적용해본다.

users/dto/create-user.dto.ts

export class CreateUserDto {
  readonly name: string;
  readonly email: string;
  readonly password: string;
}
  • 이름은 2자 이상, 30자 이하 문자열
  • 이메일은 60자 이하 문자열, 이메일 주소 형식에 부합
  • 패스워드는 영문 대소문자와 숫자 또는 특수문자 (!, @, #, $, %, ^, &, *, (, )) 로 이루어진 8자 이상, 30자 이하 문자열

users/dto/create-user.dto.ts

import {
  IsEmail,
  IsString,
  Matches,
  MaxLength,
  MinLength,
} from 'class-validator';

export class CreateUserDto {
  @IsString()
  @MinLength(2)
  @MaxLength(30)
  readonly name: string;

  @IsString()
  @IsEmail()
  @MaxLength(60)
  readonly email: string;

  @IsString()
  @Matches(/^[A-Za-z\d!@#$%^&*()]{8,30}$/)
  readonly password: string;
}

4.2. class-transformer 활용: @Transform

class-transformer 에서 @Transform 데커레이터가 가장 많이 쓰이는데 시그니처는 아래와 같다.

@Transform 데커레이터의 시그니처

export declare function Transform(transformFn: (params: TransformFnParams) => any, options?: TransformOptions): PropertyDecorator;


export interface TransformFnParams {
  value: any;
  key: string;
  obj: any;
  type: TransformationType;
  options: ClassTransformOptions;
}

@Transform 데커레이터는 TransformFnParams 타입인 transformFn 을 인수로 받는데 transformFn 은 이 데커레이터가 적용되는 속성의 값(value) 와 그 속성이 속해있는 객체(obj) 등을 인수로 받아 속성을 변형한 후 리턴하는 함수이다.

name 에 @Transform 데커레이터를 적용해서 TransformFnParams 으로 어떤 값들이 전달되는지 확인해본다.

  @IsString()
@MinLength(2)
@MaxLength(30)
@Transform((params) => {
  console.log('param: ', params);
  return params.value;  // 속성 변형을 하지 않고 그대로 리턴
})
readonly name: string;
$ curl --location 'http://localhost:3000/users' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": "ss",
    "email": "test@test.com",
    "password": "testtest"
}' | jq


param:  {
  value: 'ss',
  key: 'name',
  obj: { name: 'ss', email: 'test@test.com', password: 'testtest' },
  type: 0,
  options: {
    enableCircularCheck: false,
    enableImplicitConversion: false,
    excludeExtraneousValues: false,
    excludePrefixes: undefined,
    exposeDefaultValues: false,
    exposeUnsetFields: true,
    groups: undefined,
    ignoreDecorators: false,
    strategy: undefined,
    targetMaps: undefined,
    version: undefined
  }
}

만일 name 앞뒤에 공백을 제거한다면 아래와 같이 하면 된다.

  @IsString()
  @MinLength(2)
  @MaxLength(30)
  @Transform((params) => params.value.trim())
  readonly name: string;

obj 를 이용하여 name 이 포함된 password 는 설정 불가하게 하려면 아래와 같이 하면 된다.

  @IsString()
  @Matches(/^[A-Za-z\d!@#$%^&*()]{8,30}$/)
  @Transform(({ value, obj }) => {
    if (obj.password.includes(obj.name.trim())) {
      throw new BadRequestException('password 에 name 과 같은 문자열 포함');
    }
    return value.trim();
  })
  readonly password: string;
$ curl --location 'http://localhost:3000/users' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": "test",
    "email": "test@test.com",
    "password": "testtest"
}' | jq


{
  "statusCode": 400,
  "message": "password 에 name 과 같은 문자열 포함",
  "error": "Bad Request"
}

아래는 유효성에 부합하지 않을 경우 메시지 확인이다.

$ curl --location 'http://localhost:3000/users' \
--header 'Content-Type: application/json' \
--data '{
    "name": "testds",
    "email": "testtest.com",
    "password": "testtest"
}' | jq


{
  "statusCode": 400,
  "message": [
    "email must be an email"
  ],
  "error": "Bad Request"
}



$ curl --location 'http://localhost:3000/users' \
--header 'Content-Type: application/json' \
--data '{
    "name": "testds",
    "email": "testtest.com",
    "password": "t"
}' | jq


{
  "statusCode": 400,
  "message": [
    "email must be an email",
    "password must match /^[A-Za-z\\d!@#$%^&*()]{8,30}$/ regular expression"
  ],
  "error": "Bad Request"
}



$ curl --location 'http://localhost:3000/users' \
--header 'Content-Type: application/json' \
--data '{
    "name": "test",
    "email": "testtest.com",
    "password": "testtest"
}' | jq


{
  "statusCode": 400,
  "message": "password 에 name 과 같은 문자열 포함",
  "error": "Bad Request"
}

4.3. Custom 유효성 검사기 직접 생성 (데커레이터 생성)

위에서 @Transform 데커레이터 내에서 예외를 던졌는데 그렇게 하면 코드가 복잡해지니 이럴 땐 직접 유효성 검사를 수행하는 데커레이터를 만들어서 사용하면 된다.

  @IsString()
  @Matches(/^[A-Za-z\d!@#$%^&*()]{8,30}$/)
  @Transform(({ value, obj }) => {
    if (obj.password.includes(obj.name.trim())) {
      throw new BadRequestException('password 에 name 과 같은 문자열 포함');
    }
    return value.trim();
  })
  readonly password: string;

/src/utils/decorators/not-in.ts

import {
  registerDecorator,
  ValidationArguments,
  ValidationOptions,
} from 'class-validator';

export function NotIn(property: string, validationOptions?: ValidationOptions) {  // 데커레이터의 인수는 객체에서 참조하려고 하는 다른 속성의 이름과 ValidationOptions 을 받음
  // eslint-disable-next-line @typescript-eslint/ban-types
  return (object: Object, propertyName: string) => {  // registerDecorator 를 호출하는 함수 리턴, 이 함수의 인수로 데커레이터가 선언될 객체와 속성 이름 받음
    registerDecorator({ // registerDecorator 는 ValidationDecoratorOptions 객체를 인수로 받음
      name: 'NotIn',  // 데커레이터 이름
      target: object.constructor, // 이 데커레이터는 객체가 생성될 때 적용됨.
      propertyName,
      options: validationOptions, // 유효성 옵션은 데커레이터의 인수로 전달받은 것을 사용
      constraints: [property],  //  이 데커레이터는 속성에 적용되도록 제약을 줌
      validator: {  // validator 속성 안에 유효성 검사 규칙 기술, 이는 ValidatorConstraint Interface 를 구현한 함수
        validate(
          value: any,
          validationArguments?: ValidationArguments,
        ): Promise<boolean> | boolean {
          const [relatedPropertyName] = validationArguments.constraints;
          const relatedValue = (validationArguments.object as any)[
            relatedPropertyName
          ];
          return (
            typeof value === 'string' &&
            typeof relatedValue === 'string' &&
            !relatedValue.includes(value)
          );
        },
      },
    });
  };
}

이제 Custom 데커레이터를 속성에 적용해본다.

  @IsString()
  @MinLength(2)
  @MaxLength(30)
  @Transform((params) => params.value.trim())
  @NotIn('password', { message: 'password 는 name 과 같은 문자 포함 불가' })
  readonly name: string;
$ curl --location 'http://localhost:3000/users' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": " test ",
    "email": "test@test.com",
    "password": "testtest"
}' | jq

{
  "statusCode": 400,
  "message": [
    "password 는 name 과 같은 문자 포함 불가"
  ],
  "error": "Bad Request"
}



curl --location 'http://localhost:3000/users' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": "test",
    "email": "test@test.com",
    "password": "test"
}' | jq


{
  "statusCode": 400,
  "message": [
    "password 는 name 과 같은 문자 포함 불가",
    "password must match /^[A-Za-z\\d!@#$%^&*()]{8,30}$/ regular expression"
  ],
  "error": "Bad Request"
}

참고 사이트 & 함께 보면 좋은 사이트

본 포스트는 한용재 저자의 NestJS로 배우는 백엔드 프로그래밍을 기반으로 스터디하며 정리한 내용들입니다.






© 2020.08. by assu10

Powered by assu10