Pipes

Pipe@Injectable() decorator로 명시된 class이다. 이는 PipeTransform interface를 받는다.

클라이언트가 특정 API를 요청한다면, 먼저 middleware가 요청을 처리하고, 이후 pipe가 요청 데이터를 변환 및 검증하여 컨트롤러 메서드에 전달하게 된다.


Pipe는 두 가지의 역할을 한다.

  • transformation: input data를 원하는 형태의 form으로 바꿔준다.(e.g. string to integer)
  • validation: input data를 검사한다. 그리고 valid하다면 그대로 넘기고 그렇지 않다면 error를 발생시킨다.

위의 두 경우에서, pipe는 controller route handler에 의해 처리된 arguments에 의해서 동작한다. Nest는 method가 실행되기 전 pipe를 배치 시킨다. 그리고 pipe는 method로 전달될 argument를 받아서 작업을 수행한다. pipe를 통한 transformation 그리고 validation의 작업을 마친 후에, route handler가 그 변환된 argument로 실행시킨다.


parameter(매개변수): 함수 안에서의 정의 및 사용에 나열되어 있는 변수들을 의미
argument(전달인자): 함수를 호출할 때 전달되는 실제 값들

Nest는 별도의 설치가 필요없는 몇 개의 built-in된 pipe를 지닌다. 또한 pipe를 사용자에 맞게 custom 할 수 있다. 이번 chapter에서는, build-in pipe를 소개하고, 어떻게 그들을 route-handler와 묶을 수 있는지를 소개한다. 그리고 여러 custom pipe를 살펴보면서 처음부터 하나를 만드는 방법을 소개한다.


<팁!>
pipe는 exception zone 내부에서 동작한다. 이는 만일 Pipe가 exception을 발생시켜도, 이는 exception layer에 의해 처리된다. (global exceptions filter 그리고 어떤 exception filters) pipe에서 exception이 발생하면, 그 이후에 controller method가 실행되지 않는다는 점이 분명하다. 이는 application에서 들어오는 data의 validation을 검사하는데 있어 굉장히 유용하다.



Middleware, pipe, filter

위의 3개의 장치의 순서는 다음과 같다. Middleware -> pipe, 그리고 filter

Middleware는 reqeust와 response 객체를 변형하거나, 특정 조건에 따라 요청을 차단하거나 logging을 수행한다. 주로 HTTP 요청을 처리하는 초기 단계에서 작동한다.

  • 요청 로깅: 요청 정보를 기록
  • 인증(Authentication): 사용자의 인증을 처리한다.
  • 권한 부여(Authorization): 사용자의 권한을 확인한다.
  • 요청 변환(Request Transformation): 요청 데이터를 변환하거나 검증한다.
  • 응답 변환(Response Transformation): 응답 데이터를 변환하거나 포맷한다.

Pipe는 요청 데이터를 변형하거나 유효성을 검사한다. 주로 데이터 변환과 유효성 검사를 수행한다.

  • 변환(Transformation): 입력 데이터를 원하는 형식으로 변환한다. 예를 들어, 문자열을 정수로 변환할 수 있다.
  • 유효성 검사(Validation): 입력 데이터가 특정 조건을 만족하는지 확인한다. 조건을 만족하지 않으면 예외를 발생시킨다.

Filter는 예외가 발생했을 때 이를 처리하여, 적절한 응답을 클라이언트에게 반환한다.



Built-in pipes

Nest는 9개의 pipe가 내장되어 있다. @nestjs/common에서 import 할 수 있다.


  • ValidationPipe: 입력 데이터를 유효성 검사를 통해 확인. class-validation library와 함께 사용하여 DTO를 기반으로 유효성 검사 수행
  • ParserIntPipe: 문자열을 정수로 변환. 실패하면 "BadRequestException"
  • ParseFloatPipe: 문자열을 부동 소수점 숫자로 변환. 실패하면 "BadReqeustException"
  • ParseBoolPipe: 문자열을 불리언 값으로 변환. 실패하면 "BadRequestException"
  • ParseArrayPipe: 문자열을 배열로 변환. 실패하면 "BadRequestException"
  • ParseUUIDPipe: 문자열이 유효한 UUID인지 확인하고, 유효하지 않으면 "BadRequestException"
  • ParseEnumPipe: 문자열을 열거형값으로 변환, 실패하면 "BadRequestException"
  • DefaultValuePipe: 특정 파라미터가 없을 때 기본값으로 설정한다. 주로 선택적 파라미터에 사용
  • ParseFilePipe: 파일 업로드 시 파일을 파싱하고 검증한다. 주로 파일 크기, 타입 등의 조건을 확인

먼저 ParseIntPipe를 살펴보자. transformation을 수행한다.. 여기서 pipe는 method handler parameter가 javascript integer를 바뀜을 보장해준다.(만일 변환에 실패하면 exception을 던진다.) 후의 chapter에서는 ParseIntPipe을 implementation 간단한 custom pipe를 만들 것 이다. 아래의 예제들은 ParseBoolPipe, ParseFloatPipe, ParseEnumPipe, ParseArrayPipe, ParseUUIDPipe와 같은 다른 내장 transformation pipe들에도 적용된다. 이 글에서 이러한 pipe를 'Parse*' 라고 서술할 것이다.



Binding pipes

pipe를 사용하기 위해, pipe class의 instace를 적절한 context에 bind해야한다. 아래의 ParseIntPipe 예시에서, 특정한 route handler method와 pipe를 연결할 것이다. 그리고 method가 호출되기 전에 pipe가 실행될 것이다. 다음과 같은 구조로 사용하여, method parameter 수준에서 pipe를 binding한다.


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

findOne() method는 number type parameter를 받을 것이고, 그렇지 않다면 route handler가 호출되기 전에 exception이 발생할 것 이다.


예를 들어 다음과 같은 route가 호출되었다고 하자.

GET localhost:3000/abc

그렇다면 다음과 같은 exception이 호출 될 것이다.

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

이러한 exception은 findOne() 이 실행되는 것을 막는다.


위에서는 ParseIntPipe의 class를 호출하는 것이지, instance가 아니다. 이는 framework에 instance화의 책임을 떠넘기고 dependency injection을 가능케 한다. pipe와 guard와 마찬가지로, in-place instance를 전달할 수 있다. in-place instance를 전달하는 것은 built-in pipe의 행동을 설정하는데 있어서 유용하다.

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

모든 Parse* pipes들도 비슷하게 동작한다. 이들은 모두 route parameter, query string parameter 그리고 request body value의 유효성을 검사한다.


아래의 경우 query string paramter를 검사한다.

@Get()
async findOne(@Query('id', ParseIntPipe) id: number) {
  return this.catsService.findOne(id);
}
// 참고
// =================================================================
// 1. @Query:
//    - 출처: URL의 쿼리 문자열에서 데이터를 가져온다
//    - 용도: GET 요청에서 주료 사용된다.
//    - 예시: @@uery('id') id: string은 https://example.com/api? id=123, id 값을 가져온다
@Get()
async findOne(@Query('id', ParseIntPipe) id: number) {
  return this.catsService.findOne(id);
}

