새소식

반응형
Back-end/NestJS

[NestJS | Docs] Exception filters (예외 필터)

2024.03.04
  • -
반응형

1. Exception filters (예외 필터)

Nest에는 애플리케이션 전체에서 처리되지 않은 모든 예외를 처리하는 예외 계층이 내장되어 있습니다. 애플리케이션 코드에서 처리되지 않은 예외가 발생하면 이 계층에서 해당 예외를 포착하여 적절한 사용자 친화적인(user-friendly) 응답을 자동으로 전송합니다.

 

기본적으로 이러한 작업은 Nest에 내장된 전역 예외 필터(global exception filter)에 의해 수행되며, 이 필터는 일반적으로 HttpException 유형(및 그 하위 클래스)의 예외를 처리합니다. 예외가 인식되지 않는 경우(HttpException도 아니고 HttpException을 상속하는 클래스도 아닌 경우)에는 예외 필터에서 기본(default)값으로 다음과 같은 기본 JSON 응답을 생성합니다.

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

 

하나 중요한 점은 예외가 발생하는 즉시 처리되는 것이 아닌, 모든 계층을 거치고 나서 이 exception filter 계층에 도착했을 때 발생한 예외가 있는지 확인하고 그에 맞게 처리를 하게 된다는 것입니다.

 

전역 예외 필터는 부분적으로 http-errors 라이브러리를 지원합니다. 기본적으로 statusCodemessage 속성을 포함하는 모든 예외가 올바르게 설정되고 인식할 수 없는 예외의 경우 기본 InternalServerErrorException 응답이 대신 전송됩니다.

 

1-1. Throwing standard exceptions (표준 예외 일으키기)

Nest는 기본 모듈에서 HttpException 클래스를 제공합니다. 전형적인 HTTP REST/Graphql API 기반의 애플리케이션에서는, 특정 에러 조건이 발생하면 표준 HTTP response 객체를 보내는 것이 가장 좋은 방법입니다.

 

예를 들어, CatsController에서 findAll() 메서드(GET 라우트 핸들러)에 대해 생각해봅시다. 이 라우트 핸들러는 특정 이유로 예외를 던진다고 가정해 보고, 이를 보여줄 수 있는 다음과 같은 코드를 작성해보았습니다.

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

 

여기서 사용한 HttpStatus는 @nestjs/common 패키지에서 제공하는 helper enum 입니다.

 

클라이언트가 이 엔드포인트를 호출하면 응답은 아래와 같을 것입니다.

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

 

HttpException 생성자는 응답을 결정하는 2개의 필수 인자를 받습니다.

  • response 인자는 JSON response body를 정의합니다. 이는 string 또는 object일 수 있습니다.
  • status 인자는 HTTP status code를 정의합니다.

deafult 값으로 JSON response body는 2가지 속성을 포함합니다.

  • statusCode: 기본값은 status 인자에 제공된 HTTP 상태 코드입니다. 
  • message: state에 따른 HTTP 에러에 대한 간단한 설명입니다.

JSON response body의 message 부분만 재정의(override)하려면 response arguments에 원하는 문자열을 입력합니다. JSON 응답 body 전체를 재정의하려면 응답 인수에 해당 객체를 전달합니다. Nest는 객체를 직렬화하여 JSON 응답 body로 반환합니다.

 

두 번째 생성자 인자인 status유효한 HTTP 상태 코드여야 합니다. 가장 좋은 방법은 @nestjs/common에서 가져온 HttpStatus enum을 사용하는 것입니다.(Nest에서 정해준 대로 사용)

에러 cause(원인)을 제공하는 데 사용할 수 있는 세 번째 생성자 인자(선택 사항)인 options가 있습니다. 이 cause 객체는 응답 객체로 직렬화되지는 않지만 로깅 목적으로 유용할 수 있으며, HttpException을 발생시킨 내부 오류에 대한 중요한 정보를 제공합니다.

 

아래는 전체 응답 body를 재정의하고 오류 원인 (cause)을 제공하는 예제입니다.

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

 

위의 내용을 통해 다음과 같은 응답이 표시될 것입니다.

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

 

1-2. Custom exceptions

대부분의 경우 사용자 정의 예외(custom exception)를 작성할 필요는 없습니다. 다음 섹션에 설명된 대로 그냥 기본 제공 Nest HTTP 예외를 사용해도 충분히 넓은 범위의 예외를 커버할 수 있기 때문입니다. 하지만 사용자 정의 예외를 작성해야 하는 경우에는 기본 HttpException 클래스를 상속하는 자체적인 exception layer를 만드는 것이 좋습니다.

 

이 접근 방식을 사용하면 Nest가 예외를 인식하고 오류 응답을 자동으로 처리합니다. 이러한 사용자 정의 예외를 구현해 보겠습니다:

 

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

 

ForbiddenException은 기본 HttpException을 상속하여 구현하기 때문에 내장된 예외 핸들러와 원활하게 작동하여 findAll() 메서드 내에서 사용할 수 있습니다.

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

 

 

1-3. 기본 제공 HTTP exceptions

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

 

