NESTJS를 배워보자(8) - Pipes

yoon·2023년 7월 21일
0

NESTJS를 배워보자

목록 보기
8/21
post-thumbnail

Pipes

nest의 공식문서를 토대로 작성합니다.

파이프는 PipeTransform 인터페이스를 구현하는 @Injectable() 데코레이터가 달린 클래스입니다.

파이프는 두 가지 일반적인 사용 사례가 있습니다.

  • 변환(transformation) : 입력 데이터를 원하는 형식으로 변환.

  • 유효성 검사(validation) : 입력 데이터를 평가하여 유효하면 변경하지 않고 그대로 전달하고 그렇지 않으면 예외를 던짐.

두 경우 모두 파이프는 컨트롤러 route handler에서 처리 중인 인수에 대해 작동합니다. Nest는 메소드가 호출되기 직전에 파이프를 삽입하고 파이프는 메소드에 전달되는 인수를 받아 해당 인수를 대상으로 작업합니다. 이때 모든 변환 또는 유효성 검사 작업이 수행되고 그 후에 route handler가 변환된 인수를 사용하여 호출됩니다.

Nest에는 바로 사용할 수 있는 여러가지 기본 제공 파이프가 있고 사용자 정의 파이프를 직접 구축할 수도 있습니다.

HINT
파이프는 예외 영역 내에서 실행됨. 즉 파이프가 예외를 던지면 예외 계층에서 처리됨. 파이프에서 예외가 발생하면 컨트롤러 메소드가 이후에 실행되지 않는다는 것을 알 수 있음. 이는 외부 소스에서 애플리케이션으로 들어오는 데이터의 유효성을 검사하는 좋은 기법임.

Built-in pipes

Nest는 다음 9개의 파이프를 제공합니다.

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

모두 @nestjs/common 패키지에서 import 가능

ParseIntPipe를 간단히 살펴봅시다. 이것은 파이프가 메소드 핸들러 매개변수가 JS 정수로 변환되도록 하거나 실패 시 예외를 던지는 변환의 예입니다.

Binding pipes

파이프를 사용하려면 파이프 클래스의 인스턴스를 적절한 컨텍스트에 바인딩해야 합니다. ParseIntPipe 예제에서는 파이프를 특정 route handler 메소드와 연결하고 메소드가 호출되기 전에 파이프가 실행되도록 하려고 합니다. 이를 위해 메소드 매개변수 수준에서 파이프를 바인딩하는 구문을 사용합니다.

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

이렇게 하면 다음 두 가지 중 하나가 참인지 확인합니다.
1. findOne() 메소드에서 받은 매개 변수가 숫자.
2. route handler가 호출되기 전에 예외 발생.

예를 들어 이렇게 경로가 호출되면

GET localhost:3000/abc

Nest는 이렇게 예외를 던집니다.

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

예외가 발생하면 findOne() 메소드의 본문이 실행되지 않습니다.

위의 예제에서는 인스턴스가 아닌 클래스(ParseIntPipe)를 전달하여 인스턴스화에 대한 책임을 프레임워크에 맡기고 의존성 주입을 가능하게 합니다. 파이프 및 가드와 마찬가지로 in-place 인스턴스를 전달할 수 있습니다. in-place 인스턴스를 전달하면 옵션을 전달하여 내장된 파이프의 동작을 사용자 정의 하려는 경우에 유용합니다.

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

다른 변환 파이프를 바인딩하는 것도 비슷하게 작동합니다. 이러한 파이프는 모두 경로 매개변수, 쿼리 문자열 매개변수 및 요청 body 값의 유효성을 검사하는 컨텍스트에서 작동합니다.

쿼리 문자열 매개변수 예:

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

문자열 매개 변수를 구문 분석하고 해당 매개변수가 UUID인지 확인하는 ParseUUIDPipe 예:

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

