Skip to content

7. Pipes


파이프는 PipeTransform 인터페이스를 구현하는, @Injectable() 데코레이터로 주석이 달린 클래스이다.


001


파이프에는 일반적으로 다음과 같이 사용된다:


  • 변환(transformation): 입력 데이터를 원하는 형식으로 변환한다(예: 문자열에서 정수로 변환).
  • 유효성 검사(validation): 입력 데이터를 평가하고 유효한 경우 변경되지 않은 상태로 전달한다. 반대로 데이터가 올바르지 않을 때는 예외를 던진다.


위의 두 경우 모두에서 파이프는 컨트롤러 라우트 핸들러에 의해 처리되는 인수에 대해 작동한다. Nest는 메서드가 호출되기 직전에 파이프를 삽입하고 파이프는 메서드를 대상으로 하는 인수를 수신하고 이에 대해 작동한다. 모든 변환 또는 유효성 검사 작업은 이 과정에서 발생하며, 그 후에 라우트 핸들러가 변환된 인수와 함께 호출된다.


Nest에는 즉시 사용할 수 있는 여러 파이프가 내장되어 있다. 사용자 정의 파이프를 만들 수도 있다.


Note

파이프는 예외 영역 내에서 실행된다. 이것은 파이프가 예외를 던질 때 예외 레이어에 의해 처리된다는 것을 의미한다. 파이프에서 예외가 발생하면 이후에 컨트롤러 메서드가 실행되지 않는다.


1. Built-in pipes

Nest는 즉시 사용할 수 있는 8개의 파이프를 제공한다:


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


위의 파이프들은 @nestjs/common 패키지에서 가져올 수 있다.


2. Binding pipes

파이프를 사용하려면 파이프 클래스의 인스턴스를 적절한 컨텍스트에 바인딩해야 한다. 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);
}


다른 변환 파이프를 바인딩하는 것도 유사하게 작동한다. 이 파이프들은 모두 라우트 매개변수, 쿼리 문자열 매개변수 및 요청 바디 값의 유효성을 검사하는 컨텍스트에서 작동한다.


예를 들어 쿼리 문자열 매개변수의 경우 다음과 같다.


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


위에서는 내장 파이프 중 변환 파이프를 바인딩하는 예제를 확인했다. 유효성 검사 파이프를 바인딩하는 것은 약간 다르다.


3. Custom pipes

사용자 정의 파이프를 만들 수 있다. Nest는 기본 제공 ParseIntPipeValidationPipe 등을 제공하지만 간단한 사용자 정의 버전을 빌드하여 사용자 정의 파이프가 구성되는 방식을 살펴보자.


간단한 ValidationPipe로 시작한다. 처음에는 단순히 입력 값을 취하고 즉시 동일한 값을 반환하도록 한다.


validation.pipe.ts
import { PipeTransform, Injectable, ArgumentMetadata } from "@nestjs/common";

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


Note

PipeTrasnform<T, R>은 모든 파이프에서 구현해야 하는 제네릭 인터페이스이다. 제네릭 인터페이스는 T를 사용하여 입력 value의 타입을 나타내고, R을 사용하여 transform() 메서드의 반환 타입을 나타낸다.


모든 파이프는 PipeTransform 인터페이스의 transform() 메서드를 구현해야 한다. 이 메서드에는 두 가지 매개변수가 있다:


  • value
  • metadata


value 매개변수는 현재 처리된 메서드 인수(라우트 핸들러 메서드에서 수신하기 전)이고 metadata는 현재 처리된 메서드 인수의 메타데이터이다. 메타데이터 객체에는 다음과 같은 프로퍼티가 있다.


export interface ArgumentMetadata {
  type: "body" | "query" | "param" | "custom";
  metatype?: Type<unknown>;
  data?: string;
}


이러한 프로퍼티는 현재 처리된 인수를 나타낸다.


프로퍼티 설명
type 인수가 body(@Body()), query(@Query()), param(@Param()), 또는 사용자 정의 매개변수인지 여부를 나타낸다.
metatype 인수의 메타타입을 제공한다(예: String).
data 데코레이터에 전달된 문자열(예: @Body('string'))이다. 데코레이터의 괄호를 비워두면 정의되지 않는다.


