NestJS의 DTO number 필드로 살펴보는 API 서버의 고뇌

jYur·2023년 5월 12일
0

2024년 2월 22일에 관련 내용을 다음의 글로 새로 작성했다: Validation - Request body (@IsOptional()의 함정)


Spring에서는 숫자 타입(예: Long) 필드로 "33"이 들어와도 33으로 저장돼요.
한편, NestJS에서는 아래와 같은 DTO가 있을 때,

export class CreateProductDto {
  readonly name: string;
  readonly price: number;
}

price 값으로 "33"이 들어오면
에러는 커녕, number 타입인 price에 떡하니 string"33"이 저장돼요. 🤪

물론 아래처럼 간단하게 막을 순 있어요.

export class CreateProductDto {
  readonly name: string;
  
  @IsInt()
  @IsPositive()
  readonly price: number;
}

그런데 꼭 막아야 할까?

페이지에서 사용자에게 입력받으면 (number)string일 테니 그대로 전달할 수 있으면 프론트엔드 개발자가 편하지 않겠어요?

import { Transform } from 'class-transformer';
import { IsInt, IsPositive } from 'class-validator';

export class CreateProductDto {
  readonly name: string;
  
  @Transform(({ value }) => Number(value))
  @IsInt()
  @IsPositive()
  readonly price: number;
}

이제 "33"이 들어와도 33으로 저장됩니다.
간단하네! (해치웠나?)


이제 시작..
(실무란 이런 자잘한 것마저 섬세한 컨트롤을 요구한다)

nullable

number 필드 하나만 더 추가해 볼까요? 이번에는 nullable로.

export class CreateProductDto {
  readonly name: string;

  @Transform(({ value }) => Number(value))
  @IsInt()
  @IsPositive()
  readonly price: number;

  @Transform(({ value }) => Number(value))
  @IsOptional()
  @IsInt()
  @IsPositive()
  readonly categoryId?: number | null;
}

Q. 이 상태에서, categoryId 값으로 null이 들어오면 어떻게 될까요? (상품에 카테고리가 없다는 걸 명시하고 싶은 경우)
A. 막힙니다. nullable이라 null이 막히면 안 되는데?

가장 먼저 Transform에 의해 Number(null)이 되어 0이 되고(데코레이터 순서와 관계 없음),
그럼 값이 undefined 또는 null이 아니게 되기 때문에 @IsPositive가 작동해서 막히게 됩니다(400 Bad Request).

다른 한편으로는, 매번 @Transform(({ value }) => Number(value))이런 걸 붙여 주는 것도 좋아 보이지 않네요.

해결은, 아래처럼 간단하게 커스텀 데코레이터를 만들어 봤어요.

import { Transform } from 'class-transformer';
import { isNumberString } from 'class-validator';

export function TransformNumberStringToNumber() {
  return Transform(({ value }) => {
    if (isNumberString(value)) { // number string일 때만 숫자로 변환
      return Number(value);
    }

    return value; // 그 외의 경우에는 손대지 않음
  });
}
export class CreateProductDto {
  readonly name: string;

  @Transform(({ value }) => Number(value))
  @IsInt()
  @IsPositive()
  readonly price: number;

  @TransformNumberStringToNumber()
  @IsOptional()
  @IsInt()
  @IsPositive()
  readonly categoryId?: number | null;
}

이제 의도대로 동작해요.

  • number가 들어오면 그대로 저장.
  • number string이 들어오면 자동으로 number로 변환하여 저장.
  • number string이 아닌 string이 들어오면 에러.
  • null이 들어오면 null 저장 (validator들 무시)
  • (그런데 값을 누락하면?)

거의 다 끝났는데.
마지막으로 한 가지만 더 생각해 보자.

undefined vs null

@IsOptional()을 붙이면 두 가지를 허용하게 돼.

  • 필드 값 누락
  • 필드 값 null

그런데 이 둘은 엄연히 의미가 다르단 말이지.

  • 값을 누락했을 때 기본값으로 처리해도 되는 경우
    (null이 들어오면 null도 값이기 때문에 기본값이 할당되지 않음)
    • (주문 취소) 취소 금액이 없으면 전액 취소.
      • 취소 요청을 하는데 취소 금액이 null이라면 도대체 무슨 뜻일까?
    • (할부 결제) 할부 개월 수가 없으면 일시불.
  • 값이 없다는 사실을 명시해야 하는 경우
    • 카테고리가 없는 상품.
    • (선물하기 배송상품) 구매자가 배송지를 입력하지 않았으면 배송지 값으로 null을 보냄. 나중에 배송지가 null인 주문은 선물 받은 사람이 입력 가능.
      • 기본값을 null로 해 놓으면 그냥 누락해도 되는 거 아니냐고?
        그럼 개발자 실수로 배송지 값이 누락될 경우에도 정상적으로 처리돼 버릴 걸.
        누군가가 발견하지 못하고 배포되면 고객들이 항의할 때 그제서야 부랴부랴 고치겠지.
        가능성은 낮다고 보지만, 굳이 실수할 여지를 남겨 둘 필요는 없지 않을까?

두 가지 상황을 구분하려면 @IsOptional()을 사용하지 않아야 해.

  • 누락해도 되는데 null은 막아야 한다면

    • 필드에 기본값을 할당하거나
    • 기본값을 할당하기 애매한 경우 다음의 데코레이터들을 사용하면 돼
      @ValidateIf((_, value) => value !== undefined)
      @NotEquals(null)
  • 반대로, null은 되는데 누락하는 걸 막고 싶다면

    • @ValidateIf((_, value) => value !== null)

(@ValidateIf((_, value) => value !== null) 이것도 커스텀으로 @ValidateIfNotNull() 같은 거 만들어서 써도 될 것 같네)

0개의 댓글