본문으로 건너뛰기
Advertisement

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
}
Advertisement