[Project] #2 에러를 소통해보자

DatQueue·2024년 1월 20일
3
post-thumbnail

🧃 규격화된 에러 설계는 왜 필요한가

프로젝트 개발을 진행하면서 가장 먼저 구축에 들어갔던 작업이 "에러 공통 응답 객체" 설계였다. 여태껏 클라이언트와의 소통 없이 오로지 백엔드의 입장에서만 코드를 작성해왔던 나에게 에러를 던진다는 것은 그다지 큰 의미가 없었다.

그냥 누구나 생각할 수 있는 특정 에러 상황을 가정하고 (예를 들어 데이터 불일치와 같은...) 아래와 같이 코드 한 줄을 적으면 끝이였기 때문이었다.

throw new BadRequestException("금액 데이터 불일치입니다");

그리고 아래와 같은 에러 응답을 확인할 수 있었다.

{
    "statusCode": 400,
    "message": "금액 데이터 불일치입니다",
    "error": "BadRequest"
}

이것이 잘 못 되었단 뜻일까?

그렇지 않다. 위와 같이 NestJS에서 제공하는 에러객체를 코드레벨에서 바로 사용해 일련해 메시지만 작성해주고 NestJS에서 제공하는 응답을 바로 던질 수 있다는 것은 매우 빠른 개발 시간을 보장해준다 생각할 수도 있다. 동시에 커스텀한 무언가를 구축하지 않았으므로 코어적 코드 레벨과 충돌하는 경우도 적을 것이다.

하지만 왜 개발단계에서의 규격화된 에러 설계가 필요했고, 더 좋은 공통 에러 응답을 위해 시간을 소요하였을까? 이런 궁금증으로 글을 시작해보고자 한다.


> 에러는 던지면 끝이 아니다, 클라이언트와의 소통이다.

서버에서 정한 에러는 단순 응답을 넘어서 클라이언트가 해당 응답을 받고 핸들링 과정을 통해 일련의 처리를 수행하게 된다. 발생하게 되는 모든 에러가 유저에게 에러 메시지와 같은 일련의 UI를 제공하는 것은 아니지만 어찌됐건 클라이언트는 서버로부터 넘어온 에러에 대해 분기처리를 하고 이를 필요한 모델로 넘겨주게 된다.

개발 초기 당시, 클라이언트측과 와이어프레임에 따라 발생할 수 있는 각 도메인 별 다양한 예외 처리 상황에 대해 논의를 해보았고 자체 서버에서 발생할 수 있는 에러 뿐만 아니라 사용하는 서드파티에 따른 에러 역시 고려를 하지 않을 수 없었다.

많은 에러가 발생할 것으로 예상함과 동시에 각 API에서 중복된 에러 또한 존재할 것임을 판단하였고, 클라이언트 측에선 이를 전역 핸들러로써 관리하기로 하였다.

이에 따라, 서버측에서도 클라이언트의 간편한 분기 처리를 위해 각 에러마다의 차별화된 "무언가"를 전달해 줄 필요가 있다 판단하였고 이를 단순 에러 메시지가 아닌 사내의 규격화된 에러 코드로써 명시하기로 하였다. (에러 코드 응답은 아래의 설명에서 계속됩니다.)

이렇게 클라이언트와의 소통을 고려한 조금은 더 구체화 된 공통 에러 응답 객체를 만들어 낼 필요성을 느꼈다.


> 에러 하나하나를 클래스화 할 필요가 있다.

가장 처음에 보았던 에러 처리를 다시 살펴보자.

throw new BadRequestException("금액 데이터 불일치입니다");

클라이언트와의 소통을 떠나서, 위와 같은 에러처리의 문제점은 무엇일까?

첫 번째는 각 에러 마다의 "중요도""고유성"의 감쇠라고 생각한다. 물론 개발자 입장에서 위의 코드를 보면 메시지의 내용에 따라 누가봐도 "금액 데이터 불일치"에 대한 에러임을 예상할 수 있을 것이다. 하지만 이는 결국 NestJS의 Built-IN HttpException 클래스에 메시지만을 작성해준 격이므로, 앞으로 더 늘어나게 될 각각의 에러에 대한 명세를 하긴 아쉽다 생각하였다.

두 번째는 "활용도"의 문제이다. 아래에서 코드를 통해 예시를 보이겠지만, 추후 스웨거등의 문서화를 구현할 때 만약 각 각의 에러가 별도의 객체화가 되어있지 않다면 활용하는데 제약을 받게 되고 구체화 된 문서 작성에 어려움을 겪을 것이다.


🧃 베이스 설계 과정 (NestJS)

자, 위에서 얘기를 나눠보았던 관점/생각 및 내용들을 토대로 본격적인 예외처리 과정을 진행해보자. 예외처리를 하는데 있어 플로우를 설계하는 것이 매우 중요하지만 사용하게 될 프레임워크(NestJS)에 대한 규약과 라이프사이클을 이해하는 것 또한 중요하다.

이미지 출처 (NestJS Request Lifecycle)

"커스텀(Custom)"한 무언가를 만들어 낸다는 것은 결국 사용하고 있는 라이브러리 혹은 프레임워크를 이용함과 동시에 "충돌" 또한 불러일으킬 수 있다는 것을 의미한다 본다. 이런 점들을 염두해 두고 설계할 필요가 있었다.


