NestJS 공식문서 번역 겸 공부하는 글 입니다.
의역 및 오역이 있을 수 있습니다.
https://docs.nestjs.com/exception-filters

Exception filters

Exception filters

Nest에는 핸들링 되지 않은 예외를 처리해주는 exception layer가 내장되어 있다. 코드에서 예외를 처리해주지 않았다면 이 layer에서 예외를 캐치하여 자동으로 유저친화적인 response를 전송한다.

exception layer

특히, 이것은 HttpException과 서브클래스들을 핸들링하는 global exception filter에 의해 수행된다. 예외가 인식되지 않을 때(HttpException과 그 서브클래스가 아닌 경우), exception filter는 자동으로 아래의 데이터를 JSON 형식으로 response한다.

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

Throwing standard exceptions

Nest는 @nestjs/common 패키지를 통해, HttpException class를 제공한다. 일반적인 HTTP REST/GraphQL API 기반의 애플리케이션에서 특정 에러가 발생했을 때, 표준 HTTP response object를 보내는 것이 좋은 방법이다.

예를 들어, CatsController에 findAll() method를 정의하였다. 이 route handler가 어떠한 이유에 의해 예외를 throw 했다고 가정해보자.

// cats.controller.ts

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

클라이언트가 이 엔드포인트를 호출하면, 아래와 같은 응답이 전송될 것이다.

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

HttpException class의 생성자는 두 개의 인자를 필수로 받는다.

  • JSON response body를 정의하는데 필요한, string 또는 object 타입.
  • HTTP status code로 정의된 status.

기본적으로 JSON response body는 두개의 property로 구성된다.

  • statusCode: 기본적으로 status 인자로 받은 HTTP status code 값.
  • message: status에 따른 짧은 HTTP 에러에 대한 메세지

message만을 override 하기 위해서는 response 인자에 string 값을 전달하면 된다. 전체 JSON body를 override 하려면 response 인자로 object를 전달한다. Nest는 object를 직렬화하여 JSON body로 변환할 것이다.

2번째 인자인 status는 유효한 HTTP status code 이어야 한다. 가장 좋은 방법은 HttpStatus enum을 사용하는 것이다.

// cats.controller.ts

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

위의 예제는 아래와 같은 response를 전송할 것이다.

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

Custom Exceptions

많은 경우, custom exception을 사용하는 대신, Nest HTTP exception을 사용하면 된다. 만약 custom exception이 필요한 경우 HttpException 클래스를 상속받아 exception hierarchy를 만드는 것이 좋다. 이러한 접근법을 사용하면 Nest는 custom exception을 인식하고 자동으로 관리해 줄 것이다.

// forbidden.exception.ts

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

기본 HttpException을 상속했기때문에 내장 exception handler에 의해 같은 방식으로 동작한다.

// cats.controller.ts

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

Built-in HTTP Exceptions

Nest는 HttpException class를 상속받은 standard exception 세트를 제공한다.

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

Exception filters

내장 exception filter에 의해 대부분의 예외가 자동으로 핸들링 되지만, exception layer를 컨트롤 하고 싶은 경우가 생긴다. 예를 들어, 로그를 추가하거나 기본 JSON 형태를 변경하고 싶은 경우이다. exception filter는 이러한 목적을 위해 디자인 되었다. 이것들은 올바른 흐름통제와 response의 콘텐츠를 제공해준다.

HttpException class의 예외를 캐치하는 exception filter를 만들어보자. 이것을 위해 우리는 platform 기반의 request와 response object에 접근할 필요가 있다. 우리는 request object에 접근하여 original url을 사용하여 로그를 남길것이다. response object는 직접 컨트롤하여 response.json() method를 통해 전송할 것이다.

// http-exception.filter.ts

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

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();

    response
      .status(status)
      .json({
        statusCode: status,
        timestamp: new Date().toISOString(),
        path: request.url,
      });
  }
}

