NestJS Overview - Exception Filters

Min Su Kwon·2021년 10월 17일
0

네스트는 빌트인 예외 레이어를 기본으로 제공하며, 애플리케이션에서 발생하는 핸들링되지않은 예외를 프로세싱하는 책임이 있다. 애플리케이션 코드에서 예외가 핸들링 되지 않았다면, 이 레이어에서 이를 잡아서, 자동으로 사용자 친화적은 응답을 반환하게된다.

이 동작은 빌트인 전역 예외 필터에 의해서 일어나며, HttpException 타입의 예외들을 핸들링한다. 예외가 인지되지 않았다면(HttpException 또는 HttpException의 자식 클래스가 아닌 경우), 빌트인 예외 필터는 다음과 같은 JSON 응답을 반환한다.

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

전역 예외 필터는 부분적으로 http-errors 라이브러리를 지원한다. 기본적으로, statusCodemessage 프로퍼티를 가지는 예외는 올바르게 채워지고 응답으로 반환 될 것이다.

Throwing standard exceptions

네스트는 @nestjs/common 패키지를 통해 빌트인 HttpException 클래스를 제공한다. 일반적인 HTTP REST/GraphQL 기반의 애플리케이션에서, 표준 HTTP 응답 객체를 특정 에러 발생시 반환하는 것이 best practive로 여겨진다.

예를 들어, CatsController 내에서, findAll() 메서드가 있고, 이 라우트에서 어떤 이유로 예외를 던진다고 생각해보자.

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

만약 클라이언트에서 이 엔드포인트로 요청을 보내면, 다음과 같은 응답을 받게된다.

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

HttpException 생성자는 두가지 필수 인자를 받아서 응답을 결정한다.

  • response : JSON 응답의 바디 - string / object
  • status : HTTP 상태 코드

디폴트로, JSON 응답 바디는 두가지 프로퍼티를 가진다

  • statusCode : status 값에 넘겨진 값이 디폴트
  • message : HTTP 에러에 관한 짧은 설명

message 프로퍼티 부분을 바꾸려면, response 인자에 객체를 넣어주면된다. 네스트가 이 객체를 직렬화해서 JSON 응답 바디로 반환해준다.

두번째 인자인 status의 경우에는 HTTP 상태 코드를 반환하면 되는데, HttpStatus enum을 사용하는 것이 best practice다.

다음은 응답 바디의 전체를 오버라이딩하는 예다.

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

위의 엔드포인트에 요청을 보내게되면 다음과 같은 응답을 받게된다.

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

Custom exceptions

많은 경우에, 커스텀 예외를 작성할 필요는 없고, 그냥 네스트 HTTP 예외를 사용하면된다. 하지만 그래도 커스텀 예외를 만들어야 한다면, 자체 예외 hierarchy를 만드는 것이 추천된다. 이 커스텀 예외들은 HttpException 클래스를 상속받아야 하며, 이렇게하면 네스트가 여전히 예외를 인지하고, 적절한 응답을 반환할 수 있다.

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

ForbiddenExceptionHttpException을 상속받기 때문에, 빌트인 예외 핸들러와 잘 동작할 것이며, findAll() 메서드에서 사용해도된다.

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

Built-in HTTP exceptions

네스트는 HttpException을 상속받는 몇가지 표준 예외를 제공한다.

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

Exception filters

빌트인 예외 핸들러가 많은 경우에 잘 동작하지만, 예외 레이어에 대한 풀 컨트롤을 갖길 원할 수 있다. 예를 들어, 로깅 기능을 축하하거나, 다른 JSON 스키마를 사용하는 등의 요구사항이 있을 수 있다. 예외 필터는 이 목적을 위해서 설계되었다. 이를 통해서 정확한 플로우 통제와 클라이언트로 반환할 응답을 직접 정할 수 있다.

HttpException 클래스의 인스턴스를 모두 잡는 예외 필터를 만들고, 커스텀 응답 로직을 타도록 만들어보자. 이를 위해서, 플랫폼 Request, Response 객체에 접근할 수 있어야한다. Request 객체를 통해서 원래의 url을 가져오고, 로깅 정보에 포함시킨다. Response 객체를 통해서 어떤 응답을 보낼지 직접 정한다.

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

모든 예외 필터는 ExceptionFilter<T> 인터페이스를 구현해야한다. 이 인터페이스는 catch(exception: T, host: ArgumentsHost) 메서드를 필요로한다. 여기서 T는 예외의 타입을 뜻한다.

