[NestJS] Pipes

cdwde·2022년 11월 19일
post-thumbnail

Pipes

pipe는 @Injectable() 데코레이터로 주석이 된 클래스이다. pipe는 PipeTransform 인터페이스를 구현해야 한다.

pipe는 두 가지 사용 방법이 있다.

  • transformation: input 데이터를 원하는 형태로 변환(ex: string -> int)
  • validation: input 데이터가 유효한지 확인하고, 유효하지 않은 경우 예외 발생

두 가지 모두 controller route handlerarguments에 동작한다. Nest는 method가 호출되기 전에 끼어, pipe가 먼저 arguments를 받게하는데 transform이나 validation은 이때 동작한다. 모든 변환, 검증은 그 시간에 이루어지며, 그 후 route handler는 변환된 인수로 호출된다.

Nest는 여러 개의 built-in pipe를 가지고 있다. built-in pipe와 route handler에 pipe를 binding하는 방법에 대해 알아볼 예정이고, custom pipe도 만들어 볼 것이다.

pipe는 exception zone에서 동작한다. 이것은 pipe가 예외를 throw하면 exception layer에서 처리된다는 뜻이다. 따라서 pipe에서 예외가 발생하면, 뒤에 연결된 method는 동작하지 않는다. 이것은 외부에서 애플리케이션으로 들어오는 데이터에 대해 시스템 상으로 처리할 수 있는 가장 좋은 방법을 제공한다.

Built-in pipes

  • ValidationPipe
  • ParseIntPipe
  • ParseBoolPipe
  • ParseArrayPipe
  • ParseUUIDPipe
  • DefaultValuePipe
    위 pipe는 @nestjs/common 패키지에 의해 제공된다.

ParseIntPipetransformation의 예로, pipe는 method handler의 매개변수를 int로 변환해준다. (변환 실패 시 예외 던짐)

Binding pipes

pipe를 사용하기 위해서는, pipe 인스턴스를 bind해야 한다. ParseIntPipe 예제에서, pipe를 특정 route handler method와 연관시켜, method가 호출되기 전에 작동하게 하고 싶다. 아래처럼 pipe를 parameter level에서 binding 할 수 있다.

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

이것은 다음 두 조건 중 하나를 만족함을 보장해준다. findOne()의 파라미터는 number이거나, route handler가 호출되기 전에 예외가 발생한다.

예를 들어 route가 아래와 같이 호출되었다고 가정하면

GET localhost:3000/abc

Nest는 아래처럼 예외를 throw 할 것이다.

{
  "statusCode": 400,
  "message": "Validation failed (numeric string is expected)",
  "error": "Bad Request"
}

예외가 발생하면서 findOne()은 수행되지 않는다.
위 예제에서 우리는 인스턴스가 아닌 클래스(ParseIntPipe)를 전달하여, 프레임워크에 인스턴스화에 대한 책임을 넘기고 의존성 주입을 가능하게 한다.
대신 인스턴스를 직접 사용할 수도 있다. pipe에 옵션을 추가하고 싶을 때 이런 방식이 유용하다.

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

다른 transformation pipe도 비슷한 방식으로 동작한다. 이 pipe들은 모두 route parameter, query string, request body의 context에서 작동한다.

Custom pipes

간단하게 항등함수처럼 input value를 받아 그대로 반환하게 만들었다.

  • validation.pipe.ts
import { PipeTransform, Injectable, ArgumentMetaData } from 'nestjs/common';

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

PipeTransform<T, R>은 pipe에서 항상 구현되어야하는 제네릭 인터페이스이다. T는 value의 타입, R은 transform()의 return type이다.

모든 pipe는 PipeTransform 인터페이스를 충족하기 위해 transform()을 구현해야 한다. 이 method는 두 개의 매개변수를 받는다.

  • value
  • metadata

value는 현재 가공 중인 method argument이고, metadata는 현재 가공 중인 method argument의 metadata이다. metadata 객체는 아래와 같은 프로퍼티를 갖는다.

export interface ArgumentMetadata {
  type: 'body' | 'query' | 'param' | 'custom';
  metatype?: Type<unknown>;
  data?: string;
}
  • type: argument가 body @Body(), query @Query, param @Param(), custom parameter 중 어느 것인지 가리킨다.
  • metatype: argument의 메타타입을 제공한다. route handler method에 타입 선언을 생략하거나, 바닐라 자바스크립트 사용 시 value는 undefined이다.
  • data: 데코레이터에서 받은 string(ex: @Body('string')). 데코레이터를 비워두면 undefined이다.

