NestJS 에서 Custom Exception 활용하기

Aiden·2023년 6월 5일
13
post-thumbnail

NestJS 애플리케이션에서 발생한 예외가 더 많은 정보를 표현하도록 정의하면, Production 환경에서는 물론 개발 환경에서도 더 쉽고 빠른 디버깅이 가능합니다.

실제로 최근 NestJS 기반의 사이드 프로젝트에서는 애플리케이션 레벨의 모든 예외를 커스터마이징하여 ErrorCode 및 Request Path 에 대한 정보를 포함하도록 구현해보았고, 이러한 방법을 통해 디버깅이나 가독성 측면에서 긍정적인 효과를 얻을 수 있었습니다.

따라서 이번 글에서는, NestJS Exception 에서 느낀 아쉬운 점들에 대해 소개하고, 이를 직접 커스터마이징하며 얻게된 다양한 장점들에 대해 공유해보고자 합니다.


문제점

NestJS 에서는 기본적으로 Built-in Exception 을 제공하고 있고, 이는 @nestjs/common 패키지를 통해 사용 가능합니다.

import { ForbiddenException } from '@nestjs/common';

async throwException() {
  throw new ForbiddenException();
}

이렇게 발생한 예외는 아래와 같은 응답으로 표현되며, 애플리케이션 레벨에서 처리하지 못한 예외(Uncatched)의 경우에도 Internal server error(500) 으로 표현됩니다.

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

하지만 복잡한 비즈니스 로직의 경우에는 다양한 예외 상황이 존재할 수 있고, 이를 StatusCode 나 에러 메시지만으로 구분하기에는 정보의 표현력이 부족할 뿐더러 가독성 또한 좋지 않습니다.

아래에서 예시를 들어보겠습니다.

1. Built-in Exception 사용

async getPhotosByUserId(userId: number): Promise<PhotoEntity> {
  const user = await this.userRepository.findOne(userId);
  if (!user) throw new NotFoundException();

  if (!user.albumId) throw new BadRequestException();
  
  const photos = await this.photoRepository.find(albumId);
  if (!photos.length) throw new NotFoundException();
  
  return photos;
}

userId 를 받아 해당 유저의 사진을 조회하는 단순한 API 입니다.

이러한 상황에서 각각의 예외 상황을 명확하게 구분해내는데 statusCode 만으로는 부족하다는 것을 쉽게 알 수 있습니다.

유저가 존재하지 않을 경우의 statusCode(404)와 사진이 존재하지 않을 경우의 statusCode(404)가 중복되기 때문에 클라이언트에서 각각의 예외 상황을 구분하여 처리할 수 없고,
또한 코드 레벨에서 예외 상황을 정확하게 이해하기 위해서는 조건문을 포함한 모든 로직을 직접 살펴보아야 하기 때문에 가독성 측면에서도 불리합니다.

그렇다면 에러 메시지를 추가하는 방법은 어떨까요?

2. Error Message 사용

async getPhotosByUserId(userId: number): Promise<PhotoEntity> {
  const user = await this.userRepository.findOne(userId);
  if (!user) throw new NotFoundException('User Not Found');

  if (!user.albumId) throw new BadRequestException('Album Doesn\'t Exist');

  const photos = await this.photoRepository.find(albumId);
  if (!photos.length) throw new NotFoundException('Photos Not Found');
  
  return photos;
}

위와 같이 에러 메시지를 작성하게 되면, 예외 상황에 대해 정확하게 명세할 수 있어 코드 레벨의 가독성 측면에서는 비교적 나아졌습니다.
모든 로직을 살펴보지 않더라도 에러 메시지를 통해 예외 발생 조건을 빠르게 파악할 수 있기 때문입니다.

하지만 클라이언트에서 각각의 예외 상황을 statusCode 로 구분할 수 없다는 문제는 여전히 남아있고,
무엇보다 코드에 문자열 타입의 에러 메시지를 직접 작성하여 노출하고 있다는 점은 값의 안전성과 재사용성을 보장하지 못할 우려가 있었습니다.