> Http Exception 베이스 구축

먼저, 공통 응답 에러 객체 생성을 위한 베이스를 구축할 필요가 있다. 지난 포스팅이었던 아키텍처 편에서도 잠깐 소개가 되었지만 공통적으로 사용할 클래스를 정의하는데 있어서 항상 "추상 클래스(인터페이스)"를 선수적으로 생성하기로 한다.

BaseException Interface

// base-exception.interface.ts

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

커스텀하게 만들게 된 자체적 "에러코드(errorCode)", Http 상태코드(statusCode), 그리고 에러 메시지 및 추가적 정보(timestamp, path)를 공통 에러 응답 객체 필드로 정의하였다.


다음은 해당 인터페이스의 구현체이며, 에러 응답을 직접적으로 정의하는 곳이다. 해당 클래스는 앞서 정의한 인터페이스를 구현함과 동시에 nest에서 제공하는 HttpException 클래스를 확장받는다.

// http-base.exception.ts

import { HttpException } from "@nestjs/common";
import { IBaseException } from "./interfaces/base-exception.interface";
import { ApiProperty } from '@nestjs/swagger';
import { Expose } from "class-transformer";

export class HttpBaseException extends HttpException implements IBaseException {
  constructor(
    errorCode: string, 
    statusCode: number,
    msg: string,
  ) {
    super(errorCode, statusCode);
    this.errorCode = errorCode;
    this.statusCode = statusCode;
	this.msg = msg;
  }
  
  @ApiProperty({
    example: "1000",
    description: "자체 애플리케이션 에러코드"
  })
  @Expose()
  errorCode: string;

  @ApiProperty({
    example: "20YY. MM. DD. 오후 HH:MM:SS",
    description: "API 응답 시간"
  })
  @Expose()
  timestamp: string;

  @ApiProperty({
    example: 400,
    description: "Http Base 상태 코드 400~500대 응답"
  })
  @Expose()
  statusCode: number;

  @ApiProperty({
    example: "example msg",
    description: "에러 메시지"
  })
  @Expose()
  msg: string;

  @ApiProperty({
    example: "/sth/sth ...",
    description: "api 경로"
  })
  @Expose()
  path: string;
}

> Validation 에러 충돌과 해결

위와 같이 HttpBaseException을 구현하였을 때 고려하지 못한 케이스가 존재하였다.

바로 "유효성 검증"에 대한 예외처리이다.

서버에 요청으로 넘어오는 DTO 객체의 각 필드에 대한 유효성 검증은 class-validator 라이브러리를 사용하도록 하였다.

간단한 예시를 들어보자. 클라이언트로부터 상품 생성시 필요한 DTO 객체를 받는다고 하자. 아래와 같이 class-validator를 통한 검증 데코레이터를 사용하게 된다.

import { IsNotEmpty, IsNumber, IsString } from "class-validator";

export class ProductCreateDto {

  @IsNotEmpty()
  @IsString()
  title?: string;

  @IsNotEmpty()
  @IsString()
  description?: string;

  @IsNotEmpty()
  @IsString()
  image?: string;

  @IsNotEmpty()
  @IsNumber()
  price?: number;
}

그런데 만약 클라이언트에서 description에 string이 아닌 number값을, price에 number가 아닌 string값을 보내주었다면 어떤 형태의 에러가 응답될까?

{
    "statusCode": 400,
    "message": [
        "description must be a string",
        "price must be a number conforming to the specified constraints"
    ],
    "error": "Bad Request"
}

위와 같이, message 필드에 문자열 값이 담긴 Array 타입의 오류 메시지가 출력된다. 이는 class-validatorValidationError 구현 코드에 따른 것으로 이미 정해져 있는 규약이다.

nestjs/class-validator source code

// nest/packages/common/interfaces/external/validation-error.interface.ts

export interface ValidationError {

  target?: Record<string, any>;
  
  // DTO에 정의된 프로퍼티
  property: string;

  value?: any;
  /**
   * Constraints that failed validation with error messages.
   */
  constraints?: {
    [type: string]: string;  // 해당 부분에 따른 message 필드 규약
  };

  children?: ValidationError[];

  contexts?: {
    [type: string]: any;
  };
}

하지만, 앞서 공통 에러 응답을 위해 정의한 HttpBaseException 클래스는 HttpException을 상속받으며, 이는 또한 Error란 인터페이스를 상속받게 된다.

interface Error {
    name: string;
    message: string;
    stack?: string;
}

그리고 보다시피 message 필드는 string 타입을 받을 것을 default로 두게 된다.

여기서 "충돌"이 일어난 것이다. 기본 Base HttpException의 message 필드는 string을 바라보는 반면, 유효성 검증을 위해 사용하게 될 class-validator의 ValidationError에서 제시하는 message 필드는 Array를 바라보고 있다.

만약 어떠한 조취를 취해주지 않을 경우 여러 DTO 필드에서 복수적으로 유효성 검증 에러가 났을 시, 그에 대한 처리를 해줄 수 없게 된다.

이러한 이유로 HttpBaseException에는 아래와 같이 해당 케이스에 대한 추가적 처리를 해주도록 하였다.