HINT
ParseUUIDPipe()를 사용할 때 버전 3, 4 또는 5의 UUID를 구문 분석하는 경우 특정 버전의 UUID만 필요한 경우 파이프 옵션에서 버전을 전달할 수 있음.

위에서 다양한 Parse* 내장 파이프를 바인딩하는 예제를 봤습니다. 유효성 검사 파이프를 바인딩하는 것은 조금 다르므로 다음 섹션에서 설명합니다.

HINT
유효성 검사 파이프에 대해 더 많으 예를 보려면 여기로

Custom pipes

자신만의 파이프를 구축할 수 있습니다. Nest는 강력한 기본 제공 ParseIntPipeValidationPipe를 제공하지만 사용자 정의 파이프가 어떻게 구성되는지 알아보기 위해 각각의 간단한 사용자 정의 버전을 처음부터 빌드해 보겠습니다.

간단한 ValidationPipe부터 시작하겠습니다. 처음에는 단순히 입력값을 받고 즉시 동일한 값을 반환하여 동일성 함수처럼 동작하도록 합니다.

# validationpipe.ts

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

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

HINT
PipeTransform <T, R>은 모든 파이프에서 구현해야 하는 일반 인터페이스임. 일반 인터페이스는 T를 사용하여 입력 값의 유형을 나타내고 R을 사용하여 transform() 메소드의 반환 유형을 나타냄.

모든 파이프는 PipeTransform 인터페이스 계약을 이행하기 위해 transform() 메소드를 구현해야 합니다. 이 메소드에는 두 개의 매개 변수가 있습니다.

  • value : 현재 처리된 메소드 인자(route handler 메소드에서 수신하기 전).
  • metadata : 현재 처리된 메소드 인자의 메타데이터.

메타데이터 객체에는 다음 속성이 있습니다:

export interface ArgumentMetadata {
  type: 'body' | 'query' | 'param' | 'custom';
  metatype?: Type<unknown>;
  data?: string;
}
type인수가 @Body(), @Query(), @Param() 또는 사용자 정의 매개변수인지 여부를 나타냄.
meta
type
인수의 메타 타입을 제공(예: String).
route handler 메소드 서명에서 타입 선언을 생략하거나 바닐라 JS 사용 시 이 값은 unefined.
data데코레이터에 전달된 문자열(예: @Body('string')).
데코레이터 내부 괄호를 비워두면 undefined.

WARNING
TS 인터페이스는 트랜스파일링 중에 사라짐. 따라서 메소드 매개변수의 유형이 클래스 대신 인터페이스로 선언된 경우 메타타입 값은 Object가 됨.

Schema based validation

유효성 검사 파이프를 좀 더 유용하게 만들어 봅시다. 서비스 메소드를 실행하기 전에 post body 객체가 유효한지 확인해야 하는 CatsControllercreate() 메소드를 봅시다.

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

CreateCatDto를 자세히 봅시다.

# create-cat.dto.ts

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

create() 메소드로 들어오는 모든 요청에 유효한 body가 포함되어 있는지 확인하고자 합니다. 따라서 createCatDto 객체의 세 멤버의 유효성을 검사해야 합니다. route handler 메소드 내부에서 이 작업을 수행할 수 있지만 단일 책임 규칙(SRP)을 위반하므로 이상적이지 않습니다.

SRP?
Single Responsibility Rule
간단히 말하면 객체 지향 프로그래밍의 모든 객체는 하나의 특정 기능을 위해 만들어져야 한다는 개념입니다.
즉 메소드 내부에서 유효성을 검사하는 것은 이 규칙을 위반하는 것이죠.

또 다른 접근 방식은 유효성 검사 클래스를 생성하고 그곳에 작업을 위임하는 것입니다. 이 방법은 각 메소드를 시작할 때마다 이 유효성 검사를 호출해야 한다는 것을 기억해야한다는 단점이 있습니다.

