iamkanguk.dev

[NestJS] Class-Validator 에러 메세지 커스텀하기! 본문

Framework/NestJS

[NestJS] Class-Validator 에러 메세지 커스텀하기!

iamkanguk 2023. 12. 16. 03:17

최근에 몸이 너무 안좋고 무기력해서 아예 1주일 넘게 쉬어버렸다. 여러분 모두 감기조심... 자세조심.. 허리조심 하시길..!

그래서 그런지 오랜만에 블로그 포스팅을 시작으로 다시 열심히 준비해서 내년 초반기에 꼭 취업을 해보려고 한다!

 

Nest에서 400번대 에러, 정확히는 Class-Validator의 에러 메세지는 배열로 나오는 것을 알고 계실 것이다. 그런데 프로젝트에서도 에러코드 + 단일 메세지 조합으로 Response를 달라고 하시는 개발자분이 계시고, 커뮤니티에서도 몇몇 분이 질문으로 올려주시더라.

 

그런데 구글링을 몇개 해봤는데 그런 자료들이 없는 것 같아서.. 필자가 글을 한번 올려볼까 한다.

참고로 아래 작성되는 방법은 필자의 뇌피셜로 구현을 한 것이라서,, 부정확할 수도 있다는 점 참고해주었으면 좋을 것 같고, 잘못된 부분이 있다면 많이많이 피드백을 해주시면 좋을 것 같다.

 

전체 코드

type TExceptionResponse = ExceptionCodeDto | ValidationError;
type TBadRequestException = ExceptionCodeDto & { target?: string | undefined };
type TValidationErrorContext = { [errorKey: string]: ExceptionCodeDto };

/**
 * @summary 400번 Bad Request Exception Filter
 * @author  Jason
 */
@Catch(BadRequestException)
export class HttpBadRequestExceptionFilter
  extends ExceptionResponseHelper
  implements ExceptionFilter<BadRequestException>
{
  private readonly logger = new Logger(HttpBadRequestExceptionFilter.name);

  catch(exception: BadRequestException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const statusCode = HttpStatus.BAD_REQUEST;
    const exceptionResponse = exception.getResponse() as TExceptionResponse;

    const exceptionJson = this.generateBasicExceptionResponse(
      statusCode,
      request.url,
    );

    this.decideBadRequestExceptionByInstance(exceptionJson, exceptionResponse);
    this.logger.error(
      `[HttpBadRequestExceptionFilter - ${statusCode}] ${exceptionJson.errorCode}:${exceptionJson.message}`,
    );
    response.status(statusCode).json(exceptionJson);
  }

  /**
   * @summary ValidationError OR ExceptionCodeDto인지 확인 후 예외 결정하는 함수
   * @author  Jason
   *
   * @param   { ExceptionResponseDto } exceptionJson
   * @param   { TExceptionResponse } exceptionResponse
   */
  decideBadRequestExceptionByInstance(
    exceptionJson: ExceptionResponseDto,
    exceptionResponse: TExceptionResponse,
  ): void {
    // Class-Validator를 통해 나온 에러 객체
    if (exceptionResponse instanceof ValidationError) {
      const validationResult = this.getExceptionObj(exceptionResponse); // 첫 에러 가져오기
      this.setBadRequestException(
        exceptionJson,
        validationResult.code,
        validationResult.message,
        validationResult.target,
      );
      return;
    }
    this.setBadRequestException(
      exceptionJson,
      exceptionResponse.code,
      exceptionResponse.message,
    );
  }

  /**
   * @summary Exception을 뱉어줄 메서드
   * @author  Jason
   * @param   { ValidationError } validationError
   * @returns { TBadRequestException }
   */
  getExceptionObj(validationError: ValidationError): TBadRequestException {
    const errorChildren = validationError.children;

    // Error Children이 없을 시 ==> 바로 반환해주면 됨
    if (isEmptyArray(errorChildren)) {
      const target = validationError.property;
      const errorContext = validationError.contexts as TValidationErrorContext;

      const key = errorContext ? Object.keys(errorContext)[0] : '';
      return {
        ...errorContext?.[key],
        target,
      } as TBadRequestException;
    }
    return this.getExceptionInChildren(errorChildren);
  }

  /**
   * @summary Children에서 Exception 추출해주는 함수
   * @author  Jason
   * @param   { ValidationError[] } errorChildren
   * @returns { TBadRequestException }
   */
  getExceptionInChildren(
    errorChildren: ValidationError[],
  ): TBadRequestException {
    const firstChildren = errorChildren[0];
    const target = firstChildren.property;

    if (firstChildren.contexts) {
      const firstContexts = firstChildren.contexts;
      const key = firstContexts ? Object.keys(firstContexts)[0] : '';
      const errorValue = firstContexts[key];
      return { ...errorValue, target } as TBadRequestException;
    }
    return !firstChildren.children.length
      ? { ...InternalServerExceptionCode.ContextNotSetting, target }
      : this.getExceptionInChildren(firstChildren.children);
  }
}

 

