Pipes

이연중·2021년 7월 28일
0

NestJS

목록 보기
7/22

파이프는 @Injectable() 데코레이터로 주석이 달린 클래스이다.

파이프는 PipeTransform 인터페이스를 implement해야 한다.

파이프는 다음과 같이 두가지 경우에 사용한다.

  • 변환: 입력 데이터를 원하는 형식으로 변환(ex: string -> int)
  • 유효성 검사: 입력 데이터가 유효한지 평가하고 유효하다면, 그대로 전달하고 그렇지 않다면, 예외를 발생시킨다.

두가지 경우 파이프는 controller route handler가 처리하는 arguments에서 작동한다.

Nest는 메서드 호출 직전에 파이프를 삽입하고, 파이프는 메서드로 향하는 인수를 수신하고 이에 대해 동작한다.

변환, 유효성 검사는 해당 시점에서 적용되며, 그 후 route handler가 변환된 인수와 함께 호출된다.

Nest에는 기본적으로 사용할 수 있는 여러 내장된 파이프가 있고, Custom하게 파이프를 만들 수도 있다.

Built-in Pipes


Nest에는 즉시 사용할 수 있는 6개의 파이프가 제공된다.

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

@nestjs/common 패키지에서 export 된다.

ParseIntPipe는 메서드 핸들러 매개변수가 자바스크립트 정수로 변환되도록 한다. 변환에 실패하면 예외처리를 수행한다.

Binding Pipes


파이프를 사용하기 위해서는 파이프 클래스의 인스턴스를 적절한 컨텍스트에 바인딩해야 한다.

다음 ParseIntPipe 예제에서는 파이프를 특정 라우트 핸들러 메서드와 바인딩하고, 메서드가 호출되기 전에 실행되는지 확인한다.

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

위와 같이 메서드에 바인딩 하고, 아래와 같은 요청을 보내면 다음과 같은 결과가 나온다

GET localhost:3000/abc
{
  "statusCode": 400,
  "message": "Validation failed (numeric string is expected)",
  "error": "Bad Request"
}

findOne() 메서드는 실행되지 않는다.

위의 예에서는 인스턴스가 아닌 클래스를 전달하였다.

아래의 예에서는 내부 인스턴스를 전달해보도록 하겠다. 내부 인스턴스를 전달하는 것은 옵션을 전달해 내장 파이프의 동작을 커스텀하려는 경우 유용하다.

@Get(':id')
async findOne(
  @Param('id', new ParseIntPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE })) //pipe 적용 시 에러가 발생할 경우 이처럼 에러코드 세팅
  id: number,
) {
  return this.catsService.findOne(id);
}

다음은 ParseUUIDPipe를 사용해 문자열 매개변수를 구문분석하고 UUID인지 확인하는 예제이다.

@Get(':uuid')
async findOne(@Param('uuid', new ParseUUIDPipe()) uuid: string) {
  return this.catsService.findOne(uuid);
}

Custom Pipes


Nest는 내장 ParseIntPipeValidationPipe를 제공하지만, 커스텀한 파이프를 구성할 수도 있다.
각각 간단한 Custome Version을 만들어 Custom Pipes가 어떻게 구성되는지 보겠다.

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

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

모든 파이프는 PipeTransform 인터페이스를 implements 해야하며, transform() 메서드를 구현해야 한다.

이 메서드에는 두개의 매개변수가 있다.

  • value
  • metadata

value 매개변수는 현재 처리된 메서드의 매개변수이고, metadata는 현재 처리된 메서드 매개변수의 메타데이터이다.

메타데이터 객체에는 다음과 같은 속성이 있다.

export interface ArgumentMetadata {
  type: 'body' | 'query' | 'param' | 'custom';
  metatype?: Type<unknown>;
  data?: string;
}

각 속성들의 의미는 다음과 같다.

type인수가 본문 @Body(), 쿼리 @Query(), param @Param() 또는 커스텀 매개변수인지 여부를 나타낸다
metatype인수의 메타타입을 제공: 라우트 핸들러 메소드 signiture에서 타입 선언을 생략하거나 바닐라 자바스크립트를 사용하면 값이 정의되지 않음
data데코레이터에 전달된 문자열(예: @Body('string')). 데코레이터 괄호를 비워두면 정의되지 않음