// 2. @Body
//    - 출처: request 본문에서 데이터를 가져온다.
//     - 용도: POST, PUT, PATCH 요청에서 주료 사용된다.
//    - 예시: @Body() createCatDto: CreateCatDto 는 request 본문에서 데이터를 가져온다.
@Post()
async create(@Body() createCatDto: CreateCatDto) {
  return this.catsService.create(createCatDto);
}

// 3. @Param:
//    - 출처: URL 경로 파라미터에서 데이터를 가져온다.
//    - 용도: 주로 리소스 식별자(id)와 같은 경로 변수에 사용된다.
//    - 예시: @Param('id') id: string은 /api/resource/123 'id' 값을 가져온다.
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) {
  return this.catsService.findOne(id);
}

다음의 경우 ParseUUIDPipe를 사용하여 string paramter를 검사하고, uuid인지 유효성 검사를 실시한다.

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

<팁!>
만일 ParseUUIDPipe() 를 사용할 때는 UUID version 3, 4, 5 를 parse할 수 있다. 만일 특정한 version의 UUID를 검사한다면 pipe option을 통해 설정할 수 있다.


위에서는 Parse* 를 살펴보았다. Binding validation pipe는 약간 다르다.


<팁!>
validation pipe의 광범위한 예에 대해서는 Validation techniques를 참고할 것.



Custom pipes

앞서 언급한 대로, pipe를 customize화 할 수 있다. Nest는 강력한 ParseIntPipe그리고 ValidationPipe를 제공하지만, custom pipe가 어떻게 동작하는지 살펴보기 위해 간단한 custom version을 만들어 보자.


간단하게 input value를 받아 그대로 return 하는 pipe를 만들어보자.

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


// ValidationPipe 클래스를 NestJS의 의존성 주입 시스템에 등록한다. 이를 통해 다른 클래스에서 이 파이프를 주입하여
// 사용할 수 있다.
@Injectable()
// PipreTransform 인터페이스를 구현하여 파이프로 사용될 수 있다.
export class ValidationPipe implements PipeTransform {
  // transform 파이프의 핵심 메서드로, 입력 값을 변환한다.
  //    value: 파이프에 전달된 입력 값
  //     metadata: 현재 인수에 대한 메타 데이터
  transform(value: any, metadata: ArgumentMetadata) {
    return value;
  }
}

<팁!>
PipeTransform(string, number)를 했을 때의 console.log(metadata)의 결과


<팁!>
PipeTransform<T,R> 은 반드시 모든 pipe에서 implement해야하는 generic interface이다. T는 input value의 타입이며, R은 transform() method의 return 타입이다.

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는 PipeTransform interface에 의해 transform() method를 호출해야한다. 이 method는 다음과 같은 두 개의 parameter를 가진다.

  • value: transform 메서드에 전달되는 값으로, 변환 또는 검증이 필요한 입력 데이터이다.
    • 출처: 이 값은 controller method의 parameter에서 가져온다.
    • 용도: validation 검사나 data transform을 수행할 때 사용된다.
  • metadata: 현재 인수에 대한 메타데이터를 제공한다.
    • 구성: ArgumentMetadata 인터페이스를 통해 제공된다.
      • type: body, query, param, custom 중 하나로, 인수의 위치를 나타낸다.
      • metatype: 변환 전 원시 타입(String, Number, Boolean)
      • data: parameter의 이름(e.g. 'id')이다.

value parameter는 현재 처리되고 있는 method argument(route handling method가 받기 전의 argument)이며, metadata는 현재 처리되는 method의 argument의 metadata이다. metadata 객체는 다음과 같은 속성들을 가진다.

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

이 속성들은 현재 처리되는 argument를 설명해준다.

property describe
type argument가 @body(), @Query(), @Param() 혹은 custom paramter(read) 인지를 설명해준다.
metatype argument의 metatype을 제공해준다(e.g String). 만일 route handler method signature에 타입 선업이 생략되거나 vanilla JS를 사용하면 undefined이다.
data decorator에 전달된 문자열(e.g. @Body('string')). 만일 decorator을 비워두면 undefined이다.

<주의사항!>
typescript inteface는 transpilation 도중 사라진다. 즉, 만일 class가 아닌 interface로 선언된 method parameter의 type이면, metatype 값은 object이다.



Schema based validation

validation pipe를 좀 더 유용하게 만들어보자. CatsController의 create() method를 자세히 보면, service의 method를 실행하기 전에 POST 본문 객체가 유효한지 확인하고 싶을 것 이다.

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

다음은 CreateCatDto이다.

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

create method에 대한 모든 request가 valid한 body를 포함하도록 보장하고 싶다. 그래서 createCatDto 객체의 3개의 member의 유효성 검사를 실시해야 한다. 물론 이러한 검사를 route handler method 내부에서 실시할 수 있다. 허나 이는 single responsibility principle(SRP) 을 위배 한다.


단일 책임 원칙(Single Responsibility Principle): 객체는 단 하나의 책임만 가져야 한다. 하나의 클래스는 하나의 기능을 담당 하여 하나의 책임을 수행하는데 집중되어야 있어야 한다는 의미한다. 한 객체에 책임이 많아질 수록 클래스 내부에서 서로 다른 역할을 수행하는 코드 끼리 강하게 결합될 가능성이 높아지게 되어 시스템이 복잡해질 수 있다. 

다른 접근법으로는 validator class를 만들고 유효성검사를 위임하는 것이다. 이 방법의 단점은 각 method의 시작 부분에서 이 validator 검사기를 호출해야 한다는 점이다.


validation middleware를 만드는 것은 어떤가? 이 방법이 작동할 수는 있지만, 불행하게도 application의 전체 context에 사용되는 generic middleware를 만드는 것이 불가능하다. 이는 middleware가 호출될 handler와 그 parameter를 포함한 execution context를 인지하지 못하기 때문이다.


middleware는 execution context를 알지 못한다는 것의 의미:
    미들웨어가 실행 컨텍스트를 알지 못한다는 것은, 미들웨어가 특정 요청을 처리하는 동안 어떤 컨트롤러나 메서드가 호출될 지, 그리고 그 메서드의 매개변수가 무엇인지 알지 못한다는 뜻이다. 미들웨어는 주로 요청과 응답 객체만을 처리하며, 어플리케이션의 특정 비즈니스 로직이나, 컨트롤러 메서드에 대한 구체적인 정보를 알지 못한다.

pipe는 nestjs에서 요청 데이터를 변환하고, 유효성 검사를 위해 설계된 도구이다. 따라서 유효성 검사를 위해 파이프를 사용하는 것이 적절하다. 이제 validation pipe를 정교하게 다듬어 보자.



Object schema validation

object의 validation을 위한 clean하고 DRY한 여러 방법이 있다. 첫 번째 방법은 schema-based validation을 사용하는 것이다.

