junyeokk
Blog
TypeScript·2025. 11. 15

class-validator

TypeScript의 타입 시스템은 컴파일 타임에만 동작한다. 빌드가 끝나면 타입 정보는 전부 사라지고, 런타임에는 그냥 JavaScript다. 문제는 외부에서 들어오는 데이터 — HTTP 요청 body, 폼 입력, 파일 파싱 결과 — 는 전부 런타임에 들어온다는 것이다. 아무리 CreateUserDto에 타입을 정의해놔도, 클라이언트가 rating: "다섯"을 보내면 TypeScript는 아무것도 막지 못한다.

이 문제를 해결하는 접근법은 크게 두 가지다. 하나는 Zod처럼 스키마를 코드로 정의하고 parse()로 검증하는 방식이고, 다른 하나는 class-validator처럼 클래스 프로퍼티에 데코레이터를 붙여서 검증 규칙을 선언하는 방식이다.

class-validator는 후자의 대표주자다. 내부적으로 validator.js를 사용하며, 데코레이터로 검증 규칙을 클래스에 직접 부착한다. NestJS의 ValidationPipe과 궁합이 특히 좋아서, NestJS 생태계에서는 사실상 표준이다.


왜 데코레이터 기반인가

검증 로직을 작성하는 가장 원시적인 방법은 if 문이다.

typescript
function validateUser(body: any) {
  if (!body.email || !body.email.includes('@')) {
    throw new Error('이메일이 유효하지 않습니다');
  }
  if (!body.name || body.name.length < 2) {
    throw new Error('이름은 2자 이상이어야 합니다');
  }
  if (body.age !== undefined && (typeof body.age !== 'number' || body.age < 0)) {
    throw new Error('나이는 0 이상의 숫자여야 합니다');
  }
}

프로퍼티가 10개만 넘어가도 이런 코드는 읽기 힘들어진다. 검증 규칙이 비즈니스 로직과 섞이고, 어떤 필드에 어떤 규칙이 적용되는지 한눈에 파악하기 어렵다.

데코레이터 방식은 규칙을 프로퍼티 선언 바로 위에 붙인다. 클래스 정의 자체가 곧 검증 스키마가 된다.

typescript
import { IsEmail, MinLength, IsInt, Min, IsOptional } from 'class-validator';

export class CreateUserDto {
  @IsEmail()
  email: string;

  @MinLength(2)
  name: string;

  @IsOptional()
  @IsInt()
  @Min(0)
  age?: number;
}

각 프로퍼티의 검증 규칙이 바로 옆에 있으니 가독성이 좋고, 데코레이터를 추가/제거하는 것만으로 규칙을 변경할 수 있다.


기본 사용법

validate와 validateOrReject

검증은 validate() 또는 validateOrReject() 함수로 실행한다. 둘 다 비동기 함수다.

typescript
import { validate, validateOrReject } from 'class-validator';

const user = new CreateUserDto();
user.email = 'not-an-email';
user.name = 'J';

// 방법 1: 에러 배열 반환
const errors = await validate(user);
if (errors.length > 0) {
  console.log('검증 실패:', errors);
} else {
  console.log('검증 성공');
}

// 방법 2: 에러 시 Promise reject
try {
  await validateOrReject(user);
} catch (errors) {
  console.log('검증 실패:', errors);
}

validate()는 항상 ValidationError[]를 반환한다. 빈 배열이면 검증 통과. validateOrReject()는 검증 실패 시 에러 배열로 reject된다.

ValidationError 구조

검증 실패 시 반환되는 각 에러 객체는 이런 구조다:

typescript
{
  target: Object;        // 검증된 객체
  property: string;      // 실패한 프로퍼티 이름
  value: any;            // 실패한 값
  constraints?: {        // 실패한 검증 규칙과 에러 메시지
    [type: string]: string;
  };
  children?: ValidationError[];  // 중첩 객체의 검증 에러
}

