NestJs에서 class-validator의 ErrorMessage는 어떻게 만들어질까?

이우길·2023년 3월 18일
3

NestJs

목록 보기
20/20
post-thumbnail

NestJs에서 ValidationPipe는 어떻게 ErrorResponse를 생성할까? (with class validator)

Goal

  • NestJs에서 사용되는 ValidationPipe의 Error Response 생성의 이해

개요

class-validator를 이용하여 RequestBody에 대한 유효성을 검증하였다. ErrorResponse를 통일하는 작업을 진행하려다 보니 class-validator에서는 어떠한 방식으로 ErrorResponse를 만들어서 보여줄까? 궁금증이 발생하여 라이브러리를 뜯어보기로 하였다.


class-validator

NestJs에서는 요청이 들어오는 Request에 대한 Validation을 주로 class-validator를 이용하여 유효한지 검증을 하게 된다.(NestJs Validation)

class CreateExampleRequest {
  @IsString()
  readonly title: string;

  @IsString()
  readonly content: string;

  //...
}

다음 main.ts에서 아래와 같은 코드를 추가하면 들어오는 요청에 대해 validation 데코레이터가 붙어있는 프로퍼티에 대한 유효성 검증을 할 수 있다.

// main.ts
async function bootstrap() {
  //...
  app.useGlobalPipes(new ValidationPipe({ transform: true })); // 1
  //...
}

// 유효하지 않을 때 Error Response
{
	"statusCode": 400,
	"message": [
		"title should not be empty",
		"content should not be empty",
	],
	"error": "Bad Request"
}
  1. 요청으로 들어오는 RequestBody에 대한 유효성 검증을 추가하고 { transform: true } 옵션을 추가함으로 유효성 검증이 끝난 objectcontroller에서 정의한 class로 받을 수 있도록 한다.

그렇다면 어떻게 위와 같은 Response를 Client에서 어떻게 만들어서 보내는 걸까?


@nestjs/common의 ValidationPipe 구현체 찾기

ValidationPipe@nestjs/common모듈에 속해있다. 그렇기 때문에 Nestjs를 설치한 프로젝트라면 당연히 들어있을 것이다. NestJs는 기본적으로 typescript가 적용된 프로젝트이기 때문에 진짜 구현체를 확인하려면 .js파일을 확인하면 된다.

node_modules
ㄴ common
    ㄴ pipes
        ㄴ validation.pipe.d.ts
        ㄴ validation.pipe.js # 구현 코드가 들어있는 파일
// ...

이제 validation.pipe.js를 통해 어떻게 ErrorResponse가 어떻게 만들어지는지 확인해보자.


ValidationPipe

필자는 Response가 만들어지는 과정이 궁금하기 때문에 ValidationPipe에서 createExceptionFactory를 조금 집중해서 보려고 한다.

main.ts에서 ValidationPipe를 생성할 때 exceptionFactory라는 옵션을 줄 수 있다. 해당 Option의 파라미터로는 (errors: ValidationError[]) => any; 형태의 콜백을 받게 되며 파라미터로 들어오는 errors에는 유효성을 검사에 실패한 프로퍼티의 정보와 Error 정보를 가지고 있다. 만약 요청 데이터가 전부 유효하다면 createExceptionFactory는 호출되지 않는다.

여기서 ValidationError의 타입을 추적하기 위해 validation-error.interface.d.ts파일을 확인하면 다음과 같다.

export interface ValidationError {
  target?: Record<string, any>;
  property: string;
  value?: any;
  constraints?: {
    [type: string]: string;
  };
  children?: ValidationError[];
  contexts?: {
    [type: string]: any;
  };
}

추가적으로 ValidationPipe의 ErrorResponse를 사용자가 커스텀이 가능하다. 만약 해당 옵션을 부여하지 않으면 ValidationPipe는 빌트인 되어 있는 createExceptionFactory를 사용하게 된다.