DRY(Don't repeat yourself): 소프트웨어 개발 원칙 중 하나로, 동일한 코드나 로직을 반복하지 않도록 하는 중요한 개념이다.
schema-based: 데이터의 형식과 제약 조건을 정의하는 schema를 사용하여 데이터를 검증한다.
    schema는 데이터 구조, 필수 필드, 데이터 타입, 혀옹되는 값의 범위 등을 명확히 정의한다.

Zodlibrary는 readable API와 함께 schema를 만드는데 사용된다. Zod-based schema를 만드는 validation pipe를 만들자.

//Zod는 TS 및 JS에서 사용되는 schema 선언 및 validation 검사 라이브러리다. 
import { z } from 'zod';

// 스키마 정의
const userSchema = z.object({
  name: z.string(),
  age: z.number().min(18),
});

// 데이터 유효성 검사
const result = userSchema.safeParse({ name: "John", age: 25 });

if (!result.success) {
  console.error(result.error);
} else {
  console.log("Valid data:", result.data);
}

먼저 package를 설치해야 한다.

$ npm install --save zod

constructor argument로서 schema를 만드는 간단한 class를 만든다. 이 때 schema.parse() method를 적용하는데, 이는 incoming argument의 유효성을 검사한다.


앞서 언급한 대로, validation pipe는 값을 그대로 반환하거나 에러를 던진다.


다음 section에서는 어떻게 @UsePipes() 를 통해 controller method에 적절한 schema를 던지는가를 볼 것이다. 이렇게 하는 것은 reusable validation pipe를 전체 context를 걸쳐 적용하게 한다.

import { PipeTransform, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { ZodSchema } from 'zod';

export class ZodValidationPipe implements PipeTransform {
  // 생성자에서 ZodSchema 타입의 스키마를 받아와 저장한다.
  // 아래의 userSchema와 비교
  constructor(private schema: ZodSchema) {}

  // value: 검증할 입력 값, metadata: 매개변수에 대한 metadata를 제공
  transform(value: unknown, metadata: ArgumentMetadata) {
    try {
      // schema.parse를 사용하여 값을 검증하고 변환한다.
      const parsedValue = this.schema.parse(value);
      return parsedValue;
    } catch (error) {
      throw new BadRequestException('Validation failed');
    }
  }
}

// 사용 예시
import { z } from 'zod';

const userSchema = z.object({
  name: z.string().min(1),
  age: z.number().int().min(0),
  email: z.string().email(),
});


@Controller('users')
export class UsersController {
  @Post()
  create(@Body(new ZodValidationPipe(userSchema)) createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto);
  }
}


Binding validation pipes

앞서, 어떻게 transformation pipe(ParseIntpipe, 또는 Parse* )가 bind 되는지를 살펴보았다.


이번에는, method call level에서 pipe를 bind할 것이다. 현재 예시에서, ZodValidationPipe를 사용할 것이다.

  1. ZodValidationPipe의 instance를 만든다.
  2. Pipe의 class constructor에 context 별 Zod schema를 전달한다.
  3. pipe랑 method랑 연결 한다.

import { z } from 'zod';

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

// createCatSchema의 스키마를 기반으로 Typescript 타입을 생성
export type CreateCatDto = z.infer<typeof createCatSchema>;

@UsePipes() decorator를 사용한다.


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

<팁!>
@UsePipes() decorator는 @nestjs/common에서 가져온다.

<주의사항!>
zod 라이브러리는 tsconfig.json file에서 strictNullChecks 옵션이 필요하다.



Class validator


<주의사항!>
이번에 배우는 것은 Typescript에서 요구하는 것이다. 만일 vanilla JavaScript라면 필요없다.


validation technique에 대해 대체 구현을 살펴보자.
Nest는 class-validator 라이브러리에 잘 작동한다. 이는 decorator기반의 validate를 가능케 한다. decorator 기반의 유효성 검사는 Nest의 파이프 기능과 결합할 때 매우 강력하다. 이는 처리되는 속성 metatype에 접근할 수 있기 때문이다.


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

패키지를 이용하면 CreateCatDto class에 몇 개의 decorator를 추가할 수 있다. 여기에는 몇 가지 장점이 있다. 별도의 유효성 검사를 위한 Class를 만들 필요가 없이 데이터를 검증할 수 있다.

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

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

  @IsInt()
  age: number;

  @IsString()
  breed: string;
}

<팁!>
class-validator decorate의 자세한 설명


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> {
  // value: 검증할 입력값, metadata: 입력 값의 메타데이터(type 정보 등...)
  async transform(value: any, { metatype }: ArgumentMetadata) {
    // metatype이 없거나 기본 타입인 경우 검증하지 않고 값을 그대로 반환, 검증을 수행할 필요가 없음을 의미
    //     - metatype이 없다는 것은, 요청된 데이터의 타입 정보가 제공되지 않았음을 의미한다. NestJS에서 파이프는
    //         입력 값의 메타타입을 통해 어떤 타입을 변환해야 하는지, 그리고 어떤 검증을 수행해야 하는지를 결정한다.
    // toValidate(metatype) : 기본 타입(String, Boolean, Number, Array, Object)이 아닌 경우에만 검증을 수행하도록 함
    //    - toValidate method는 메타타입이 기본 타입(String, Boolean, Number, Array, Object)인지 확인
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    }

    // class-transformer의 plainToInstance 함수를 사용하여 일반 JS객체('value')를 특정 클래스('metatype')의 인스턴스로 변환 
    const object = plainToInstance(metatype, value);
    // validate 함수를 사용하여 변환된 객체('object')의 유효성을 검사한다.
    // 유형성 검사에 실패하면, 유효하지 않은 필드와 관련된 오류 정보가 배열 형태로 저장된다.
    // 성공하면, errors 배열은 비어 있다.
    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];
    // types 배열에 포함되어 있으면 false를 반환하여 검증을 건너뛴다.
    // 포함되어 있지 않으면, true를 반환하여 검증을 수행
    return !types.includes(metatype);
  }
}

<팁!>
앞서 언급한 대로, ValidationPipe는 Nest에서 기본적으로 제공해주기에 별도의 generic validation pipe를 만들 필요가 없다. built-in ValidationPipe는 이번 쳅터에서 만든 sample보다 더 많은 option을 지닌다. 자세한 사항


우리는 위에서 class-transformer 라이브러리를 사용했는데, 이는 class-validator 라이브러리와 동일한 작성자가 만든 것이므로 이 두 라이브러리는 매우 잘 호환됩니다.


위의 코드에서 주목해야할 5개가 있다. 첫 번째로, transform() method는 async가 붙어있다. 이는 Nest에서는 synchronous와 asynchronous pipe 둘다 지원하기 때문이다. asynch method를 만들었는데, 이는 일부 class-validator validations는 Promises를 이용해서 async가 될 수 있기 때문이다.


