Node.js/NestJS

[NestJS] 7. 파이프

턴태 2022. 9. 29. 15:11

파이프

파이프는 @Injectable() 데코레이터와 함께 사용하는 클래스입니다. 이는 PipeTransform 인터페이스로부터 구현합니다.

파이프는 일반적으로 두 가지 방법을 사용합니다.

  • 변환: 입력 데이터를 원하는 폼으로 변환합니다(e.g., 문자열을 정수형으로).
  • 유효성 검사: 입력데이터를 평가하여 만약 유효하다면 그대로 값을 전달하지만, 그렇지 않고 데이터가 유효하지 않다면 예외를 발생시킵니다.

둘의 경우 파이프는 컨트롤러 라우트 핸들러에 의해 처리되는 인수에 대하여 작동합니다. Nest는 메서드가 호출되기 직전에 파이프를 넣고, 파이프는 메서드에 지정된 인수를 받아서 작동합니다. 변환과 검증은 모두 그때 실행되며, 그 이후에 라우트 핸들러가 잠재적으로 변환될 수도 있는 인수와 함께 호출됩니다.

 

 Nest에는 필요할 때 바로 사용할 수 있는 여러 내장 파이프를 갖고 있습니다. 혹은 직접 파이프를 정의할 수도 있습니다.

 

더보기

파이프는 예외처리 과정에서 동작합니다. 이것은 파이프가 예외를 발생시킬 때, 예외 계층(전역 필터와 현재 환경에서 적용된 모든 예외 필터들)에 의해 처리된다는 의미입니다. 위에서 언급했듯이, 파이프에서 언제 예외가 발생하는 지 명확해야 하며 어떠한 컨트롤러 메서드도 그 후에 실행되면 안됩니다. 외부 소스로부터 어플리케이션으로 들어오는 데이터를 검증하는 것은 좋은 방법입니다.

내장 파이프

Nest는 9가지 즉시 사용할 수 있는 파이프를 제공합니다.

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

위 파이프들은 모두 @nestjs/common 패키지에 있습니다.

 

ParseIntPipe를 간단하게 살펴봅시다. 이것은 메서드 핸들러 파라미터가 자바스크립트의 정수형으로 변환되도록 파이프를 사용할 때 사용하는 변환의 사례입니다(변환이 실패하면 예외가 발생합니다). 

 

파이프 바인딩

 

파이프를 사용하기 위해서는 적절한 맥락에 파이프 클래스의 인스턴스를 적용해야 합니다. ParseIntPipe의 예에서, 파이프와 특정 라우트 핸들러를 연결하고 메서드가 실행되기 전에 실행시키고자 합니다. 그래서 아래처럼 파이프를 메서드 매개변수 단계에서 파이프를 사용하여 설계할 수 있습니다.

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

이는 findOne() 메서드로 받은 파라미터가 정수형이거나 예외가 라우트 핸들러를 호출하기 전에 발생시키거나 둘 중 하나는 보증합니다.

 

예를 들어, 아래와 같은 라우트가 호출됐다고 가정해봅시다.

GET localhost:3000/abc

그렇다면 Nest는 아래와 같은 예외를 발생시킬 것입니다.