3. StatusCode 분리

async getPhotosByUserId(userId: number): Promise<PhotoEntity> {
  const user = await this.userRepository.findOne(userId);
  if (!user) throw new ForbiddenException();

  if (!user.albumId) throw new BadRequestException();

  const photos = await this.photoRepository.find(albumId);
  if (!photos.length) throw new NotFoundException();
  
  return photos;
}

클라이언트가 쉽게 예외를 구분할 수 있도록 각각의 예외마다 다른 statusCode 를 사용하는 방법도 고려해보았습니다.

이 경우, 유저가 존재하지 않으면 Forbiden(403), 소유한 앨범이 없다면 BadRequest(400), 저장한 사진이 없다면 NotFound(404) 를 응답으로 반환하게 되므로, 모든 예외 상황을 명확하게 구분하여 처리할 수 있습니다.

하지만, 예외 상황이 더욱 많아질 경우에는 statusCode 가 현재 발생한 예외 상황을 적절하게 표현하지 못하게 될 수도 있습니다.

통상적으로 statusCode 가 표현하는 의미(Forbidden, BadRequest, NotFound, Unauthorized, ...) 와 각각의 예외 상황을 이질감없이 매칭하는 것이 가장 직관적인 예외 처리 방법이라 생각했기 때문에 이 방법 또한 사용하지 않기로 했습니다.


해결 방안

정리하자면, 앞서 살펴본 모든 문제점들을 해결하기 위해서는 아래와 같은 사항들을 모두 만족하여야 합니다.

  • 클라이언트에서 각각의 예외 상황을 명확하게 구분하여 처리할 수 있다.
  • statusCode 는 하나의 API 에서 중복해 사용할 수 있다.
  • 코드 레벨에서의 가독성이 좋아야 한다.
  • 값의 안전성과 재사용성을 보장해야 한다.

또한 Exception 이 반환하는 응답 메시지에 예외를 발생시킨 Request Path 와 시간 정보가 포함되어 있다면, Production 환경에서 애플리케이션 로그로 활용하기에도 좋을 것 같아 추가해주기로 했습니다.

  • Request PathTimeStamp 정보가 포함되어 있다.

1. ErrorCode 사용

먼저 하나의 API 에서 동일한 statusCode 를 중복해서 사용하면서도, 클라이언트에서 예외를 명확하게 구분하여 처리하기 위해서는 statusCode 외에 또 다른 기준을 세우고 각각의 예외에 할당해 줄 수 있어야 합니다.

이를 위해 errorCode 를 직접 정의하여 사용하기로 했습니다.

errorCode 를 정의하는 규칙은 따로 정해진건 없으며 모든 도메인에서 공통적으로 발생할 수 있는 예외의 경우, 기존에 정의해놓은 errorCode 를 재사용하기 위해 전체 애플리케이션에서 공유할 수 있도록 구현해주었습니다.

아래는 모든 도메인에서 공통적으로 발생할 수 있는 인증 관련 예외들의 errorCode 구현 예시입니다.

// lib/enum/exception.enum.ts

export enum AuthExceptionCodeEnum {
    EmailNotFound = '0001',
    NotAuthenticated = '0002',
    EmailExists = '0003',
    JwtInvalidToken = '0004',
    JwtUserNotFound = '0005',
    JwtExpired = '0006',
    JwtInvalidSignature = '0007',
    UserNotFound = '0008',
}

값의 안전성을 보장하기 위해 각각의 errorCode 들을 enum 으로 작성해주었으며, 앞의 두 자리는 도메인을 식별하는 용도이고 뒤의 두 자리는 각각의 예외 상황을 식별하는 용도로 사용하고 있습니다.

모든 errorCode 는 이렇게 하나의 파일에서 관리되며, 이를 통해 클라이언트와 소통할 수 있습니다.
또한 클라이언트가 errorCode 를 기준으로 예외를 처리할 수 있도록 모든 예외의 응답 메시지는 errorCode 를 포함하여야 합니다.

