class-validator
TypeScript의 타입 시스템은 컴파일 타임에만 동작한다. 빌드가 끝나면 타입 정보는 전부 사라지고, 런타임에는 그냥 JavaScript다. 문제는 외부에서 들어오는 데이터 — HTTP 요청 body, 폼 입력, 파일 파싱 결과 — 는 전부 런타임에 들어온다는 것이다. 아무리 CreateUserDto에 타입을 정의해놔도, 클라이언트가 rating: "다섯"을 보내면 TypeScript는 아무것도 막지 못한다.
이 문제를 해결하는 접근법은 크게 두 가지다. 하나는 Zod처럼 스키마를 코드로 정의하고 parse()로 검증하는 방식이고, 다른 하나는 class-validator처럼 클래스 프로퍼티에 데코레이터를 붙여서 검증 규칙을 선언하는 방식이다.
class-validator는 후자의 대표주자다. 내부적으로 validator.js를 사용하며, 데코레이터로 검증 규칙을 클래스에 직접 부착한다. NestJS의 ValidationPipe과 궁합이 특히 좋아서, NestJS 생태계에서는 사실상 표준이다.
왜 데코레이터 기반인가
검증 로직을 작성하는 가장 원시적인 방법은 if 문이다.
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개만 넘어가도 이런 코드는 읽기 힘들어진다. 검증 규칙이 비즈니스 로직과 섞이고, 어떤 필드에 어떤 규칙이 적용되는지 한눈에 파악하기 어렵다.
데코레이터 방식은 규칙을 프로퍼티 선언 바로 위에 붙인다. 클래스 정의 자체가 곧 검증 스키마가 된다.
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() 함수로 실행한다. 둘 다 비동기 함수다.
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 구조
검증 실패 시 반환되는 각 에러 객체는 이런 구조다:
{
target: Object; // 검증된 객체
property: string; // 실패한 프로퍼티 이름
value: any; // 실패한 값
constraints?: { // 실패한 검증 규칙과 에러 메시지
[type: string]: string;
};
children?: ValidationError[]; // 중첩 객체의 검증 에러
}
예를 들어 email에 "not-an-email"을 넣으면:
{
"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" 같은 형태다. 한국어 서비스라면 당연히 커스터마이징이 필요하다.
문자열 메시지
@MinLength(10, {
message: '제목은 최소 10자 이상이어야 합니다',
})
title: string;
date: "2026-02-15"
템플릿 토큰
메시지 안에서 특수 토큰을 사용할 수 있다:
$property— 프로퍼티 이름$value— 실제 입력된 값$constraint1,$constraint2— 데코레이터에 전달된 제약 조건 값$target— 클래스 이름
@MinLength(10, {
message: '$property의 길이가 너무 짧습니다. 최소 $constraint1자, 현재 $value자',
})
title: string;
date: "2026-02-15"
title에 "짧음"을 넣으면: "title의 길이가 너무 짧습니다. 최소 10자, 현재 2자"
함수 메시지
더 세밀한 제어가 필요하면 함수를 전달한다:
@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 옵션을 사용한다.
export class CreatePostDto {
@MaxLength(20, { each: true })
@IsString({ each: true })
tags: string[];
}
이렇게 하면 tags 배열의 각 요소가 문자열인지, 20자 이하인지를 검증한다. 배열 자체가 아니라 배열 안의 아이템 하나하나를 검사하는 것이다.
Set과 Map에도 동일하게 동작한다:
@MaxLength(20, { each: true })
tags: Set<string>;
@MaxLength(20, { each: true })
tags: Map<string, string>; // value를 검증
중첩 객체 검증
DTO 안에 또 다른 DTO가 중첩된 경우, @ValidateNested()를 사용해야 내부 객체까지 검증이 이루어진다.
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의 ValidationPipe이 transform: true 옵션으로 자동으로 처리해준다.
배열로 된 중첩 객체도 동일하다:
@ValidateNested({ each: true })
@Type(() => AddressDto)
addresses: AddressDto[];
주의: @ValidateNested() 없이 중첩 객체를 넣으면, 그 객체 안의 데코레이터가 전혀 실행되지 않는다. 자주 빠지는 함정이다.
조건부 검증 (@ValidateIf)
특정 조건에서만 검증을 실행해야 할 때 @ValidateIf()를 사용한다. 조건 함수가 false를 반환하면 해당 프로퍼티의 모든 검증 데코레이터가 건너뛰어진다.
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든 상관없이 통과된다.
이런 패턴은 폼에서 "기타"를 선택했을 때만 추가 입력을 요구하는 경우에 유용하다.
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가 필수지만, 수정 시에는 선택이다.
class UserDto {
@IsString({ always: true })
name: string;
@IsString({ groups: ['create'] })
@MinLength(8, { groups: ['create'] })
password: string;
@IsEmail({}, { groups: ['create', 'update'] })
email: string;
}
검증할 때 그룹을 지정한다:
// 생성 시: 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 공격을 막으려면 화이트리스팅을 사용한다.
// 알려지지 않은 프로퍼티를 조용히 제거
validate(user, { whitelist: true });
// 알려지지 않은 프로퍼티가 있으면 에러 발생
validate(user, { whitelist: true, forbidNonWhitelisted: true });
whitelist: true는 데코레이터가 하나도 없는 프로퍼티를 결과 객체에서 제거한다. forbidNonWhitelisted: true를 함께 쓰면 제거 대신 검증 에러를 발생시킨다.
데코레이터가 필요 없지만 허용은 해야 하는 프로퍼티에는 @Allow()를 붙인다:
class UpdateProfileDto {
@IsString()
name: string;
@Allow()
metadata: any; // 검증 규칙은 없지만 화이트리스트에 포함
}
커스텀 검증 데코레이터
내장 데코레이터로 부족할 때 직접 만들 수 있다. 두 가지 방법이 있다.
방법 1: ValidatorConstraint 클래스
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,
});
};
}
사용:
class CreateEventDto {
@IsAfterNow({ message: '이벤트 시작일은 미래여야 합니다' })
startDate: Date;
}
방법 2: 인라인 validate 함수
간단한 검증이라면 클래스 없이 인라인으로도 가능하다:
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 조회가 필요한 유니크 검증 같은 경우:
@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 설정
// 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, // 쿼리 파라미터 등 문자열 → 숫자 자동 변환
},
}),
);
컨트롤러에서 사용
@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로 커스터마이징한다:
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() 함수에 전달할 수 있는 옵션들:
| 옵션 | 기본값 | 설명 |
|---|---|---|
skipMissingProperties | false | true면 null/undefined인 프로퍼티의 검증을 건너뜀 (@IsDefined 제외) |
whitelist | false | 데코레이터 없는 프로퍼티 제거 |
forbidNonWhitelisted | false | 데코레이터 없는 프로퍼티가 있으면 에러 |
groups | undefined | 검증 그룹 지정 |
dismissDefaultMessages | false | 기본 에러 메시지 비활성화 |
forbidUnknownValues | true | 알 수 없는 객체 검증 차단 (보안상 true 유지 권장) |
stopAtFirstError | false | 첫 번째 에러에서 검증 중단 |
validationError.target | true | 에러에 대상 객체 포함 여부 |
validationError.value | true | 에러에 실패 값 포함 여부 |
forbidUnknownValues가 true(기본)이면, 클래스 인스턴스가 아닌 plain object를 넘겼을 때 무조건 검증에 실패한다. 이건 보안을 위한 설계인데, 데코레이터가 없는 랜덤 객체가 검증을 통과하는 걸 막아준다.
상속과 검증
검증 데코레이터는 상속된다. 자식 클래스에서 부모의 프로퍼티를 재정의하면, 부모와 자식의 데코레이터가 모두 적용된다.
class BaseDto {
@IsEmail()
email: string;
@IsString()
password: string;
}
class CreateUserDto extends BaseDto {
@MinLength(2)
@MaxLength(20)
name: string;
@MinLength(8) // 부모의 @IsString()도 함께 적용됨
password: string;
}
CreateUserDto의 password는 @IsString() (부모) + @MinLength(8) (자식) 두 가지 규칙이 모두 적용된다. DTO 상속으로 공통 필드를 재사용하면서 개별 규칙을 추가할 수 있다.
자주 하는 실수
1. plain object에 검증을 시도한다
// ❌ 동작하지 않음
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 ValidationPipe의 transform: true가 이걸 자동으로 해주는 것이다.
2. 중첩 객체에 @ValidateNested를 빼먹는다
// ❌ address 내부는 검증되지 않음
class UserDto {
@Type(() => AddressDto)
address: AddressDto;
}
// ✅ @ValidateNested 필요
class UserDto {
@ValidateNested()
@Type(() => AddressDto)
address: AddressDto;
}
3. 그룹 사용 시 always를 빼먹는다
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-validator | Zod | |
|---|---|---|
| 방식 | 데코레이터 (클래스 기반) | 스키마 빌더 (함수 기반) |
| 타입 추론 | 별도 타입 정의 필요 | 스키마에서 타입 자동 추론 |
| 생태계 | 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 옵션으로 제어한다.
관련 문서
- class-transformer - plain ↔ class 변환, @Type, @Expose
- reflect-metadata - 데코레이터 메타프로그래밍 기반
- TypeScript 타입 가드 - 런타임 타입 좁히기