Nestjs에서 class validator IsEnum 제대로 써보자

Nestjs에서 class validator IsEnum 제대로 써보자

D
dongAuthor
5 min read

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