유효성 검사 미들웨어를 만드는 것은 어떨까요?
이 방법은 효과적일 수 있지만 전체 애플리케이션의 모든 컨텍스트에서 사용할 수 있는 일반 미들웨어를 만드는 것은 불가능합니다. 미들웨어는 호출될 핸들러와 그 매개변수 등 실행 컨텍스트를 인식하지 못하기 때문입니다.

이러한 이유 때문에 파이프를 설계한 것입니다. 이제 유효성 검사 파이프를 개선해 봅시다.

Object schema validation

깔끔한 방식으로 객체 유효성 검사를 수행하는 데 사용할 수 있는 몇 가지 방식이 있습니다. 일반적인 접근 방식 중 하나는 스키마 기반 유효성 검사를 사용하는 것입니다.

Joi 라이브러리를 사용하면 읽기 쉬운 API를 사용하여 간단한 방식으로 스키마를 만들 수 있습니다. Joi 기반 스키마를 사용하는 유효성 검사 파이프를 구축해 봅시다.

먼저 필요한 패키지를 설치합니다.

$ npm i --save joi

아래 코드 샘플에서는 스키마를 constructor 인수로 받는 간단한 클래스를 생성합니다. 그 후 제공된 스키마에 대해 인수의 유효성을 검사하는 schema.validate() 메소드를 적용합니다.

앞서 언급했듯이 유효성 검사 파이프는 변경되지 않은 값을 반환하거나 예외를 던집니다.

다음 섹션에서는 @UsePipes() 데코레이터를 사용하여 주어진 컨트롤러 메소드에 적절한 스키마를 제공하는 방법을 살펴보겠습니다. 이렇게 하면 처음에 설정한 대로 여러 컨텍스트에서 유효성 검사 파이프를 재사용할 수 있습니다.

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

유효성 검사 파이프 바인딩 또한 매우 간단합니다.

이 경우 메소드 호출 수준에서 파이프를 바인딩하려고 합니다. 현재 예제에서는 JoiValidationPipe를 사용하려면 다음을 수행해야 합니다.

  1. JoiValdationPipe의 인스턴스를 생성.
  2. 컨텍스트별 Joi 스키마를 파이프의 클래스 constructor에 전달.
  3. 파이프를 메소드에 바인딩.

예:

const createCatSchema = Joi.object({
  name: Joi.string().required(),
  age: Joi.number().required(),
  breed: Joi.string().required(),
})

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

그 후 @UsePipes()를 사용합니다.

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

HINT
@UsePipes() 데코레이터는 @nestjs/common 패키지에서 import.

Class validator

WARNING
이 섹션의 기술은 TS가 필요하며 바닐라 JS를 사용하여 앱을 작성하는 경우에는 이용 불가.

유효성 검사 기술에 대한 다른 구현을 살펴봅시다.

Nest는 class-validator 라이브러리와 잘 작동합니다. 이 강력한 라이브러리를 사용하면 데코레이터 기반 유효성 감사를 사용할 수 있습니다.
데코레이터 기반 유효성 검사는 특히 처리된 property의 메타 타입에 접근할 수 있기 때문에 Nest의 파이프 기능과 결합할 때 매우 강력합니다.

먼저 필요한 패키지를 설치합니다.

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

설치 후 CreateCatDto 클래스에 몇 가지 데코레이터를 추가할 수 있습니다. 이 기법의 장점은 별도의 유효성 검사 클래스를 만들지 않고도 CreateCatDto 클래스가 Post body 객체에 대한 단일 소스로 유지된다는 점입니다.

# create-cat.dto.ts

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

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

  @IsInt()
  age: number;

  @IsString()
  breed: string;
}

HINT
class-validator 데코레이터에 대해 더 자세히 보려면 여기로

이제 이러한 어노테이션을 사용하는 ValidationPipe 클래스를 만들 수 있습니다.

# validation.pipe.ts

import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { validate } from 'class-validator';
import { plainToInstance } 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 = 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);
  }
}