Note

TypeScript 인터페이스는 변환 중에 사라진다. 따라서 메서드 매개변수의 유형이 클래스가 아닌 인터페이스로 선언되면 metatype 값은 Object가 된다.


4. Schema based validation

유효성 검사 파이프를 좀 더 유용하게 만들어 보자. CatsControllercreate() 메서드를 살펴보자. 여기서 서비스 메서드를 실행하기 전에 포스트 바디 객체가 유효한지 확인해 보자.


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


CreateCatDto 타입의 createCatDto 바디 매개변수를 생성한다.


create-cat.dto.ts
export class CreateCatDto {
  name: string;
  age: number;
  breed: string;
}


create 메서드로 들어오는 모든 요청에 유효한 바디가 포함되도록 해야 하기 때문에 createCatDto 객체의 세 가지 멤버를 검증해야 한다. 라우트 핸들러 메서드 내에서 이 작업을 수행할 수 있긴 하지만 SRP(Single Responsibility Rule)를 위반하므로 이상적이지 않다.


또 다른 접근 방식은 유효성 검사기 클래스를 만들고 거기에 작업을 위임하는 것이다. 하지만 각 메서드의 시작 부분에서 이 유효성 검사기를 호출하는 것을 항상 기억해야 한다는 단점이 있다.


유효성 검사 미들웨어를 만드는 경우도 생각할 수 있지만, 전체 애플리케이션의 모든 컨텍스트에서 사용할 수 있는 제네릭 미들웨어를 만드는 것은 불가능하다. 이는 미들웨어가 호출될 핸들러와 해당 매개변수를 포함한 실행 컨텍스트를 인식하지 못하기 때문이다.


이러한 경우들을 고려하여 유효성 검사 파이프를 수정해 보자.


5. Object schema validation

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


Joi 라이브러리를 사용하면 간단한 방식으로 스키마를 생성할 수 있다. 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;
  }
}


6. Binding validation pipes

이전 ParseIntPipe 등의 변환 파이프를 바인딩하는 것과 같이 유효성 검사 파이프를 바인딩하는 것도 간단하다.


예제의 경우 메서드 호출 레벨에서 파이프를 바인딩하려고 한다. JoiValidationPipe를 사용하려면 다음과 같이 수행한다:


  • JoiValidationPipe의 인스턴스 생성
  • 파이프의 클래스 생성자에서 컨텍스트별 Joi 스키마 전달
  • 파이프를 메서드에 바인딩


다음과 같이 @UsePipes() 데코레이터를 사용하여 주어진 컨트롤러 메서드에 적절한 스키마를 제공한다. 이렇게 하면 설정한 대로 컨텍스트 전체에서 유효성 검사 파이프를 재사용할 수 있다.


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


7. Class validator

위의 예제에서 사용한 방법뿐만 아니라 Nest의 class-validatorclass-transformer 라이브러리를 사용할 수도 있다. 이 라이브러리를 사용하면 데코레이터 기반 유효성 검사를 사용할 수 있다. 데코레이터 기반 유효성 검사는 처리된 프로퍼티의 metatype에 액세스할 수 있으므로 파이프 기능과 결합할 때 좋다. 다음과 같이 필요한 패키지를 설치한다.


npm i --save class-validator class-transformer


라이브러리가 설치되면 CreateCatDto 클래스에 몇 가지 데코레이터를 추가할 수 있다.


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() 메서드가 async로 표시된다. 이는 Nest가 동기 및 비동기 파이프를 모두 지원하기 때문에 가능하다. class-validator 유효성 검사가 비동기(Promise 활용)로 처리될 수 있도록 이 메서드를 async 메서드로 만든다.


다음으로 metadata 매개변수로 metatype 필드를 추출하기 위해 구조화({ metatype })를 사용한다. 이는 전체 ArgumentMetadata(type, metatype, data 필드 존재)를 가져온 다음 metatype 변수를 할당하여 추가 명령문을 사용 하기 위한 것이다.


