NestJS Overview - Pipes

Min Su Kwon·2021년 10월 23일
1

파이프는 @Injectable() 데코레이터가 달린 클래스로, PipeTransform 인터페이스를 implement 해야한다.

파이프는 두가지 유즈케이스를 가지고 있다.

  • 변환 : 인풋 데이터를 원하는 형태로 변경
  • 유효성 검증 : 인풋 데이터가 유효한지 확인, 유효하면 통과시키고 유효하지 않으면 예외 throw

두 케이스 모두에서, 파이프는 컨트롤러 라우트 핸들러가 가공하는 arguments를 기반으로 실행된다. 네스트는 메서드가 호출되기 직전에 파이프를 끼워넣으며, 이때 파이프가 핸들러가 받게 될 인자들을 가로채서 실행되는 것이다. 변환이나 검증 연산이 이 시기에 수행되며, 이후 라우트 핸들러에게 (잠재적으로) 변환된 형태로 전달된다.

네스트는 몇가지 빌트인 파이프를 기본적으로 제공한다. 원한다면 커스텀 파이프를 얼마든지 만들 수 있다.

파이프는 예외 구역 안에서 실행된다. 즉, 파이프가 예외를 던지면 예외 레이어(전역 예외 필터나 적용된 아무 예외 필터)에 의해 핸들링된다. 따라서 파이프가 예외를 던지게 되면, 컨트롤러 메서드는 호출되지 않는다. 이를 통해서 유효성 검증에 실패한 데이터가 시스템 바운더리로 들어오는 것을 막을 수 있다.

Built-in pipes

네스트는 8가지 빌트인 파이프를 제공한다.

  • ValidationPipe
  • ParseIntPipe
  • ParseFloatPipe
  • ParseBoolPipe
  • ParseArrayPipe
  • ParseUUIDPipe
  • ParseEnumPipe
  • DefaultValuePipe

이들은 모두 @nestjs/common 패키지에서 export 된다.

ParseIntPipe를 간단하게 살펴보면, 해당 파이프는 변환 유즈케이스에 속하며 인풋 파라미터를 JS Integer로 변환해주거나, 실패할경우 예외를 뱉는다.

Binding pipes

파이프를 사용하려면, 알맞은 문맥에서 파이프 클래스 인스턴스를 바인딩해줘야한다. ParseIntPipe 예시에서, 파이프를 특정 라우트 핸들러 메서드에 적용시켜 해당 핸들러 메서드가 호출되기 전에 실행되길 원한다고 하자. 아래와 같은 방법으로 메서드 파라미터 레벨에서 파이프를 바인딩하면된다.

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

위 코드를 통해서 findOne 메서드가 받는 파라미터가 무조건 숫자를 받게 되며, 그렇지 않다면 메서드 호출 전에 예외가 던져지게 된다.

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

만약 예외가 던져지면 위와 같은 오류를 반환하고, findOne 메서드는 호출되지 않는다.

위의 예시에서는 인스턴스가 아닌 ParseIntPipe 클래스를 넘기는데, 이렇게 해주면 프레임워크의 의존성 주입 기능에게 인스턴스화 책임을 넘기게 되는 셈이다. 원한다면 직접 인스턴스를 만들어서 넘길 수 있으며, 이는 빌트인 파이프의 동작방식을 다르게 하고싶을 때 유용하다.

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

다른 변환 파이프(모든 Parse- 파이프 포함)를 바인딩 하는 것 역시 비슷하게 이뤄진다. 이 파이프들은 라우트 파라미터, 쿼리 파라미터, 바디 파라미터 등을 검증하는데에 사용될 수 있다.

아래와 같이 쿼리 파라미터를 검증하거나

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

ParseUUIDPipe를 사용해서 문자열 파라미터를 검증하도록 할 수 있다

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

ParseUUIDPipe를 사용할 때는 UUID version 3/4/5를 파싱하게 된다. 특정 버젼만 필요한 경우 파이프 옵션으로 넘겨줘야한다.