이제 하나의 API 안에서 statusCode 를 중복해 사용하더라도 errorCode 를 통해 각각의 예외 상황을 명확하게 구별할 수 있게 되었습니다.
또한, 하나의 파일에서 errorCode 를 관리하고 애플리케이션 레벨에서 이를 공유하기 때문에 재사용성도 보장할 수 있게 되었습니다.

2. Exception Class Naming 으로 예외 상황 노출

앞서 살펴보았듯이, 복잡한 비즈니스 로직에서 Built-in Exception 을 그대로 사용하게 되면 예외 발생 조건을 파악하기 위해 모든 로직을 살펴보아야 한다는 불편함이 있습니다.

이를 해결하기 위해 아래와 같이 에러 메시지를 작성하는 방법도 고려해보았지만, 재사용성은 물론 값의 안전성 측면에서도 좋은 방법은 아니었습니다.
또한, 전체 애플리케이션의 동일한 예외 처리 구문에서는 항상 같은 에러 메시지를 작성하도록 강제할 수 없기 때문에 코드의 일관성을 보장할 수 없다는 점도 아쉬운 점 중 하나였습니다.

// API 1
if (!user) throw new NotFoundException('User Not Found');

// API 2
if (!user) throw new NotFoundException('User doesn\'t exist');

따라서 기존의 Built-in Exception 을 그대로 사용하지 않고, Exception Class Naming 으로 예외 상황을 직접 노출하기로 했습니다.

아래와 같이 Exception Class 의 이름에서 직관적으로 예외 상황을 파악할 수 있다면, 모든 로직을 살펴보거나 에러 메시지를 작성할 필요없이 예외 발생 조건을 빠르게 이해할 수 있을 것입니다.

또한 errorCode 와 마찬가지로, 이렇게 정의된 Exception Class 를 애플리케이션 전체에서 공유하여 사용한다면 재사용성과 코드의 일관성을 보장할 수도 있습니다.

// API 1
if (!user) throw new UserNotFoundException();

// API 2
if (!user) throw new UserNotFoundException();

이렇게 예외 처리 구문의 가독성은 물론 재사용성과 코드의 일관성을 모두 해결할 수 있게 되었습니다.


구현

이제 지금까지 살펴본 해결 방안들을 애플리케이션에 직접 구현해보도록 하겠습니다.

구현해야 할 사항들은 아래와 같습니다.

  • 🚧 errorCode 정의 및 구현
  • 🚧 Exception Class Name 수정
  • 🚧 예외 발생 시 응답 메시지 정의

애플리케이션에서 공통적으로 사용할 errorCode 를 구현하고, Exception Class Name이 예외 발생 조건을 표현하도록 수정합니다.
마지막으로는 errorCode 와 로깅을 위한 Request Path, TimeStamp 가 포함되도록 응답 메시지 타입을 정의해주어야 합니다.

최종적인 예외 발생 시 응답 메시지는 아래 예시와 같은 형태여야 합니다.

{
  "errorCode": "0008",
  "statusCode": 404,
  "path": "/api/v1/users/photos",
  "timestamp": "2023-06-03 17:36:06",
}

1. errorCode 정의 및 구현

errorCode 는 위에서 예시와 함께 살펴보았기 때문에 여기에서는 간단한 구현 코드만으로 설명을 대체하겠습니다.

// lib/enum/exception.enum.ts

export enum AuthExceptionCodeEnum {
    EmailNotFound = '0001',
    NotAuthenticated = '0002',
    EmailExists = '0003',
    JwtInvalidToken = '0004',
    JwtUserNotFound = '0005',
    JwtExpired = '0006',
    JwtInvalidSignature = '0007',
    UserNotFound = '0008',
}

2. Exception Class Name 수정

Exception Class 의 이름이 예외 상황을 노출할 수 있도록 수정해주어야 합니다.