다음으로 헬퍼 함수 toValidate()를 생성하여 사용한다. 처리 중인 현재 인수가 기본 JavaScript 타입인 경우(DTO를 인터페이스로 구현한 경우 기본 JavaScript 타입으로 인식) 유효성 검사 단계를 우회한다. 이러한 JavaScript 타입에는 유효성 검사 데코레이터를 연결할 수 없으므로 유효성 검사 단계를 실시할 이유가 없다.


다음으로 class-transformer 함수 plainToClass()를 사용하여 일반 JavaScript 인수 객체를 타입이 지정된 객체로 변환하여 유효성 검사를 적용할 수 있게 한다. 이렇게 해야 하는 이유는 네트워크 요청에서 역직렬화될 때 들어오는 포스트 바디 객체에 타입 정보가 없기 때문(위의 toValidate() 사용과는 무관)이다. class-validator는 DTO에 대해 유효성 검사 데코레이터를 사용해야 하므로 들어오는 바디를 일반 바닐라 객체가 아니라 데코레이터가 달린 객체로 처리하기 위해 이 변환을 수행해야 한다.


위의 코드 구현은 변환 파이프가 아니라 유효성 검사 파이프에 대한 것이므로 유효성 검사 후 변경되지 않은 값을 반환하거나 예외를 던지게 된다.


마지막 단계는 ValidationPipe를 바인딩하는 것이다. 파이프는 매개변수 범위, 메서드 범위, 컨트롤러 범위, 또는 글로벌 범위에 적용될 수 있다. 이전 Joi 기반 유효성 검사 파이프를 사용하여 메서드 레벨에서 파이프를 바인딩하는 예제를 살펴봤다. 다음 예제에서는 파이프 인스턴스를 라우트 핸들러 @Body() 데이코레이터에 바인딩하여 파이프가 포스트 바디의 유효성을 검사하도록 호출한다.


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


파라미터 범위 파이프는 유효성 검사 로직이 지정된 매개변수 중 하나만 검사할 때 유용하다.


8. Global scoped pipes

ValidationPipe는 가능한 한 제네릭으로 생성되었으므로 전체 애플리케이션의 모든 라우트 핸들러에 적용되도록 글로벌 범위 파이프로 설정할 수 있다.


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


의존성 주입의 관점에서, 어떤 모듈의 외부에서 등록된 글로벌 파이프는 바인딩 자체가 모든 모듈의 컨텍스트 외부(main.ts)에서 수행되었기 떄문에 의존성을 주입할 수 없다. 이 문제를 해결하기 위해 다음 구성을 사용하여 모든 모듈에서 직접 글로벌 파이프를 설정할 수 있다.


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

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


9. The built-in ValidationPipe

참고로 ValidationPipe는 Nest에서 기본 제공되므로 제네릭 유효성 검사 파이프를 직접 구축할 필요가 없다. 내장된 ValidationPipe는 위의 예제에서 만든 샘플보다 더 많은 옵션을 제공한다.


10. Transformation use case

클라이언트에서 전달된 데이터가 라우트 핸들러 메서드에서 제대로 처리되기 전에 변환(예: 문자열을 정수로 변환) 과정을 거쳐야 할 때가 있다. 또한 일부 필수 데이터 필드가 누락된 경우 기본값을 적용해야 할 때가 있다. 변환 파이프는 클라이언트 요청과 요청 핸들러 사이에 처리 기능을 삽입하여 이러한 기능을 수행할 수 있다.


다음은 문자열을 정수 값으로 구문 분석하는 역할을 하는 간단한 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;
}


11. Providing defaults

변환 파이프는 매개변수 값이 정의될 것을 예상한다. null 또는 undefined 값을 수신하면 예외가 발생한다. 엔드포인트가 누락된 쿼리 문자열 매개변수 값을 처리할 수 있도록 하려면 변환 파이프가 이러한 값에 대해 작동하기 전에 주입할 기본값을 제공해야 한다. DefaultValuePipe는 이러한 목적을 수행한다. 다음과 같이 관련 변환 파이프 앞에 @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 });
}

References