Custom pipes

ValidationPipe를 만들어보자. 먼저, 간단하게 인풋 값을 받아서 즉시 같은 값을 돌려주자.

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

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

PipeTransform<T, R>은 제네릭 인터페이스로, 모든 파이프에 의해서 implement되어야한다. 제네릭 인터페이스는 인풋 값의 타입 Ttransform 메서드의 반환형 R을 인자로 받는다.

모든 파이프는 PipeTransform 인터페이스를 충족하기 위해서 transform() 메서드를 구현해야한다. 이 메서드는 두개의 파라미터를 가진다.

  • value : 현재 처리하고 있는 메서드의 매개변수
  • metadata : 현재 처리하고 있는 메서드의 매개변수의 메타데이터

메타데이터는 아래와 같은 프로퍼티들을 가진다.

export interface ArgumentMetadata {
  type: 'body' | 'query' | 'param' | 'custom';
  metatype?: Type<unknown>;
  data?: string;
}
  • type
    매개변수가 바디 @Body()인지, 쿼리 @Query()인지, 파라미터 @Param()인지, 아니면 커스텀 파라미터인지 알려줌
  • metatype
    매개변수의 메타타입 정보를 제공(예를 들면, String). 라우트 핸들러 메서드 시그니처에서 타입 선언을 생략하면, undefined가 됨.
  • data
    데코레이터에게 전달되는 문자열(ex : @Body('string')), 아무값도 넘기지 않으면 undefined

타입스크립트 인터페이스는 트랜스파일링 이후 사라지기 때문에, 메서드의 파라미터 타입이 인터페이스로 정의되었다면 metatype 값은 Object가 된다.

Schema based validation

검증 파이프를 조금 더 유용하게 만들어보자. CatsControllercreate() 메서드에서 post 바디 객체가 유효한지 검증해보자.

@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 클래스를 만들어서 그쪽으로 책임을 넘기는 것이다. 하지만 각 메서드의 앞부분에서 이 Validator를 호출하는 것을 기억해야한다는 단점이 있다.

검증 미들웨어를 만드는 것은 어떨까? 가능하지만, 전체 애플리케이션 어떤 문맥에서도 사용할 수 있는 제네릭 미들웨어를 만드는 것은 불가능하다. 이는 미들웨어가 호출될 핸들러나 파라미터를 포함한 실행 컨텍스트에 대해서 모르기 때문이다.

따라서 이런 경우에 파이프를 사용해야한다. 이제 위에서 만든 파이프를 조금 더 발전시켜보자.

Object schema validation

깔끔하고 DRY(Don't Repeat Yourself)하게 객체 검증을 하기 위한 접근법이 몇가지 있다. 한가지 방법은 스키마 기반의 검증이다. 한번 해보자.

Joi 라이브러리를 통해서 직관적인 방법으로 스키마를 만들고, 가독성이 높은 API를 만들 수 있다. Joi 기반의 스키마를 사용하는 검증 파이프를 만들어보자.

먼저 패키지를 설치하고

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

아래의 예시에서 생성자 매개변수로 스키마를 받는 간단한 클래스를 만들어본다. 이후 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;
  }
}

다음 섹션에서 @usePipes() 데코레이터를 이용해서 알맞은 스키마를 컨트롤러에게 넘기는 방법에 대해서 알아본다. 이를 통해서 파이프를 여러곳에서 재사용 가능하도록 만들 수 있다.

Binding validation pipes

검증 파이프를 바인딩 하는 것도 직관적이다.

이 케이스에서는 파이프를 메서드 호출 레벨에서 바인딩하고싶다. 현재 예시에서 JoiValidationPipe를 사용하기 위해서 아래와 같은 과정을 거쳐야한다.

  1. JoiValidationPipe의 인스턴스를 만든다
  2. context-specific한 스키마를 파이프의 생성자에게 넘긴다
  3. 메서드에 파이프를 바인딩한다

