처음부터 차근차근

[NestJS] Custom Exception 구현 본문

FrameWork/NestJS

[NestJS] Custom Exception 구현

HangJu_95 2023. 12. 3. 18:27
728x90

NestJS에서는 Custom Exception을 구현할 수 있습니다.

Custom Exception을 구현하여 Production 환경에서는 물론 개발 환경에서도 더 쉽고 빠른 디버깅이 가능하도록 구현할 수 있습니다.

 

저는 Custom Exception을 구현하여 다음과 같은 기능을 구현했습니다.

  • message가 아닌 errorCode를 구현하여 클라이언트에서 예외를 명확하게 구분할 수 있도록 수정
  • 일반 예외처리(설문지 조사 실패, ID 로그인 실패 등)과 서버에서 발생한 치명적인 에러(ORM Error, Server 자체 에러)를 구분하고, 치명적인 에러의 경우 StatusCode를 500으로 일괄 처리하며, 서버 문제를 외부에 노출시키지 않도록 처리
  • Exception을 직접 구현하여 코드 레벨에서 가독성이 좋게 하며, 재사용성을 보장

GraphQL의 Error handling

HTTP에서 에러가 발생한 경우에는, JSON을 통해 우리가 원하는 형식을 전달할 수 있습니다.

그러나 GraphQL에서 에러가 발생한 경우에는, 기본적인 형식이 지정되어 있습니다.

NestJS의 terminal 화면
playground의 화면

GraphQL에서 Error를 custom 하려면 extensions에 해당하는 내역을 추가할 수 있습니다.

GraphQL에서 Error handling을 할 경우, 기본적으로 message, locations, path는 고정이며, extensions을 통해 custom error에 해당하는 내역을 추가할 수 있습니다.

구현 방법

1. 새로운 Custom Exception Class 구현

우리가 원하는 새로운 Exception Class를 구현해야 합니다.

기본적으로 GraphQL의 Error에서는 Error 객체에서 상속받고 있으며, message를 인자로 넘겨줘야 합니다.

export declare class GraphQLError extends Error {
  readonly locations: ReadonlyArray<SourceLocation> | undefined;
  readonly path: ReadonlyArray<string | number> | undefined;
  readonly nodes: ReadonlyArray<ASTNode> | undefined;
  readonly source: Source | undefined;
  readonly positions: ReadonlyArray<number> | undefined;
  readonly originalError: Error | undefined;
  readonly extensions: GraphQLErrorExtensions;
  constructor(message: string, options?: GraphQLErrorOptions);
  
  constructor(
    message: string,
    nodes?: ReadonlyArray<ASTNode> | ASTNode | null,
    source?: Maybe<Source>,
    positions?: Maybe<ReadonlyArray<number>>,
    path?: Maybe<ReadonlyArray<string | number>>,
    originalError?: Maybe<
      Error & {
        readonly extensions?: unknown;
      }
    >,
    extensions?: Maybe<GraphQLErrorExtensions>,
  );
  get [Symbol.toStringTag](): string;
  toString(): string;
  toJSON(): GraphQLFormattedError;
}

하지만 이 코드에서는 기본적으로 errorCode와 timestamp 속성 값이 없으므로, Custom Exception Class를 구현해보겠습니다.

// common/interface/base.exception.interface.ts
import { GraphQLErrorExtensions } from 'graphql';

export interface IBaseException {
  message: string;
  // GraphQl의 extensions interface로 type 지정
  extensions: GraphQLErrorExtensions;
}
// src/common/exceptions/base.exception.ts
import { IBaseException } from '../interfaces/base.exception.interface';
import { GraphQLError, GraphQLErrorExtensions } from 'graphql';

// GraphQLError를 상속받아 사용
export class BaseException extends GraphQLError implements IBaseException {
  constructor(errorCode: string, message: string, statusCode: number) {
    super(message);
    // extension에 추가할 내용
    // 에러 코드 추가
    this.extensions.errorCode = errorCode;
    // http 상태 코드 추가
    this.extensions.code = statusCode;
    // timestamp 추가
    this.extensions.timestamp = new Date().toLocaleString();
  }
  message: string;
  extensions: GraphQLErrorExtensions;
}

GraphQLError에서 상속받아와 BaseException을 구현하였습니다.

기본적으로 message를 상속받았으며, extension에 추가할 내용으로 errorCode와 StatusCode, timestamp를 추가시켰습니다.

2. errorCode와 Custom Exception Class 생성

// src/common/exception/codeError.enum.ts

export enum errorCode {
  NotFoundSurvey = '0001',
  NotFoundQuestion = '0002',
  NotFoundAnswer = '0003',
  NotEqualSelectAndScore = '0004',
  NotEqualQuestionAndAnswer = '0005',
  UnCatched = '9999',
}