예를 들어 email"not-an-email"을 넣으면:

json
{
  "property": "email",
  "value": "not-an-email",
  "constraints": {
    "isEmail": "email must be an email"
  }
}

constraints 객체의 키가 검증 규칙 이름이고, 값이 에러 메시지다. 하나의 프로퍼티에 여러 데코레이터가 있으면 실패한 모든 규칙이 constraints에 들어간다.

주의: HTTP 응답으로 에러를 돌려줄 때는 target을 숨기는 게 좋다. 전체 객체가 노출되면 보안 문제가 될 수 있다.

typescript
> validate(user, { validationError: { target: false } });
> 

핵심 검증 데코레이터

class-validator는 validator.js를 기반으로 수십 개의 내장 데코레이터를 제공한다. 전부 외울 필요는 없지만, 자주 쓰는 것들을 카테고리별로 정리해보자.

공통 검증

데코레이터설명
@IsDefined()null, undefined가 아닌지 확인. skipMissingProperties에도 무시되지 않음
@IsOptional()값이 null이나 undefined면 다른 모든 검증을 건너뜀
@Equals(value)특정 값과 일치하는지 (===)
@NotEquals(value)특정 값이 아닌지
@IsEmpty()'', null, undefined인지
@IsNotEmpty()'', null, undefined가 아닌지
@IsIn(values)주어진 배열에 포함되는지
@IsNotIn(values)주어진 배열에 포함되지 않는지

타입 검증

데코레이터설명
@IsBoolean()boolean인지
@IsDate()Date 객체인지
@IsString()문자열인지
@IsNumber(options?)숫자인지 (maxDecimalPlaces, allowNaN, allowInfinity 옵션)
@IsInt()정수인지
@IsArray()배열인지
@IsEnum(entity)주어진 enum의 값인지

숫자 검증

데코레이터설명
@Min(n)최솟값
@Max(n)최댓값
@IsPositive()양수인지
@IsNegative()음수인지

문자열 검증

데코레이터설명
@MinLength(n)최소 길이
@MaxLength(n)최대 길이
@Length(min, max?)길이 범위
@Contains(seed)특정 문자열 포함 여부
@Matches(pattern)정규식 매칭
@IsEmail()이메일 형식
@IsUrl()URL 형식
@IsUUID(version?)UUID 형식 ('3', '4', '5', 'all')
@IsDateString()ISO 8601 날짜 문자열
@IsPhoneNumber(region?)전화번호 형식
@IsCreditCard()신용카드 번호 형식

이 외에도 @IsIP(), @IsJSON(), @IsJWT(), @IsHexColor() 등 수십 개가 더 있다. 전체 목록은 공식 README에서 확인할 수 있다.


에러 메시지 커스터마이징

기본 에러 메시지는 영어로 "title must be longer than or equal to 10 characters" 같은 형태다. 한국어 서비스라면 당연히 커스터마이징이 필요하다.

문자열 메시지

typescript
@MinLength(10, {
  message: '제목은 최소 10자 이상이어야 합니다',
})
title: string;
date: "2026-02-15"

템플릿 토큰

메시지 안에서 특수 토큰을 사용할 수 있다:

  • $property — 프로퍼티 이름
  • $value — 실제 입력된 값
  • $constraint1, $constraint2 — 데코레이터에 전달된 제약 조건 값
  • $target — 클래스 이름
typescript
@MinLength(10, {
  message: '$property의 길이가 너무 짧습니다. 최소 $constraint1자, 현재 $value자',
})
title: string;
date: "2026-02-15"

title"짧음"을 넣으면: "title의 길이가 너무 짧습니다. 최소 10자, 현재 2자"

함수 메시지

더 세밀한 제어가 필요하면 함수를 전달한다:

typescript
@MinLength(10, {
  message: (args: ValidationArguments) => {
    if (args.value.length === 0) {
      return `${args.property}은(는) 필수 항목입니다`;
    }
    return `${args.property}은(는) 최소 ${args.constraints[0]}자여야 합니다 (현재: ${args.value.length}자)`;
  },
})
title: string;
date: "2026-02-15"

