우당탕탕 우리네 개발생활

class-validator와 class-transformer 데코레이터들의 동작 순서 본문

tech

class-validator와 class-transformer 데코레이터들의 동작 순서

미스터카멜레온 2024. 8. 18. 00:06

결론부터 말하자면 validator와 transformer의 데코레이터들은 다음과 같은 순서로 동작이 됩니다.

Type(class-transformer) -> ValidateIf -> IsOptional -> 기타 class-validator 데코레이터들

참고로 ChatGPT는 IsOptional과 ValidateIf를 반대로 설명해 줬었고 이로 인해 더 혼란스러웠지만 머릿속엔 강하게 각인될 시행착오가 됐습니다 :)

개요

validation은 항상 중요합니다다. 제 블로그에서도 관심 있어해 주시는 keyword가 validation과 관련되어 있습니다. 아래 포스트를 그나마 많이 봐주셨답니다.

https://khjeong0423.tistory.com/31

 

[Nestjs] ! 과 ? 그리고 class-validator에 대한 고찰

저는 Nestjs + typescript 기술 조합으로 백엔드 개발을 하고 있습니다. 최근 개발을 하면서 두 가지 궁금증이 생겼습니다.- class-validator를 사용하여 decorating한 클래스는 어느 상황에서든 validation을

khjeong0423.tistory.com

현업에서 validation에 대한 책임을 언제 주는 게 좋을까 한동안 고민을 많이 했었습니다. 엔티티의 생명주기가 시작될 때 줄지? Controller에서 request인자를 받을 때의 그 DTO에서 validation에 대한 책임을 줄지? 깊이 있는 고민을 하다 보면 validation에 대한 세부 철학도 생기기 마련인 것 같습니다.

 

아무튼 한동안 class-validator와 class-transformer를 이용하여 극한으로 validation을 사용해보고 싶었습니다.

 

이 극한의 validation의 대상은 개념적으로 묶여있는데 따로따로 존재하는 필드들이었습니다. 예를 들어, onOfflineType이라는 필드가 있는데 이 필드의 값이 offline이면 반드시 location이라는 필드는 null이면 안됩니다. 반대로 onOfflineType이라는 필드가 online이면 location이라는 필드는 반드시 null이어야 합니다.

 

아마 비슷한 니즈가 있으신 분들이 많이 계실거라 생각합니다.

 

이와 관련해서 겪었던 시행착오를 정리해보려고 합니다.

예시 코드

import { BadRequestException } from '@nestjs/common';
import { Type } from 'class-transformer';
import {
  IsInt,
  IsOptional,
  IsString,
  Max,
  Min,
  ValidateIf,
} from 'class-validator';

export class UpdateProductBodyDto {
  @IsString()
  public name!: string;

  @ValidateIf((obj) => {
    if (obj.price !== undefined && obj.discountPrice === undefined) {
      throw new BadRequestException(
        'discountPrice 값이 존재해야만 price 값을 수정할 수 있습니다',
      );
    }
    return true;
  })
  @IsOptional()
  @IsInt()
  @Min(0)
  @Max(Number.MAX_SAFE_INTEGER)
  // 우선순위를 고려해보기 위해 추후 사용할 예정입니다.
  // @Type(() => String)
  public price?: number;

  @ValidateIf((obj) => {
    if (obj.price === undefined && obj.discountPrice !== undefined) {
      throw new BadRequestException(
        'price 값이 존재해야만 discountPrice 값을 수정할 수 있습니다',
      );
    }
    return true;
  })
  @IsOptional()
  @IsInt()
  @Min(0)
  @Max(Number.MAX_SAFE_INTEGER)
  // 우선순위를 고려해보기 위해 추후 사용할 예정입니다.
  // @Type(() => String)
  public discountPrice?: number;
}
위 코드는 해당 github 링크에서 자세히 확인하실 수 있습니다.

 

예시 코드를 보시면 아시겠지만 price필드와 discountPrice필드에 데코레이터가 꽤 많이 붙어있습니다.

요구사항을 간략하게 정리해보면 아래와 같습니다.

  • 수정 시, price와 discountPrice는 독립적으로 존재할 수 없다. 한 값을 수정하고 싶으면 다른 한 값도 함께 수정되어야 한다.
  • 각 필드는 optional하다.
  • 각 필드는 양의 정수여야 한다.

가설 검증

1. ValidateIf vs IsOptional

@ValidateIf((obj) => {
  console.log(`price의 값: ${obj.price}`);
  if (obj.price !== undefined && obj.discountPrice === undefined) {
    throw new BadRequestException(
      'discountPrice 값이 존재해야만 price 값을 수정할 수 있습니다',
    );
  }
  return true;
})
@IsOptional()
@IsInt()
public price?: number;

ValidateIf와 IsOptional을 검증해 볼 수 있는 방법은 간단했습니다.

ValidateIf의 인자로 콜백함수를 넘길 수 있는데, 이 함수 내에 console.log를 찍어 해당 멤버의 값이 undefined인데도 log가 찍히는지 확인을 하면 됐습니다. log가 찍히는 것을 확인했고 ValidateIf가 IsOptional보다 우선순위가 높다는 사실을 확인할 수 있었습니다.

 

2. @Type vs @ValidateIf

@ValidateIf((obj) => {
  console.log(`변환된 price의 타입: ${typeof obj.price}`);
  if (obj.price !== undefined && obj.discountPrice === undefined) {
    throw new BadRequestException(
      'discountPrice 값이 존재해야만 price 값을 수정할 수 있습니다',
    );
  }
  return true;
})
@Type(() => String)
@IsInt()
public price?: number;

Type과 ValidateIf 역시 위 방법과 비슷하게 검증해 볼 수 있었습니다.

Type이 우선순위로 동작하게 되어 멤버의 타입이 변경된다면 ValidateIf에서 console.log를 찍어 그 변경된 타입을 확인해 보는 방법이었습니다. 실험 결과 console.log로 변경된 타입을 확인했고, Type이 ValidateIf보다 우선순위가 높다는 사실을 확인할 수 있었습니다.

 

기존에 IsOptional이 나머지 class-validator의 데코레이터들보다 우선순위가 높다는 사실은 알고 있었기에(애초에 IsOptional이라는 데코레이터의 목적이 undefined나 null값일 경우 validation을 무시하는 것입니다), 궁극적으로 아래와 같은 결과를 얻게 되었습니다.

Type(class-transformer) -> ValidateIf -> IsOptional -> 기타 class-validator 데코레이터들

마치며

 위 사용된 데코레이터들은 사용성이 높기 때문에 우선순위에 대해 잘 알고 있으면 ValidateIf를 이용한 커스텀 validation도 무리 없이 사용하실 수 있을 거라 생각됩니다. 제 시행착오가 많은 분들께 도움이 되었으면 좋겠습니다.