최종 응답 객체의 msg(메시지)는 무조건 string 타입이 와야하고, 이에 따라 Array로 넘어온 validation 에러 메시지를 string으로 포맷팅하는 작업을 취해준다. 이때, 앞서 살펴본 ValidationError 클래스의 propertyconstraints를 활용함으로써 복수 필드에 대한 에러를 응답할 수 있도록 한다.


수정된 HttpBaseException

export class HttpBaseException extends HttpException implements IBaseException {
  constructor(
    errorCode: string, 
    statusCode: number,
    // msg 타입 구체화 
    msg: string | {property: string; constraints: { [key: string]: string }}[],
  ) {
    super(errorCode, statusCode);
    this.errorCode = errorCode;
    this.statusCode = statusCode;
    
    // 받게 될 message 타입에 대한 조건 처리
    if (typeof msg === "string") {
      this.msg = msg; // If msg is a string, assign it directly
    } else if (Array.isArray(msg)) {
      this.msg = this.formatErrorMessage(msg); // Format the array into a string
    } else {
      this.msg = "Invalid message format"; // Handle other types of msg if needed
    }
  }
  
  // formats custom msg Array to string using "property" and "constraints" which contains in `ValidationError`.
  private formatErrorMessage(errors: { property: string; constraints: { [key: string]: string } }[]): string {
    return errors
      .map(error => `validation-error) ${error.property} => ${Object.values(error.constraints).join(", ")}`)
      .join("; ");
  }
  
  // swagger property 생략
  
  errorCode: string;

  timestamp: string;

  statusCode: number;

  msg: string;

  path: string;
}

현재 단계로 아직 에러 응답이 완성 된 것은 아니지만, 아래와 같은 형태를 보일 것이다.

{
    "errorCode": "1111",
    "statusCode": 400,
    "msg": "validation-error) pointAmount => pointAmount must be a number conforming to the specified constraints; validation-error) couponAmount => couponAmount must be a number conforming to the specified constraints",
    "timestamp": "2024. 1. 18. 오후 10:08:14",
    "path": "/order/payment/create/temporary-orders"
}

> Filter

베이스 객체가 위와 같이 생성되었다면 마지막으로 취해주어야 할 부분은 "Exception Filter"이다. 앞서 그림(NestJS Request LifeCycle 참조)에서도 볼 수 있듯이 생명 주기 전반에 걸쳐 에러를 핸들링하게 되는 필터를 생성할 필요가 있다. 그리고 지금 생성할 필터는 Http Exception에 대한 애플리케이션 전역 필터가 될 것이다.

// http-exception.filter.ts

@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const request = ctx.getRequest<Request>();
    const response = ctx.getResponse<Response>();
    const curr_timestamp: string = new Date().toLocaleString("ko-KR", { timeZone: "Asia/Seoul"});

    logger.debug(exception);

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

    res.timestamp = curr_timestamp;
    res.path = request.url;
    
    return response.status(res.statusCode).json({
      errorCode: res.errorCode,
      statusCode: res.statusCode,
      msg: res.msg,
      timestamp: res.timestamp,
      path: res.path,
    })
  }
}

해당 에러 필터는 전역적으로 사용하기 위해 부트스트래핑 단계의 main.ts에 설정하게끔 한다.

app.useGlobalFilters(new GlobalExceptionFilter());

> 커스텀 ValidationPipe 설계

앞선 과정까지만 진행되면 참 좋았겠지만 아직 또 함정이 남았다. 바로 이전에 마주한 class-validator 이슈이다.

HttpBaseException에서 진행하였던 msg 포맷팅 과정은 "이러한 배열 형식의 Validation msg가 들어왔을 경우 이를 Array to string 하여라" 이다. HttpBaseException 클래스가 리턴하는 msg는 string이지만 생성자 매개변수에는 앞서 정의한 규약의 Array를 허용한다는 것이다. 즉, ValidationError는 이러한 과정을 거쳐서 최종 응답이 된다.

결국 우리는 @nestjs/common에서 제공하는 ValidationPipe 그대로를 사용할 수 없다는 것이다. 배열로 메시지를 HttpBaseException에 전달하는 건 둘째치고, 애초에 해당 파이프가 리턴하는 값 자체가 HttpBaseException이 아니기 때문에 유효성 검증에 대한 에러 응답 시 우리가 정한 에러를 뱉을 수 없게 된다.

⬆⬆⬆ nestjs/validation.pipe.ts source code

이 사실을 마주하고 굉장히 머리가 아팠지만 직접 커스텀한 ValidationPipe를 만들기로 하였다.

원하든, 원치 않았던 소스 코드를 까보는 상황을 마주하였고 간단한 차원에서 이를 아래와 같이 구현해 낼 수 있었다.

// custom-validation.pipe.ts

import {
  ArgumentMetadata,
  HttpStatus,
  Injectable,
  PipeTransform,
} from '@nestjs/common';
import { plainToClass } from 'class-transformer';
import { validate, ValidationError } from 'class-validator';
import { HttpBaseException } from '../exception-handle/base/http-base.exception';
import { ValidateExceptionErrCodeEnum } from '../exception-handle/enums/validate-exception.enum';

@Injectable()
export class CustomValidationPipe implements PipeTransform<any> {
  async transform(value: any, { metatype }: ArgumentMetadata) {
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    }
    const object = plainToClass(metatype, value);
    const errors = await validate(object);
	