사실 Class 의 이름을 수정하는 것 뿐 아니라, errorCode, statusCode, path, timestamp 와 같은 정보들을 Exception 객체 안에 저장하기 위해 속성도 함께 정의해주어야 합니다.
Exception 객체가 생성되는 시점에 응답에 필요한 값들을 함께 생성해준다면, 추후 응답 메시지를 반환할 때 Exception 객체의 속성값을 그대로 반환하기만 하면 됩니다.
자세한 내용은 아래에서 설명하도록 하겠습니다.

이를 위해 애플리케이션에서 공통적으로 사용할 새로운 Custom Exception Class 를 작성하여야 합니다.
먼저, NestJS Built-in Exception 의 타입을 확인해보겠습니다.

NestJS 에서 제공하는 모든 Built-in Exception 들은 아래와 같이 HttpException 이라는 Class 를 상속하고 있습니다.

// forbidden.exception.d.ts

export declare class ForbiddenException extends HttpException {
    constructor(objectOrError?: string | object | any, descriptionOrOptions?: string | HttpExceptionOptions);
}

그리고 HttpException 의 타입은 아래와 같이 구현되어 있습니다.

// http.exception.d.ts

export declare class HttpException extends Error {
    private readonly response;
    private readonly status;
    private readonly options?;
    
    constructor(response: string | Record<string, any>, status: number, options?: HttpExceptionOptions);
    cause: Error | undefined;
    
    initCause(): void;
    initMessage(): void;
    initName(): void;
    getResponse(): string | object;
    getStatus(): number;
  
    static createBody(objectOrErrorMessage: object | string, description?: string, statusCode?: number): object;
    static getDescriptionFrom(descriptionOrOptions: string | HttpExceptionOptions): string;
    static getHttpExceptionOptionsFrom(descriptionOrOptions: string | HttpExceptionOptions): HttpExceptionOptions;
    static extractDescriptionAndOptionsFrom(descriptionOrOptions: string | HttpExceptionOptions): DescriptionAndOptions;
}

기본적으로 Error 객체를 상속받고 있으며, 생성 시점에 responsestatus 를 인자로 넘겨주어야 합니다.
최종적으로 response 는 응답 메시지의 JSON Body 를, status 는 statusCode 를 정의하게 됩니다.

HttpException 을 상속받아 우리가 원하는 새로운 Custom Exception Class 를 구현할 수 있을 것입니다.
하지만 현재 HttpException 에는 errorCode 와 path, timestamp 속성값이 정의되어 있지 않기 때문에 Base Exception Class 를 먼저 구현해주었습니다.

// lib/interfaces/base.exception.interface.ts

export interface IBaseException {
    errorCode: string;
    timestamp: string;
    statusCode: number;
    path: string;
}


// lib/exceptions/base/base.exception.ts

export class BaseException extends HttpException implements IBaseException {
    constructor(errorCode: string, statusCode: number) {
        super(errorCode, statusCode);
        this.errorCode = errorCode;
        this.statusCode = statusCode;
    }

    @ApiProperty()
    errorCode: string;

    @ApiProperty()
    statusCode: number;

    @ApiProperty()
    timestamp: string;

    @ApiProperty()
    path: string;
}

HttpException 을 상속받는 Base Exception Class 입니다.
앞으로 정의해줄 모든 Custom Exception Class 는 이 Base Exception Class 를 상속받아야 합니다.

생성 시, errorCode 와 statusCode 를 받아 HttpException 에 그대로 전달하고 있으며, 속성으로 path 와 timestamp 도 정의해주었습니다.
path 와 timestamp 는 이후 구현할 Exception Filter 에서 값을 직접 지정해줄 것이기 때문에 별도의 인자로 받지는 않았습니다.

이제 아래와 같이 Custom Exception Class 들을 정의할 수 있습니다.

// lib/exceptions/auth.exception.ts

export class UserNotFoundException extends BaseException {
    constructor() {
        super(AuthExceptionCodeEnum.UserNotFound, HttpStatus.NOT_FOUND);
    }
}


// lib/exceptions/profile.exception.ts