ArgumentMetadata 아직 뭔소린지 모르겠어요..

Scheme based validation

validation pipe를 좀 더 유용하게 만들어보자.

@Post()
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}
  • create-cat.dto.ts
export class CreateCatDto {
  name: string;
  age: number;
  breed: string;
}

우리는 crate()로 들어오는 어떠한 request도 유효한 body를 갖고 있음을 보장하기를 원한다. 따라서 createCatDto로 3개의 멤버를 갖고 있는지 유효성을 검사해야한다. 이것을 route handler method 내부에서도 할 수 있지만, 이것은 single responsibility rule(단일 책임 원칙)을 어기는 것이다.

다른 접근 방법은 validator class를 만들어 유효성 검사를 위임하는 것이다. 이것은 우리가 각 method의 시작 부분에 이 validator를 호출해야한다는 단점이 있다.

validation 미들웨어를 만드는 것은 어떨까? 동작은 하지만 애플리케이션 내의 모든 context에 적용할 수 있는 제네릭 미들웨어를 만드는 것은 불가능하다. 미들웨어가 호출될 때 handler와 매개변수를 포함하여 excution context를 인식하지 못하기 때문이다.

Object scheme validation

DRY한 방법으로 깔끔하게 object validation을 하는 방법은 여러가지가 있다. 그 중 한 가지 일반적인 방법은 scehma-based validation이다.

joi 라이브러리는 스키마를 간단하게 생성해준다. joi0based 스키마로 validation pipe를 만들어보자.

npm install --save joi

아래 예제에서, 생성자에서 간단한 스키마를 constrouctor argument로 사용하는 간단한 클래스를 만든다. schema.validate()를 통해 들어오는 argument를 확인할 수 있다.

위에서도 얘기했듯, validation pipe는 값을 그대로 리턴하거나 예외를 throw한다.

다음으로, @UsePipe()를 사용해 컨트롤러에 적절한 스키마를 적용하는 방법에 대해 알아볼 것이다. 그렇게 함으로써, validation pipe를 context 간 재사용이 가능하도록 만들 것이다.

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

validation pipe를 binding하는 것은 매우 간단하다. method call level에서 pipe를 bind하기 원한다.

  1. JoiValidationPipe 인스턴스를 만든다.
  2. pipe의 생성자를 통해 context-specific joi 스키마를 보낸다.
  3. pipe를 method에 bind한다.
@Post()
@UsePipes(new JoiValidationPipe(createCatSchema))
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

Class validator

class-validator 라이브러리를 사용한 다른 validation 테크닉을 알아보자. 이 라이브러리는 데코레이터 기반 validation을 할 수 있게 해준다. Nest의 pipe는 metatype에 접근할 수 있기 때문에, Nest와 결합되면 데코레이터 기반 validation은 매우 강력해진다.

$ npm i --save class-validator class-transformer

해당 라이브러리르 설치하면, CreateCatDto에 몇몇 데코레이터를 붙일 수 있다. CreateCatDto는 post body의 유효성 검사를 위한 단일 소스로 사용할 수 있다.(여러 개의 validation class를 만들지 않아도 된다.)

  • create-cat.dto.ts
import { IsString, IsInt } from 'class-validator';

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

  @IsInt()
  age: number;

  @IsString()
  breed: string;
}

이제 ValidationPipe 클래스를 만들 수 있다.

  • validation.pipe.ts
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transfomer';

@Injectable()
export class ValidationPipe implements PipeTransform<any> {
  async transform(value: any, { metatype }: ArgumentMetadata) {
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    }
    
    const object = plainToInstance(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);
  }

일단 코드만 봤을 때는 뭔소린지 1도 이해가 안가니 차근차근 설명을 읽어보자..

먼저 transform()은 async로 표시되어 있다. 이것은 Nest가 동기, 비동기 pipe를 모두 지원하기 때문에 가능하다. 일부 class validator는 비동기적일 수 있기 때문에 이 메서드를 비동기화한다.