    /* 중요 포인트 !!! */
    if (errors && errors.length > 0) {
      const translatedError = await this.transformError(errors);
      
      // ValidationPipe의 예외 처리로써 우리가 규약한 HttpBaseException을 던지게끔 한다. 
      // 내부인자는 (에러코드, http 상태코드, Array 형태의 에러 메시지)
      throw new HttpBaseException(ValidateExceptionErrCodeEnum.VALIDATION_ERROR, HttpStatus.BAD_REQUEST, translatedError);
    }
    return value;
  }

  async transformError(errors: ValidationError[]) {
    const data = [];
    for (const error of errors) {
      // define property which wants to assign in response 
      data.push({
        property: error.property,
        constraints: error.constraints
      });
    }
    return data;
  }

  private toValidate(metatype: unknown): boolean {
    // 아래 타입의 에러에 대한 검증만을 진행한다.
    const types: unknown[] = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}

마찬가지로 해당 커스텀 파이프 또한 main.ts에 전역으로 설정해준다.

app.useGlobalPipes(new CustomValidationPipe());

이러한 장치를 둠으로써 우린 최종적으로 규격화된 에러 응답에 대한 베이스 구축을 함과 동시에 외부 라이브러리(class-validator)에 대한 충돌을 해결할 수 있었다.

(유효성 검증과같이 전역적으로 작용하는 예외에 대해선 이를 무시할 순 없기에 위와 같은 별도의 수고가 동반됨이 불필요하지 않다고 생각한다)


🥤 (+추가)class-transform 적용 수정

글을 업로드 한 시점에서 찾게 된 수정사항이 생겨 추가적으로 내용을 덧붙인다.

위와 같이 Custom Validation Pipe를 생성할 경우 transform() 함수 내부에서 오로지 value 값만 리턴하기 때문에 만약 class-transformer를 통한 클래스화 변형을 특정 멤버변수에 주입하였더 하더라도 적용받지 못하게 된다.

일반적으로 우리가 전역 Validation Pipe에서 class-transformer를 사용하기 위해 main.ts에서 아래와 같이 설정하는 것 처럼