모든 기본 제공 예외들은 options 매개변수를 사용하여 에러에 대한 cause와 description을 모두 제공할 수도 있습니다:

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

 

위의 내용을 통해 다음과 같은 응답이 표시될 것입니다.

{
  "message": "Something bad happened",
  "error": "Some error description",
  "statusCode": 400,
}

 

1-4. Exception filters

기본 예외 필터가 대부분의 경우를 자동으로 처리할 수 있지만, 예외 계층을 완전히 내 마음대로 컨트롤하고 싶은 경우가 있을 수도 있습니다. 예를 들어 로깅 기능을 추가한다거나 일부 동적 요인에 따라 다른 JSON 스키마를 사용하고 싶은 경우도 있을 것입니다.

 

Exception filters는 바로 이러한 목적들을 위해 설계되었습니다. exception filter를 사용하면 더 정확한 제어 흐름과 클라이언트로 다시 전송되는 응답의 내용을 제어할 수 있습니다.

 

HttpException 클래스의 인스턴스인 특정 예외를 캐치하여 이에 대한 custom 응답 로직을 구행하는 예외 필터 구현체를 만들어 보겠습니다. 이를 위해서는 기본 플랫폼(exrpess 혹은 fastify)의 RequestResponse 객체에 접근해야 합니다.

Request 객체을 통해서 원본 url을 가져와 이를 로깅 정보에 포함시킬 수 있고, Response 객체를 통해서 response.json() 메서드를 사용하여 전송될 응답을 직접 제어할 수 있습니다.

 

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

 

모든 예외 필터는 일반 ExceptionFilter<T> 인터페이스를 구현해야 합니다. 이를 위해서는 catch(exceoption: T, host: ArgumentsHost) 메서드에 지정된 시그니처를 제공해 주어야합니다. 여기서 T는 예외의 제네릭 타입을 의미합니다.

 

WARNING
@nestjs/platform-fastify를 사용하는 경우 response.json() 대신 response.send()를 사용합니다.

 

 

@Catch() 데코레이터는 필수 메타데이터를 예외 필터에 바인딩하여 특정 필터가 HttpException 유형의 예외만 찾고 있음을 Nest에 알립니다.

 

@Catch() 데코레이터를 통해 단일 매개변수 또는 쉼표로 구분된 리스트 형태의 매개변수를 받을 수 있고, 이를 통해 한 번에 여러 유형의 예외에 대한 필터를 설정할 수 있습니다.

 

 

1-5. Arguments host

catch() 메서드의 매개변수를 살펴봅시다.

 

exception 매개변수는 현재 처리 중인 예외 객체를 의미합니다.

 

host 매개변수는 ArgumentsHost 객체입니다. ArgumentsHost실행 컨텍스트(exceution context) 챕터에서 자세히 다루었는데, 이는 Nest에서 자주 사용되는 강력한 유틸리티 객체입니다.

 

이 코드 샘플에서는 이 ArgumentHost 객체를 사용하여 원래의 요청 핸들러(예외가 발생한 컨트롤러)로 전달되는 요청 및 응답 객체에 대한 참조(reference)를 얻습니다. 또한 원하는 요청 및 응답 객체를 얻기 위해서 ArgumentsHost의 몇 가지 helper 메서드를 사용했습니다. ArgumentsHost에 대해 추가적인 정보에 대해서 알고 싶으신 분들은 여기를 참고해주세요.

 

이러한 수준의 추상화가 필요한 이유는 ArgumentsHost가 모든 컨텍스트(예: 지금 작업 중인 HTTP 서버 컨텍스트뿐만 아니라 마이크로서비스와 웹소켓도 포함)에서 동작하기 때문입니다. 실행 컨텍스트 챕터에서는 ArgumentsHost와 그 helper 함수를 이용해 모든 실행 컨텍스트에 적합한 기본 인자에 접근하는 방법에 대해 자세히 다룹니다. 이를 통해 모든 컨텍스트에서 작동하는 일반 예외 필터를 작성할 수 있기 때문에 사용한다고 보시면 될 것 같습니다.

 

1-6. Binding filters (필터 바인딩하기)

우리가 만든 HttpExceptionFilter를 CatsController의 create() 메서드에 바인딩해보도록 하겠습니다.

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

 

여기서는 @UseFilters() 데코레이터를 사용했습니다. @Catch() 데코레이터와 마찬가지로 단일 필터 인스턴스 또는 쉼표로 구분된 필터 인스턴스의 리스트를 받을 수 있습니다. 여기서는 HttpExceptionFilter 인스턴스를 new 키워드를 통해 제자리에서 생성했습니다.

 

물론 위와 같은 방식처럼 인스턴스를 직접 전달하는 대신 클래스 이름을 전달하여 인스턴스화에 대한 책임을 프레임워크에 맡기는 의존성 주입을 활용할 수도 있습니다.

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

 

그리고 당연히도 Nest에서 필터를 적용할 때는 가능하면 위와 같이 인스턴스 대신 클래스를 사용하는 것을 선호합니다. 이는 전체 모듈에서 동일한 클래스의 인스턴스를 쉽게 재사용할 수 있게 하기 때문에 메모리 사용량을 줄일 수 있습니다.

 