※ TypeScript 인터페이스는 변환중에 사라진다. 따라서 메서드 매개변수의 타입이 클래스가 아닌 인터페이스로 선언되면 metatype 값은 object가 된다.

Schema Based Validation


CatsControllercreate() 메서드 실행 전 매개변수 객체가 유효한지 확인해보고 싶을 것이다.

@Post()
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

createCatDto 본문 매개변수의 타입은 CreateCatDto이다.

export class CreateCatDto {
  name: string;
  age: number;
  breed: string;
}

create() 메서드의 객체가 유효한지 확인하기 위해 createCatDto 객체의 세 멤버를 검증해야 한다.

메서드내에서 이를 수행하는 것은 SRP(단일 책임 규칙)을 위반하므로 적절치 않다.

그래서 validator class를 만들고 이곳에 작업을 위임하거나, validation middleware를 만들어야 한다.

전자의 경우에는 메서드의 시작 부분에서 validator class를 호출해야 한다는 단점이 있고, 후자는 전체 어플리케이션의 모든 컨텍스트에서 사용할 수 있는 일반 미들웨어로 만드는 것은 불가능하다.(미들웨어가 호출될 handler 및 매개변수를 포함해 실행 컨텍스트를 인식하지 못하기 때문이다.)

Object Schema Validation


효율적으로 객체 유효성 검사를 수행하는 몇가지 방법이 있다.

  1. 스키마 기반 유효성 검사

    Joi 라이브러리를 사용해 읽기 쉬운 API를 사용하여 간단한 방식으로 스키마를 만들 수 있다.

    npm install --save joi
    npm install --save-dev @types/joi

    아래 코드 샘플은 스키마를 constructor 매개변수로 사용하는 클래스이다.

    제공된 스키마에 대해 들어오는 인수의 유효성을 검사하는 schema.validate() 메서드를 적용한다.

    import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
    import { ObjectSchema } from 'joi';
    
    @Injectable()
    export class JoiValidationPipe implements PipeTransform {
      constructor(private schema: ObjectSchema) {}
    
      transform(value: any, metadata: ArgumentMetadata) {
        const { error } = this.schema.validate(value);
        if (error) {
          throw new BadRequestException('Validation failed');
        }
        return value;
      }
    }

Binding Validation Pipes


변환 파이프를 바인딩하는 것은 위에서 살펴보았다.

ValidationPipe를 바인딩 하는 것도 매우 간단하다.

JoiValidationPipe를 메서드 수준에서 바인딩 하려면 다음을 수행해야 한다.

  1. JoiValidationPipe의 인스턴스를 만든다.
  2. 파이프의 클래스 생성자에 컨텍스트별 Joi 스키마를 전달한다.
  3. 파이프를 메서드에 바인딩한다.

아래와 같이 @UsePipes() 데코레이터를 사용한다.

@Post()
@UsePipes(new JoiValidationPipe(createCatSchema))
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

Class Validtion


Class-Validator 라이브러리를 사용해 데코레이터 기반 유효성 검사를 할 수 있다.

데코레이터 기반 유효성 검사는 처리된 속성의 metatype에 엑세스 할 수 있으므로, Nest의 파이프 기능과 결합할 때 매우 유용하다

npm i --save class-validator class-transformer

설치를 수행하면 CreateCatDto 클래스에 데코레이터를 몇개 추가할 수 있다.

import { IsString, IsInt } from 'class-validator';

export class CreateCatDto {
  @IsString()
  name: string;

  @IsInt()
  age: number;

  @IsString()
  breed: string;
}

이제, 이 데코레이터들을 사용하는 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) {
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    }
    const object = plainToClass(metatype, value);
    const errors = await validate(object);
    if (errors.length > 0) {
      throw new BadRequestException('Validation failed');
    }
    return value;
  }

  private toValidate(metatype: Function): boolean {
    const types: Function[] = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}

코드를 보면 transform() 메서드 앞에 async 키워드가 붙어있다. 이는 일부 class-validator 에서 유효성 검사 결과가 Promise로 반환될 수 있기 때문에 그렇다.

다음으로, 매개변수에서 ArgumentMetadata에서 metatype만 추출한다.

다음으로, Helper Function toValidate()를 확인한다. 처리중인 현재 매개변수가 네이티브 자바스크립트 타입이면, 유효성 검사 단계를 건너뛴다.(유효성 검사 데코레이터를 연결할 수 없기 때문이다.)