app.useGlobalPipes(new ValdiationPipe({ transform: true });

우리가 커스텀하게 생성한 파이프에도 동일한 처리를 해주어야 한다.

즉, CustomValidationPipe의 생성자 매개변수로 해당 option 설정을 받고, transform() 메서드 내부의 plainToClass() 과정을 거친 object를 리턴해줄 필요가 있다.


수정

@Injectable()
export class CustomValidationPipe implements PipeTransform<any> {
  
  // 추가
  constructor(private readonly options?: { transform: boolean }) {}

  async transform(value: any, { metatype }: ArgumentMetadata) {
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    }
    const object = plainToClass(metatype, value);
    const errors = await validate(object);
    
    if (errors && errors.length > 0) {
      const translatedError = await this.transformError(errors);
      throw new HttpBaseException(ValidateExceptionErrCodeEnum.VALIDATION_ERROR, HttpStatus.BAD_REQUEST, translatedError);
    }
	
    // 수정 
    return this.options?.transform ? object : value;
  }
  
 // 나머지 동일 ... 

적용

// main.ts
app.useGlobalPipes(new CustomValidationPipe({ transform: true }));

🧃 예외 클래스의 생성 및 적용

앞선 베이스 구축에 따른 서버내의 개별적 클래스를 생성해보고 이를 로직내에 적용해보자.

간단하게 "유저 인증(Auth) 권한"에 대한 예외 클래스는 어떻게 구현되며 적용되는지를 알아보자.

> ExceptionClass 생성 (feat. 에러코드)

개별 예외 클래스는 HttpBaseException을 상속받게끔 한다. 이는 곧 HttpBaseException의 내부 생성자인 "errorcode, http statuscode, msg"를 품게 됨을 의미한다.

이를 인지하며, 아래와 같은 유저 권한 에러에 대한 (유효하지 않는 엑세스 토큰 체킹) 예외 클래스를 생성할 수 있다.

// user_unauthorized.exception.ts

import { HttpStatus } from "@nestjs/common";
import { HttpBaseException } from "../../base/http-base.exception";
import { AuthExceptionErrCodeEnum, AuthExceptionMsgEnum } from "../../enums/auth-exception.enum";

// 유저 권한 에러 (유효하지 않은 액세스 토큰 or 액세스 토큰 만료)
export class UserUnAuthorizedException extends HttpBaseException {
  constructor() {
    super(AuthExceptionErrCodeEnum.USER_UNAUTHORIZED, HttpStatus.UNAUTHORIZED, AuthExceptionMsgEnum.USER_UNAUTHORIZED); 
  }
}

super()의 첫 번째 인자, 세 번째 인자는 자체 에러코드와 메시지 값이 될 것이고 이는 별개의 enum 객체에서 정의하도록 한다.


AuthExceptionErrCodeEnum && AuthExceptionMsgEnum

이왕 보는 김에 Auth에 대한 에러 코드및 메시지를 담고 있는 Enum 객체 전체를 확인해보자.

(에러코드 및 메시지 Enum 객체는 도메인에 따른 파일 분리를 진행하였습니다)

// auth_exception.enum.ts 

export enum AuthExceptionErrCodeEnum {
  EMAIL_NOTFOUND = "0001",
  JWT_INVALID_TOKEN = "0003",
  JWT_MALFORMED = "0004",
  JWT_INVALID_SIGNATURE = "0005",
  JWT_EXPIRED = "0006",
  USER_UNAUTHORIZED = "0007",   // 해당 부분 (인증 권한 에러)

  // ....

  REFRESH_TOKEN_MISS_MATCHED = "0013",
  REFRESH_TOKEN_UNAUTHORIZED = "0014",
}

export enum AuthExceptionMsgEnum {
  EMAIL_NOTFOUND = "이메일을 찾을 수 없습니다.",
  JWT_INVALID_TOKEN = "잘못된 jwt 토큰 값 입니다.",
  JWT_MALFORMED = "토큰의 구성요소가 잘못되었습니다.",
  JWT_INVALID_SIGNATURE = "시크릿키 값 오류입니다.",
  JWT_EXPIRED = "리프레시 토큰이 만료되었습니다.",
  USER_UNAUTHORIZED = "유효하지 않은 유저입니다 (인증 권한 에러)",  // 해당 부분 (인증 권한 에러)

  // ....
  REFRESH_TOKEN_MISS_MATCHED = "요청 된 리프레시 토큰과 불일치 합니다.",
  REFRESH_TOKEN_UNAUTHORIZED = "권한이 없는 토큰입니다 (삭제된 리프레시 토큰).",
}

해당 에러코드및 메시지는 추후 재사용을 위해 Enum 객체화를 진행하였고, 별도의 파일에 분리하였다.

코드에서 확인할 수 있듯이 Enum의 member에 대해 정의한 해당 숫자 조합의 문자열이 바로 "에러코드(errorcode)"이다.

추후 블로그에서도 볼 수 있겠지만 인증, 유저, 주문 등의 큼지막한 도메인 별로 "0~0999", "1000~1999", "2000~2999" ... 의 에러코드를 나눌 수 있었고 해당 에러코드에 대한 범위 및 규약은 클라이언트와 서버간의 협의로 규정을 두었고 추가되는 에러및 에러코드는 문서(현재는 swagger)로써 공유하도록 하였다.

(클라이언트는 위의 에러 코드를 통해 에러 핸들링을 진행합니다)


> ExceptionClass 사용

NestJS를 해보신 분들이라면 잘 아시겠지만 엑세스 토큰 검증을 통한 인증 권한 예외 처리는 "가드(Guard)"에서 진행할 수 있다.

이에 따라 만들어준 가드의 catch 문 내부에 해당 에러 클래스를 던지면 될 것이다. (인증가드 구현에 대한 설명은 생략하겠습니다)

// jwt-access.guard.ts

import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import logger from "../../logger/log.util";
import { ExtendedRequest } from "../../../apis/auth/domain/auth/utils/jwt-request.interface";
import { UserUnAuthorizedException } from "../../exception-handle/exception-classes/unauthorized_exception/user_unauthorized.exception";

@Injectable()
export class JwtAccessAuthGuard implements CanActivate {
  constructor(
    private jwtService: JwtService,
  ) {}
  async canActivate(
    context: ExecutionContext,
  ): Promise<any> {
    
    const request = context.switchToHttp().getRequest<ExtendedRequest>();
    const method = request.method;
    const url = request.url;
    const now = Date.now();
    const token = this.extractTokenFromHeader(request);

    try {
      const decodedToken = this.jwtService.verify(token, {
        secret: process.env.JWT_ACCESS_SECRET
      });
      request.userId = decodedToken.userId;
      return decodedToken;
    } catch(err) {
      logger.error(
        `${method} ${url} ${Date.now() - now}ms`,
        context.getClass().name,
      );
      
      // ----------------------------  //
      /* 앞서 생성한 예외 클래스를 던진다. */
      // ----------------------------  //
      throw new UserUnAuthorizedException();
    }
  }

  private extractTokenFromHeader(request: ExtendedRequest): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
  }
}

에러 발생 후 응답 확인

{
    "errorCode": "0007",
    "statusCode": 401,
    "msg": "유효하지 않은 유저입니다 (인증 권한 에러)",
    "timestamp": "2024. 1. 18. 오후 11:55:29",
    "path": "/order/payment/create/temporary-orders"
}

다른 예외 클래스들도 마찬가지로 필요한 각 로직및 각 계층의 예외처리를 요하는 곳에 위치시키면 된다.



🧃 외부 의존적 에러는 어떻게 처리할까


일반적으로 클라이언트와 에러를 소통하는데 있어 굳이 클라이언트가 알 필요 없는 에러, 혹은 보안상 민감한 에러의 경우 UnCatchedExcxeptionClass를 응답케 하였다(즉, 아무 처리도 취해주지 않은 에러 _ errorcode:"9999", msg: "알 수 없는 에러입니다").

이는 외부 라이브러리에 대한 특정 에러 혹은 데이터베이스(orm 수준의)차원에서 발생한 에러 등이 될 수 있을 것이다.

하지만 이런 외부 의존적 에러들 중에도 단순 기록을 넘어 클라이언트에게 응답을 취해주어야 하는 에러들이 존재하였다. 물론, 이는 어디까지나 프로덕션이 아닌 개발단계에서의 제스쳐일수도 있고, 혹은 팀이 정한 룰에 따라 그렇지 아니할 수도 있다.