{
  "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를 사용하여 문자열 매개변수를 찾아서 이것이 UUID인지 유효성 검사하는 예입니다.

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

사용자 정의 파이프

앞서 언급했듯이, 사용자 정의 고유 파이프를 구축할 수 있습니다. Nest가 ParseIntPipe와 ValidationPipe를 제공해주긴 하지만, 어떻게 사용자 정의 파이프가 설계되는지 각각 사용자 정의 버전을 빌드하여 확인해봅시다.

 

일단 간단한 ValidationPipe로 시작해봅시다. 처음에 간단하게 입력데이터를 받게할 수 있고 즉시 그 값을 반환하도록 기능합니다.

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

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

PipeTransform<T, R>은 모든 파이프에 구현되어야 하는 일반적인 인터페이스입니다. 일반적인 인터페이스는 입력 값의 타입을 지정하는 T를 사용하며, transform() 메서드의 반환 타입을 지정하는 R을 사용합니다.

모든 파이프는 반드시 PipeTransform 인터페이스 규약을 충족시키는 transform() 메서드를 구현해야 합니다. 이 메서드는 두 가지 매개변수를 가집니다.

  • value
  • metadata

value 파라미터는 현재 처리되고 있는 메서드 인수이고, metadata는 현재 처리되는 메서드 인수의 메타데이터입니다. 메타데이터 객체는 다음의 프로퍼티를 가집니다.

export interface ArgumentMetadata {
  type: 'body' | 'query' | 'param' | 'custom';
  metatype?: Type<unknown>;
  data?: string;
}
  • type: 인수가 바디 @Body(), 쿼리 @Query(), 파라미터 @Param(), 또는 사용자 정의 파라미터인지 지정합니다.
  • metatype: 인수의 메타 타입(문자열 같은)을 제공합니다. 만약 라우트 핸들러 메서드 시그니처에서 타입 선언을 생략하거나, 바닐라 자바스크립트를 사용한다면 undefined 값을 가집니다.
  • data: 예를 들어, @Body('string')일 때 문자열이 데코레이터로 전해집니다. 비워두면 기본값은 undefined입니다.

스키마 기반 유효성 검사

유효성 검사 파이프를 조금 더 유용하게 사용해봅시다. CatsController의 create() 메서드를 유심하게 봅시다. 아마 post의 바디 객체가 서비스 메서드를 실행하기 전에 검사하는 게 좋을 것 같다는 생각이 들 겁니다.

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

createCatDto의 바디 매개변수를 자세히 보면 CreateCatDto의 타입은 다음과 같습니다.

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

create가 메서드에 들어오는 모든 요청이 유효한 바디를 포함하는지 보증하고 싶습니다. 그래서 createCatDto 객체의 세 가지 멤버가 유효한지 검사해야 합니다. 라우트 핸들러 내부에서 할 수도 있겠지만, 단일책임원칙(Single Resposibility Rule, SRP)을 어길 수 있어 이상적이진 않습니다.

 

다른 방법은 validator class를 만들어서 여기에 위임하는 것입니다. 이것은 각 메서드마다 시작지점에 이 validator를 호출해야 하는 것을 계속 기억해야 한다는 단점이 있습니다.

 

그러면 validation 미들웨어를 만들어보는 것은 어떨까요? 안타깝게도 작동은 하겠지만, 전체 어플리케이션을 거쳐서 모든 맥락에 대해서 사용할 수 있는 일반적인 미들웨어를 생성하는 것은 불가능합니다. 왜냐하면 미들웨어는 호출될 핸들러와 이것의 파라미터들을 포함하여, 실행 컨텍스트를 인지하지 못하기 때문입니다(미들웨어는 라우트 핸들러 직전에 설치되기 때문이죠!).

 

물론 이는 파이프가 설계된 정확한 사례입니다. 이제 우리의 유효성 검사 파이프를 수정해봅시다.

 

객체 스키마 유효성 검사

객체 유효성 검사를 깔끔하고 DRY방식(중복 배제)으로 수행할 수 있는 다양한 방법이 있습니다. 흔히 사용하는 방식은 스키마 기반 유효성 검사를 사용하는 것입니다.

 

Joi 라이브러리는 읽을 수 있는 API를 사용하여 스키마를 간단하게 생성할 수 있게 해줍니다. 그러면 Joi 기반 스키마를 응용하여 유효성 검사 파이프를 구축해봅시다.

 

필요한 패키지를 설치해줍니다.

npm i joi

아래의 코드 예제에서, 생성자 인수로 스키마를 갖는 클래스를 만들었습니다. 그리고 schema.validate() 메서드를 적용해, 제공된 스키마에 대하여 인수를 검사합니다.

 

미리 말했듯이, 유효성 검사 파이프는 값을 그대로 반환하거나 예외를 발생시킵니다. 다음 섹션에서 @UsePipes() 데코레이터를 사용하여 주어진 컨트롤러 메서드에 대해서 어떻게 적절한 스키마를 공급하는지 살펴보겠습니다. 이렇게 하면, 우리의 유효성 검사 파이프를 컨텍스트 전반에 걸쳐서 재사용할 수 있습니다.

import { PipeTransform, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { Injectable } from '@nestjs/common/decorators';
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;
  }
}

 

유효성 검사 파이프 적용

이전에, 우리는 변환 파이프를 어떻게 적용하는지 살펴봤었습니다.

 

유효성 검사 파이브를 적용하는 것도 매우 간단합니다.

 

이 경우에, 우리는 메서드 단계에서 호출하여 파이프를 적용하고자 할 것입니다. 현재 예제에서, 우리는 JoiValidationPipe를 사용할 것입니다.

  1. JoiValidationPipe의 인스턴스를 생성합니다
  2. 파이프의 클래스 생성자에 특정 환경의 Joi schema를 전달합니다.
  3. 파이프를 메서드에 적용합니다.

@UsePipes() 데코레이터를 사용해서 구현합니다.

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

클래스 유효성 검사

유효성 검사 기술의의 다른 방법을 살펴봅시다.

 

Nest는 class-validator 라이브러리와 잘 작동합니다. 이 강력한 라이브러리는 데코레이터 기반 유효성 검사를 사용하도록 해줍니다. 데코레이터 기반 유효성 검사는 매우 효과적이며, 특히 처리된 프로퍼티 타입에 접근할 수 있기 때문에 Nest의 파이브 기능이 결합될 때 더욱 강력합니다.

 

아래의 필요한 패키지들을 설치해줍니다.

npm i class-validator class-transformer

 

일단 설치하면, 몇몇 데코레이터를 CreateCatDto 클래스에 추가할 수 있습니다. 이러한 방법의 중요한 장점이 있습니다. 유효성 검사 클래스를 분리하여 생성해야 하는 것이 아니라 CreateCatDto 클래스는 우리의 Post 바디 객체에 대해서 단일 소스로서 오롯히 존재할 수 있게 됩니다.

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

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

  @IsInt()
  age: number;

  @IsString()
  breed: string;
}