두 번째로, destructuring을 통해 metatypeArgumentMetadata에서 추출했다. 이는 ArgumentMetadata의 전부를 뽑아내서 metatype 변수를 할당하는 추가 구문을 사용하는 것에 대한 간단한 표현이다.


세 번째로, helper function인 toValidate() 를 주목해야 한다. 현재 처리 중인 argument가 native JavaScript 타입일 경우, 유효성 검사 단계를 건너뛰는 역할을 한다. 이 타입들은 유효성 검사 decorator를 붙일 수 없으므로, 유효성 검사 단계를 거칠 필요가 없다.


네 번째로, class-transformer의 plainToInstance() 를 사용한다. 이는 일반 javascript argument object를 타입이 지정된 object로 바꿔준다. 그래서 validation을 검사할 수 있다. 반드시 이것을 해야하는 이유는 incoming post body object(network request에서 deserialized된)는 타입 정보를 가지고 있지 않기 때문이다.(이는 Express와 같은 기본 플랫폼이 작동하는 방식이다.) Class-validator은 앞서 DTO에서 정의 내린대로 validation decorator를 사용해야 하므로, 들어오는 본문을 단순한 일반 객체가 아닌 적절히 decorator가 적용된 object로 처리하기 위해 이 변환을 수행해야 한다.


마지막으로, 앞서 언급한 대로 이것은 validation pipe이기에, 바뀌지 않은 값, 혹은 에러를 던진다.


마지막 스탭은 ValidationPipe를 bind 하는 것이다. Pipe는 parameter-scoped, method-scoped, controller-scoped, global-scoped가 될 수 있다. 일찍이, Zod 기반의 validation pipe로, method 수준에서 binding 예를 봤다. 이번 아래의 예는, pipe instance를 route handler에 @Body() decorator 로 묶을 것이다. 그래서 pipe는 post body의 유효성 검사를 위해 호출될 것 이다.


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

parameter-scoped pipe는 validation logic이 오직 specified parameter에 적용될 때만 유용하다.



Global scoped pipes

ValidationPipe는 가능한 일반적으로 만들어 졌기에, 이를 global scoped 의 pipe로 설정하여 전체 application의 모든 route handler에 적용함으로써 그 유용성을 최대한 발휘할 수 있다.

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

hybrid apps의 경우에 useGlobalPipes() method는 gateway와 microservice에 대해 pipe를 설정하지 않는다. "표준"(non-hybrid) microservice 앱의 경우, useGlobalPipes()는 pipe를 전역적으로 mount한다.


Global pipe는 전체 application, 모든 controller, 모든 route handler에 사용된다.


Dependency Injection면에서는 (위의 예시 처럼 useGlobalPipes() 로) 어떤 module 밖에서 등록된 global pipes는 dependency를 주입하지 못한다. 왜냐하면 binding이 어떤 module의 밖에서 일어났기 때문이다. 이를 해결하기 위해서, module에서 바로 global pipe를 설정할 수 있다.

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

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

<팁!>
pipe에 대해 이러한 접근으로 dependency injection을 수행할 때, 이 구성을 사용하는 module에 관계없이 pipe는 global하다. 어디서 수행되는가? pipe(위의 예제의 경우 ValidationPipe)가 정의된 모듈을 선택한다. 또한 useClass는 custom provider registration을 처리하는 유일한 방법은 아니다. 자세히



The built-in ValidationPipe

앞서 언급한 대로, ValidationPipe가 이미 Nest에서 제공하기 때문에 따로 generic validation pipe를 build할 필요가 없다.
내장된 ValidationPipe는 이번 쳅터에서 다룬 것보다 더 많은 옵션을 제공한다. 자세히



Transformation use case

유효성 검사는 커스텀 파이프의 유일한 사용 사례가 아니다.이 쳅터의 처음에서 pipe는 inputdata를 원하는 format으로 바꿀 수 있다고 했다. 이것이 가능한 것은 transform 함수에서 반환된 값은 이전의 argument 값을 덮어 씌우기 때문이다.

언제 유용한가? 가끔 client으로부터 전달된 data를 change할 필요가 있다. 예를 들어 string to integer, route handler method에 의해 다뤄지기 전에. 게다가, 어떤 required data filed가 missing됬을 때 이를 default value로 바꿀 필요가 있다. Transformation pipes가 processing function과 client request 또는 request handler 사이에 위치에서 이를 수행한다.

ParseIntPipe는 string을 integer value로 바꿀 수 있다.

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

@Injectable()
export class ParseIntPipe implements PipeTransform<string, number> {
  transform(value: string, metadata: ArgumentMetadata): number {
    // 10진법으로 변환
    const val = parseInt(value, 10); 
    if (isNaN(val)) {
      throw new BadRequestException('Validation failed');
    }
    return val;
  }
}

pipe랑 param을 묶을 수 있다.


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

다른 유용한 transformation case는 request에서 공급된 id를 이용하여 DB에서 user entity에서 select하는 것이다.


// userEntity는 UserByIdPipe에 의해 처리된 결과로, UserEntity 타입의 객체이다.
@Get(':id')
findOne(@Param('id', UserByIdPipe) userEntity: UserEntity) {
  return userEntity;
}

다른 모든 transformation pipe와 마찬가지로, input value(an id)를 받아 출력 값(UserEntity object)을 반환한다는 점에 유의해야 한다. 이렇게 하면 반복되는 코드를 handler에서 공통 pipe로 추상화하여 선언적이고 DRY(중복 배제)하게 만들 수 있다.



Providing defaults

Parse* pipes 는 parameter의 값이 정의되어 있을 것으로 예상한다. pipe들은 null 혹은 undefined values를 받으면 exception을 던질 것 이다. endpoint에서 놓친 query string parameter value를 다루도록 하기 위해서, 이러한 값에 대해 작동하기 전에 default value를 제공해야 한다. DefaultValuePipe는 이러한 목적으로 제공된다. 간단히 @Query() decorator에서 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 });
}

'NestJS > Docs_OVERVIEW' 카테고리의 다른 글

Interceptors  (0) 2024.06.07
Guards  (0) 2024.06.07
Exception filters  (0) 2024.06.05
Middleware  (1) 2024.06.04
Modules  (1) 2024.06.04

Exception filters


Nest에는 exception layer이 내장되어 있다. 이는 application의 전반에 걸쳐 모든 unhandled exception의 처리에 책임이 있다.
만일 개발자의 application code에 의해 발생되는, not handled한 exception이 발생할 때, 이것을 이 layer가 처리한다. 그리고 바로 자동적으로 적절하게 user-friendly response를 보낸다.



별도의 설치나 설정이 필요없이, 이러한 action은 built-in된 global exception filter에 의해 수행된다. 이러한 filter는 HttpException(혹은 이것의 subclass) type의 exception(예외)를 다룬다. 만일 unrecognized한 exception이 발생한다면(HttpException, HttpException에 의한 class가 둘다 아님), built-in된 exception filter가 다음과 같은 default JSON response를 일으킨다.