@Catch(HttpException) 데코레이터는 예외 필터에 필요한 메타데이터를 바인딩해주며, 네스트가 어떤 예외 타입을 보고 있는지 알 수 있게 해준다. @Catch() 데코레이터는 하나 이상의 파라미터를 받으며, 여러 예외 타입에 대한 필터를 한번에 설정할 수 있도록 도와준다.

Arguments host

catch() 메서드에 넘겨지는 파라미터를 살펴보자. exception 파라미터는 현재 가공하고자 하는 예외 객체다. host 파라미터는 ArgumentsHost 객체로, 다음 문서에서 더 자세히 살펴볼 강력한 유틸리티 객체다.

위 예시에서는 host 파라미터를 통해서 원래 요청 핸들러에게 넘겨진 RequestResponse 객체에 대한 레퍼런스를 받았다. ArgumentsHost에 대한 자세한 내용은 다음 문서에서 다룬다.

ArgumentsHost를 이런식으로 사용하는 이유는 모든 문맥(HTTP 서버, 마이크로서비스, 웹소켓 등)에서 똑같이 기능하도록 만들기 위해서다. 다음 문서에서 어떻게 적절하게 underlying arguments에 접근할 수 있는지 다룬다. 이를 통해서 모든 문맥에서 동일하게 동작하는 예외 필터를 만들 수 있다.

Binding filters

HttpExceptionFilterCatsControllercreate() 메서드를 묶어보자.

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

가능하다면 인스턴스를 넘기기보다 클래스를 넘기는 것이 좋다. 그렇게하면 네스트가 판단해서 같은 인스턴스를 여러 모듈에서 재사용할 수 있다.

위 예시에서 HttpExceptionFiltercreate() 라우트 핸들러에만 적용된다. 예외 필터는 다양한 레벨에서 사용할 수 있다 - 메서드, 컨트롤러, 전역. 아래 예시에서는 컨트롤러에 적용해본다.

@UseFilters(new 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 {}

위 같은 방법을 사용할 경우, 어느 모듈에서 의존성 주입을 진행하던 항상 전역 필터가 된다는 점을 알아야한다. 해당 필터가 정의된 모듈 쪽에서 선언해주는 것이 좋다.

이 방법으로 원하는 만큼 필터를 추가할 수 있으며, 간단히 providers 배열에 추가만 해주면된다.

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

위 예외 필터는 클래스 상관없이 모든 예외를 잡게된다.

Inheritance

일반적으로 애플리케이션 요구사항 충족을 위해서 완전히 커스터마이즈된 예외 필터를 만들게 될 것이다. 하지만, 간단하게 기존 전역 예외 필터를 살짝만 확장하거나 override하고 싶을 수 있다.

예외 프로세싱을 베이스 필터에게 위임하기 위해서는, BaseExceptionFilter를 extend하고 상속받은 catch() 메서드를 호출하면된다.

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를 extend하는 메서드/컨트롤러 스코프 필터는 new 키워드를 통해서 인스턴스화되면 안된다. 클래스를 넘기고 네스트가 알아서 하게 둬야한다.

위의 예시는 접근법을 보여주기 위함이다. 실제로 구현된 확장 예외 필터는 비즈니스 로직을 포함할 수 있다.

전역 필터도 베이스 필터를 extend 할 수 있다. 두가지 방법으로 가능하다.

첫 번째는 커스텀 글로벌 필터를 만들 때 HttpServer 레퍼런스를 주입하는 것이다.

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 토큰을 사용하는 방법이다.

느낀점

비즈니스 로직 구현만큼 중요한 에러처리/예외 핸들링 부분이다. 현재까지 적용해본건 전역 필터밖에 없는데, 이쪽 스터디를 아예 안하고 적용해보다보니 거의 건드리지 못했고, 개선할 부분이 꽤 있을 것 같다.

종류별로 Exception을 제공해서 따로 외부 라이브러리 사용없이 그냥 네스트가 빌트인으로 제공하는 것들을 사용하는 것이 좋다는 생각이 든다..

profile
이제 막 커리어를 시작한 소프트웨어 엔지니어입니다. 배운 것을 정리하면서 조금 더 깊이 이해하려는 습관을 들이려고 합니다. 피드백은 언제나 환영입니다.

0개의 댓글