export class AlbumNotFoundException extends BaseException {
    constructor() {
        super(ProfileExceptionCodeEnum.AlbumNotFound, HttpStatus.NOT_FOUND);
    }
}

export class PhotoNotFoundException extends BaseException {
    constructor() {
        super(ProfileExceptionCodeEnum.PhotoNotFound, HttpStatus.NOT_FOUND);
    }
}

각각의 Custom Exception Class 들은 사전에 정의한 errorCode 중 하나와, statusCode 중 하나를 고정으로 할당받게 되며, 이를 BaseException 에 그대로 전달합니다.

이 때 생성자의 두 번째 인자인 statusCode 에 전달된 HttpStatus 는 @nestjs/common 패키지에서 기본적으로 제공하는 enum 타입의 객체입니다.
모든 statusCode 가 number 타입으로 정의되어 있기 때문에 리터럴로 숫자값을 직접 입력하는 것보다 가독성이 좋습니다.

이렇게 정의한 Custom Exception Class 는 아래와 같이 사용할 수 있습니다.

async getPhotosByUserId(userId: number): Promise<PhotoEntity> {
  const user = await this.userRepository.findOne(userId);
  if (!user) throw new UserNotFoundException();

  if (!user.albumId) throw new AlbumNotFoundException();

  const photos = await this.photoRepository.find(albumId);
  if (!photos.length) throw new PhotoNotFoundException();
  
  return photos;
}

예외 발생 상황을 Class Naming 으로 쉽게 파악할 수 있으며, 동일한 statusCode 가 할당된 Exception 을 사용하더라도 각각의 예외에 할당된 errorCode 가 다르기 때문에 클라이언트에서도 예외를 명확하게 구분할 수 있습니다.

또한, 도메인 별로 발생할 수 있는 예외들에 대해 별도의 파일로 분리하여 관리하기 때문에 명세로서의 역할도 가능하고, 애플리케이션 전체에서 공유하여 사용하기 때문에 재사용 측면에서도 유리해졌습니다.

3. 예외 발생 시 응답 메시지 정의

이제 예외 발생 시의 응답 메시지를 아래와 같이 원하는 형태로 정의하여야 합니다.

{
  "errorCode": "0008",
  "statusCode": 404,
  "path": "/api/v1/users/photos",
  "timestamp": "2023-06-03 17:36:06",
}

이를 위해서는 NestJS 의 Request LifeCycleException Filter 에 대해 이해할 수 있어야 합니다.
아래에서 간단하게 살펴보겠습니다.

NestJS 애플리케이션의 Request LifeCycle 은 다음과 같습니다.

Request 가 들어온 이후, Middleware, Guard, Interceptor, Pipe 를 거쳐 Controller, Service 를 통과합니다.
이후, 다시 Interceptor 를 거쳐 Exception FilterResponse 로 전달되고 있습니다.

여기서 Exception Filter 는 Response 직전의 레이어로, 애플리케이션에서 발생한 예외를 추적하고 이에 대한 처리 로직을 담당하고 있습니다.
실제로 NestJS 애플리케이션에서 처리하지 못한 예외(Uncatched) 는 기본적으로 내장된 Exception Filter 를 거치게 되고, 이 곳에서 사용자 친화적인 응답 메시지로 정의되어 반환됩니다.

다시 말해 NestJS 에 내장된 Exception Filter 를 커스텀하면, 우리가 원하는 예외 처리 로직이나 응답 메시지 Body 를 작성할 수 있습니다.

이를 위해 Exception Filter 의 타입을 먼저 확인해보겠습니다.

// exception-filter.interface.d.ts

export interface ExceptionFilter<T = any> {
    catch(exception: T, host: ArgumentsHost): any;
}

새롭게 커스텀할 Exception Filter Class 는 위 인터페이스를 따라 구현되어야 하고, catch 메서드를 필수적으로 구현하여 내부에 예외 처리 로직을 작성해주어야 합니다.

이 때, 첫 번째 인자인 exception 에는 Exception Filter 에서 포착하여 현재 처리 중인 예외 객체가 전달되고, host 에는 ArgumentsHost 객체가 전달됩니다.