프로젝트 진행 중 처리하도록 한 외부 의존적 에러는 대표적으로 아래와 같았다.

  • jwt 라이브러리 에러 ---------- (선택적)

  • pg사(toss-payments)응답 에러 ------------ (필수적)


jwt 라이브러리 관련 에러는 사실 개발 단계에서 원만한 소통을 위해 처리해주어야 하는 선택적 사항이었다면 결제 시(+결제 취소 시) pg사에서 넘어온 에러는 굉장히 민감하게 필수적으로 처리해주어야 했다.

하지만 해당 외부 에러들을 처리하는데는 몇 가지 짚고 넘어가야 할 점이 존재하였다.

  • 생성한 HttpBaseException 규격이 적용되지 않은 상태이다.

  • 비즈니스 로직 수준에서 에러를 정의할 수 없다. (+ 다뤄야 할 에러가 너무 많다)

  • 사용될 api(라우트 핸들러 함수)는 제한적이다.


위 세 가지 사항을 고려하였을 때 해당 에러는 별도의 "커스텀 예외 필터"로 생성한 뒤, 필요한 컨트롤러 레벨 혹은 라우터 레벨에 주입해주도록 하였다.


> JwtExceptionFilter

jwt 예외필터는 외부 에러에 대한 처리를 해주기 위함이다. 더 좋은 방법이 있을 수도 있겠지만, 해당 라이브러리 에러가 가지고 있는 개별적 에러에 대한 분기처리 작업이 필요하였다.

// jwt-exception.filter.ts

@Catch(Error)
export class JwtExceptionFilter implements ExceptionFilter {
  
  catch(exception: any , host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const curr_timestamp: string = new Date().toLocaleString("ko-KR", { timeZone: "Asia/Seoul"});

    let errorCode: string;
    let statusCode: number;
    let msg: string;

    if (exception instanceof HttpBaseException) {
      statusCode = exception.getStatus();
    
      switch (statusCode) {
        case HttpStatus.TOO_MANY_REQUESTS:
          errorCode = TooManyRequestsErrCodeEnum.API_TOO_MANY_REQUESTS;
          break;
    
        case HttpStatus.BAD_REQUEST:
          errorCode = ValidateExceptionErrCodeEnum.VALIDATION_ERROR;
          break;
    
        default:
          errorCode = UnCatchedExceptionErrCodeEnum.UNCATCHED;
      }
    
      msg = exception.msg;
    } else {
      const ex = handlingException(exception);
      errorCode = ex.code;
      msg = ex.message;
      statusCode = HttpStatus.UNAUTHORIZED;
    }

    response.status(statusCode).json({
      errorCode,
      statusCode,
      msg,
      timestamp: curr_timestamp,
      path: request.url,
    });
  }
}

interface ExceptionStatus {
  code: string;
  message: string;
}

const handlingException = (err: Error): ExceptionStatus => {
  if (err.name === "JsonWebTokenError" && err.message === 'invalid token') {
    return { code: AuthExceptionErrCodeEnum.JWT_INVALID_TOKEN, message: AuthExceptionMsgEnum.JWT_INVALID_TOKEN }
  }

  if (err.name === "JsonWebTokenError" && err.message === 'jwt malformed') {
    return { code: AuthExceptionErrCodeEnum.JWT_MALFORMED, message: AuthExceptionMsgEnum.JWT_MALFORMED}
  }

  if (err.name === "JsonWebTokenError" && err.message === 'invalid signature') {
    return { code: AuthExceptionErrCodeEnum.JWT_INVALID_SIGNATURE, message: AuthExceptionMsgEnum.JWT_INVALID_SIGNATURE};
  }

  if (err.name === "TokenExpiredError") {
    return { code: AuthExceptionErrCodeEnum.JWT_EXPIRED, message: AuthExceptionMsgEnum.JWT_EXPIRED}
  }

  return { code: UnCatchedExceptionErrCodeEnum.UNCATCHED, message: UnCatchedMsg.UNCATCHED_MSG }
}

해당 라이브러리를 통해 넘어온 값 중 분기처리를 가능케 하는 필드로 namemessage를 선택하였고 이를 통해 처리하고자 하는 예외에 대해 핸들링 할 수 있었다.

사실 여기서 핵심은 jwt 외부 라이브러리에 대한 예외 처리인데 "왜" HttpException에 대한 별도의 처리가 또 추가되었느냐 이다.

우리는 앞서 전역 http 필터인 GlobalExceptionFilter를 생성해 주었기 때문에 JwtExceptionFilter에서 http 예외처리를 한 점에 의문이 들 것이다.


하지만 이는 NestJS ExceptionFilter의 "적용 순서"로써 설명이 된다.

다른 Enhancer들과 달리, 유일하게 ExceptionFilter는 전역필터가 먼저 적용되지 않는다.

라우터 레벨 -> 컨트롤러 레벨 -> 전역 순서로 적용되므로, 결국 최하단의 라우터 레벨에서 결국 전역 필터를 처리받지 못하게 된다.

이에 따라 해당 라우터에서 발생하게 되는 http에러(Too Many Requests, Validation, ...)를 별도 처리 할 필요가 있다.