다음으로, class-transformer function plainToClass() 를 사용해 일반 자바스크립트 인수 객체를 타입이 지정된 객체로 변환해 유효성 검사를 적용할 수 있다. 이 작업의 수행 이유는 네트워크 요청에서 역직렬화될 때 들어오는 POST 본문 객체가 아무 타입 정보도 가지고 있지 않기 때문이다. class-transformer는 이전에 DTO에 대해 정의한 유효성 검사 데코레이터를 사용해야 하므로 들어오는 본문을 단순한 바닐라 객체가 아닌 decorated 객체로 처리하기 위해 이 변환을 수행해야 한다.

마지막으로는 값을 그대로 반환하거나 예외를 던진다.

마지막 단계는 ValidationPipe를 바인딩 하는 것이다.

파이프는 매개변수 범위, 메서드 범위, 컨트롤러 범위, 전역 범위로 바인딩 된다.

아래 예제에서는 파이프 인스턴스를 route handler @Body() 데코레이터에 바인딩하여 파이프가 POST 본문의 유효성을 검사하도록 호출한다.

@Post()
async create(
  @Body(new ValidationPipe()) createCatDto: CreateCatDto,
) {
  this.catsService.create(createCatDto);
}

매개변수 범위 파이프는 유효성 검증 로직이 지정된 매개변수 하나에만 관련될 때 유용하다.

The Built-in Validation Pipe


ValidationPipe는 Nest에서 제공하므로, 유효성 검사 파이프를 직접 빌드할 필요는 없다.

Global Scoped Pipes


ValidationPipe는 일반적으로 생성되었으므로, 전체 어플리케이션의 모든 route handler에 적용되도록 전역 범위 파이프로 설정할 수 있다.

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

하이브리드 어플리케이션의 경우 uesGlobalPipe() 메서드는 파이프를 전역으로 마운트하지 않는다.

Excpetion Filter와 마찬가지로 전역 범위 파이프로 설정할 때 모듈 외부에서 등록했으므로, 종속성을 주입할 수 없다.

종속성 주입을 가능하게 하기 위해 모든 모듈에서 직접 전역 파이프를 설정하면 된다.

import { Module } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';

@Module({
  providers: [
    {
      provide: APP_PIPE,
      useClass: ValidationPipe,
    },
  ],
})
export class AppModule {}

Transformation Use Case


파이프를 통해 전달된 데이터의 변환은 다음과 같은 경우에 유용하다.

클라이언트에서 전달된 데이터가 route handler 메서드에 의해 처리되기 전에 string -> int로 변환할 때,

일부 필수 데이터 필드가 누락되었을 때, 기본값을 적용하려는 경우

다음은 문자열을 정수로 변환하는 ParseIntPipe이다.

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

@Injectable()
export class ParseIntPipe implements PipeTransform<string, number> {
  transform(value: string, metadata: ArgumentMetadata): number {
    const val = parseInt(value, 10);
    if (isNaN(val)) {
      throw new BadRequestException('Validation failed');
    }
    return val;
  }
}

해당 파이프를 아래와 같이 선택한 매개변수에 바인딩 한다.

@Get(':id')
async findOne(@Param('id', new ParseIntPipe()) id) {
  return this.catsService.findOne(id);
}

또 다른 사례는 전달된 ID를 통해 DB에서 사용자를 선택하는 것이다.

@Get(':id')
findOne(@Param('id', UserByIdPipe) userEntity: UserEntity) {
  return userEntity;
}

Providing Defaults


변환 파이프에 매개변수로 null이나 undefined 값을 받게되면 예외가 발생한다.

엔드포인트가 누락된 쿼리 문자열 매개변수 값을 처리할 수 있게 하려면, DefaultValuePipe를 이용해 중간 과정을 거치게 하면 된다.

해당 파이프 앞에 @Query() 데코레이터에서 DefaultValuePipe를 인스턴스화하면 된다.

@Get()
async findAll(
  @Query('activeOnly', new DefaultValuePipe(false), ParseBoolPipe) activeOnly: boolean,
  @Query('page', new DefaultValuePipe(0), ParseIntPipe) page: number,
) {
  return this.catsService.findAll({ activeOnly, page });
}

참고

https://docs.nestjs.kr/pipes

profile
Always's Archives

0개의 댓글