ValidationArguments는 다음 정보를 담고 있다:

프로퍼티설명
value검증 중인 값
constraints데코레이터에 전달된 제약 조건 배열
targetName클래스 이름
object검증 중인 전체 객체
property프로퍼티 이름

배열·Set·Map 요소 검증

프로퍼티가 배열이고 각 요소를 검증하고 싶다면 each: true 옵션을 사용한다.

typescript
export class CreatePostDto {
  @MaxLength(20, { each: true })
  @IsString({ each: true })
  tags: string[];
}

이렇게 하면 tags 배열의 각 요소가 문자열인지, 20자 이하인지를 검증한다. 배열 자체가 아니라 배열 안의 아이템 하나하나를 검사하는 것이다.

Set과 Map에도 동일하게 동작한다:

typescript
@MaxLength(20, { each: true })
tags: Set<string>;

@MaxLength(20, { each: true })
tags: Map<string, string>;  // value를 검증

중첩 객체 검증

DTO 안에 또 다른 DTO가 중첩된 경우, @ValidateNested()를 사용해야 내부 객체까지 검증이 이루어진다.

typescript
import { ValidateNested, IsString, IsInt } from 'class-validator';
import { Type } from 'class-transformer';

class AddressDto {
  @IsString()
  street: string;

  @IsString()
  city: string;

  @IsInt()
  zipCode: number;
}

class CreateUserDto {
  @IsString()
  name: string;

  @ValidateNested()
  @Type(() => AddressDto)
  address: AddressDto;
}

여기서 @Type(() => AddressDto)이 중요하다. JSON으로 들어온 plain object를 AddressDto 클래스 인스턴스로 변환해야 class-validator의 데코레이터가 동작한다. 이건 class-transformer의 역할이고, NestJS의 ValidationPipetransform: true 옵션으로 자동으로 처리해준다.

배열로 된 중첩 객체도 동일하다:

typescript
@ValidateNested({ each: true })
@Type(() => AddressDto)
addresses: AddressDto[];

주의: @ValidateNested() 없이 중첩 객체를 넣으면, 그 객체 안의 데코레이터가 전혀 실행되지 않는다. 자주 빠지는 함정이다.


조건부 검증 (@ValidateIf)

특정 조건에서만 검증을 실행해야 할 때 @ValidateIf()를 사용한다. 조건 함수가 false를 반환하면 해당 프로퍼티의 모든 검증 데코레이터가 건너뛰어진다.

typescript
import { ValidateIf, IsNotEmpty, IsUrl } from 'class-validator';

class SocialProfileDto {
  @IsString()
  platform: string;

  @ValidateIf(o => o.platform === 'website')
  @IsUrl()
  @IsNotEmpty()
  url: string;
}

platform'website'일 때만 url을 검증한다. 다른 플랫폼이면 url이 빈 문자열이든 undefined든 상관없이 통과된다.

이런 패턴은 폼에서 "기타"를 선택했을 때만 추가 입력을 요구하는 경우에 유용하다.

typescript
class PaymentDto {
  @IsEnum(PaymentMethod)
  method: PaymentMethod;

  @ValidateIf(o => o.method === PaymentMethod.CARD)
  @IsCreditCard()
  cardNumber: string;

  @ValidateIf(o => o.method === PaymentMethod.BANK)
  @IsString()
  @IsNotEmpty()
  accountNumber: string;
}

결제 수단에 따라 필요한 필드만 검증한다.


검증 그룹

하나의 DTO를 여러 상황에서 다른 규칙으로 검증해야 할 때가 있다. 예를 들어 생성 시에는 password가 필수지만, 수정 시에는 선택이다.

typescript
class UserDto {
  @IsString({ always: true })
  name: string;