{
  "statusCode": 500,
  "message": "Internal server error"
}

<팁!>
global exception filter는 부분적으로 http-errors library를 지원한다. 기본적으로, statusCode 그리고 message 속성을 포함하는 모든 exception은 적절하게 채워서 response로 반환된다. (기본적으로 인식되지 못한 예외에 대한 InternalSererErrorException 대신)



Throwing standard exceptons

Nest는 @nestJs/common에서의 build-in HttpException class를 제공한다. 전형적인 HTTP REST/GraphQL API 기반 applications에 대해서, 특정한 error가 발생했을 때, standard한 HTTP response object를 보내는게 best이다.


예를 들어, CatsController에서의 findAll() method가 있다고 하자. 그리고 어떠한 reason으로 route handler가 exception을 throw했다고 하자. 다음과 같다.


@Get()
async findAll() {
  throw new HttpException('Forbidden', HttpStatus.FORBIDDEN);
}

<팁!>
@nestjs/common의 HttpStatus는 상태 정보를 담는 enum helper 이다.

만일 client가 endpoint에서 호출한다면, response는 다음과 같다.


{
  "statusCode": 403,
  "message": "Forbidden"
}

HttpException 생성자는 두 개의 argument를 담는데, 이는 response를 결정한다.

  • response argument는 JSON response body를 정의한다. 이는 string 혹은 object가 될 수 있다.
  • status argument는 HTTP status code를 결정한다.

기본적으로 JSON response body는 두 개의 속성을 지닌다.

  • statusCode: status argument에 제공된 HTTP status code로 기본 설정된다.
  • message: status에 기반한 HTTP error의 짧은 설명이다.

JSON response body의 message를 덮어 쓰기 위해서는, string을 response argument에 공급하면 된다.


JSON response body를 전부 덮기 위해서는, response argument에 object를 전달하면 된다. Nest는 객체를 serialize하고 JSON response body 형태로 반환할 것 이다.

두 번째 argument인 status는 반드시 유효한 HTTP status code를 넣어야 하다. 가장 최고의 방법은 @nestjs/common의 HttpStatus enum을 넣는 것이다.

optional한 세 번째 argument, optionerror의 cause을 전달하는데 사용된다.
cause object는 response object로 직렬화 되지 않지만, HttpException에 의해 발생한 내부 error에 대한 정보를 보여주는 logging의 목적으로 유용하다.

다음은 전체 response body와 에러의 원인을 제공하는 예제 코드이다.

@Get()
async findAll() {
  try {
    await this.service.findAll()
  } catch (error) {
    throw new HttpException({
      status: HttpStatus.FORBIDDEN,
      error: 'This is a custom message',
    }, HttpStatus.FORBIDDEN, {
      cause: error
    });
  }
}

그렇다면 response는 다음과 같다.

{
  "status": 403,
  "error": "This is a custom message"
}


Custom Exceptions

많은 경우에, custom exception을 작성할 필요가없다. 단지 built-in 되있는 Nest HTTP exception을 쓰면된다.
만일 customized exception을 만들 필요가 있다면 제일 좋은 방법은 HttpException class를 상속받는 자신만의 exception을 만드는 것이다. Nest는 exception을 인식하고, 자동으로 error responses를 관리할 것이다.

export class ForbiddenException extends HttpException {
  constructor() {
    super('Forbidden', HttpStatus.FORBIDDEN);
  }
}

ForbiddenException은 기본 HttpException을 확장하므로 내장된 예외 처리기와 원활하게 작동합니다. 따라서 이를 findAll() 메서드 내에서 사용할 수 있습니다.

@Get()
async findAll() {
  throw new ForbiddenException();
}


Built-in HTTP exceptions

Nest는 HttpException을 상속받은 standard exception 집합을 제공한다. 이들은 @nestjs/common pacakge에 있다.
다음은 대표적인 HTTP Exception들이다.

  • BadRequestException
  • UnauthorizedException
  • NotFoundException
  • ForbiddenException
  • NotAcceptableException
  • RequestTimeoutException
  • ConflictException
  • GoneException
  • HttpVersionNotSupportedException
  • PayloadTooLargeException
  • UnsupportedMediaTypeException
  • UnprocessableEntityException
  • InternalServerErrorException
  • NotImplementedException
  • ImATeapotException
  • MethodNotAllowedException
  • BadGatewayException
  • ServiceUnavailableException
  • GatewayTimeoutException
  • PreconditionFailedException

모든 built-in exception들은 모두 options parameter를 이용하여 error causeerror description을 제공할 수 있다.

throw new BadRequestException('Something bad happened', { cause: new Error(), description: 'Some error description' })
{
  "message": "Something bad happened",
  "error": "Some error description",
  "statusCode": 400,
}


Exception filters

base(built-in) exception filter는 자동적으로 많은 경우들을 다루지만, exception layer을 모두 제어하기를 원할 수 있다. 예를 들어
동적인 요소들에 근거한 다른 JSON schema를 사용하거나 log를 붙이기를 원할 수 있다. Exception filters는 이러한 목적을 위해 디자인되었다. 이 filter들은 제어의 흐름과, client로 반환되는 response의 내용을 정확하게 제어할 수 있다.


HttpException class의 instance인 exception을 잡고, 이를 위한 사용자 정의 응답 로직을 구현하는 exception filter를 만들 것이다. 이것을 위해, 기본 플랫폼의 RequestResponse 객체에 접근할 필요가 있다. Request 객체에 접근하고, 원래의 url을 빼내고, 이를 log 정보에 포함시킬 것이다. Response 객체를 이용하여 응답을 직접 제어하고 response.json() method를 사용하여 응답을 전송할 것이다.

import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';

// @Catch(HttpExeception):  HttpException 타입의 예외를 잡아 처리하는 필터임을 지정한다.
@Catch(HttpException)
// HttpExceptionFilter는 ExceptionFilter interface를 구현한다. 이는 'catch' method를 포함한다.
export class HttpExceptionFilter implements ExceptionFilter {
  // catch() : ExceptionFilter의 일부로, 예외가 발생했을 때 이를 처리하는 메서드. 예외를 잡아내고, 적절하게 처리하여 클라이언트에게 응답을 반환하는 역할
  // exception: 발생한 HttpException 객체이다.
  // host: ArgumentsHost 객체로, 여러 유형의 요청 컨텍스트를 캡슐화한다.
  // 요청 컨텍스트: 특정 요청에 대한 정보를 포함하는 객체로, 요청의 전체 생명 주기 동안 관련 데이터를 캡슐화한다.
  //     다음과 같은 정보를 갖는다.
  //         1. HTTP 요청/응답 객체: Express나 Fastify의 요청 및 응답 객체
  //         2. WebSocket 데이터: WebSocket 연결 및 메시지 데이터
  //         3. RPC 요청: 원격 프로시저 호출에 대한 요청 데이터
  catch(exception: HttpException, host: ArgumentsHost) {
    // HTTP 컨텍스트로 전환하여 express의 request, response 객체를 가져온다.
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    // 예외 객체에서 HTTP 상태 코드를 가져온다.
    const status = exception.getStatus();

    // 상태 코드와 JSON 형식의 응답을 클라이언트에게 전송한다.
    response
      .status(status)
      .json({
        statusCode: status,
        timestamp: new Date().toISOString(),
        path: request.url,
      });
  }
}