위의 코드는 BadRequestException 전용 Filter를 만든 파일이다.이 글을 보시는 분들은 Nest를 사용해보신 분들이라고 생각하고 글을 앞으로 작성해보도록 하겠다. 메서드 하나하나씩 천천히 분석을 해보자!

 

catch 메서드

catch(exception: BadRequestException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const statusCode = HttpStatus.BAD_REQUEST;
    const exceptionResponse = exception.getResponse() as TExceptionResponse;

    const exceptionJson = this.generateBasicExceptionResponse(
      statusCode,
      request.url,
    );

    this.decideBadRequestExceptionByInstance(exceptionJson, exceptionResponse);
    this.logger.error(
      `[HttpBadRequestExceptionFilter - ${statusCode}] ${exceptionJson.errorCode}:${exceptionJson.message}`,
    );
    response.status(statusCode).json(exceptionJson);
  }

 

catch 메서드는 Exception이 발생했을 때 딱 잡는 곳이라고 생각하면 될 것 같다. 안의 코드들을 세부적으로 살펴보자면..

 

exceptionResponse 변수

getResponse()를 통해 우리는 에러 Response를 가져올 수 있다. TExceptionResponse는 ExceptionCodeDto 또는 ValidationError 타입이 될 수 있다. ValidationError는 Class-Validator를 통해 생긴 에러 객체라고 생각하면 될 것 같다. 그리고 ExceptionCodeDto는 code와 message로 구성이 되어있는데 예시를 들어주겠다.

// common.exception-code.ts
MultipartJsonFormatError: setExceptionCode(
    '8011',
    '올바른 JSON 형식으로 입력해주시고 Multipart에서 이미지를 제외한 데이터는 항상 data라는 키로 JSON 형태로 보내주세요.',
),

// main.ts
throw new BadRequestException(
        CommonExceptionCode.MultipartJsonFormatError,
);

 

필자는 저렇게 커스텀으로 에러코드를 만드는 편이다. setExceptionCode의 첫번째 요소는 커스텀 에러 코드고, 2번째 요소는 그에 해당하는 메세지이다. 이후 애플리케이션 내에서 저렇게 throw를 통해 에러를 보내야 할 경우가 생기는데 이 경우에 ExceptionCodeDto를 사용하는 것이다.

exceptionJson 변수

generateBasicExceptionResponse 메서드는 그냥 기본 에러 골격을 잡아주는 기능이라고 생각하면 된다. statusCode와 요청 경로를 넣어주면 포함시켜서 기본 에러 골격을 만들어준다.

export class ExceptionResponseDto {
  public readonly isSuccess: boolean;
  public readonly statusCode: number;
  public readonly timestamp: string;
  public readonly path: string;

  public target: string;
  public message: string;
  public errorCode: string;
  public errorStack: string;

  private constructor(statusCode: number, path: string) {
    this.isSuccess = false;
    this.statusCode = statusCode;
    this.errorCode = '';
    this.target = '';
    this.message = '';
    this.errorStack = '';
    this.timestamp = getKoreaTime();
    this.path = path;
  }