먼저 Enum을 간단하게 생성하였습니다.

현재 정의한 사용자 에러 코드는 5개로, 간단하게 정의하였으며, 예상한 Exception이 아닌 경우 Uncatched를 통해 알 수 있도록 Enum에 추가하였습니다.

이제 이 에러코드를 사용하여 Custom Exception을 제작하겠습니다.

// src/common/exceptions/question.exception.ts

import { HttpStatus } from '@nestjs/common';
import { BaseException } from './base.exception';
import { errorCode } from './codeError.enum';

export class NotFoundQuestionException extends BaseException {
  constructor() {
    super(
      errorCode.NotFoundQuestion,
      '문항을 찾을 수 없습니다.',
      HttpStatus.NOT_FOUND,
    );
  }
}

export class NotEqualSelectAndScoreException extends BaseException {
  constructor() {
    super(
      errorCode.NotEqualSelectAndScore,
      '선택지 수와 점수 개수가 다릅니다.',
      HttpStatus.BAD_REQUEST,
    );
  }
}

설문지 문항에 필요한 Custom Exception을 제작했습니다.

기본적으로 errorCode와 message, StatusCode를 받습니다.

errorCode를 통해 동일한 statusCode, message를 받아도 할당된 Exception이 다르기 때문에 클라이언트에서도 예외를 명확하게 구분할 수 있습니다.

  // 단일 문항 조회 메서드
  async getQuestion(id: number) {
    const question = await this.questionRepository.findOneBy({ id });
    if (!question) throw new NotFoundQuestionException();
    return question;
  }

3. Exception Filter 구현

이제 예외 발생 시의 응답 메세지를 원하는 형태로 전달하기 위해 Filter를 새로 구현해야 합니다.

@Catch() 데코레이터의 인자에는 ExceptionFilter가 추적할 예외 클래스를 명시하며, 만약 내부 인자에 아무것도 넣지 않는다면 애플리케이션에서 처리하지 못한 예외까지도 모두 추적할 수 있습니다.

import { ArgumentsHost, Catch, LoggerService } from '@nestjs/common';
import { GqlArgumentsHost, GqlExceptionFilter } from '@nestjs/graphql';
import { BaseException } from '../exceptions/base.exception';
import { UnCatchedException } from '../exceptions/uncatched.exception';

@Catch()
export class AllExceptionFilter implements GqlExceptionFilter {
  constructor(private readonly logger: LoggerService) {}
  catch(exception: any, host: ArgumentsHost) {
    // gql 전용 context로 변경하기.
    // https://docs.nestjs.com/graphql/other-features
    const gqlHost = GqlArgumentsHost.create(host);
    // host에서 info 정보를 받아와 path와 typename을 받아올 수 있다.
    const info = gqlHost.getInfo();
    const ip = gqlHost.getContext().req.ip;

    if (exception instanceof BaseException) {
      // 우리가 지정한 예외처리의 경우에는
      // customHandler로 들어가며, warn을 통해 명시한다.
      this.logger.warn({
        context: 'CustomHandler',
        message: exception.message,
        ip,
        extensions: exception.extensions,
        typename: info.path.typename,
        path: info.fieldName,
      });
      return exception;
    } else {
      // 그 외의 에러처리의 경우
      // log를 error로 남겨 심각한 상태의 로그라고 남기며,
      // slack이나 다른 APM tool을 통해 알 수 있도록 할 수 있다.
      const newException = new UnCatchedException(exception.message);
      this.logger.error({
        context: 'FatalError',
        message: newException.message,
        stack: newException.stack,
        ip,
        extensions: newException.extensions,
        typename: info.path.typename,
        path: info.fieldName,
      });
      return newException;
    }
  }
}

제가 작성한 코드는

  1. 애플리케이션에서 처리한 예외: warn 메세지로 남겨 표시 진행
  2. app에서 처리하지 못한 예외: error로 Log를 남기며, 이후 slack이나 다른 APM Tool을 통해 알람 수신 진행

이후 exception을 return하여 어떠한 예외처리가 발생했는지 구분하여 Logger를 작성할 수 있습니다.

이를 통해 Application에서 처리하지 못한 예외처리에 대하여 알람 처리를 받을 수 있으며, Logger에 남아있기 때문에 빠른 대응이 가능합니다.

이때 예외처리 Logger에는 Stack또한 포함되어 있어 손쉽게 문제가 발생한 곳에 접근이 가능합니다.

참고

https://velog.io/@cataiden/nestjs-custom-exception

https://docs.nestjs.com/graphql/other-features

https://docs.nestjs.com/exception-filters#exception-filters-1

https://spec.graphql.org/draft/#sec-Errors