모든 exception filter는 ExceptionFilter interface를 구현해야한다. 이를 통해 catch(exception: T, host: ArgumentsHost) method를 제공받는다.

@Catch(HttpException) 데코레이터는 exception filter에 필요한 메타데이터를 bind하고 있어서 Nest에게 특정 filter가 HttpException임을 알려준다. @Catch() 데코레이터는 단일 혹은 콤마로 구분된 여러개의 인자를 받는다. 이를 통해 여러가지 예외를 필터에 적용할 수 있다.

Arguments host

catch() method를 살펴보자. exception 파라미터는 현재 처리되고 있는 예외이다. host parameter는 ArgumentsHost obejct이다. ArgumentHost는 execution context 챕터에서 자세히 알아보자. 위의 예제에서는 request와 response object의 참조값을 얻기 위해 사용되었다.

Binding filters

새로 만든 HttpExceptionFilter를 CatsController에 적용해보자.

// cats.controller.ts

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

@UseFilter() 데코레이터는 @Catch() 데코레이터와 유사하게, 단일 filter 인스턴스 또는 여러개의 filter 인스턴스 리스트를 받는다. 위의 예제에서는 새로운 HttpExceptionFilter 인스턴스를 만들어서 넣어주었다. 인스턴스 대신, 클래스를 넣어도 dependency injection에 의해 인스턴스화 된다.

// cats.controller.ts

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

가능하면 인스턴스를 넣는것보다 클래스를 넣자. 그러면 Nest는 같은 클래스의 인스턴스를 다른 모듈에서 재사용하여 메모리낭비를 줄일 수 있다.

위 예제에서, HttpExceptionFilter는 method-scope로 하나의 create() route handler에만 적용되었다. Exception filter는 다른 scope로 적용가능하다(method-scope, controller-scope, global-scope 등). 예를 들어, filter를 controller-scope로 적용하고 싶다면 아래의 예제처럼 하면 된다.

// cats.controller.ts

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

이러한 구조는 HttpExceptionFilter를 CatsController 내의 모든 route handler에 정의해준다.

global-scope로 적용하기 위해서는, 아래의 예제처럼 적용한다.

// app.module.ts

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

useGlobalFilters() method는 gateway나 hybrid application에 적용되지 않는다.

global-scope filter는 모든 controller와 route handler에 적용된다. module 외부에서 등록된 global filter(위의 예제처럼, useGlobalFilters()를 적용하는 경우)는 module context 외부에서 완료되기 때문에 dependency를 주입할 수 없다. 이것을 해결하기 위해, global-scope filter를 직접 module에 등록할 수 있다.

// app.module.ts

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

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

DI를 위해 이러한 접근법을 사용하면, 어떠한 module에서 사용되었든 filter는 global이다.

위와 같은 방법으로 array 형태의 여러개의 filter를 등록가능하다.

Catch everything

모든 예외를 핸들링하고 싶다면, @Catch() 데코레이터의 인자를 비워두면 된다.

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

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();

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

    response.status(status).json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
    });
  }
}

위의 에제에서 filter는 예외의 타입에 관계없이 모든 예외를 처리한다.

Inheritance

일반적으로, 애플리케이션의 요구사항에 충족하는 완전히 커스터마이징 된 filter를 만들것이다. 그러나, 내장된 기본 global exception filter를 상속하여 일부분을 override하여 사용할 수도 있다.

base filter를 이용하려면, BaseExceptionFilter를 상속받고, 상속받은 catch() method를 호출한다.

// all-exception.filter.ts

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-scope filter와 controller-scope filter는 new를 통해 인스턴스화 하면 안된다. 대신, 프레임워크가 자동으로 인스턴스화 할 것이다.

global filter도 base filter를 상속받을 수 있다. 이것에는 두가지 방법이 있다.

첫번째 방법은, custom global filter가 인스턴스화 될 때, http server에 injection 하는 방법이다.

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를 사용하는 것이다.