let ValidationPipe = class ValidationPipe {
    constructor(options) {
        // ...
        this.exceptionFactory = options.exceptionFactory || this.createExceptionFactory();
        // ...
    }

그럼 이제 빌트인 되어 있는 createExceptionFactory를 확인하기 위해 validation.pipe.js파일을 살펴보자.

//validation.pipe.js
createExceptionFactory() {
    return (validationErrors = []) => {
        if (this.isDetailedOutputDisabled) { // 1
            return new http_error_by_code_util_1.HttpErrorByCode[this.errorHttpStatusCode]();
        }
        const errors = this.flattenValidationErrors(validationErrors); // 2
        return new http_error_by_code_util_1.HttpErrorByCode[this.errorHttpStatusCode](errors); // 3
    };
}

1. if (this.isDetailedOutputDisabled)

isDetailedOutputDisabled옵션 또한 마찬가지로 ValidationPipe를 생성할 때 부여할 수 있는 옵션이다. disableErrorMessages옵션을 true로 주게되면 Client에게 유효하지 않은 요청에 대한 응답을 보낼 때 Error 메세지는 응답하지 않게 된다. 즉 아래와 같이 statusCodeerror만 응답되게 된다.

{
	"statusCode": 400,
	"error": "Bad Request"
}

HttpErrorByCode@nest/commonutil에 들어있으며 Http Status 코드가지고 NestJs에 빌트인 되어있는 HttpException과 매핑 해주는 역할을 한다.

참고로 ValidatonPipe의 기본 Http Status 코드는 400이며 ValidationPipe를 생성할 때 이 또한 커스텀 할 수 있다.


2. const errors = this.flattenValidationErrors(validationErrors);

ErrorResponse가 어떻게 만들어지는지 알고자 한다면 중점적으로 봐야하는 부분이다.

flattenValidationErrors는 아래와 같이 생겼다.

flattenValidationErrors(validationErrors) {
    return (0, iterare_1.iterate)(validationErrors) // 1
        .map(error => this.mapChildrenToValidationErrors(error)) // 2
        .flatten() // 3
        .filter(item => !!item.constraints) // 4
        .map(item => Object.values(item.constraints)) // 5
        .flatten() // 6
        .toArray(); // 7
}
  1. 인자로 들어온 validationErrors를 순회하는 API를 사용하기 위해 iterate라이브러리로 한번 래핑한다.

  2. map을 이용하여 validationErrors의 요소들을 순회하며 mapChildrenToValidationErrors를 실행한다. (자세한 건 아래서 확인해보자.)

  3. mapChildrenToValidationErrors의 결과를 가지고 flatten을 이용하여 배열을 평탄화 한다. 그 이유는 유효성 검증에 대상이 되는 프로퍼티에 여러개의 유효성 검증 데코레이터가 적용되어 있으며 Nested Object 혹은 Array에 대한 Validation도 진행해야 하기 때문이다.

  4. validationErrors에서 실제로 Client에게 보여지는 message는 constraints이기 때문에 constraints가 있는 error만 필터링 한다.

  5. error에서 constraintsmap을 이용하여 뽑아온다.

  6. Client에게 응답되는 ErrorResponse에는 1차원 배열 안에 유효성 검증에 실패한 모든 Error 메세지가 포함되기 때문에 평탄화를 진행한다.

  7. 배열로 변경 후 리턴하게 된다.


3. return new http_error_by_code_util_1.HttpErrorByCode[this.errorHttpStatusCode](errors);

2번에서 리턴된 Error들의 constraints를 받아 NestJs의 빌트인 된 HttpException의 생성자와 같이 호출되면서 message라는 프로퍼티에 들어가 응답이 되게 되는 것이다.


flattenValidationErrors 에서 사용된 method

Error에 대한 평탄화를 위해 validationErrors를 순회하며 내부적으로 호출되는 함수이며 최종적인 ErrorMessage를 만드는 과정들이 들어있다. flattenValidationErrors를 간단히 정리하면 유효성 검증에 실패한 모든 프로퍼티에 대한 Error Message를 정리하고 역할을 한다. 여기서 말한 모든 프로퍼티에는 데코레이터가 여러 개 적용된 프로퍼티, Nested Object, Array 등등에 해당된다.


mapChildrenToValidationErrors

mapChildrenToValidationErrorsvalidationErrors이 순회하면서 가장 먼저 호출되는 api이다. 생김세는 아래와 같다.

mapChildrenToValidationErrors(error, parentPath) {
    if (!(error.children && error.children.length)) { // 1
        return [error];
    }
    const validationErrors = []; // 2
    parentPath = parentPath // 3
        ? `${parentPath}.${error.property}`
        : error.property;
    for (const item of error.children) { // 4
        if (item.children && item.children.length) {
            validationErrors.push(...this.mapChildrenToValidationErrors(item, parentPath));
        }
        validationErrors.push(this.prependConstraintsWithParentProp(parentPath, item));
    }
    return validationErrors;
}
  1. 인자로 들어온 error가 children을 가지고 있지 않는다면 즉 중첩되어 있는 Error를 가지고 있지 않다면 Return한다. (Error 가 없는 경우)

  2. 새로운 Error 배열을 생성한다.

  3. 부모의 경로를 정의한다. 인자로 들어온 parentPath의 존재 유무로 결정된다. parentPath의 쓰임세는 prependConstraintsWithParentProp에서 확인할 수 있다.

  4. 인자로 들어온 error를 순회하면서 계속해서 내부 Error를 탐색해 나간다. 여기서 자식이 있는 경우와 자식이 없는 경우로 나뉘어지는데 유효성 검증 대상의 프로퍼티가 데코레이터가 여러 개 적용된 프로퍼티, Nested Object, Array 등등 경우가 다양하게 있기 때문에 mapChildrenToValidationErrors재귀하면서 최하위까지 타고 들어간다.


prependConstraintsWithParentProp

prependConstraintsWithParentProp는 간단하게 이야기 하면 Error로 응답되는 Message의 Key값에 부모가 있다면 부모의 이름을 붙혀주는 역할 및 key 값에 걸맞는 constraints를 매핑해주는 역할이다. 이 후 인자로 들어온 error에 프로퍼티에 constraints를 추가하거나 덮어 쓰여지게 된다.

prependConstraintsWithParentProp(parentPath, error) {
    const constraints = {};
    for (const key in error.constraints) {
        constraints[key] = `${parentPath}.${error.constraints[key]}`;
    }
    return Object.assign(Object.assign({}, error), { constraints });
}

ArrayNested Object의 경우가 이에 해당하며 해당 로직을 거쳐야 아래와 같은 ErrorResponse의 형태를 띄게 된다.

// EX
"message": [
    // ...
    "person.name must be a string",
]

정리

라이브러리의 코드를 하나씩 살펴보면서 NestJsclass-validator를 통해 들어온 Error를 어떻게 가공하고 사용하는지에 대해 알아 볼 수 있었다. 이 과정을 통해 라이브러리의 코드를 열어본다는 자신감을 얻을 수 있었으며 추 후 NestJs에서 Error 공통화를 할 때 조금 더 도움이 될 수 있을 것 같다는 생각을 하게 되었다. (정작 class-validator에서 Error를 어떻게 만들어 CallBack으로 던지는지에 대한 내용은 없다...)


Reference

profile
leewoooo

0개의 댓글