  @IsString({ groups: ['create'] })
  @MinLength(8, { groups: ['create'] })
  password: string;

  @IsEmail({}, { groups: ['create', 'update'] })
  email: string;
}

검증할 때 그룹을 지정한다:

typescript
// 생성 시: name, password, email 모두 검증
validate(user, { groups: ['create'] });

// 수정 시: name, email만 검증 (password는 건너뜀)
validate(user, { groups: ['update'] });

규칙:

  • groups가 지정된 데코레이터는 해당 그룹에서만 실행
  • always: true가 있으면 어떤 그룹에서든 항상 실행
  • groups가 없는 데코레이터는 그룹 미지정 시에만 실행 (그룹을 지정해서 validate() 호출하면 무시됨)

마지막 규칙이 함정이다. validate(user, { groups: ['create'] })를 호출하면, groups 옵션이 없는 데코레이터는 실행되지 않는다. 모든 상황에서 실행하고 싶으면 반드시 always: true를 붙여야 한다.


화이트리스팅

클라이언트가 DTO에 정의되지 않은 프로퍼티를 보내는 것은 보안 위험이 될 수 있다. Mass Assignment 공격을 막으려면 화이트리스팅을 사용한다.

typescript
// 알려지지 않은 프로퍼티를 조용히 제거
validate(user, { whitelist: true });

// 알려지지 않은 프로퍼티가 있으면 에러 발생
validate(user, { whitelist: true, forbidNonWhitelisted: true });

whitelist: true는 데코레이터가 하나도 없는 프로퍼티를 결과 객체에서 제거한다. forbidNonWhitelisted: true를 함께 쓰면 제거 대신 검증 에러를 발생시킨다.

데코레이터가 필요 없지만 허용은 해야 하는 프로퍼티에는 @Allow()를 붙인다:

typescript
class UpdateProfileDto {
  @IsString()
  name: string;

  @Allow()
  metadata: any;  // 검증 규칙은 없지만 화이트리스트에 포함
}

커스텀 검증 데코레이터

내장 데코레이터로 부족할 때 직접 만들 수 있다. 두 가지 방법이 있다.

방법 1: ValidatorConstraint 클래스

typescript
import {
  ValidatorConstraint,
  ValidatorConstraintInterface,
  ValidationArguments,
  registerDecorator,
} from 'class-validator';

@ValidatorConstraint({ name: 'isAfterNow', async: false })
class IsAfterNowConstraint implements ValidatorConstraintInterface {
  validate(value: any, args: ValidationArguments): boolean {
    if (!(value instanceof Date)) return false;
    return value.getTime() > Date.now();
  }

  defaultMessage(args: ValidationArguments): string {
    return `${args.property}은(는) 현재 시간 이후여야 합니다`;
  }
}

// 데코레이터 팩토리
function IsAfterNow(validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      target: object.constructor,
      propertyName,
      options: validationOptions,
      constraints: [],
      validator: IsAfterNowConstraint,
    });
  };
}

사용:

typescript
class CreateEventDto {
  @IsAfterNow({ message: '이벤트 시작일은 미래여야 합니다' })
  startDate: Date;
}

방법 2: 인라인 validate 함수

간단한 검증이라면 클래스 없이 인라인으로도 가능하다:

typescript
function IsLongerThan(property: string, validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      name: 'isLongerThan',
      target: object.constructor,
      propertyName,
      constraints: [property],
      options: validationOptions,
      validator: {
        validate(value: any, args: ValidationArguments) {
          const [relatedPropertyName] = args.constraints;
          const relatedValue = (args.object as any)[relatedPropertyName];
          return typeof value === 'string' &&
                 typeof relatedValue === 'string' &&
                 value.length > relatedValue.length;
        },
        defaultMessage(args: ValidationArguments) {
          return `${args.property}은(는) ${args.constraints[0]}보다 길어야 합니다`;
        },
      },
    });
  };
}

