NestJS 공식문서 번역 겸 공부하는 글 입니다.
의역 및 오역이 있을 수 있습니다.
https://docs.nestjs.com/pipes

Pipe

Pipe

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

pipe

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

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

두가지 경우 모두 controller route handler의 argument에 동작한다. Nest는 method가 수행되기전에 끼어들어, pipe가 argument를 먼저 받게 한다. transform이나 validation은 이때 동작한다.

Nest는 여러개의 내장 pipe를 갖고 있다. 또한, custom pipe를 만드는 것도 가능하다. 해당 챕터에서는, 내장 pipe에 대한 소개와, route handler에 pipe를 binding하는 방법에 대해 알아 볼 것이다. 그리고 custom pipe를 만드는 법에 대해서도 알아 볼 것이다.

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

Built-in pipes

Nest는 6가지의 내장 pipe를 제공한다.

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

위 pipe들은 @nestjs/common 패키지에 의해 제공된다.

ParseIntPipe를 통해 pipe에 대해 알아보자. 이것은 transformation의 예로, pipe는 method handler의 매개변수를 int로 변환해준다(변환 실패시 예외를 throw한다). 챕터 뒤에서, 간단한 custom ParseIntPipe를 볼 것이다. 아래에서 보게 될 예제는, 다른 내장 transformation pipe(ParseBoolPipe, ParseArrayPipe, ParseUUIDPipe)에도 동일하게 적용 될 것이다.

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가 호출되기 전에 예외가 발생한다.

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

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들은 모두 validating route parameter, query string parameter, request body의 context에서 작동한다.

query string paramter에 대한 예제이다.

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

아래는 ParseUUIDPipte를 이용한 예제이다.

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

ParseUUIDPipe를 사용하여 uuid 3, 4, 5 버전을 파싱할 때, 특정 버전의 uuid만 요구하도록 옵션을 추가할 수 있다.

위에서 다양한 Parse* 내장 pipe(transformation pipe)의 binding을 보았다. validation pipe의 binding은 조금 다르다. 다음 섹션에서 해당 내용을 볼 것이다.

Custom pipes

위에서 말했듯, custom pipe를 만들수 있다. validation pipe로 시작해보자. 간단하게, 항등함수처럼 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()의 리턴타입이다.

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

  • value
  • metadata

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

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의 메타타입을 제공한다(예를 들어, String). route handler method에 타입선언을 생략하거나, 바닐라 자바스크립트 사용시 value는 undefined 이다.
data 데코레이터에서 받은 string(예를 들어 @Body(‘string’)). 데코레이터를 비워두면 undefined 이다.

타입스크립트 인터페이스는 transpilation 도중 사라진다. 따라서, method parameter의 타입이 클래스 대신 인터페이스로 선언되었다면, metadata의 value는 object가 된다.

Scheme based validation

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

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

body parameter인 createCatDto에 집중해보자. 이것의 타입은 CreateCatDto이다.

// create-cat.dto.ts

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

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

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

validation 미들웨어를 만드는 것은 어떨까? 동작은 하겠지만, 애플리케이션 내의 모든 context에 적용할 수 있는 제네릭 미들웨어를 만드는 것은 불가능하다.

Object scheme validation

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

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

먼저, 아래의 패키지를 설치해야한다.

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

아래의 예제에서, 생성자에서 간단한 스키마 클래스를 만들었다. scheme.validate() method를 통해, 들어오는 argument를 확인할 수 있다.

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

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

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 하기를 원한다. 아래 예제에서, JoiValidationPipe를 사용할 것이다.

  1. JoiValidationPipe 인스턴스를 만든다.
  2. pipe의 생성자를 통해 context-specific joi 스키마를 보낸다.
  3. pipe를 method에 bind 한다.

아래처럼 @UsePipe() 데코레이터를 사용한다.

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

Class validator

이 섹션의 테크닉은 typescript에서만 유효하다. 바닐라 자바스크립트에서는 유효하지 않다.

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 { 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() method는 async이다. 이것은 Nest가 동기, 비동기 pipe를 모두 지원하기 때문에 가능하다. 일부 class validator는 비동기이기 때문에 비동기 방식을 사용할 것이다.(참고:utilize Promises)

다음으로, ArgumentMetadata로부터 metatype 필드를 추출한다는 것이다. 이것은 ArgumentMetadata 전체에 대한 요약본이며, metatype 변수에 대한 추가적인 상태를 포함하고 있다.

다음으로, class-transformer 함수인 plainToClass()를 사용하고 있다. 이는 자바스크립트 오브젝트에 타입을 부여하여 validation을 하기 위함이다. 또한 네트워크 request를 역직렬화하여 얻은 오브젝트는 타입에 대한 정보가 없기 때문이다. class-validator는 앞서 우리가 dto에 정의한 validation 데코레이터가 필요하다.

마지막으로, 앞서 말했듯, validation pipe는 value를 변경하지 않고 그대로 리턴하거나, 예외를 throw 한다.

마지막 스텝은 ValidationPipe를 bind 하는 것이다. pipe는 parameter-scope, method-scope, controller-scope 또는 global-scope이다. 앞서 본 joi-based validation pipe는 method level의에서 binding 된 pipe 였다. 아래의 예제처럼, validation pipe 인스턴스를 route hadler의 @Body() 데코레이터에 bind 할 수 있다.

// cats.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();

[주의] 하이브리드앱에서는 UseGlobalPipes()는 게이트웨이와 마이크로서비스 모두에 적용이 되지 않는다.

모듈 외부에서 useGlobalPipes()를 통해 등록된 global pipe는 의존성 주입이 아닌, 모듈 context 외부에서 bind 되어 있다. 이것을 해결하기 위해 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 {}

[Hint] 이러한 접근 방식으로 의존성 주입을 한 pipe는 어떠한 모듈에서 생성이 되었듯 global이다.

The built-in ValidationPipe

내장 ValidationPipe가 Nest에서 제공되기 때문에 제네릭 validation pipe를 직접 만들 필요는 없다. 내장 ValidationPipe는 이 챕터에서 본것보다 더 많은 옵션을 제공하지만, custom pipe의 메커니즘을 설명하기 위한 간단한 예제만 보여주었다. 더 상세한 내용과 예제는 여기에서 볼 수 있다.

Transformation use case

custom pipe는 validation의 경우에만 있는 것은 아니다. 챕터에 시작에서 말했듯, pipe는 input data를 원하는 포맷으로 가공할 수 있다고 하였다. 이것은 transform() 함수에서 리턴되는 값은 기존 argument 값을 덮어쓰기 때문에 가능하다.

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

아래는 string을 int로 바꿔주는 간단한 예제이다. (위에서 말했듯, Nest가 제공하는 내장 pipe가 더 세련되었지만, custom 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를 받으면 예외를 throw 할 것이다. 생략된 query string value를 처리하기 위해서는, 미리 기본값을 주입해두어야 한다. 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 });
}