아래와 같이 @UsePipes 데코레이터를 활용해서 위 과정을 거친다.

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

Class validator

이 기능은 타입스크립트를 필요로 하며, 바닐라 JS만 사용하는 경우 사용할 수 없습니다.

네스트는 class-validator 라이브러리와 아주 잘 동작한다. 이 강력한 라이브러리를 통해서 데코레이터 기반의 검증이 가능해진다. 데코레티어 기반 검증은 그 자체만으로 매우 강력하지만, 프로퍼티의 metatype에 대한 정보를 얻을 수 있는 네스트의 파이프 기능과 결합되면 더욱 더 강력해진다.

우선 필요한 패키지들을 설치한다.

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

설치 후에, CreateCatDto 클래스에 몇가지 데코레이터를 추가할 수 있다. 이때 이 기법의 중요한 장점이 두드러지는데, CreateCatDto 클래스가 별도의 Validation 클래스를 작성하지 않고도 계속해서 Post body 객체에 대한 single source of truth로 남아있게 된다.

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

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

  @IsInt()
  age: number;

  @IsString()
  breed: string;

class-validator에 대한 좀 더 자세한 정보는 문서를 참고한다.

이제 이 데코레이터들을 사용하는 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);
  }
}

위 예시에서 class-transformer 라이브러리를 사용한다. 이 역시 class-validator 라이브러리를 만든 사람이 만든 라이브러로, 함께 잘 동작

위 코드를 살펴보자. 먼저, transform() 메서드가 async로 선언되었다. 이는 네스트가 sync/async 파이프 모두 지원하기 때문에 가능하다. 몇몇 class-validator 검증이 async일 수 있기 때문에 async로 선언된 것이다.

다음으로 destructuring 문법을 통해서 metatype 필드를 추출해서 metatype 파라미터로 받는다. 이는 ArgumentMetadata를 받기 위한 shorthand일 뿐이다.

다음으로 toValidate 헬퍼 함수를 살펴보자. 이는 현재 처리하고 있는 매개변수가 자바스크립트 원시 타입인지 확인하고, 그럴 경우 검증 단계를 스킵하는 책임을 가지고 있다. 이들은 당연히 클래스가 아니고 검증 데코레이터들이 달려있지 않기 때문에, 검증 단계를 거칠 이유가 없다.

다음으로, class-transformer의 함수인 plainToClass()를 사용해서 JS 매개변수 객체를 타이핑된 객체로 바꿔준다. 이 과정을 거쳐야 하는 이유는 post body 객체가 네트워크 요청 후로부터 deserialize 된 후에는 아무런 타입 정보가 없기 때문이다. Class-validator는 DTO에서 선언한 검증 데코레이터를 필요로 하기 때문에, 이 변환 과정을 거쳐야하는 것이다.

마지막으로, 이는 검증 파이프기 때문에, value 그대로 또는 예외를 던진다.

마지막 단계는 ValidationPipe를 바인딩 하는 것이다. 파이프는 파라미터, 메서드, 컨트롤러, 전역 스코프로 적영될 수 있다. 앞에서, Joi 기반의 검증 파이프에서는 메서드 레벨에서 파이프를 바인딩 했다. 아래의 예시에서는 파이프 인스턴스를 라우트 핸들러 @Body() 데코레이터 쪽에서 사용해서 post body를 검증할 때 호출되도록 한다.

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

파라미터 스코프의 파이프는 검증 로직이 하나의 지정된 파라미터만 생각할 때 유용하다.

Global scoped pipes

ValidationPipe는 최대한 제네릭하게 작성되었으므로, 전역 스코프로 세팅해서 모든 라우트에 적용되도록 할 수 있다.

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