NOTICE
위에서 class-transformer 라이브러리를 사용함. class-validator 라이브러리와 같은 작성자가 만들었기에 두 라이브러리는 매우 잘 어울림.

위 코드를 살펴봅시다. 먼저 transform() 메소드가 비동기로 표시되어 있습니다. 이는 Nest가 동기 및 비동기 파이프를 모두 지원하기에 가능합니다. class-validator 중 일부가 비동기일 수 있기 때문에 이 메소드를 비동기화했습니다(Promises 활용).

다음으로 메타타입 필드를 메타타입 매개변수로 추출하기 위해 destructuring을 사용했습니다(ArgumentMetadata에서 이 멤버만 추출).

다음으로 helper 함수 toValidate()를 봅시다. 이 함수는 현재 처리 중인 인수가 순수 JS일 때 유효성 검사 단계를 우회하는 역할을 합니다(유효성 검사 데코레이터가 첨부될 수 없으므로 유효성 감사 단계를 통해 실행할 이유가 없음).

다음으로 class-transformer 함수인 plainToInstance()를 사용하여 일반 JS 인수 객체를 타입이 지정된 객체로 변환하여 유효성 검사를 적용할 수 있도록 합니다. 이 작업을 수행하는 이유는 네트워크 요청에서 역직렬화된 수신 post body 객체에는 타입 정보가 없기 때문입니다. class-validator는 앞서 DTO에 대해 정의한 유효성 검사 데코레이터를 사용해야 하므로 들어오는 본문을 단순한 객체가 아닌 적절하게 데코레이션된 객체로 처리하려면 이 변환을 수행해야 합니다.

마지막으로 값을 변경하지 않고 반환하거나 예외를 던집니다.

마지막 단계는 ValidationPipe를 바인딩하는 것입니다. 파이프는 매개변수, 메소드, 컨트롤러 또는 전역 범위가 될 수 있습니다.
앞서 Joi 기반 유효성 검사 파이프를 사용하여 메소드 수준에서 파이프를 바인딩하는 예를 살펴봤습니다. 아래 예에서는 파이프 인스턴스를 route handler @Body() 데코레이터에 바인딩하여 파이프를 호출하여 post body를 유효성 검사하도록 합니다.

# cats.controller.ts

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

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

Global scoped pipes

ValidationPipe는 최대한 범용적으로 만들어졌기 때문에 전체 애플리케이션의 모든 route handler에 적용되도록 전역 범위 파이프로 설정하면 그 유용성을 최대한 발휘할 수 있습니다.

# main.ts

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

NOTICE
하이브리드 앱의 경우 useGlobalPipes() 메소드는 게이트웨이 및 마이크로 서비스에 대한 파이프를 설정하지 않음. 비하이브리드 마이크로서비스 앱의 경우 useGlobalPipes()는 파이프를 전역적으로 마운트.

전역 파이프는 모든 컨트롤러와 모든 route handler에 대해 전체 애플리케이션에서 사용됩니다.

종속성 주입과 관련하여 모듈 외부에서 등록한 전역 파이프는 바인딩이 모듈의 컨텍스트 외부에서 수행되었기 때문에 종속성을 주입할 수 없습니다. 이 문제를 해결하기 위해 다음을 사용하여 모든 모듈에서 직접 전역 파이프를 설정할 수 있습니다.

# 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
이 방식을 사용하여 파이프에 대한 종속성 주입을 수행할 때 이 구조가 사용되는 모듈에 관계없이 파이프는 실제로 전역임.

The built-in ValidationPipe

ValidationPipe는 Nest에서 기본으로 제공되므로 일반적인 유효성 검사 파이프를 직접 빌드할 필요가 없습니다. 기본 제공 ValidationPipe는 이 장에서 빌드한 샘플보다 더 많은 옵션을 제공하지만 사용자 정의 구축 파이프의 메커니즘을 설명하기 위해 기본으로 유지했습니다. 자세한 내용과 더 많은 예제는 여기로

