Nestjs에서 class validator IsEnum 제대로 써보자
NestJS에서 DTO 검증을 하다 보면 “특정 값만 허용해야 하는 경우”가 많습니다. 예를 들어, 역할(role)은 'admin' | 'user' | 'blogger' 중 하나만, 상품 사이즈(size)는 'S' | 'M' | 'L' | 'XL' 중 하나만 허용하고 싶을 때죠. 이럴 때 class-validator의 @IsEnum 데코레이터를 제대로 활용하면 깔끔하고 안전한 입력 검증을 구현할 수 있습니다.
@IsEnum의 기본 사용법 복습
// enums/user-role.enum.ts
export enum UserRole {
ADMIN = 'admin',
USER = 'user',
BLOGGER = 'blogger',
}
// dto/create-user.dto.ts
import { IsEnum, IsString, IsNotEmpty, IsEmail } from 'class-validator';
import { UserRole } from '../enums/user-role.enum';
export class CreateUserDto {
@IsString()
@IsNotEmpty()
name: string;
@IsEmail()
email: string;
@IsEnum(UserRole, {
message: 'role must be one of: admin, user, blogger',
})
role: UserRole;
}
@IsEnum은 타입 안정성과 데이터 무결성을 동시에 보장합니다. 즉, role 값이 enum에 포함되지 않으면 자동으로 400 에러를 발생시킵니다.
대소문자 구분 없는 검증 (Case-insensitive Validation)
현실에서는 사용자가 "Admin", "ADMIN", "admin" 등 다양한 입력을 보냅니다. 이를 허용하지 않으면 불필요한 사용자 오류를 유발하죠. 이 문제를 해결하는 세 가지 패턴을 소개합니다 👇
1️⃣ @Transform으로 미리 소문자 변환하기
가장 간단하면서 실용적인 방법입니다.
import { IsEnum, IsString, IsNotEmpty } from 'class-validator';
import { Transform } from 'class-transformer';
import { UserRole } from '../enums/user-role.enum';
export class CreateUserDto {
@IsString()
@IsNotEmpty()
name: string;
@Transform(({ value }) => value?.toString().toLowerCase())
@IsEnum(UserRole, {
message: 'role must be one of: admin, user, blogger',
})
role: UserRole;
}
💡 장점: 구현 간단, 런타임 오버헤드 거의 없음
⚠️ 단점: enum 값이 반드시 소문자로 정의되어 있어야 함
Tip: enum 값을 모두 소문자로 정의하고, 입력값을 .toLowerCase()로 변환하면 REST API 요청 케이스 차이로 인한 오류를 방지할 수 있습니다.
2️⃣ @Matches를 통한 정규식 기반 검증
enum 값이 많거나 동적으로 변경될 때 유용합니다.
import { Matches, IsString } from 'class-validator';
import { UserRole } from '../enums/user-role.enum';
export class CreateUserDto {
@IsString()
@Matches(`^(${Object.values(UserRole).join('|')})$`, 'i', {
message: 'role must be one of: admin, user, blogger (case-insensitive)',
})
role: UserRole;
}
💡 장점: 대소문자 무시(
i플래그) 가능
⚠️ 단점: 정규식 복잡성 증가, enum 타입 자동완성 불가
3️⃣ 커스텀 데코레이터로 IsEnumCaseInsensitive 만들기
진짜 “현업용” 방식입니다. 반복되는 변환과 정규식 대신, 재사용 가능한 검증기를 만들어봅시다.
import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface
} from 'class-validator';
@ValidatorConstraint({ async: false })
export class IsEnumCaseInsensitiveConstraint implements ValidatorConstraintInterface {
validate(value: any, args: any) {
const [enumObject] = args.constraints;
const enumValues = Object.values(enumObject).map(v => v.toString().toLowerCase());
return enumValues.includes(value?.toString().toLowerCase());
}
defaultMessage() {
return '입력값이 허용된 enum 목록에 없습니다.';
}
}
export function IsEnumCaseInsensitive(enumObject: any, validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [enumObject],
validator: IsEnumCaseInsensitiveConstraint,
});
};
}
사용 예시 👇
import { Transform } from 'class-transformer';
import { IsEnumCaseInsensitive } from '../validators/is-enum-case-insensitive.decorator';
import { UserRole } from '../enums/user-role.enum';
export class CreateUserDto {
@Transform(({ value }) => value?.toString().toLowerCase())
@IsEnumCaseInsensitive(UserRole, {
message: 'role은 admin, user, blogger 중 하나여야 합니다 (대소문자 무관)',
})
role: UserRole;
}
💡 장점: enum별로 재사용 가능
⚙️ 단점: 약간의 보일러플레이트 존재
현업 팁: 공용 유효성 데코레이터를 src/validators/ 경로에 두고, 모든 DTO에서 불러 쓰면 유지보수가 훨씬 편해집니다.
실무 팁: transform: true 옵션은 꼭 활성화하기
NestJS의 main.ts에서 ValidationPipe를 설정할 때 반드시 transform: true를 켜야 합니다.
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
);
이 설정이 있어야 @Transform()이 적용되고 문자열 → enum 변환이 정상 동작합니다.
주의: transform을 끄면 DTO의 @Transform()과 @Type()이 무시되어, enum 검증이 항상 실패하게 됩니다.
다른 실용적인 검증 패턴
@IsEnum 외에도 class-validator에는 DTO를 더 견고하게 만들어주는 유용한 조합들이 있습니다 👇
복합 문자열 검증
@IsString()
@MinLength(2)
@MaxLength(30)
@Matches(/^[가-힣a-zA-Z\s]+$/, {
message: '이름은 한글, 영문, 공백만 포함할 수 있습니다.',
})
name: string;
중첩 객체 검증
import { ValidateNested, Type } from 'class-validator';
class AddressDto {
@IsString()
city: string;
@IsString()
street: string;
}
export class UserWithAddressDto {
@IsString()
name: string;
@ValidateNested()
@Type(() => AddressDto)
address: AddressDto;
}
배열 내 객체 검증
import { IsArray, ValidateNested, Type } from 'class-validator';
import { ProductSize } from '../enums/product-size.enum';
class OrderItemDto {
@IsString()
productName: string;
@IsEnum(ProductSize)
size: ProductSize;
}
export class OrderDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
items: OrderItemDto[];
}
IsEnum vs 타입 안전성
TypeScript의 enum은 컴파일 타임에 타입을 보장하고, @IsEnum은 런타임에서 실제 데이터를 검증합니다. 즉, 둘을 함께 써야 진짜 안전합니다.
| 구분 | TypeScript Enum | @IsEnum |
|---|---|---|
| 시점 | 컴파일 타임 | 런타임 |
| 역할 | 타입 제한 | 실제 요청 검증 |
| 실패 시 | 컴파일 에러 | 400 Bad Request |