Node.js/NestJS

[NestJS] 6. 예외 필터

턴태 2022. 9. 29. 12:25

예외 필터

Nest는 어플리케이션 전반에서 처리되지 않은 모든 예외를 처리하는 예외 필터를 내장하고 있습니다. 어플리케이션 코드에서 예외가 처리되지 않았을 때, 이 예외 필터를 거쳐서 자동적으로 적절한 유저 친화적인 응답을 보냅니다. 예외 필터가 예외를 감지해서 유저에게 적절한 방식으로 응답을 보낸다는 이야기 같습니다.

기본적으로 HttpException(및 하위 클래스)의 예외를 처리하는 내장 전역 예외 필터에 의해 처리됩니다. 예외를 감지하지 못했을 때(HttpException 이나 HttpExpecption을 상속하는 클래스에서도 조차), 내장 예외 필터는 아래와 같은 JSON 응답을 기본적으로 만들어냅니다.

{
  "statusCode": 500,
  "message": "Internal server error"
}
더보기

전역 예외 필더는 부분적으로 http-errors 라이브러리를 지원합니다. 기본적으로 statusCodemessage 프로퍼티를 포함하는 예외가 응답으로 전송됩니다.

표준 예외 발생

Nest는 @nestjs/common 패키지로부터 HttpException 내장 클래스를 제공합니다. 특정 에러 조건이 발생했을 때, HTTP REST/GraphQL API 기반 어플리케이션에서는 표준 HTTP 응답 객체를 반환하는 것이 가장 좋은 방법입니다.

 

예를 들어, CatsController에서 findll() 메서드를 갖고 있습니다. 이 라우트 핸들러가 모종의 이유로 에러를 발생시킨다고 가정해봅시다. 이를 증명하기 위해서 아래와 같이 하드코딩 해줍니다.

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

사용자가 이 엔드포인트를 불러올 때, 이러한 응답을 볼 수 있습니다.

HttpException 생성자는 응답을 지정하는 두 가지 매개변수를 가집니다.

  • response 매개변수는 JSON 응답 바디를 정의합니다. 문자열이나 객체로 만들어집니다.
  • status 매개변수는 HTTP 상태코드를 정의합니다.

기본적으로 JSON 응답 바디는 두 가지 프로퍼티를 포함합니다.

  • statusCode: status 매개변수에서 제공되는 HTTP 상태코드가 기본값입니다.
  • message: status를 기반으로 한 HTTP 에러에 대한 간단한 설명입니다.

단순히 JSON 응답바디에서 부분적으로 메시지를 덮어쓰기 위해서, response 매개변수에 문자열을 넣으면 됩니다. JSON 응답 바디 전체를 덮어쓰기 위해서는 response 매개변수에 객체를 전달합니다. Nest는 객체를 직렬화하여 JSON 응답바디로 반환합니다.

 

두 번째 생성자 매개변수인 status는 반드시 유효한 상태코드여야 합니다. 가장 좋은 방법은 HttpStatus를 사용하는 것입니다.

 

아래는 응답 바디 전체를 덮어쓰는 예시입니다.

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

사용자 정의 예외

많은 경우, 사용자 정의 예외를 작성해야 하거나, 다음 섹션에서 설명하는 Nest 내장 HTTP 예외를 사용할 수 있습니다. 사용자 정의 예외를 사용해야 한다면, HttpException을 상속하여 예외 계층을 생성하는 것이 좋습니다. 이러한 방법으로, Nest는 정의한 예외를 파악하여 자동으로 에러에 대한 응답을 처리할 것입니다. 아래와 같이 사용자 정의 예외를 구현해봅시다!

 

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

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

ForbiddenExceptionHttpException을 기반으로 만들어졌기에, 내장 예외 핸들러와 비슷하게 작동하며, 그러므로 findAll() 메서드 내부에서 이 예외를 사용할 수 있습니다.

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

내장 HTTP 예외

Nest는 HttpException을 기반으로 상속하는 표준 예외들을 제공합니다.이들은 @nestjs/common 패키지로부터 사용하고, 대부분의 HTTP 예외를 표현합니다.

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

예외 필터

기본 예외 필터가 자동으로 많은 것을 처리할 수 있음에도, 예외 레이어를 전반적으로 컨트롤하고 싶을 수 있습니다. 예를 들어 로그를 기록한다거나, 동적인 요소를 기반으로 하는 JSON 스키마를 원할 수 있습니다. 예외 필터는 정확히 이런 목적으로 고안됐습니다. 예외 필터는 정확한 제어 흐름과 클라이언트에게 다시 전송되는 응답의 내용을 제어할 수 있게 해줍니다.

 

HttpException 클래스의 인스턴스인 예외들을 포착하고, 이에 대하여 사용자가 어떠한 응답의 과정을 구현할지 예외 필터를 만들어봅시다. 이를 위해서 Request와 Response 객체에 대해 접근해야 합니다. 원본 url을 추출해 이를 로그 정보에 넣기 위해 Request 객체를 사용하며, response.json() 메서드를 사용하는 응답을 직접적으로 제어할 수 있도록 Response 객체를 사용합니다.

 