Transformation use case

유효성 검사만이 사용자 정의 파이프의 유일한 사용 사례는 아닙니다. 파이프를 사용하여 입력 데이터를 원하는 형식으로 변환할 수도 있습니다. 이는 변환 함수에서 반환된 값이 인수의 이전 값을 완전히 재정의하기 때문에 가능합니다.

클라이언트에서 전달된 데이터가 route handler 메소드에서 제대로 처리되기 전에 문자열을 정수로 변환하는 등 일부 변경을 거쳐야 하는 경우가 있을 때 유용합니다. 또한 일부 필수 데이터 필드가 누락되어 기본값을 적용하고자 할 수도 있습니다. 변환 파이프는 클라이언트 요청과 요청 핸들러 사이에 처리 함수를 삽입하여 이러한 기능을 수행할 수 있습니다.

다음은 문자열을 정수 값으로 구문 분석하는 간단한 ParseIntPipe입니다. 앞서 언급했듯이 Nest에는 더 정교한 ParseIntPipe가 내장되어 있으며 여기서는 사용자 정의 변환 파이프의 간단한 예로 봅니다.

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

그 다음 아래와 같이 파이프를 선택한 매개변수에 바인딩할 수 있습니다

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

이 파이프의 구현은 다루지 않지만 다른 모든 변환 파이프와 마찬가지로 입력 값(id)를 받고 출력 값(UserEntity 객체)을 반환한다는 점에 유의하세요. 이렇게 하면 코드를 보다 선언적이고 간결하게 만들 수 있습니다.

Providing defaults

Parse* 파이프는 매개변수 값이 정의되어 있을 것으로 기대합니다. null 또는 undefined 값을 받으면 예외가 발생합니다. 엔드포인트에서 누락된 쿼리 문자열 매개 변수 값을 처리할 수 있도록 하려면 Parse* 파이프가 이러한 값에 대해 작동하기 전에 주입할 기본값을 제공해야 합니다.
DefaultValuePipe가 바로 그 역할을 합니다. 아래와 같이 Parse* 파이프 앞에 @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 });
}

이렇게 Nest 공식 문서에서 알려주는 pipe 사용 방법들을 봤는데요 저는 joi 라이브러리보다 class-validator 라이브러리가 더 편하더라구요 👍.
class-validator를 사용한 제 코드입니다.
https://github.com/cxzaqq/cxzaqq-velog/tree/2.7-pipes

제 코드를 보시면 이렇게 되어있습니다.

# main.ts

...
app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
    }),
  );
...

일단 아무 옵션이 없을 때의 결과를 먼저 볼까요?

이렇게 아무것도 body에 담지 않으면 값이 필요하다라고 예외를 던집니다.

해당 값들을 모두 타입에 맞게 전달하면 제가 작성한대로 success: true를 반환합니다. 근데 만약 다른 값이 섞여있다면 어떻게 될까요?

여전히 success: true를 반환하는 것을 볼 수 있습니다. 이렇게 내가 원한 값 외에 다른 값이 들어오면 프로그램에 안 좋은 영향을 미칠 수도 있습니다. 이럴 때 사용하는 것이 whitelist 옵션입니다.

whitelist 옵션을 true로 설정하면 여전히 success: true를 반환하지만 console.log로 들어온 값을 살펴보면

이렇게 클래스에 선언된 속성들만 처리하고 그 외 속성들은 버립니다.

만약 원하지 않는 값이 들어왔을 때 예외를 던지고 싶다면 forbidNonWhitelisted 옵션을 true로 주면 됩니다.

그러면 해당 메소드가 실행되기 전에 예외를 던질 수 있는 거죠.

고생하셨습니다!
다음 글에서 만나요~~😀


저도 아직 배우는 단계입니다. 지적 감사히 받겠습니다. 함께 열심히 공부해요!!

profile
백엔드 개발자 지망생

0개의 댓글