만약, 해당 처리를 해주지 않는다면 만약 Validation(class-validator) 에러가 발생하였을 시 체킹을 하지 못하고 Uncatched Error를 받게 될 것이다.


> TossPaymentsExceptionFilter

토스 페이먼츠 (pg사) 연동 시 발생할 수 있는 에러이다.

결제 승인 시, 결제 취소 승인 시. 이렇게 두 가지 케이스에 예외가 발생할 수 있고, 이에 따라 각 경우에 대한 커스텀 필터를 정의할 필요가 있었다.

(에러 내용에 대한 자세한 설명은 추후 결제-주문 포스팅에서 다루도록 하겠습니다)


TossPaymentsConfirmFilter (결제 승인시 라우터 수준의 에러 필터)

// payments-confirm.filter.ts

@Catch(AxiosError)
export class TossPaymentsConfirmFilter implements ExceptionFilter {

  catch(exception: any, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const curr_timestamp: string = new Date().toLocaleString("ko-KR", { timeZone: "Asia/Seoul"});

    const ex = handlingException(exception);

    let responseStatus = HttpStatus.INTERNAL_SERVER_ERROR;

    switch (exception.response.status) {
      case 400:
        responseStatus = HttpStatus.BAD_REQUEST;
        break;
      case 401:
        responseStatus = HttpStatus.UNAUTHORIZED;
        break;
      case 403:
        responseStatus = HttpStatus.FORBIDDEN;
        break;
      case 404:
        responseStatus = HttpStatus.NOT_FOUND;
        break;
      default:
        responseStatus = HttpStatus.INTERNAL_SERVER_ERROR;
    }
    
    response.status(responseStatus).json({
      errorCode: ex.code,
      status: responseStatus,
      msg: ex.message,
      timestamp: curr_timestamp,
      path: request.url,
    });
  }
}

interface ExceptionStatus {
  code: string;
  message: string;
}

// 핸들링 할 익셉션의 모든 케이스는 필터에서 직접 정의하는 것이 아닌 별도의 객체로 위임해준다.
const handlingException = (err: AxiosError): ExceptionStatus => {
  const code = err.response.data['code'];

  if (!!code) {
    return Object.keys(confirmErrorCodeMessageObject).includes(code) 
      ? confirmErrorCodeMessageObject[code] 
      : { code: UnCatchedExceptionErrCodeEnum.UNCATCHED, message: "알 수 없는 오류로 인한 결제 승인 거부 :) 고객센터 문의 바람" }
  }

  return { code: UnCatchedExceptionErrCodeEnum.UNCATCHED, message: "알 수 없는 오류로 인한 결제 승인 거부 :) 고객센터 문의 바람" };
}

TossPaymentsCancelFilter (결제 취소시 라우터 수준의 에러 필터)

위의 TossPaymentsConfirmFilter와 형식은 동일하다. 단지 에러 핸들링 시 받게 될 메시지 객체에 차이가 있다.


confirmErrorCodeMessageObject && cancelErrorCodeMessageObject (이미지 코드 참조)


🧃 Swagger에 에러를 조금 더 "예쁘게" 담아보자.

스웨거에는 위와 같이 에러를 응답할 수 있게 끔 하는 데코레이터가 존재한다.

400, 401, 403 ... 등의 각 상태코드에 대해 분리 할 수 있고, 이를 스웨거 상에 명세할 수 있지만 "각 상태코드"에 대한 "여러" 에러 응답을 나타내는데 있어서 불편함이 있었다.

그에 따라 특정 API 마다 발생할 수 있는 각 상태코드 별 에러를 커스텀하게 표현하기로 하였다.

> 커스텀 데코레이터 생성

커스텀 데코레이터를 생성하는데 있어서 기존 우리가 정의해두었던 에러 형식인 HttpBaseException의 필드값을 그대로 불러온다. 그 후, 응답 객체만 동일 시하게 설정하면 끝이다.

export interface ErrorResponseOption {
  /**
   * 예시의 제목을 기술
   */
  exampleTitle: string;
  /**
   * service및 implements 레이어에서 적었던 오류 메시지를 기술.
   */
  message: string | {property: string; constraints: { [key: string]: string }}[];
  /**
   * 오류가 나는 상황을 기술.
   */
  exampleDescription: string;
  /**
   * 에러 코드에 대해 기술(자체 애플리케이션 내의 에러코드).
   */
  errorcode?: string;
}

// ---------------------------------------------------------

export const ErrorResponse = (
  statusCode: HttpStatus,
  errorResponseOptions: ErrorResponseOption[]
) => {
  const examples = errorResponseOptions
    .map((err: ErrorResponseOption) => {
      const errorResDto = new HttpBaseException(
        err.errorcode,
        statusCode,
        err.message,
      );
      return {
        [err.exampleTitle]: {
          value: {
            errorCode: errorResDto.errorCode,
            statusCode: errorResDto.statusCode,
            msg: errorResDto.msg,
            timestamp: new Date().toLocaleString("ko-KR", { timeZone: "Asia/Seoul"}),
            path: 'http://localhost:3030/sth/sth/...',
          },
          description: err.exampleDescription
        }
      };
    })
    .reduce(function (result, item) {
      Object.assign(result, item);
      return result;
    }, {}) // null값 있을 경우 필터링

  return applyDecorators(
    ApiExtraModels(
      HttpBaseException,
    ),
    ApiResponse({
      status: statusCode,
      content: {
        'application/json': {
          schema: {
            oneOf: [
              {
                $ref: getSchemaPath(HttpBaseException)
              }
            ]
          },
          examples: examples
        }
      }
    })
  )
}

