class-transformer란?
NestJS에서 API 응답을 가공할 때 class-transformer는 핵심 라이브러리다. 평문 객체(Plain Object)를 클래스 인스턴스로 변환하고, 데코레이터로 필드 노출/제외, 이름 변경, 타입 변환, 조건부 직렬화를 선언적으로 처리한다. NestJS의 ClassSerializerInterceptor와 결합하면 엔티티에서 응답 DTO로의 변환이 자동화된다.
기본 설정과 Interceptor 등록
// main.ts — 전역 설정
import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 전역 직렬화 인터셉터
app.useGlobalInterceptors(
new ClassSerializerInterceptor(app.get(Reflector), {
strategy: 'excludeAll', // 기본: 모든 필드 제외, @Expose만 노출
excludeExtraneousValues: true,
}),
);
// ValidationPipe와 함께 사용
app.useGlobalPipes(new ValidationPipe({
transform: true, // 자동 타입 변환
whitelist: true, // DTO에 없는 필드 제거
transformOptions: {
enableImplicitConversion: true,
},
}));
await app.listen(3000);
}
@Expose와 @Exclude: 필드 제어
import { Expose, Exclude, Transform, Type } from 'class-transformer';
// 전략 1: excludeAll + @Expose (권장 — 화이트리스트)
@Exclude()
export class UserResponseDto {
@Expose()
id: number;
@Expose()
email: string;
@Expose()
name: string;
@Expose({ name: 'display_name' }) // JSON 키 이름 변경
displayName: string;
// 패스워드는 @Expose 없으므로 자동 제외
password: string;
// 역할은 ADMIN에게만 노출
@Expose({ groups: ['admin'] })
role: string;
// 가상 필드: getter 기반
@Expose()
get fullName(): string {
return `${this.name} (${this.email})`;
}
@Expose()
@Transform(({ value }) => value?.toISOString())
createdAt: Date;
}
// 전략 2: 기본 노출 + @Exclude (블랙리스트)
export class ProductDto {
id: number;
name: string;
price: number;
@Exclude()
internalCost: number; // 이 필드만 제외
@Exclude({ toPlainOnly: true }) // 직렬화 시만 제외, 역직렬화 시 포함
secretCode: string;
}
@Transform: 값 변환
@Exclude()
export class OrderResponseDto {
@Expose()
id: number;
// 금액 포맷팅
@Expose()
@Transform(({ value }) => `₩${value.toLocaleString()}`)
totalAmount: number;
// enum → 한국어 레이블
@Expose()
@Transform(({ value }) => {
const labels: Record<string, string> = {
PENDING: '대기중',
CONFIRMED: '확인됨',
SHIPPED: '배송중',
DELIVERED: '배송완료',
};
return labels[value] ?? value;
})
status: string;
// 민감 데이터 마스킹
@Expose()
@Transform(({ value }) =>
value ? value.replace(/(d{4})d{8}(d{4})/, '$1****$2') : null
)
cardNumber: string;
// null 대체
@Expose()
@Transform(({ value }) => value ?? '미지정')
shippingAddress: string;
// 객체에서 특정 필드만 추출
@Expose()
@Transform(({ obj }) => obj.user?.name)
authorName: string;
// 역직렬화 시 변환 (요청 파싱)
@Transform(({ value }) => value?.trim().toLowerCase(), { toClassOnly: true })
email: string;
}
@Type: 중첩 객체 변환
@Exclude()
export class OrderDetailDto {
@Expose()
id: number;
// 중첩 객체도 class-transformer 적용
@Expose()
@Type(() => OrderItemDto)
items: OrderItemDto[];
// 단일 중첩 객체
@Expose()
@Type(() => UserSummaryDto)
user: UserSummaryDto;
// 날짜 자동 변환
@Expose()
@Type(() => Date)
@Transform(({ value }) => value?.toISOString())
createdAt: Date;
}
@Exclude()
export class OrderItemDto {
@Expose()
productId: number;
@Expose()
productName: string;
@Expose()
quantity: number;
@Expose()
@Transform(({ value }) => `₩${value.toLocaleString()}`)
price: number;
// 계산 필드
@Expose()
get subtotal(): string {
return `₩${(this.quantity * this.price).toLocaleString()}`;
}
}
@Exclude()
export class UserSummaryDto {
@Expose()
id: number;
@Expose()
name: string;
// email 제외 — 주문 응답에서는 불필요
}
@Type() 없이는 중첩 객체에 데코레이터가 적용되지 않는다. Prisma나 TypeORM에서 include/relations로 가져온 중첩 데이터를 정리할 때 필수다.
Groups: 역할별 응답 분기
@Exclude()
export class UserProfileDto {
@Expose({ groups: ['user', 'admin'] })
id: number;
@Expose({ groups: ['user', 'admin'] })
name: string;
@Expose({ groups: ['user', 'admin'] })
email: string;
@Expose({ groups: ['admin'] }) // 관리자만
role: string;
@Expose({ groups: ['admin'] }) // 관리자만
lastLoginIp: string;
@Expose({ groups: ['owner', 'admin'] }) // 본인 + 관리자
phoneNumber: string;
}
// 컨트롤러에서 그룹 지정
@Controller('users')
@UseInterceptors(ClassSerializerInterceptor)
export class UserController {
@Get(':id')
@SerializeOptions({ groups: ['user'] }) // 일반 사용자 뷰
getUser(@Param('id') id: string) {
return this.userService.findById(id);
}
@Get(':id/admin')
@SerializeOptions({ groups: ['admin'] }) // 관리자 뷰
@UseGuards(AdminGuard)
getUserAdmin(@Param('id') id: string) {
return this.userService.findById(id);
}
@Get('me')
@SerializeOptions({ groups: ['owner'] }) // 본인 뷰
getMe(@CurrentUser() user: User) {
return this.userService.findById(user.id);
}
}
같은 엔티티를 엔드포인트마다 다른 형태로 반환할 수 있다. Response DTO를 여러 개 만들지 않아도 된다.
plainToInstance: 수동 변환
import { plainToInstance, instanceToPlain } from 'class-transformer';
// DB 결과 → DTO 변환
const users = await this.prisma.user.findMany();
const dtos = plainToInstance(UserResponseDto, users, {
excludeExtraneousValues: true, // @Expose 없는 필드 제거
groups: ['user'],
});
// DTO → 평문 객체 (캐시 저장 등)
const plain = instanceToPlain(dto, {
groups: ['admin'],
});
// 배열 변환
const orderDtos = plainToInstance(OrderDetailDto, rawOrders, {
excludeExtraneousValues: true,
enableImplicitConversion: true,
});
커스텀 직렬화 인터셉터
// 공통 응답 포맷 래핑 + 직렬화
@Injectable()
export class ResponseSerializerInterceptor<T> implements NestInterceptor {
constructor(private readonly classToSerialize: ClassConstructor<T>) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => {
const serialized = plainToInstance(this.classToSerialize, data, {
excludeExtraneousValues: true,
});
return {
success: true,
data: serialized,
timestamp: new Date().toISOString(),
};
}),
);
}
}
// 커스텀 데코레이터로 간편 적용
export function Serialize(dto: ClassConstructor<any>) {
return UseInterceptors(new ResponseSerializerInterceptor(dto));
}
// 사용
@Controller('orders')
export class OrderController {
@Get(':id')
@Serialize(OrderDetailDto) // 한 줄로 직렬화 적용
getOrder(@Param('id') id: string) {
return this.orderService.findById(id);
}
}
이 패턴은 NestJS Interceptor RxJS의 map 연산자와 Custom Decorator를 조합한 것이다.
class-validator와 조합
// 요청 DTO: class-validator + class-transformer 동시 사용
export class CreateOrderDto {
@IsNumber()
@Type(() => Number) // 문자열 → 숫자 변환
productId: number;
@IsInt()
@Min(1)
@Max(100)
@Type(() => Number)
quantity: number;
@IsEmail()
@Transform(({ value }) => value?.trim().toLowerCase())
email: string;
@IsOptional()
@IsString()
@Transform(({ value }) => value?.trim())
note?: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => OrderItemDto) // 중첩 배열 변환 + 검증
items: OrderItemDto[];
}
class-transformer는 NestJS의 요청/응답 양쪽 모두에서 작동한다. 요청에서는 타입 변환과 정규화를, 응답에서는 필드 제어와 포맷팅을 담당하여 컨트롤러 코드를 깔끔하게 유지할 수 있다.