이런 식으로 다른 프로퍼티와 비교하는 검증도 만들 수 있다. args.object로 전체 객체에 접근할 수 있기 때문이다.

비동기 커스텀 검증

DB 조회가 필요한 유니크 검증 같은 경우:

typescript
@ValidatorConstraint({ name: 'isUniqueEmail', async: true })
class IsUniqueEmailConstraint implements ValidatorConstraintInterface {
  constructor(private userRepository: UserRepository) {}

  async validate(email: string): Promise<boolean> {
    const user = await this.userRepository.findByEmail(email);
    return !user;  // 이미 존재하면 false
  }

  defaultMessage(): string {
    return '이미 사용 중인 이메일입니다';
  }
}

async: true를 명시하면 validate 메서드가 Promise를 반환할 수 있다. 서비스 컨테이너와 함께 사용하면 의존성 주입도 가능하다.


NestJS 통합

class-validator가 가장 빛나는 곳이 NestJS다. ValidationPipe을 글로벌로 설정하면 모든 컨트롤러에서 자동으로 검증이 이루어진다.

글로벌 ValidationPipe 설정

typescript
// main.ts
import { ValidationPipe } from '@nestjs/common';

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,              // DTO에 없는 프로퍼티 제거
    forbidNonWhitelisted: true,   // 알 수 없는 프로퍼티가 있으면 400 에러
    transform: true,              // plain object → DTO 클래스 인스턴스로 변환
    transformOptions: {
      enableImplicitConversion: true,  // 쿼리 파라미터 등 문자열 → 숫자 자동 변환
    },
  }),
);

컨트롤러에서 사용

typescript
@Controller('users')
class UserController {
  @Post()
  create(@Body() dto: CreateUserDto) {
    // dto는 이미 검증 완료된 CreateUserDto 인스턴스
    // 검증 실패 시 자동으로 400 Bad Request 응답
    return this.userService.create(dto);
  }

  @Get()
  findAll(@Query() query: PaginationDto) {
    // 쿼리 파라미터도 검증 가능
    return this.userService.findAll(query);
  }
}

컨트롤러 코드에 검증 로직이 전혀 없다. ValidationPipe이 요청 body를 DTO 인스턴스로 변환하고, class-validator로 검증한 뒤, 실패하면 자동으로 BadRequestException을 던진다.

검증 에러 응답 커스터마이징

기본 에러 응답 형태가 마음에 안 들면 exceptionFactory로 커스터마이징한다:

typescript
new ValidationPipe({
  exceptionFactory: (errors: ValidationError[]) => {
    const messages = errors.map(error => {
      const constraints = Object.values(error.constraints || {});
      return {
        field: error.property,
        errors: constraints,
      };
    });
    return new BadRequestException({
      statusCode: 400,
      message: '입력값 검증에 실패했습니다',
      errors: messages,
    });
  },
});

이렇게 하면 프론트엔드에서 필드별 에러 메시지를 쉽게 매핑할 수 있는 구조가 된다.


검증 옵션 정리

validate() 함수에 전달할 수 있는 옵션들:

옵션기본값설명
skipMissingPropertiesfalsetruenull/undefined인 프로퍼티의 검증을 건너뜀 (@IsDefined 제외)
whitelistfalse데코레이터 없는 프로퍼티 제거
forbidNonWhitelistedfalse데코레이터 없는 프로퍼티가 있으면 에러
groupsundefined검증 그룹 지정
dismissDefaultMessagesfalse기본 에러 메시지 비활성화
forbidUnknownValuestrue알 수 없는 객체 검증 차단 (보안상 true 유지 권장)
stopAtFirstErrorfalse첫 번째 에러에서 검증 중단
validationError.targettrue에러에 대상 객체 포함 여부
validationError.valuetrue에러에 실패 값 포함 여부

forbidUnknownValuestrue(기본)이면, 클래스 인스턴스가 아닌 plain object를 넘겼을 때 무조건 검증에 실패한다. 이건 보안을 위한 설계인데, 데코레이터가 없는 랜덤 객체가 검증을 통과하는 걸 막아준다.