<팁!>
모든 exception filter들은 모두 generic ExceptionFilter interface를 사용해야 한다. 이는 catch(exception: T, host: ArgumentHost) method에게 나타난 <T>를 제공해야한다. 여기서 T는 예외의 유형을 나타낸다.


<주의사항!>
만일 @nestjs/platform-fastify를 사용한다면, response.json() 대신 response.send()를 사용해야 한다.


@Catch(HttpException) decorator는 요구된 metadata와 exception filter를 묶어준다. exception filter는 Nest에게 이 특수한 filter는 HttpException 타입의 예외를 원한다고 말해줘야한다. @Catch() decorator는 한 개의 파라미터나, comma로 구분된 리스트를 받는다. 이는 한 번에 filter의 타입을 설정하도록 해준다.



Arguments Host

catch() 메서드의 parameter를 보자. exception parameter는 현재 처리되고 있는 exception object이다. host parameter는 ArgumentsHost 객체이다. ArgumentsHost는 강력한 utility object로, 나중에 execution context chapter*에서 더 자세히 설명할 것 이다. 이번 code sample에서는 원래의 요청 핸들러(예외가 발생한 controller)로 전달되는 Request 및 Response 객체에 대한 참조를 얻기 위해 이를 사용한다. 이 code sample에서는 _ArgumentsHost*_의 몇 가지 helper method를 사용하여 원하는 ReqeustResponse 객체를 가져왔다. ArgumentsHost

이 정도의 추상화(abstraction)를 사용하는 이유는 ArgumentsHost가 모든 context에서 작동하기 때문이다.(e.g. 지금 작업 중인 HTTP 서버 context뿐만 아니라, 마이크로서비스와 WebSockets도 포함된다.) execution context chapter에서는 ArgumentsHost와 그 helper 함수의 기능을 통해 모든 실행 컨텍스트에 대해 적절한 인자를 접근할 수 있는 방법을 살펴볼 것 이다. 이를 통해 모든 context에서 작동하는 일반적인 exception filter를 작성할 수 있다.



Binding filters

이제 우리의 HttpExceptionFilter를 CatsController의 create() method에 연결하자.

@Post()
@UseFilters(new HttpExceptionFilter())
async create(@Body() createCatDto: CreateCatDto) {
  throw new ForbiddenException();
}

<팁!>
@UseFilters()는 @nestjs/common에서 가져올 수 있다.

@UseFilter()는 하나의 filter instance를 받거나, comma로 구분된 filter instance를 받는다. 코드에는 위에 작성된 HttpExceptionFilter instance를 넣었다. 대신에 instance 대신 class를 전달하여 instance화 책임을 프레임워크에 맡기고, dependency injection을 가능케 할 수 있다.


@Post()
@UseFilters(HttpExceptionFilter)
async create(@Body() createCatDto: CreateCatDto) {
  throw new ForbiddenException();
}

<팁!>
가능하다면, instance 대신 class를 사용함으로써 filter를 적용하는 것을 선호하자. 이는 Nest가 전체 module에서 같은 class의 instance를 쉽게 재사용할 수 있기 때문이다. 곧, memory 사용에 있어 이점을 취한다.


위의 코드에서 method-scoped에서 HttpExceptionFilter는 오직 하나의 create() route handler가 적용됬다. exception filter들은 다른 수준에서 적용될 수 있다. controller, resolver, gateway, controller-scoped, global-scope 등등 예를 들어 controller에 적용하는 것은 아래와 같다.

@UseFilters(new HttpExceptionFilter())
export class CatsController {}

이 구성은 CatsController내에 정의된 모든 route handler에 대해 HttpExceptionFilter가 적용된다.

이를 global-scope filter로 만들자고하면, 다음과 같다.

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

<주의사항!>
useGlobalFilters() method는 gateway나 hybrid application에 대해 filter를 적용하지 못한다.


global-scopde filter는 모든 controller, route handler에 대해 application 전반적으로 사용된다. DI 측면에서 위의 useGlobalFilters() 와 같이, 어떤 모듈의 바깥에서 등록된 global filter은 dependency를 inject할 수 없다. 이는 any module의 context 바깥에서 완료됬기 때문이다. 이러한 문제를 해결하기 위해서는, any module에서 부터 바로 global-scope filter를 등록할 수 있다.

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

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

이러한 접근 방식을 사용하여 filter에 대해 dependency inject을 수행할 때, 이 구성이 사용되는 module에 상관없이 filter는 실제로 global이라는 점에 유의해야 한다. 이 작업은 어디에서 수행되는가? filter(위의 경우 HttpExceptionFilter)가 정의된 모듈을 선택해야 한다. 또한 useClass는 custom provider registration을 처리하는 유일한 방법은 아니다. 자세히

많은 filter들을 이 기술로 추가할 수 있다. 간다히 각 provider array에 추가하기만 하면 된다.



Catch everything

모든 unhandled exception을 catch하기 위해, @Catch() decorator의 parameter를 비워두면 된다.


아래의 코드는 플랫폼과 무관하다. 왜냐하면 response를 처리하기 위한 HTTP adapter를 사용하고 Request, Response 따위의 플랫폼 종속 객체를 쓰지 않기 때문이다.

import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  // HttpAdapterHost를 주입받는다.
  constructor(private readonly httpAdapterHost: HttpAdapterHost) {}


  catch(exception: unknown, host: ArgumentsHost): void {
    // In certain situations `httpAdapter` might not be available in the
    // constructor method, thus we should resolve it here.
    // httpAdapter는 현재 애플리케이션에서 사용 중인 HTTP 서버 어댑터이다. 
    //    1. 요청 URL 가져오기: httpAdapter.getReqeustUrl(req)
    //    2. 응답 보내기: httpAdapter.replay(res, body, statusCode)
    const { httpAdapter } = this.httpAdapterHost;

    const ctx = host.switchToHttp();

    const httpStatus =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    // 응답으로 반환될 JSON 객체를 구성
    const responseBody = {
      statusCode: httpStatus,
      timestamp: new Date().toISOString(),
      // 현재 요청의 URL 경로를 얻기 위해 사용된다. 
      path: httpAdapter.getRequestUrl(ctx.getRequest()),
    };

    httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus);
  }
}

<주의사항!>
다양한 예외 필터를 조합하여 사용할 때, 모든 예외를 잡는 필터와 특정 타입에 바인딩된 필터를 함께 사용하는 경우, "모든 예외를 잡는" 필터를 먼저 선언해야 한다. 이렇게 해야 특정 필터가 바인됭 타입의 예외를 올바르게 처리할 수 있다.