  static from(statusCode: number, path: string): ExceptionResponseDto {
    return new ExceptionResponseDto(statusCode, path);
  }
}

 

decideBadRequestExceptionByInstance 메서드 (여기서부터 시작!)

이 부분부터 핵심이라고 생각하면 될 것 같다.

/**
   * @summary ValidationError OR ExceptionCodeDto인지 확인 후 예외 결정하는 함수
   * @author  Jason
   *
   * @param   { ExceptionResponseDto } exceptionJson
   * @param   { TExceptionResponse } exceptionResponse
   */
  decideBadRequestExceptionByInstance(
    exceptionJson: ExceptionResponseDto,
    exceptionResponse: TExceptionResponse,
  ): void {
    // Class-Validator를 통해 나온 에러 객체
    if (exceptionResponse instanceof ValidationError) {
      const validationResult = this.getExceptionObj(exceptionResponse); // 첫 에러 가져오기
      this.setBadRequestException(
        exceptionJson,
        validationResult.code,
        validationResult.message,
        validationResult.target,
      );
      return;
    }
    this.setBadRequestException(
      exceptionJson,
      exceptionResponse.code,
      exceptionResponse.message,
    );
  }

 

 

매개변수에서 exceptionJson은 아까 우리가 위에서 만든 기본 에러 골격이다. 그리고 exceptionResponse는 filter에서 catch한 에러 리스폰스이다. 그래서 우리는 로직에서 ValidationError인지 아닌지를 판단해서 exception을 다르게 한다. 보면 차이점은 target을 넣느냐마냐 + getExceptionObj 메서드의 유무이다. target을 넣은 이유는 디버깅 + 클라분들 에러가 어디서 발생하는지 확인하는 용도라고 생각하면 될 것 같다. 그리고 getExceptionObj는 추후 설명하도록 하겠다.

 

이후에 공통적으로 setBadRequestException을 통해 에러 기본 골격에 세팅을 해준다. 

/**
   * @summary BadRequestException Property를 ExceptionResponseDto에 적용시켜주는 함수
   * @author  Jason
   *
   * @param   { ExceptionResponseDto } exceptionResponse
   * @param   { string } errorCode
   * @param   { string } message
   * @param   { string | undefined } target
   */
  setBadRequestException(
    exceptionResponse: ExceptionResponseDto,
    errorCode: string,
    message: string,
    target?: string | undefined,
  ): void {
    exceptionResponse.errorCode = errorCode;
    exceptionResponse.message = message;
    exceptionResponse.target = target ?? '';
  }

 

그러면 이제 getExceptionObj 메서드가 왜 ValidationError인 경우에만 있는지 설명하겠다. 필자가 위에서 말했듯이 ValidationError가 아닌 경우는 개발자가 직접 애플리케이션 단에서 throw를 통해 에러를 보내준 경우이다. 이 경우에는 가공할 필요가 없기 때문에 따로 전처리 과정 없이 바로 내보내줘도 된다. 그러면 이제 getExceptionObj 메서드를 살펴보자.

getExceptionObj 메서드와 getExceptionInChildren 메서드

/**
   * @summary Exception을 뱉어줄 메서드
   * @author  Jason
   * @param   { ValidationError } validationError
   * @returns { TBadRequestException }
   */
  getExceptionObj(validationError: ValidationError): TBadRequestException {
    const errorChildren = validationError.children;

    // Error Children이 없을 시 ==> 바로 반환해주면 됨
    if (isEmptyArray(errorChildren)) {
      const target = validationError.property;
      const errorContext = validationError.contexts as TValidationErrorContext;

      const key = errorContext ? Object.keys(errorContext)[0] : '';
      return {
        ...errorContext?.[key],
        target,
      } as TBadRequestException;
    }
    return this.getExceptionInChildren(errorChildren);
  }

 