ValidationPipe 클래스는 이러한 방식으로 만들 수 있습니다.

import {
  ArgumentMetadata,
  BadRequestException,
  PipeTransform,
} from '@nestjs/common';
import { Injectable } from '@nestjs/common/decorators';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';

@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): boolean {
    const types = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}

첫 번째로, async를 사용한 transform() 메서드를 보겠습니다. Nest가 동기, 비동기 파이프를 모두 지원하기 때문에 가능합니다. class-validator의 유효성 검사가 몇몇 비동기이기 때문에 이 메서드를 비동기로 만들었습니다.

 

그 다음 구조분해 할당으로 metatype 속성을 metatype 매개변수로 가져왔습니다. 이는 단순히 ArgumentMetadata에서 metatype만 가져와서 추가적으로 사용할 명령문에 쓰기 위함입니다.

 

다음 toValidate() 헬퍼 기능을 봅시다. 현재 처리되고 있는 인수가 원본 자바스크립트 타입일 때 유효성 검사 단계를 우회하도록 해줍니다. 왜냐하면, 이들은 유효성 검사 데코레이터를 넣을 수가 없어서 유효성 검사를 통해 실행할 이유가 없기 때문입니다.

 

다음으로 class-transformer 기능의 plainToInstance()를 사용해 우리의 자바스크립트 인수 객체를 타입을 가진 객체로 바꿔서 유효성 검사를 할 수 있도록 해줍니다. 이렇게 해야 하는 이유는 네트워크 요청으로부터 변환되지 않았을 때 들어오는 POST 바디 객체가 어떠한 타입 정보도 갖지 않기 때문입니다. Class-validator는 이전에 정의한 DTO 유효성 검사 데코레이터를 사용해야 하므로, 들어오는 바디 값을 일반적인 바닐라 객체가 아니라 적절히 변형된 객체로 받아들이기 위해 이 변형을 미리 수행해줘야 합니다.

 

마지막으로 이전에 언급한 것처럼, 이것은 유효성 검사 파이프이기 때문에, 원본 값을 반환하거나 예외를 발생시킵니다.

 

최종 단계는 ValidationPipe를 적용하는 것입니다. 파이프는 파라미터 스코프, 메서드 스코프, 컨트롤러 스코프, 전역 스코프로 사용될 수 있습니다. Joi기반 유효성 검사 파이프를 메서드 단계에 적용했던 것과 같습니다. 아래의 코드에선 파이프 인스턴스를 @Body() 데코레이터에 넣어서 파이프가 Post 바디를 검사하도록 적용할 수 있습니다.

 

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

파라미터 스코프 파이프는 오직 한 개의 특정 파라미터에 관해서 유효성 로직을 수행할 때 유용합니다.

전역 스코프 파이프

ValidationPipe가 가능한 일반적으로 사용하도록 만들어졌기 때문에, 이를 전역 스코프로 사용하여 전체 어플리케이션에서 모든 라우트에 적용할 수 있습니다.

 

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

전역 파이프는 전체 어플리케이션에서 사용되며, 모든 컨트롤러와 라우트 핸들러에서 사용합니다.

 

의존성 주입에 측면에서, useGlobalPipes()와 함께 외부의 모든 모듈로부터 등록된 전역 파이프는 의존성을 주입할 수 없습니다. 왜냐하면 이미 모든 모듈의 컨텍스트 외부에서 적용이 되었기 때문입니다. 이러한 문제를 해결하기 위해서는, 전역 파이프를 아래와 같이 작성합니다.

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

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

내장 유효성 검사 파이프

유효성검사 파이프가 이미 Nest에서 제공되기 때문에 일반적인 유효성 검사 파이프를 설계할 필요는 없습니다. 내장 ValidationPipe는 앞서 만든 예제보다 더 많은 옵션을 제공합니다.

 

변환 사용 사례

유효성 검사는 사용자 정의 파이프의 유일한 사례는 아닙니다. 이 게시물 처음에서 파이프는 인력 데이터를 원하는 포맷으로 변형할 수 있다고 언급했었습니다. 이는  transform 기능으로 부터 반환된 값이 완전히 이전 인수의 값을 덮어쓰기 때문에 가능합니다.

 

언제 유용할 까요? 종종 사용자로부터 전달받은 데이터는 라우트 핸들러 메서드에서 처리되기 전에 문자형에서 정수형으로 전환같은 몇몇 변화를 겪습니다. 그리고 몇몇 필요한 데이터가 없을 수도 있고, 기본값을 적용하고 싶을 때가 있습니다. 변환 파이프는  사용자 요청과 요청 핸들러 사이에 처리 기능을 끼워넣어 수행할 수 있습니다.

 

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

@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를 사용해 데이터베이스를 순회하여 선택하는 것입니다.

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

기본값 제공

Parse* 파이브는 파라미터의 값이 정의될 것이라고 예측합니다. 그래서 null이나 undefined 값을 받으면 예외를 발생시킵니다. 쿼리스트링 파라미터 값을 적지 않았을 때 이를 허용하기 위해서는, Parse* 파이프가 적용되기 전에 미리 기본값을 제공해줘야 합니다. 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 });
}