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
Nestjs์—์„œ class validator IsEnum ์ œ๋Œ€๋กœ ์จ๋ณด์ž