하이브리드 앱인 경우에는 useGlobalPipes() 메서드가 게이트웨이나 마이크로 서비스들을 위해서 파이프를 셋업하지 않는다. 표준 마이크로서비스 앱을 위해서는 useGlobalPipes() 메서드가 제대로 동작한다.

전역 파이프는 전체 애플리케이션을 통틀어서 사용된다. 즉, 모든 컨트롤러의 모든 라우트 핸들러가 사용한다.

의존성 주입의 관점에서, 전역 파이프는 모듈 바깥에서 등록되기 때문에 다른 의존성들을 주입할 수 없다. 이 문제를 해결하려면, 아무 모듈에서 다음과 같이 전역 파이프를 셋업해준다.

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

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

이 접근법을 사용해서 파이프에게 의존성 주입을 할 때, 어떤 모듈에서 이 파이프가 등록되었던 파이프는 항상 전역으로 등록된다. 파이프가 정의된 모듈 쪽에서 등록하는 것이 좋다. useClass 외에 다른 방법들도 사용할 수 있다.

The built-in ValidationPipe

제네릭 검증 파이프를 직접 만들 필요 없이, 네스트가 제공하는 ValidatinPipe를 사용하면 된다. 위에서 구현한 것보다 더 많은 기능을 제공하며, 다양한 예시도 있다.

Transformation use case

검증만이 커스텀 파이프의 유일한 유즈케이스는 아니다. 파이프는 변환의 기능도 수행할 수 있는데, 이는 transform 함수가 반환하는 값이 완벽하게 이전 값을 오버라이딩하기 때문이다.

언제 이 값이 유용한가? 클라이언트로부터 넘겨진 데이터가 핸들러에 전달되기 전에 조금 변화되어야 한다고 생각해보자. 예를 들면 string → integer. 추가적으로 몇몇 필수 데이터 필드가 없어서 디폴트 값을 적용하고 싶을 수 있다. 클라이언트 요청과 요청 핸들러 사이에서 변환 파이프가 이 역할을 할 수 있다.

아래는 ParseIntPipe의 간단한 예로, string → integer로 변환해주는 역할을 가지고 있다.

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);
}

다른 유즈케이스로는, 이미 존재하는 유저 엔티티를 DB에서 뽑아오는 것이다.

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

구체적인 구현은 파이프에게 맡긴다. 다른 변환 파이프와 같이, 인풋을 받아서 아웃풋 값을 뱉는다. 코드가 더욱 선언적이게 되고, DRY(Don't Repeat Yourself) 원칙을 지킬 수 있게 된다.

Providing defaults

Parse* 파이프들은 파라미터의 값이 정의되어있길 기대한다. 따라서, null이나 undefined 값을 받는 경우 예외를 던진다. 존재하지 않는 쿼리 파라미터 값을 핸들링하기 위해서, 디폴트 값을 이 파이프들에게 넘겨줘야한다. DefaultValuePipe가 이때 역할을 할 수 있다. 간단하게 DefaultValuePipe@Query() 데코레이터에게 넘겨줘서 적응할 수 있다.

@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 });
}

느낀점

확실히 여러 핸들러에 거쳐서 반복되는 로직들을 많이 줄이고 통합할 수 있는 수단이 될 것 같다. 현재까지 사용해본건 class-validator, class-transformer를 이용한 validation 쪽인데, 파이프 적용 후 파라미터 타입 선언만 적절하게 해주니 알아서 오류를 뱉거나, 지정한 클래스로 바꿔서 핸들러 메서드에 도착하는 것이 매우 편리했다. input id를 이용한 데이터 페칭 & 클래스 화하는 변환 파이프 쪽을 매우 유용하게 쓸 수 있을 것 같다. 기회가 나면 적용해봐야지.

Reference

profile
이제 막 커리어를 시작한 소프트웨어 엔지니어입니다. 배운 것을 정리하면서 조금 더 깊이 이해하려는 습관을 들이려고 합니다. 피드백은 언제나 환영입니다.

0개의 댓글