ArgumentsHost 는 @nestjs/common 패키지에서 제공하는 헬퍼 객체로, 예외가 발생한 핸들러에 전달된 인수들을 가지고 있습니다.
이를 통해 Request 객체, Response 객체에 접근할 수 있습니다.


이제 Exception Filter 인터페이스를 구현하는 Custom Exception Filter 를 작성해보도록 하겠습니다.

// lib/filter/exception.filter.ts

@Catch()
export class AllExceptionFilter implements ExceptionFilter {
    catch(exception: unknown, host: ArgumentsHost): void {}
}

Catch 데코레이터의 인자에는 ExceptionFilter 가 추적할 예외 클래스를 명시합니다.
하지만 예외 클래스와는 관계없이 애플리케이션에서 처리하지 못한 예외(Uncatched) 까지도 모두 추적하기 위해서는 인자를 전달하지 않고 비워놓으면 됩니다.
우리는 모든 예외에 대해 errorCode 를 할당하고, 동일한 응답 메시지를 반환받고 싶기 때문에 위와 같이 인자를 전달하지 않았습니다.

// lib/filter/exception.filter.ts

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

        const res = exception instanceof BaseException ? exception : new UnCatchedException();

        res.timestamp = format(new Date(), DateFormatEnum.Datetime);
        res.path = request.url;

        response.status(res.statusCode).json({
            errorCode: res.errorCode,
            statusCode: res.statusCode,
            timestamp: res.timestamp,
            path: res.path,
        });
    }
}

catch 메서드 내부는 위와 같이 구현하였습니다.
아래에서 하나하나 살펴보도록 하겠습니다.

먼저, ArgumentsHost 로부터 받아온 컨텍스트에서 Request 와 Response 를 추출하였습니다.
이후 로직에서 Request Path 를 추출하고, 새로운 응답 메시지를 정의하여 반환할 때 활용하여야 합니다.

const ctx = host.switchToHttp();
const request = ctx.getRequest();
const response = ctx.getResponse();

다음으로 아래 구문은 현재 처리 중인 exception 이 BaseException 을 상속받고 있는지 여부를 확인하여 상속받고 있다면 그대로 res 에 할당하고, 그렇지 않다면 UnCatchedException 의 인스턴스를 res 에 할당하는 로직입니다.

const res = exception instanceof BaseException ? exception : new UnCatchedException();

이렇게 처리하는 이유는 애플리케이션에서 처리하지 못한 예외가 발생하였을 때도 그에 맞는 errorCode 와 statusCode 를 할당해주고, 동일한 응답 메시지로 반환하기 위함입니다.
애플리케이션에서 사용하는 모든 예외들은 앞서 구현한 BaseException 을 상속받고 있기 때문에, BaseException 을 상속받고 있지 않은 예외는 애플리케이션에서 처리하지 못한 Uncatched 예외라고 볼 수 있습니다.

이를 위해 UnCatchedException 은 아래와 같이 errorCode 는 '9999', statusCode 는 InternalServerError(500) 로 직접 정의해주었습니다.
이는 애플리케이션에서 처리하지 못한 Uncatched 예외 클래스를 의미합니다.

// lib/exceptions/uncatch.exception.ts