다음으로 ArgumentMetadata로부터 metatype 필드를 추출한다. 이것은 전체 ArgumentMetadata를 가져오고 metatype 변수를 할당하기 위해 shorthand(?)이다.

다음으로 toValidate()에 주목해보자. 현재 처리 중인 인수가 native Javascript 유형일 때 유효성 검사 단계를 우회할 책임이 있다.(이것들은 유효성 검사 데코레이터를 연결할 수 없으므로 유효성 검사 단계를 실행할 이유가 없다)

다음으로 class-transformer의 함수인 plainToClass()를 사용하고 있다. 이는 자바스크립트 오브젝트를 타입화된 오브젝트로 변환하여 validation을 적용할 수 있다. 우리가 이것을 해야하는 이유는 들어오는 post body 객체가 네트워크 요청에서 역직렬화될 때 타입 정보가 없기 때문이다. class-validator는 이전에 dto에 대해 정의한 validation decorator를 사용해야하므로, 들어온 body를 plain vanilla 객체가 아닌 decorated 객체로 취급하기 위해 해당 변형이 필요하다.

validation pipe는 value를 변경하지 않고, 그대로 리턴하거나 예외를 던진다.

마지막으로 ValidationPipe를 bind하자. pipe는 parameter-scope, method-scope, controller-scope 또는 global-scope이다. validation pipe 인스턴스를 route handler의 @Body() 데코레이터에 bind 할 수 있다.

  • cat.controller.ts
@Post()
async create(@Body(new ValidationPipe()) createCatDto: CreateCatDto){
  this.catsService.create(createCatDto);

parameter-scope pipe는 validation 로직이 하나의 파라미터와 연관되어 있을 떄 유용하다.

Global scoped pipes

ValidationPipe는 제네릭으로 만들 수 있기 때문에, global-scope로 만들어 모든 route handler에 적용할 수 있다.

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

종속성 주입 측면에서, 모든 모듈 외부에서 등록된 global pipe는 바인딩이 모든 모듈 컨텍스트 외부에서 수행되었기 때문에 종속성을 주입할 수 없다. 이것을 해결하기 위해 global pipe를 모듈에 직접 bind할 수도 있다.

  • app.module.ts
import { Module } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';

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

이런 접근 방식으로 의존성 주입을 한 pipe는 모듈에 상관 없이 전역적이다.

The built-in Valiadation Pipe

내장 ValidationPipe가 Nest에서 제공되기 때문에 제네릭 validation pipe를 직접 만들 필요가 없다. 내장 ValidationPipe는 위에서 본 것보다 더 많은 옵션을 제공한다.

Transformation use case

validation이 custom pipe의 유일한 사용 사례는 아니다. pipe는 input data를 원하는 포맷으로 transform 할 수 있다. 이것은 transform()에서 리턴되는 값은 기존 argument 값을 재정의하기 때문에 가능하다.

이건 언제 유용할까? 종종 클라이언트로부터 받은 데이터는 router handler method에서 처리되기 전에 가공될 필요가 있다.(string -> int 등) 또한 일부 필수 필드가 없는 경우 기본값을 제공해야 하는 경우도 있다. transformation pipe는 client request와 request handler 사이에서 이런 역할을 한다.

아래는 string을 int로 바꿔주는 예시이다. (Built-in pipe가 있지만 예제를 위해 추가)

  • parse-int.pipe.ts
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;
  }
}

pipe는 아래와 같은 방법으로 특정 파라미터에 bind할 수 있다.

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

또 다른 transformation의 좋은 예는 request에서 id를 받아 데이터베이스로부터 존재하는 사용자를 가져오는 것이다.

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

위와 같은 경우, input value로 id를 받지만, return value는 UserEntity 객체이다. 이것은 pipe를 통해 handler 외부에서 반복되는 코드를 줄여, 코드를 더욱 DRY하게 만드는 방법이다.

Providing defaults

Parse* pipe들은 파라미터가 정의되어 있기를 기대한다. null 또는 undefined를 받으면 예외를 던질 것이다. 엔드포인트가 누락된 querystring 매개변수 값을 처리할 수 있도록 하려면 Parse* pipe가 이러한 값으로 작동하기 전에 삽입할 기본값을 제공해야한다. 아래처럼 관련 pipe 전에 @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 });
}

참고
NestJS 공식문서
[NestJS] (공식문서 번역) Pipe

0개의 댓글