상속과 검증

검증 데코레이터는 상속된다. 자식 클래스에서 부모의 프로퍼티를 재정의하면, 부모와 자식의 데코레이터가 모두 적용된다.

typescript
class BaseDto {
  @IsEmail()
  email: string;

  @IsString()
  password: string;
}

class CreateUserDto extends BaseDto {
  @MinLength(2)
  @MaxLength(20)
  name: string;

  @MinLength(8)  // 부모의 @IsString()도 함께 적용됨
  password: string;
}

CreateUserDtopassword@IsString() (부모) + @MinLength(8) (자식) 두 가지 규칙이 모두 적용된다. DTO 상속으로 공통 필드를 재사용하면서 개별 규칙을 추가할 수 있다.


자주 하는 실수

1. plain object에 검증을 시도한다

typescript
// ❌ 동작하지 않음
const body = { email: 'test@test.com', name: 'J' };
validate(body);  // 데코레이터가 없으니 검증이 안 됨

// ✅ 클래스 인스턴스로 변환 후 검증
import { plainToInstance } from 'class-transformer';
const dto = plainToInstance(CreateUserDto, body);
validate(dto);

class-validator는 클래스 인스턴스에서만 동작한다. JSON으로 들어온 plain object는 plainToInstance()로 변환해야 한다. NestJS ValidationPipetransform: true가 이걸 자동으로 해주는 것이다.

2. 중첩 객체에 @ValidateNested를 빼먹는다

typescript
// ❌ address 내부는 검증되지 않음
class UserDto {
  @Type(() => AddressDto)
  address: AddressDto;
}

// ✅ @ValidateNested 필요
class UserDto {
  @ValidateNested()
  @Type(() => AddressDto)
  address: AddressDto;
}

3. 그룹 사용 시 always를 빼먹는다

typescript
class UserDto {
  @IsString()  // groups 미지정
  name: string;

  @IsString({ groups: ['create'] })
  password: string;
}

// validate(dto, { groups: ['create'] }) 호출 시
// name의 @IsString()은 실행되지 않음!
// always: true 또는 groups: ['create'] 추가 필요

4. interface에 데코레이터를 붙이려 한다

데코레이터는 런타임에 동작하는데, TypeScript interface는 컴파일 후 사라진다. 검증이 필요하면 반드시 class를 사용해야 한다.


class-validator vs Zod

둘 다 런타임 검증을 해결하지만 접근 방식이 다르다.

class-validatorZod
방식데코레이터 (클래스 기반)스키마 빌더 (함수 기반)
타입 추론별도 타입 정의 필요스키마에서 타입 자동 추론
생태계NestJS와 긴밀 통합React Hook Form, tRPC 등
비동기 검증지원 (async validator)지원 (.refine())
의존성reflect-metadata, class-transformer없음 (standalone)
번들 크기validator.js 포함으로 큼상대적으로 작음

NestJS를 쓴다면 class-validator가 자연스러운 선택이다. ValidationPipe, ClassSerializerInterceptor 등 프레임워크 레벨에서 지원이 잘 되어 있다. 프론트엔드나 NestJS 외 환경이라면 Zod가 더 간결할 수 있다.


정리

  • 데코레이터를 프로퍼티 위에 붙이는 것만으로 검증 규칙을 선언하고, validate() 한 번으로 모든 필드를 일괄 검증한다. 클래스 정의가 곧 검증 스키마가 된다.
  • NestJS ValidationPipe의 transform + whitelist + forbidNonWhitelisted 조합이 자동 변환, 검증, Mass Assignment 방어를 한꺼번에 처리한다.
  • 중첩 객체는 @ValidateNested + @Type 조합이 필수이고, 조건부 검증은 @ValidateIf, 상황별 규칙 분기는 groups + always 옵션으로 제어한다.

관련 문서