본문 바로가기

Back-End/Nest.js

파이프와 유효성 검사

1. 파이프 

- 요청이 라우터 핸들러로 전달하기 전에 요청 객체를 변환할 수 있는 기회를 제공

- 미들웨어의 역할과 비슷함.

- 보통 파이프는 2가지 목적으로 사용된다.

  • 변환(transformation) : 입력 데이터를 원하는 형식으로 변환
  • 유효성 검사(validation) : 입력 데이터가 사용자가 정한 기중에 유효하지 않은 경우 예외 처리

- @nest/common 패키지에는 여러 내장 파이프가 마련되어 있음

  • ValidationPipe
  • ParseIntPipe
  • ParseBoolPipe
  • ParseArrayPipe
  • ParseUUIDPipe
  • DefaultValuePipe

ParseIntPipe, ParseBoolPipe, ParseArrayPipe, ParseUUIDPipe

- 전달된 인수의 타입을 검사하는 용도.

@Get('/:id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.userService.findOne(id);
}

* 만약 id에 정수가 아닌 다른 타입의 값을 넣게 될 경우 유효성 에러가 발생하게 된다.

- 클래스를 전달하지 않고, 파이프 객체를 직접 생성하여 전달할 수도 있다.(객체의 동작을 원하는대로 바꾸고자 할 때 사용한다.)

@Get('/:id')
  findOne(
    @Param(
      'id',
      new ParseIntPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE }),
    )
    id: number,
  ) {
    return this.userService.findOne(id);
  }

 

DefaultValuePipe

- 인수의 값에 기본값을 설정할 때 사용한다.

- 쿼리 매개변수가 생략된 경우 유용하게 사용할 수 있다.

  @Get()
  findAll(
    @Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number,
    @Query('limit', new DefaultValuePipe(0), ParseIntPipe) limit: number,
  ) {
    console.log(offset, limit);
    return this.userService.findAll();
  }

커스텀 파이프를 통해 ValidationPipe 직접 만들어 보기

- 커스텀 파이프는 PipeTransform 인터페이스를 상속받은 클래스에 @Injectable 데커레이터를 붙여준다.

@Injectable()
export class ValidationPipe implements PipeTransform {

 

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

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

- 구현해야 하는 transform 함수는 다음처럼 2개의 매개변수를 가지고 있다.

  • value: 현재 파이프에 전돨된 인수
  • metadata: 현재 파이프에 전달된 인수의 메타데이터

- ArgumentMetadata의 정의는 다음과 같다.

export interface ArgumentMetadata {
    readonly type : Paramtype;
    readonly metatype? : Type<any> | undefined;
    readonly data?: string | undefined;
}

export declare type Paramtype = 'body' | 'query' | 'param' | 'custom';
  • type: 파이프에 전달된 인수가 body, query, param, custom인지 나타냄
  • metatype: 라우트 핸들러에 정의도니 인수의 타입
  • data : 데커레이터에 전달된 문자열, 즉 매개변수의 이름.

- 예를 들어 유저 정보를 가져오는 라우터 핸들러를 다음과 같이 구현했다고 해보자

@Get('/:id')
findOne(@Param('id', ValidationPipe) id: number) {
return this.userService.findOne(id);
}

- ArgumentMetadata는 라우터 핸들러에서 다음을 의미한다.

 

2. 유효성 검사 파이프 만들기

- nest 공식 문서에서는 @UsePipes 데커레이터와 joi 라이브러리를 이용하여 커스텀 파이프를 바인딩하는 방법을 설명하고 있다.

- 하지만 우린 class-validator쓸거임

$ yarn add class-validator class-transformer

 

- 신규 유저 생성시 본문이 유효성에 적합한지 검사해보기

- 유저 생성시 본문을 받아올 dto를 다음과 같이 정의한다.

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

export class CreateUserDto {
  @IsString() // 문자열인가?
  @MinLength(1) // 문자열 길이가 1을 넘기는가?
  @MaxLength(20) // 문자열의 길이가 10 이하인가?
  name: string;

  @IsEmail() //email 형식을 따르는가?
  email: string;