그 후 아래와 같이 직접 특정 에러코드에 대한 정의를 명시해준다. 이는 굉장한 수작업을 동반하지만... 좋은 스웨거 에러 응답을 위해 어쩔 수 없는 과정이었다.

ErrorsDefine

아래는 주문 시에 발생하게 되는 에러 응답에 대한 정의 예시이다.

// order_erros_define.ts

export const OrderErrorsDefine = {
  '3102': {
    model: UserStoreCouponNotFoundException,
    exampleDescription: '주문 진행 중 유저의 가게 쿠폰을 찾을 수 없을 때',
    exampleTitle: '존재하지 않는 유저의 가게 쿠폰 감지',
    message: UserExceptionMsgEnum.USER_STORE_COUPON_NOT_FOUND,
    errorcode: UserErrCodeEnum.USER_STORE_COUPON_NOT_FOUND
  },
  '4500': {
    model: MenuOutOfStockException,
    exampleDescription: '주문 진행 중 메뉴 재고가 소진되었을 때',
    exampleTitle: '메뉴 재고 소진',
    message: OrderExceptionMsgEnum.MENU_OUT_OF_STOCK,
    errorcode: OrderExceptionErrCodeEnum.OUT_OF_STOCK_ERROR
  },
  
  // ... ...
  
  '4520': {
    model: OrderNotFoundException,
    exampleDescription: 'request param에 해당하는 orderId값에 대한 주문 데이터를 찾을 수 없을 때',
    exampleTitle: 'orderId에 대해 존재하지 않는 주문 데이터',
    message: OrderExceptionMsgEnum.ORDER_NOT_FOUND,
    errorcode: OrderExceptionErrCodeEnum.ORDER_NOT_FOUND,
  }
};

> 적용및 스웨거 확인

이렇게 커스텀 데코레이터와 내부 매개변수로 쓰이게 될 ErrorResponseOption[]을 정의하였다면 아래와 같이 컨트롤러 레벨 또는 라우터 레벨에 주입해주면 된다.

// order-process.controller.ts

@ApiTags('order')
@ApiBearerAuth('access-token')

// 유저 인증 권한에 대한 에러 응답이다. 해당 명세는 거의 대부분의 컨트롤러에 주입될 것이다.
@ErrorResponse(HttpStatus.UNAUTHORIZED, [
  AuthErrorsDefine['0007']
])
@UseGuards(JwtAccessAuthGuard)
@Controller('order')
export class OrderProcessController {
  
  // 생략 ...
  
  @ApiOperation({
    summary: '주문 트랜잭션 api',
    description: 'pg사 결제 승인 완료에 따른 자체 서비스 주문 트랜잭션 시작'
  })
  @ApiBody({
    type: OrderReqDto,
  })
  @ApiCreatedResponse({
    status: 201,
    description: '주문 생성'
  })
  
  // 주문 과정 중 발생 할 수 있는 400 상태 코드 에러들에 관한 명세이다.
  @ErrorResponse(HttpStatus.BAD_REQUEST, [
    PaymentErrorsDefine['1130'],
    OrderErrorsDefine['3101'],
    OrderErrorsDefine['3102'],
    OrderErrorsDefine['4500'],
    OrderErrorsDefine['4501'],
    OrderErrorsDefine['4502'],
    OrderErrorsDefine['4503'],
  ])
  @Post('/start-transaction')
  @UseFilters(TossPaymentsCancelFilter)
  @UsePipes(new ValidateOrderIdPipe())
  async startOrderTransaction(
    @Req() request: ExtendedRequest,
    @Body() orderReqDto: OrderReqDto,
  ) { 
    //  생략 ...   
  }

Swagger Test

아래와 같이 커스텀 데코레이터에서 설정한 폼에 따른 동적인 에러 응답이 보여지게 된다.

🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍



🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍



생각정리

프로젝트를 진행하며 에러처리를 하는 과정은 참 어쩌면 막노동? 이었단 생각이 들기도 한다.

에러 코드및 메시지, 그리고 스웨거에 명세하기 위한 에러 description 정의와 같은 작업은 일일이 수작업으로 작성해 주어야 했으며 이는 굉장히 고된 작업이었다.

하지만 이러한 과정을 수행한 뒤, 에러를 처리하는데 있어 유연한 사고를 기를 수 있었고 추가적으로 프레임워크(NestJS)의 특성에 대해 조금 더 깊게 생각해보는 계기가 되었다.

에러를 잘 핸들링 하는 것도 중요하지만 동시에 에러를 잘 "소통"하는 것 역시 중요하다 생각한다. 설령 그것이 개발단계 일지라도 클라이언트와 발생하게 될 에러에 대해 규약하고, 명세하는 과정은 굉장히 의미있는 시간이었다.

profile
You better cool it off before you burn it out / 티스토리(Kotlin, Android): https://nemoo-dev.tistory.com

0개의 댓글