먼저, TBadRequestException 타입은 { code, message, target? } 이라고 생각하자. 여기서 일단 먼저 알아둬야 할 부분은 children 부분이다. children은 class-validator에서 nested한 부분을 처리할 때 여기에 넣곤 한다. 예를 들어 보겠다.

"area": [
        {
            "date": "2023-11-22"
        }
    ],

 

위와 같은 객체가 있다고 했을 때 필자는 각 배열의 원소에 value라는 key가 있는지 확인해야 한다. 이렇게 key-value 구조에서 value가 nested할 때 children에 들어간다고 알면 되겠다.

 

이제 위 코드를 보면, children 배열이 비어있으면 따로 추가 처리 없이 반환해주게 된다. 이 때 context는 다음과 같다.

 

위 사진에서 contexts를 볼 수 있을 것이다. 보면 IsNotEmpty를 key로 code와 message가 있다. 그래서 key는 context가 있는 경우에 object.keys의 0번째 원소를 가져온다. 0번째를 가져오는 이유는 nest에서 에러 메세지는 배열로 구성되어 있는데 맨 위의 메세지를 보여주려고 하기 때문이다. 그리고 그 첫번째 key를 가지고 code와 message를 추출한다. 그리고 target까지 구성해서 return 해준다.

 

children 배열이 비어있지 않은 경우를 살펴보겠다.

 

위의 사진과는 다르게 children 객체가 들어가있다. children 객체에 데이터가 들어가있다는 것은 nested 객체에 유효성 에러가 발생했다는 뜻이고 거기에 접근해야 에러처리가 가능하다는 것이다. 그래서 children 객체에 들어가보겠다.

 

children 객체를 들어가보니 위에서 보던 ValidationError와 동일한 구조이다. 즉, children도 ValidationError 객체로 이루어져있다. 위의 사진을 보면 children 배열은 비어있고 context가 들어가있는 것을 볼 수 있다. context에 에러 내용이 포함되어 있다.

 

/**
   * @summary Children에서 Exception 추출해주는 함수
   * @author  Jason
   * @param   { ValidationError[] } errorChildren
   * @returns { TBadRequestException }
   */
  getExceptionInChildren(
    errorChildren: ValidationError[],
  ): TBadRequestException {
    const firstChildren = errorChildren[0];
    const target = firstChildren.property;

    if (firstChildren.contexts) {
      const firstContexts = firstChildren.contexts;
      const key = firstContexts ? Object.keys(firstContexts)[0] : '';
      const errorValue = firstContexts[key];
      return { ...errorValue, target } as TBadRequestException;
    }
    return !firstChildren.children.length
      ? { ...InternalServerExceptionCode.ContextNotSetting, target }
      : this.getExceptionInChildren(firstChildren.children);
  }

 

 

매개변수로 children 배열을 받으면 일단 가장 첫번째 children을 가져온다. 그리고 children 내에 context가 있는 경우에 우리가 getExceptionObj에서 처리한 것 처럼 처리한다.

 

그런데 만약에 context도 없고, children도 비어있는 배열이라면 context가 설정되어 있지 않다고 500번대 에러를 발생시킨다. 이 것은 필자가 Class-Validator쪽에 context를 지정해주지 않았기 때문이다.

 

만약 children이 있다면 context를 지정해 준 것이기 때문에 다시 재귀함수로 호출해준다. 왜냐면 children 안에 또 children이 있을 수 있기 때문이다. nested 안에 또 nested 객체가 들어갈 수 있다는 의미이기 때문에 재귀를 통해 처리하기로 했다.

 

 

위와 같이 처리하게 되면 다음과 같이 에러를 받을 수 있다.

 

 

원래 Nest의 Response와는 다르게 message를 단일로 받을 수 있게 되었다. 위 코드를 작업한 뒤 main.ts에 Filter를 Global하게 적용해주면 된다.

 

참고로, 재귀함수를 호출하는 과정에서 코드를 개선할 수 있을 것 같다고 생각이 드는데.. 일단 필자의 머리로는 이것이 최선인 것 같다.

조만간 더욱 더 깊게 분석해봐야 할 것 같다.