  readonly password: string;
}

- 이제 위에서 정의한 것과 같은 dto 객체를 받아서 유효성 검사를 하는 파이프(ValidationPipe)를 직접 구현해 보자. 

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

import { validate } from 'class-validator';
import { plainToClass } from 'class-transformer';
@Injectable()
export class ValidationPipe implements PipeTransform<any> {
  async transform(value: any, { metatype }: ArgumentMetadata) {
    //(1)metatype이 비어있거나, 파이프가 지원하는 타입인지 검사
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    }
    //(2)plainToClass를 통해 순수 js 객체를 클래스의 객체로 바꿔줌.
    const object = plainToClass(metatype, value);
    //(3)유효성 검사
    const errors = await validate(object);
    if (errors.length > 0) {
      throw new BadRequestException('Validation failed');
    }
    return value;
  }
  //toValdiate 함수 선언
  private toValidate(metatype: Function): boolean {
    const types: Function[] = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}

- (2)의 경우 네트워크 요청을 통해 들어온 데이터는 역질렬화 과정에서 본문의 객체가 아무런 타임 정보도 가지고 있지 않기 때문에 타입을 지정하는 변환 과정을 plainT0Class로 수행한다.

- 유효성 검사를 통과했다면 원래의 값 그대로 전달한다.

 

- 이제 이 ValidationPipe를 적용해보자.

  //회원가입 로직
  @Post()
  async createUser(@Body(ValidationPipe) dto: CreateUserDto): Promise<void> {
    const { name, email, password  //회원가입 로직
  @Post()
  async createUser(@Body(ValidationPipe) dto: CreateUserDto): Promise<void> {
    const { name, email, password } = dto;
    await this.userService.createUser(name, email, password);
    console.log(dto);
  } } = dto;
    await this.userService.createUser(name, email, password);
    console.log(dto);
  }

 

- 원하는 파이프를 모든 핸들러에서 일일이 지정하지 않고 전역으로 설정하려면 부트스트랩 과정에서 적용하면 된다.

// main.ts

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

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

 

- Nest에서 제공하는 ValidationPipe를 전역으로 적용한다. 이때 class-transformer가 적용되게 하려면 transform 속성을 true로 주어야 한다.

// main.ts

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

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

 

class-transformer

- @Transform 데커레이터의 정의를 살펴보자.

- Transform 데커레이터는 transformFn을 인수로 받는다.

- transformFn은 이 데커레이터가 적용되는 속성의 값(value)과 그 속성을 가지고 있는 객체(obj) 등을 인수로 받아 속성을 변형한 후 리턴하는 함수이다. 

 

  @Transform((params) => {
    console.log(params);
    return params.value;
  })
  @IsString()
  @MinLength(1)
  @MaxLength(20)
  name: string;

- 위 코드는 name속성에 데커레이터를 다음과 같이 적용한 것이다.

- 위 api 요청에 대한 값으로 다음과 같이 출력된다.

- 이는 TransformFnParams이다.

{
	value: '홍길동'
   	key: 'name',
   	 obj: {
    		name: '홍길동',
        	email: 'kxxxx@gmail.com',
        	password: 'pass1232'
        },
   	 type:0,
    	options: {
    ......
}

- 그래서 다음과 같이 value의 공백을 제거하는 로직으로 사용해도 된다.

  @Transform((params) => params.value.trim())
  @IsString()
  @MinLength(1)
  @MaxLength(20)
  name: string;

 

- TransformFnParams에는 obj 속성이 있다. obj는 현재 속성이 있는 객체를 가리킨다.

- 즉, name 속서을 가지고 있는 CreateUserDto 객체를 뜻한다.

- Transformer에서는 이 obj를 활용하여 password에는 name과 동일한 문자열을 포함할 수 없도록 하는 로직을 구현할 수 있다.

@Transform(({ value, obj }) => {
    if (obj.password.includes(obj.name.trim())) {
      throw new BadRequestException(
        'password는 name과 같은 문자열을 포함할 수 없습니다.',
      );
    }
    return value.trim();
  })

 

- Transform은 입력 값을 원하는 값으로 바꾸어주기 위해 사용하는데, 여기 안에서 예외처리까지 일어나게 되면 보기 매우 불편할 수 있다.

- 암튼 불편하다.

- 그런 경우에는 직접 필요한 유효성 검사를 수행하는 데커레이터를 만들어 활용할 수 있다.

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

export function NotIn(property: string, validationOptions?: ValidationOptions) {
  // eslint-disable-next-line @typescript-eslint/ban-types
  return (object: Object, propertyName: string) => {
    registerDecorator({
      name: 'NotIn',
      target: object.constructor,
      propertyName,
      options: validationOptions,
      constraints: [property],
      validator: {
        validate(value: any, args: ValidationArguments) {
          const [relatePropertyName] = args.constraints;
          const relatedValue = (args.object as any)[relatePropertyName];
          return (
            typeof value === 'string' &&
            typeof relatedValue === 'string' &&
            !relatedValue.includes(value)
          );
        },
      },
    });
  };
}