import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { AllExceptionsFilter } from './filters/all-exceptions.filter';
import { SpecificExceptionFilter } from './filters/specific-exception.filter';

@Module({
  providers: [
    {
      provide: APP_FILTER,
      useClass: AllExceptionsFilter,
    },
    {
      provide: APP_FILTER,
      useClass: SpecificExceptionFilter,
    },
  ],
})
export class AppModule {}


Inheritance

일반적으로, application 요구사항을 충족시키기 위해 완전하 사용자 정의된 exception filter를 만든다. 하지만, 간단히 built-in default global exception fileter를 확장하고, 특정 요소에 따라 동작을 재정의하고자 할 때가 있을 수 있다.


예외 처리를 기본 필터에 위임하려면, BaseExceptionFilter를 확장하고, 상속받은 catch() method를 호출해야 한다.
아래와 같은 방식으로 기본 전역 예외 필터를 확장하고 커스터마이징하여 애플리케이션의 요구 사항에 맞게 사용할 수 있다.

import { Catch, ArgumentsHost } from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core';

@Catch()
export class AllExceptionsFilter extends BaseExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    super.catch(exception, host);
  }
}

<주의사항!>
BaseExceptionFilter를 확장한 Method-scoped와 Controller-scoped filter는 new를 이용해 instance화 되면 안된다. 대신에 framework가 이들을 자동으로 instance화하도록 해야 한다.


Global filter는 base filter를 확장할 수 있다. 이는 두 가지 방법으로 된다.

첫 번째 방법은 custom global filter를 인스턴스화할 때 HttpAdapter를 주입하는 것이다.

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const { httpAdapter } = app.get(HttpAdapterHost);
  app.useGlobalFilters(new AllExceptionsFilter(httpAdapter));

  await app.listen(3000);
}
bootstrap();

두 번째 방법은 APP_FILTER를 사용하는 것이다.

import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { CustomGlobalExceptionFilter } from './custom-global-exception.filter';

@Module({
  providers: [
    {
      provide: APP_FILTER,
      useClass: CustomGlobalExceptionFilter, // 글로벌 필터로 설정
    },
  ],
})
export class AppModule {}

'NestJS > Docs_OVERVIEW' 카테고리의 다른 글

Guards  (0) 2024.06.07
Pipes  (0) 2024.06.06
Middleware  (1) 2024.06.04
Modules  (1) 2024.06.04
Providers  (0) 2024.06.04

Middleware

Middleware는 route handler가 호출되기 전에 실행되는 함수이다. NestJs에서는 middleware는 Express middleware와 동일한 기능을 가진다.
NestJS에서 미들웨어는 요청과 응답 사이에 실행되는 함수. 요청을 처리하는 도중 특정 작업을 수행할 수 있게 한다.
http 요청과 응답 사이 즉 라우터 핸들러 이전에 호출되는 함수이다.
다시 말해서 클라이언트 요청을 라우터 핸들러가 받기전에 가로채서 다른 작업을 수행할 수 있다.


다음과 같은 특징들이 있다.

  • 요청에 대한 검증, 로깅, 인증, 권한 체크, 캐싱 등의 작업을 수행할 수 있다.
  • 다수의 미들웨어 등록을 토해 다양한 작업을 순차적으로 처리할 수 있다.
  • next 함수 호출을 통해 미들웨어 체인을 연결한다. next 함수를 호출하면 다음 미들웨어가 실행되며, 호출하지 않으면 다음 미들웨어가 실행되지 않는다.
  • NestJS는 전역 미들웨어(Global Middleware)로컬 미들웨어(Local Middleware)를 지원한다. 전역 미들웨어는 모든 요청과 응답에 적용되며, 로컬 미들웨어는 특정 라우터에만 적용된다.

Middleware는 전에 route handler라고 불리던 함수이다. Middleware 함수는 request 그리고 response 객체에 접근한다. 그리고 application의 request-response cycle 에서의 next() middleware 함수에 접근한다. next middleware 함수는 보통 변수명 next로 표기된다.


next() 는 middleware function에서 중요한 역할을 하는 callback 함수이다. 이는 다음 middleware function이나 route handler로 제어를 넘기는 역할을 한다. 구체적으로는 다음과 같은 역할을 한다.


  1. 제어 흐름 유지: middleware function은 request와 response 객체에 접근할 수 있는데, next()를 호출하지 않으면 요청은 해당 middleware에서 멈추고, 이후의 middleware 함수나 route handler로 제어가 넘어가지 않는다.
  2. middleware chain 형성: 여러 개의 middleware 함수가 존재할 때, next()를 사용하여 순서대로 미들웨어를 실행할 수 있다.
  3. 오류 처리: next(err)의 형태로 오류가 발생했음을 알릴 수 있다. 이 경우 프레임워크에서 오류 처리 middleware를 찾아서 실행하게 된다.


Nest middleware은 기본적으로 express middleware와 동등하다. 다음의 설명은 공식적인 'express'의 docs에서 가져온 것으로 middleware의 capability가 담겨있다.


Middleware function은 다음의 task를 수행할 수 있다.
    1. 임의의 code들을 실행할 수 있다.
    2. request, response object들을 변경할 수 있다.
    3. request-response cycle을 끝낼 수 있다.
    4. stack에서 다음 middleware function을 호출할 수 있다.
    5. 현재 middleware function가 request-response cycle을 종료하지 못하면, 
        제어권을 다음 middleware function으로 넘기기 위해 반드시 next()를 호출해야한다.
        그렇지 않으면 request는 처리되지 않은 상태로 남게 된다.

custom Nest middleware는 함수로 구현하거나, @Injectable(). decorator가 있는 클래스로 구현할 수 있다. class의 경우 반드시 NestMiddleware interface를 구현해야 한다. 반면에 함수의 경우 다른 특별한 requirement는 없다. 이제 class method를 사용하여 간단한 middleware을 만들어보자


// 클래스 기반 미들웨어
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
  // req: 요청 객체, res: 응답 객체, next: 다음 미들웨어를 호출
  // use method는 요청 정보를 출력하고, 응답 완료 후에는 응답 코드를 출력
  use(req: Request, res: Response, next: NextFunction) {
    console.log(`Request...`);
    // next를 통해 현재 미들웨어의 처리가 완료되었음을 알리고, 다음 미들웨어로 제어를 전달한다.
    // 이를 호출하지 앟으면 요청 처리가 중단된다.
    next();
  }
}

// 함수 기반 미들웨어
import { Request, Response, NextFunction } from 'express';

export function LoggerMiddleware(req: Request, res: Response, next: NextFunction) {
  console.log(`Request...`);
  next();
}

주의사항
'Express'나 'Fastify' 는 middleware를 다르게 처리하고, 또한 다른 method signature를 제공한다. read More


method signature: 메서드의 정의에서 메서드의 이름과 매개변수 리스트의 조합을 의미한다. 이는 오버로딩과  관련이 있다.

