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;
}
이제 의도대로 동작해요.
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()
같은 거 만들어서 써도 될 것 같네)