export class UnCatchedException extends BaseException {
    constructor() {
        super(UncatchedExceptionCodeEnum.UnCatched, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

다음으로, 예외 객체의 timestamp 와 path 에 현재 시각과 Request Path 를 할당합니다.

res.timestamp = format(new Date(), DateFormatEnum.Datetime);
res.path = request.url;

이제 응답 메시지 전송을 위한 모든 데이터가 준비되었습니다.

앞서 Custom Exception Class 를 정의하면서 errorCode 와 statusCode 는 예외 객체 안에 할당해주었기 때문에 해당 값들을 그대로 가져다 사용할 수 있을 것이고, Request Path 와 timestamp 정보 또한 예외 객체 안에 할당되었습니다.

응답 메시지 전송을 위해서는 앞서 추출한 response 객체를 통해 JSON Body 를 리터럴로 직접 작성하여야 합니다.
문법은 Express 프레임워크에서 응답을 전송하는 방식과 동일합니다.

response.status(res.statusCode).json({
            errorCode: res.errorCode,
            statusCode: res.statusCode,
            timestamp: res.timestamp,
            path: res.path,
        });

이제 Custom Exception Filter 의 모든 구현이 완료되었습니다.
마지막으로, 애플리케이션에서 해당 Exception Filter 를 사용할 수 있도록 등록해주어야 합니다.

// main.ts

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

    await app.listen(3000);
}

bootstrap();

이제 애플리케이션에서 예외를 발생시키면, 아래와 같은 응답 메시지를 반환받습니다.

{
  "errorCode": "0008",
  "statusCode": 404,
  "path": "/api/v1/users/photos",
  "timestamp": "2023-06-03 17:36:06",
}

반면 애플리케이션에서 처리하지 못한 Uncatched 예외의 경우, 아래와 같이 응답 메시지가 반환됩니다.

{
  "errorCode": "9999",
  "statusCode": 500,
  "path": "/api/v1/users/photos",
  "timestamp": "2023-06-03 17:36:06",
}

이제 클라이언트는 statusCode 와는 무관하게 응답 메시지의 errorCode 를 기준으로 모든 예외를 명확하게 구분해 처리할 수 있습니다.
또한 이를 통해, 예외 상황에 가장 적절한 statusCode 를 제약없이 활용할 수도 있게 되었습니다.

이 뿐 아니라 Custom Exception Class 를 직접 정의하여 코드 레벨에서 예외 처리 로직의 가독성도 향상되었습니다.
작성된 예외 클래스의 이름만 보고도 예외 발생 상황을 파악할 수 있기 때문에 더 이상 모든 로직을 살펴볼 필요가 없기 때문입니다.
또한 errorCode 는 하나의 파일에서, Custom Exception Class 들은 도메인 별 파일로 관리되어 애플리케이션 전체에서 공유되기 때문에 재사용성은 물론 코드도 일관되게 작성할 수 있습니다.

추가적으로 Custom Exception Filter 를 통해 예외가 발생한 API 의 url 과 timestamp 를 응답 메시지에 포함시켜 로깅 데이터로도 활용할 수 있게 되었습니다.
이 곳에서 포착한 예외 객체를 Sentry 와 같은 모니터링 시스템에 그대로 넘겨주면 손쉽게 애플리케이션 에러 로그를 받아볼 수도 있습니다.
이 때 에러 로그는 예외 객체가 가지고 있는 errorCode, Request Path, Timestamp 를 모두 포함하고 있어 Production 환경의 디버깅에서도 많은 장점이 있습니다.

지금까지 NestJS 프레임워크에서 기본적으로 제공하는 예외 클래스와 예외 처리 레이어를 직접 커스텀하여 원하는 데이터와 정보를 받아볼 수 있도록 구현해보았고, 그로 인해 얻을 수 있는 다양한 장점들에 대해 정리해보았습니다.

물론 위와 같은 방법에는 여러 단점도 존재합니다.
클라이언트와 errorCode 로 소통하기 위해서는 그에 따른 커뮤니케이션 비용이 발생하고, 애플리케이션에서 공통적으로 사용하는 예외 클래스에 대한 관리 포인트 증가, 예외 처리 로직 구현 시 예외 클래스 구현에 따른 엔지니어링 비용 증가도 무시할 수 없는 고려 대상입니다.

따라서 프레임워크가 제공하는 다양한 기능들을 편의를 위해 커스텀하여 사용하기 전에는 그로 인해 얻을 수 있는 이점과 단점들을 모두 고려하여 비교해보고 판단하여야 합니다.

1개의 댓글

comment-user-thumbnail
2023년 7월 19일

정말 잘 읽었습니다… 여러번 읽어봐야겠네요..!!

답글 달기