위의 예제에서 HttpExceptionFilter는 메서드 범위에서 지정되어 단일 create() 라우트 핸들러(메서드 범위)에만 적용됩니다. 하지만 예외 필터는 controller/reoslver/gateway의 메서드 범위, 컨트롤러 범위 또는 전역 범위 등 다양한 수준의 범위들을 지정할 수 있습니다.

 

예를 들어 컨트롤러 범위로 필터를 설정하고 싶다면 아래와 같이 작성하면 됩니다:

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

 

이는 CatsController 내부에서 정의된 모든 라우트 핸들러에 대해 HttpExceptionFilter를 적용하게 됩니다.

 

1-6-2. 전역 범위 필터

만약 전역 범위의 필터를 만들고 싶다면 다음과 같이 작성하면 됩니다:

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

 

단, useGlobalFilters() 메서드는 게이트웨이 또는 하이브리드 애플리케이션에 대한 필터를 설정하지 않습니다.

 

전역 범위 필터는 모든 컨트롤러와 모든 라우트 핸들러에 대해 전체 애플리케이션에 걸쳐 사용됩니다.

 

그런데 의존성 주입 관점에서, 모듈 외부에서 등록된 전역 필터(위 예제에서와 같이 useGlobalFilters()를 사용하여)는 모든 모듈의 컨텍스트 바깥에서 수행되기 때문에 다른 곳으로 해당 의존성을 주입할 수 없습니다.

 

이 문제를 해결하기 위해선 다음과 구성으로 모든 모듈에서 직접 전역 범위 필터를 등록할 수 있도록 할 수 있습니다:

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

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

 

이 접근 방식을 사용하여 filter에 대한 의존성 주입을 수행할 때는 이 구조가 사용되는 모듈과 관계없이 필터가 실제로는 전역으로 적용된다는 점에 유의해야 합니다.
그렇다면 이러한 명시는 어느 모듈에서 작성되어야 할까요? 정답은 필터(위 예시에서는 HttpExceptionFilter)가 정의된 모듈입니다. 또한 useClass만 custom provider의 등록을 처리할 수 있는 유일한 방법은 아님을 유의합니다.

 

이러한 테크닉을 사용하여 필요한 만큼 필터를 추가할 수 있으며, 그 필터를 사용하려면 각 필터를 providers 배열에 추가하기만 하면 됩니다.

 

1-7. catch everything

처리되지 않은 모든 예외예외 유형에 관계없이 잡으려면 @Catch() 데코레이터의 매개변수 목록을 비워둡니다(예: @Catch()).

아래 예시는 HTTP 어댑터를 사용하여 응답을 전달하고 플랫폼별 객체(Request 및 Response)를 직접 사용하지 않으므로 플랫폼에 구애받지 않는 코드를 보여줍니다:

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

 

모든 것을 캐치하는 예외 필터특정 유형에 바인딩된 필터를 결합하는 경우, 특정 필터가 바인딩된 유형을 올바르게 처리할 수 있도록 'Catch anything' 필터를 먼저 선언해야 합니다.

 

1-8. Inheritance (상속)

일반적으로 애플리케이션 요구 사항을 충족하기 위해서는 fully-customized(전부 다 커스텀하는) 예외 필터를 만들게 됩니다. 그러나 단순히 기본으로 제공되는 기본 전역 예외 필터를 확장시켜, 특정 요인에 따라 동작을 오버라이드하는 사용 사례도 있습니다.

예외 처리를 기본 필터에 위임하려면 BaseExceptionFilter를 상속하고 생성자에서 super.catch() 메서드를 호출해야 합니다.

// all-exceptions.fileter.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를 확장하는 메서드 범위컨트롤러 범위 필터는 new 키워드를 통해 인스턴스화하면 안 됩니다. 반드시 프레임워크가 자동으로 대신 인스턴스화하도록 합니다.

 

위의 구현은 단순한 껍데기일 뿐입니다. 실제로 사용되는 확장 예외 필터의 구현에는 다양한 조건문 처리와 같은 맞춤형 비즈니스 로직이 포함될 수 있습니다.

전역 필터는 기본 필터를 확장할 수 있으며 이는 두 가지 방법 중 하나로 수행할 수 있습니다.

  • 첫 번째 방법은 사용자 정의 전역 필터를 인스턴스화할 때 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 토큰을 사용하는 것입니다.

 

에러 핸들링을 잘 사용하려면 다음을 지킵시다.

  • Consistency: 애플리케이션에 걸친 에러 메세지가 일관된 구조를 유지하도록 한다.
  • Status Code: 에러의 속성을 정확히 반영하는 HTTP 상태 코드(status code)를 사용한다.
  • Logging: 개발 단계에서는, 오류 진단을 위한 구체적인 stack traces를 로깅한다. 운영 단계에서는 민감한 세부사항은 숨기면서도 에러는 로깅하도록 한다.
  • User-friendly Messages: 에러 메세지는 응답을 받는 사용자에게 혼란을 줄 수 있는 기술적 용어를 피하면서, 유익하면서도 사용자 친화적이어야 한다. 

 

 

반응형
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.