import { ArgumentsHost, ExceptionFilter, HttpException } from '@nestjs/common';
import { Catch } from '@nestjs/common/decorators';
import { Response, Request } 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,
    });
  }
}
더보기

모든 예외 필터는 반드시 ExceptionFilter<T> 인터페이스를 구현해야 합니다. 타입을 지정하여 catch(exception: T, host: ArguementsHost) 메서드를 사용해야 합니다. T는 예외의 타입을 말합니다.

@Catch(HttpException) 데코레이터는 예외 필터에 필요한 메타데이터를 바인드하여 Nest에게 이 특정 필터는 HttpException 타입의 예외라는 것을 알려줍니다. @Catch() 데코레이터는 한개의 파라미터나 콤마로 구분된 리스트를 갖습니다. 예외 타입을 여러 개 설정할 수 있다는 의미입니다.

 

매개변수 호스트

 

catch() 메서드의 파라미터를 봅시다. exception 파라미터는 현재 처리되고 있는 예외 객체입니다. host 파라미터는 ArgumentsHost 객체입니다. ArgumentsHost는 강력한 유틸리티 객체입니다. 이 코드 샘플에서는, 원본 요청 핸들러가 전해지는 Request, Response 객체에 대한 참조를 ArgumentHost를 통해서 획득했습니다.

 

필터 바인딩

앞서 생성한 HttpExceptionFilter를 CatsController의 create 메서드에 엮어봅시다.

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

@UseFilters() 데코레이터를 여기서 사용했었습니다. @Catch() 데코레이터와 비슷하게, 단일 필터 인스턴스를 사용할 수도 있고, 콤마로 구분한 필터 인스턴스 리스트를 사용할 수도 있습니다. 여기에서는 HttpExceptionFilter 인스턴스를 생성해서 넣었습니다. 또는 인스턴스 대신 클래스를 전달하여 인스턴스화에 대한 책임을 프레임워크에 넘기고 의존성 주입을 활성화할 수 있습니다.

 

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

Nest를 사용할 때 가능하다면 인스턴스 대신 클래스를 사용하여 필터를 적용하는 것을 선호합니다. Nest는 전체 모듈에서 동일한 클래스의 인스턴스를 쉽게 재사용할 수 있어서 메모리 사용을 줄여줍니다.

위의 예에서는 HttpExceptionFilter는 오직 create() 라우트 핸들러 하나에만 적용되어서 메서드 스코프로 만들었습니다. 예외 필터는 다른 수준(메서드 스코프, 컨트롤러 스코프, 전역 스코프)으로도 스코프를 설정할 수 있습니다. 예를 들어, 컨트롤러 스코프로 필터를 세팅하기 위해서 아래와 같이 작성할 수 있습니다.

@Controller('cats')
@UseFilters(HttpExceptionFilter)
export class CatsController {}

이 설계는 CatsController안에 정의된 모든 라우트 핸들러에 대해 HttpExceptionFilter를 세팅했습니다.

 

전역 스코프 필터를 만들기 위해서 아래와 같이 작성할 수 있습니다.

 

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

글로벌 스코프 필터는 어플리케이션 전반에 걸쳐 사용되어 모든 컨트롤러와 라우트 핸들러에 적용됩니다. 의존성 주입에 대하여 모듈 바깥에서 등록된 전역 필터는 특정 모듈의 환경에서 의존성이 주입되기 때문에 전역 모듈은 의존성을 주입할 수 없습니다. 이러한 문제를 해결하기 위해서, 아래의 설계를 통해 전역 스코프 필터를 모든 모듈로부터 직접 등록할 수 있습니다.

 

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

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

이러한 방법으로 필터를 원하는 만큼 추가할 수 있습니다. 단순히 프로바이더 배열에 각각 넣어주면 됩니다.

 

모든 것을 캐치

예외 타입에 관계없이 다루지 못한 모든 예외를 잡아내기 위해, @Catch() 데코레이터의 매개변수를 비워두면 됩니다. 아래의 코드에서는 HTTP adapter를 사용하여 응답을 전달하며 플랫폼 기반 객체를 사용하지 않기 때문에 플랫폼에 구애받지 않는 코드가 있습니다.

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

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  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.
    const { httpAdapter } = this.httpAdapterHost;

    const ctx = host.switchToHttp();

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

    const responseBody = {
      statusCode: httpStatus,
      timestamp: new Date().toISOString(),
      path: httpAdapter.getRequestUrl(ctx.getRequest()),
    };

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

상속

일반적으로 어플리케이션의 요구사항을 만족하도록 만들어진 사용자 정의 예외 필터를 만듭니다. 그러나 단순히 내장 기본 전역 필터를 확장하여 특정 요인에 기반한 행동을 덮어서 사용하고 싶을 때, 사용하는 방법이 있습니다.

 

기본 필터에 예외 처리를 위임하기 위해서, BaseExceptionFilter를 불러와 상속된 catch 메서드를 사용해야 합니다.

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

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

위의 예외처리 구현은 단지 접근 방식만을 보여줍니다. 앞서 구현한 상속받은 예외 필터는 우리가 잘 다듬은 비즈니스 로직을 포함합니다.

 

전역 필터는 base 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';

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