14.2 DTO와 Validation — class-validator와 class-transformer
DTO (Data Transfer Object)란?
DTO는 계층 간 데이터 전송을 위한 타입 안전한 객체입니다. NestJS에서는 class-validator로 유효성 검사, class-transformer로 타입 변환을 처리합니다.
npm install class-validator class-transformer
기본 DTO 작성
// dto/create-user.dto.ts
import {
IsString,
IsEmail,
IsOptional,
IsEnum,
MinLength,
MaxLength,
IsInt,
Min,
Max,
IsArray,
ArrayMaxSize,
} from 'class-validator'
import { Transform } from 'class-transformer'
export enum UserRole {
ADMIN = 'admin',
USER = 'user',
GUEST = 'guest',
}
export class CreateUserDto {
@IsString()
@MinLength(2, { message: '이름은 2자 이상이어야 합니다.' })
@MaxLength(50, { message: '이름은 50자 이하이어야 합니다.' })
name: string
@IsEmail({}, { message: '올바른 이메일 주소를 입력하세요.' })
email: string
@IsString()
@MinLength(8, { message: '비밀번호는 8자 이상이어야 합니다.' })
password: string
@IsOptional()
@IsEnum(UserRole, { message: '유효한 역할을 선택하세요.' })
role?: UserRole
@IsOptional()
@IsInt()
@Min(0)
@Max(150)
@Transform(({ value }) => parseInt(value)) // 문자열을 숫자로 변환
age?: number
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(10)
tags?: string[]
}
업데이트 DTO (PartialType 활용)
// dto/update-user.dto.ts
import { PartialType, OmitType, PickType } from '@nestjs/mapped-types'
import { CreateUserDto } from './create-user.dto'
// 모든 필드를 선택적으로 만들기
export class UpdateUserDto extends PartialType(CreateUserDto) {}
// 특정 필드 제외
export class UpdateProfileDto extends OmitType(CreateUserDto, ['password', 'email'] as const) {}
// 특정 필드만 선택
export class UpdatePasswordDto extends PickType(CreateUserDto, ['password'] as const) {
@IsString()
@MinLength(8)
currentPassword: string
}
복잡한 DTO 패턴
중첩 DTO
// dto/address.dto.ts
import { IsString, IsOptional, Length } from 'class-validator'
export class AddressDto {
@IsString()
street: string
@IsString()
city: string
@IsString()
@Length(2, 2)
country: string
@IsOptional()
@IsString()
zipCode?: string
}
// dto/create-user.dto.ts
import { IsString, IsEmail, ValidateNested } from 'class-validator'
import { Type } from 'class-transformer'
import { AddressDto } from './address.dto'
export class CreateUserDto {
@IsString()
name: string
@IsEmail()
email: string
@ValidateNested() // 중첩 객체 검증
@Type(() => AddressDto) // 타입 변환
address: AddressDto
}
배열 DTO
import { IsArray, ValidateNested, ArrayMinSize } from 'class-validator'
import { Type } from 'class-transformer'
export class CreateUsersDto {
@IsArray()
@ValidateNested({ each: true }) // 배열 각 요소 검증
@ArrayMinSize(1)
@Type(() => CreateUserDto)
users: CreateUserDto[]
}
커스텀 검증 데코레이터
import {
registerDecorator,
ValidationOptions,
ValidationArguments,
} from 'class-validator'
// 커스텀 데코레이터: 비밀번호 일치 검사
export function IsPasswordMatch(
property: string,
validationOptions?: ValidationOptions
) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'IsPasswordMatch',
target: object.constructor,
propertyName,
constraints: [property],
options: validationOptions,
validator: {
validate(value: unknown, args: ValidationArguments) {
const [relatedPropertyName] = args.constraints
const relatedValue = (args.object as Record<string, unknown>)[relatedPropertyName]
return value === relatedValue
},
defaultMessage() {
return '비밀번호가 일치하지 않습니다.'
},
},
})
}
}
// 사용
export class RegisterDto {
@IsString()
@MinLength(8)
password: string
@IsString()
@IsPasswordMatch('password')
confirmPassword: string
}
ValidationPipe 설정
// main.ts
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // DTO에 없는 필드 자동 제거
forbidNonWhitelisted: true, // DTO에 없는 필드 있으면 400 오류
transform: true, // 타입 자동 변환 (@Transform 실행)
transformOptions: {
enableImplicitConversion: true, // string → number 암묵적 변환
},
disableErrorMessages: false, // 개발 환경에서는 true로
exceptionFactory: (errors) => {
// 커스텀 에러 형식
const messages = errors.map(error =>
Object.values(error.constraints ?? {}).join(', ')
)
return new BadRequestException({ errors: messages })
},
})
)
class-transformer 활용
import { Expose, Exclude, Transform, Type } from 'class-transformer'
// 응답 DTO — 민감한 필드 제외
export class UserResponseDto {
@Expose()
id: string
@Expose()
name: string
@Expose()
email: string
@Exclude() // 응답에서 제외
password: string
@Expose()
@Transform(({ value }) => value.toISOString())
createdAt: Date
@Expose()
@Type(() => AddressDto)
address?: AddressDto
}
// 서비스에서 변환
import { plainToInstance } from 'class-transformer'
function toDto(user: User): UserResponseDto {
return plainToInstance(UserResponseDto, user, {
excludeExtraneousValues: true, // @Expose() 없는 필드 제외
})
}
고수 팁
1. Swagger와 DTO 통합
npm install @nestjs/swagger
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
export class CreateUserDto {
@ApiProperty({ description: '사용자 이름', example: '홍길동' })
@IsString()
name: string
@ApiProperty({ description: '이메일', example: 'user@example.com' })
@IsEmail()
email: string
@ApiPropertyOptional({ description: '역할', enum: UserRole })
@IsOptional()
@IsEnum(UserRole)
role?: UserRole
}
2. 쿼리 파라미터 DTO
import { IsOptional, IsInt, Min, IsString } from 'class-validator'
import { Type } from 'class-transformer'
export class PaginationDto {
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
page?: number = 1
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
limit?: number = 10
@IsOptional()
@IsString()
search?: string
}