Dependency injection

Nest의 middleware는 DI를 지원한다. provider와 controller와 같이, 같은 module안에서 inject dependencies가 가능하다. 유사하게, constructor을 통해 수행된다.

Applying middleware

@Module() decorator안에는 middleware를 위한 공간은 없다. 대신에 module class의 configure() method를 통해 설정할 수 있다.
middleware를 포함하는 Module은 반드시 NestModule interface를 구현해야 한다.

import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
import { CatsModule } from './cats/cats.module';

@Module({
  imports: [CatsModule],
})
// middleware를 설정하기 위해 configure를 호출해야한다. 그러기 위해서는
// NestModule을 implements 해야한다.
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware) 
      .forRoutes('cats'); // cats 경로에 적용한다.
  }
}

위에서는 /cats route handler 를 위한 LoggerMiddleware를 설정했다. 또한 middleware를 특정 request method에 제한 할 수 있는데, 이를 위해 middleware를 구성할 때 route path와 request method가 포함된 객체를 forRoutes() method에 전달하여 middleware를 구성할 수 있다. 아래의 코드에서 우리는 request method type을 참고하기 위해 RequestMethod enum을 import 하고 있다.

import { Module, NestModule, RequestMethod, MiddlewareConsumer } from '@nestjs/common';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
import { CatsModule } from './cats/cats.module';

@Module({
  imports: [CatsModule],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)
      .forRoutes({ path: 'cats', method: RequestMethod.GET });
  }
}

<팁!>
configure() method는 async/await를 사용하여 asynchronous 하게 만들어졌다. configure() method는 NestJs 어플리케이션을 설정하는 데 사용되며, 이 method 안에서 비동기 작업을 수행할 수 있다. 예를 들어 db 연결 설정, 환경 변수 로드 등과 같은 작업을 configure() method 내부에서 비동기적으로 수행할 수 있다.

<주의사항>
express adapter를 사용할 때, NestJS app은 기본적으로 package body-parser를 통해 jsonurlencoded을 등록할 것이다. 그렇기에 만일 MiddlewareConsumer를 통해 middleware를 customize를 한다면, NestFactory.create() 로 application을 만들 때 global middleware를 bodyParser flag를 false로 함으로써 꺼야 한다.



Route wildcard

route base의 Pattern들 또한 지원된다. 예를 들어 wildcard로써 asterisk(*)가 사용되고 또 어떤 문자의 조합과 매칭된다.

forRoutes({ path: 'ab*cd', method: RequestMethod.ALL });

'ab*cd' route path는 'abcd', 'ab_cd', 'abecd' 과 매칭될 것이다. 이 밖에 ?, +, *, () 또한 route path에 사용된다. 그리고 이들은 regular expression의 짝이다. 단순히 hyphen(-), dot(.)은 문자로 해석된다.

<주의사항!>
fastify package는 path-to-regexp package의 최신버전을 사용한다. 이는 더 이상 asterisk(*)를 지원하지 않는다. 대신,반드시 parameters를 사용해야 한다.(e.g. '(.*)', :splat*))



Middleware consumer

MiddlewareConsumer은 helper class이다. 이는 middleware를 관리하기 위한 내장 method를 제공한다. 모든 method는 fluent stylechained 된다. forRoutes() method는 single string, multiple stirngs, a RouteInfo object, a controller class, 심지어 multiple controller classes를 값으로 받을 수 있다. 보통은 comma(,)로 구분된 controller의 리스트를 전달한다.

//fluent style: 각 메서드 호출이 자신을 반환하여 다음 메서드를 바로 호출할 수 있게하는 코드 기법

consumer
  .apply(LoggerMiddleware)
  .forRoutes('cats')
  .apply(AnotherMiddleware)
  .forRoutes('dogs');

아래의 코드는 CatsController의 모든 route handler에 대해 middleware인 LoggerMiddleware를 적용한다는 의미이다.

import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
import { CatsModule } from './cats/cats.module';
import { CatsController } from './cats/cats.controller';

@Module({
  imports: [CatsModule],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)
      .forRoutes(CatsController);// CatsController에 들어있는 route handler에 적용
  }
}

apply() method는 single middleware를 받거나, 여러 개의 argument를 받을 것이다. [middleware](https://docs.nestjs.com/middleware#multiple-middleware)



Excluding routes

때때로 middleware의 적용에서 특정한 route를 제외(exclude) 할 수 있다. exclude() method를 통해 쉽게 route를 제외할 수 있다. 이 method는 single string, multiple strings, 또는 route를 나타내는 Route Info object 를 인자로 받아 route를 제외한다.

consumer
  .apply(LoggerMiddleware)
  .exclude(
    { path: 'cats', method: RequestMethod.GET },
    { path: 'cats', method: RequestMethod.POST },
    'cats/(.*)',
  )
  .forRoutes(CatsController);

<팁!>
exclude() method는 path-to-regexp package를 이용한 wildcard parameter를 지원한다.


위의 예에서, LoggerMiddleware는 CatsController 내부에 정의된 모든 route들을 둘러싼다. excpet에 적혀있는 것을 빼고.



Functional middleware

앞의 LoggerMiddleware class는 단순한 기능만 넣었다. member, method, dependency 다 없다. 그렇다면 class 대신에 간단한 function으로 작성할 수 있는데 이러한 middleware type을 functional middleware라고 한다. class 기반의 logger middleware를 functional middleware로 다꾸면 다음과 같다.

import { Request, Response, NextFunction } from 'express';

export function logger(req: Request, res: Response, next: NextFunction) {
  console.log(`Request...`);
  next();
};

AppModule에서 다음과 같이 사용한다.

consumer
  .apply(logger)
  .forRoutes(CatsController);

<팁!> 만일 별다른 dependency가 없다면 functional middleware를 사용를 고려해보라.



Multiple middleware

언급한 대로, 여러 개의 middleware를 묶기위해서는 단순히 comma로 구분한 list를 apply() method에 넣어라.

consumer.apply(cors(), helmet(), logger).forRoutes(CatsController);


Global middleware

만일 한 번에 구분된 모든 route에 middleware를 적용하고 싶다면, use() method를 사용하면 된다. 이는 INestApplication instance에 의해 지원된다.

const app = await NestFactory.create(AppModule);
app.use(logger);
await app.listen(3000);

global middleware에서 DI(Dependency Injection) 컨테이너에 접근하는 것이 불가능하다. 대신, app.use()를 사용할 때는 함수형 미들웨어를 사용할 수 있다. 또 다른 방법으로는 class 미들웨어를 사용하고, AppModule 내에서 .forRoutes('*')를 사용하여 모든 경로에 적용할 수 있다.

'NestJS > Docs_OVERVIEW' 카테고리의 다른 글

Pipes  (0) 2024.06.06
Exception filters  (0) 2024.06.05
Modules  (1) 2024.06.04
Providers  (0) 2024.06.04
Controllers  (0) 2024.06.03

+ Recent posts