class-transformer
API에서 JSON을 받아와서 JSON.parse로 파싱하면 뭘 얻는가? 평범한 JavaScript 객체다. TypeScript에서 User 타입이라고 선언해도, 런타임에서 그 객체는 User 클래스의 인스턴스가 아니다. 메서드도 없고, getter도 없고, 프로토타입 체인도 연결되지 않는다.
class User {
id: number;
firstName: string;
lastName: string;
getFullName() {
return `${this.firstName} ${this.lastName}`;
}
}
const response = await fetch('/api/users');
const users: User[] = await response.json();
users[0].getFullName(); // ❌ TypeError: users[0].getFullName is not a function
TypeScript의 타입 시스템은 컴파일 타임에만 존재한다. users: User[]라고 적어도 런타임에서는 그냥 Object다. 컴파일러에게 거짓말을 한 것이다. 이 문제를 해결하려면 plain object를 실제 클래스 인스턴스로 변환해야 한다.
수동으로 하면 이렇게 된다:
const user = new User();
user.id = plainObj.id;
user.firstName = plainObj.firstName;
user.lastName = plainObj.lastName;
프로퍼티가 3개일 때는 감당되지만, 중첩 객체가 있고 배열이 있고 조건부 필드가 있으면 금방 지옥이 된다. class-transformer는 이 변환 과정을 데코레이터 기반으로 자동화한다.
핵심 함수 4가지
class-transformer의 모든 기능은 네 가지 변환 방향으로 정리된다.
plainToInstance — plain → class
가장 많이 쓰는 함수다. plain object를 클래스 인스턴스로 변환한다.
import { plainToInstance } from 'class-transformer';
const plain = { id: 1, firstName: 'John', lastName: 'Doe' };
const user = plainToInstance(User, plain);
console.log(user instanceof User); // true
console.log(user.getFullName()); // "John Doe"
배열도 그대로 넘기면 된다:
const users = plainToInstance(User, [
{ id: 1, firstName: 'John', lastName: 'Doe' },
{ id: 2, firstName: 'Jane', lastName: 'Smith' },
]);
// User[] — 각 원소가 User 인스턴스
첫 번째 인자가 대상 클래스, 두 번째 인자가 원본 데이터다. 타입 추론도 자동으로 되기 때문에 제네릭 파라미터를 명시할 필요가 없다.
instanceToPlain — class → plain
반대 방향이다. 클래스 인스턴스를 plain object로 변환한다. API에 데이터를 보내기 전, 민감한 필드를 제거하거나 이름을 바꿀 때 유용하다.
import { instanceToPlain } from 'class-transformer';
const plain = instanceToPlain(user);
console.log(plain); // { id: 1, firstName: 'John', lastName: 'Doe' }
이 함수가 진짜 힘을 발휘하는 건 @Exclude나 @Expose 데코레이터와 조합할 때다. 나중에 자세히 다룬다.
instanceToInstance — class → class (딥 클론)
클래스 인스턴스를 새로운 인스턴스로 복제한다. 데코레이터 규칙이 적용된 딥 클론이라고 보면 된다.
import { instanceToInstance } from 'class-transformer';
const cloned = instanceToInstance(user);
console.log(cloned instanceof User); // true
console.log(cloned === user); // false (새 인스턴스)
serialize / deserialize — JSON 문자열 직접 처리
JSON.stringify/JSON.parse를 한 번에 해준다:
import { serialize, deserialize } from 'class-transformer';
const json = serialize(user); // string
const restored = deserialize(User, json); // User 인스턴스
실무에서는 plainToInstance과 instanceToPlain을 직접 쓰는 경우가 더 많다. HTTP 클라이언트가 이미 JSON 파싱을 해주기 때문이다.
@Expose와 @Exclude — 필드 노출 제어
class-transformer의 핵심 데코레이터 두 가지다.
기본 동작: 모든 프로퍼티가 포함된다
아무 데코레이터도 안 붙이면, plain object의 모든 프로퍼티가 그대로 매핑된다. 심지어 클래스에 정의되지 않은 프로퍼티도 포함된다.
class User {
id: number;
name: string;
}
const result = plainToInstance(User, {
id: 1,
name: 'John',
hackField: 'malicious', // 클래스에 없는 필드
});
console.log((result as any).hackField); // "malicious" — 그대로 들어옴
이건 보안상 위험할 수 있다. API 입력을 클래스로 변환할 때 의도하지 않은 필드가 침투할 수 있기 때문이다.
excludeExtraneousValues로 화이트리스트 방식 강제
@Expose()를 명시한 필드만 허용하고 나머지는 전부 무시하는 방식이다:
import { Expose, plainToInstance } from 'class-transformer';
class CreateUserDto {
@Expose() firstName: string;
@Expose() lastName: string;
@Expose() email: string;
}
const input = {
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
isAdmin: true, // 침투 시도
};
const dto = plainToInstance(CreateUserDto, input, {
excludeExtraneousValues: true,
});
console.log((dto as any).isAdmin); // undefined — 차단됨
NestJS에서 DTO를 다룰 때 이 패턴이 거의 필수다. ValidationPipe의 transform: true 옵션과 함께 쓰면 자동으로 plainToInstance가 적용된다.
@Exclude — 특정 필드 제외
출력 시 민감한 필드를 숨길 때 쓴다:
import { Exclude } from 'class-transformer';
class User {
id: number;
name: string;
@Exclude()
password: string;
@Exclude()
internalNote: string;
}
const plain = instanceToPlain(user);
// { id: 1, name: 'John' } — password, internalNote 없음
클래스 레벨 @Exclude + 개별 @Expose
"기본적으로 전부 숨기고, 명시한 것만 노출" 패턴이다. 화이트리스트 방식으로 가장 안전하다:
import { Exclude, Expose } from 'class-transformer';
@Exclude()
class User {
@Expose() id: number;
@Expose() name: string;
@Expose() email: string;
password: string; // 자동 제외
refreshToken: string; // 자동 제외
}
방향별 제어 — toPlainOnly / toClassOnly
변환 방향에 따라 다르게 동작하게 할 수 있다:
class User {
@Expose() id: number;
@Expose() name: string;
@Exclude({ toPlainOnly: true })
password: string; // class→plain 변환 시에만 제외. plain→class는 포함.
@Expose({ toClassOnly: true })
temporaryToken: string; // plain→class 변환 시에만 포함. class→plain은 제외.
}
toPlainOnly: true는 "API 응답에서는 숨기지만, 내부에서는 사용"하는 시나리오에 맞다. 비밀번호가 대표적이다.
@Type — 중첩 객체 변환
TypeScript의 타입 정보는 런타임에 사라진다. 배열이나 중첩 객체의 "안에 뭐가 들어있는지"를 class-transformer가 알 수 없다. @Type 데코레이터로 명시적으로 알려줘야 한다.
import { Type } from 'class-transformer';
class Photo {
id: number;
filename: string;
url: string;
}
class Album {
id: number;
name: string;
@Type(() => Photo)
photos: Photo[];
}
@Type(() => Photo)는 "이 프로퍼티의 각 원소를 Photo 클래스로 변환하라"는 뜻이다. 화살표 함수로 감싸는 이유는 순환 참조 문제를 피하기 위해서다. 클래스 선언 순서에 의존하지 않고, 실행 시점에 참조를 해결한다.
@Type이 없으면 photos는 그냥 plain object 배열이 된다. Photo 클래스에 메서드가 있어도 호출할 수 없다.
Date 변환
JSON에서 날짜는 문자열로 온다. @Type으로 자동 변환할 수 있다:
class Post {
title: string;
@Type(() => Date)
createdAt: Date;
}
const post = plainToInstance(Post, {
title: 'Hello',
createdAt: '2026-01-15T09:00:00Z',
});
console.log(post.createdAt instanceof Date); // true
console.log(post.createdAt.getFullYear()); // 2026
다형성 — discriminator
중첩 객체가 여러 타입 중 하나일 수 있는 경우, 판별 필드(discriminator)를 지정한다:
class Notification {
id: number;
@Type(() => Object, {
discriminator: {
property: 'type',
subTypes: [
{ value: EmailNotification, name: 'email' },
{ value: SmsNotification, name: 'sms' },
{ value: PushNotification, name: 'push' },
],
},
})
details: EmailNotification | SmsNotification | PushNotification;
}
plain object의 type 필드 값에 따라 EmailNotification, SmsNotification, PushNotification 중 적절한 클래스로 변환된다. 변환 후 type 프로퍼티는 기본적으로 제거되는데, keepDiscriminatorProperty: true를 추가하면 유지된다.
@Transform — 커스텀 변환 로직
@Expose, @Exclude, @Type으로 해결이 안 되는 변환은 @Transform으로 직접 로직을 작성한다.
import { Transform } from 'class-transformer';
class Product {
name: string;
@Transform(({ value }) => Math.round(value * 100) / 100)
price: number;
@Transform(({ value }) => value?.trim().toLowerCase())
sku: string;
@Transform(({ value }) => (value ? new Set(value) : new Set()))
tags: Set<string>;
}
@Transform의 콜백은 TransformFnParams 객체를 받는다:
| 파라미터 | 설명 |
|---|---|
value | 현재 필드의 원본 값 |
key | 프로퍼티 이름 |
obj | 원본 객체 전체 (다른 필드 참조 가능) |
type | 변환 방향 (0: plain→class, 1: class→plain) |
options | 변환 옵션 |
다른 필드를 참조해야 할 때 obj가 유용하다:
class Order {
quantity: number;
unitPrice: number;
@Transform(({ obj }) => obj.quantity * obj.unitPrice)
totalPrice: number;
}
방향별 Transform
변환 방향에 따라 다른 로직을 적용할 수 있다:
class Event {
@Transform(({ value }) => new Date(value), { toClassOnly: true })
@Transform(({ value }) => value.toISOString(), { toPlainOnly: true })
startDate: Date;
}
plain→class일 때는 문자열을 Date로, class→plain일 때는 Date를 ISO 문자열로 변환한다.
@Expose의 고급 기능
이름 매핑
API 응답의 필드 이름과 클래스의 프로퍼티 이름이 다를 때:
class User {
@Expose({ name: 'user_id' })
id: number;
@Expose({ name: 'first_name' })
firstName: string;
@Expose({ name: 'last_name' })
lastName: string;
}
const user = plainToInstance(User, {
user_id: 1,
first_name: 'John',
last_name: 'Doe',
});
console.log(user.id); // 1
console.log(user.firstName); // "John"
snake_case API와 camelCase 클래스 사이의 매핑에 딱이다.
getter와 메서드 노출
instanceToPlain 시 getter나 메서드의 반환값도 plain object에 포함시킬 수 있다:
class User {
firstName: string;
lastName: string;
@Expose()
get fullName() {
return `${this.firstName} ${this.lastName}`;
}
@Expose()
getDisplayName() {
return `@${this.firstName.toLowerCase()}`;
}
}
const plain = instanceToPlain(user);
// { firstName: 'John', lastName: 'Doe', fullName: 'John Doe', displayName: '@john' }
계산된 필드를 API 응답에 포함시킬 때 유용하다.
그룹 기반 노출
같은 클래스를 다른 API 엔드포인트에서 다르게 직렬화하고 싶을 때:
class User {
@Expose({ groups: ['list', 'detail'] })
id: number;
@Expose({ groups: ['list', 'detail'] })
name: string;
@Expose({ groups: ['detail'] })
email: string;
@Expose({ groups: ['detail'] })
address: string;
@Expose({ groups: ['admin'] })
internalId: string;
}
// 목록 API — id, name만
const listData = instanceToPlain(user, { groups: ['list'] });
// 상세 API — id, name, email, address
const detailData = instanceToPlain(user, { groups: ['detail'] });
// 관리자 API — 전부
const adminData = instanceToPlain(user, { groups: ['admin', 'detail'] });
버전 기반 노출
API 버전에 따라 필드를 다르게 노출할 수도 있다:
class User {
@Expose({ since: 1.0 })
id: number;
@Expose({ since: 1.0 })
name: string;
@Expose({ since: 2.0 })
email: string; // v2부터 추가
@Expose({ since: 1.0, until: 2.0 })
username: string; // v1에만 존재, v2에서 제거
}
// v1 클라이언트
instanceToPlain(user, { version: 1.0 });
// { id, name, username }
// v2 클라이언트
instanceToPlain(user, { version: 2.0 });
// { id, name, email }
NestJS에서의 활용
class-transformer는 NestJS와 깊이 통합되어 있다. NestJS의 ValidationPipe가 내부적으로 class-transformer를 사용하기 때문이다.
자동 변환 — ValidationPipe
// main.ts
app.useGlobalPipes(
new ValidationPipe({
transform: true, // plainToInstance 자동 적용
whitelist: true, // 데코레이터 없는 필드 제거
forbidNonWhitelisted: true, // 허용되지 않은 필드가 있으면 400 에러
}),
);
이 설정이면 컨트롤러에서 DTO 클래스를 파라미터 타입으로 지정하는 것만으로 자동 변환된다:
@Post('users')
create(@Body() dto: CreateUserDto) {
// dto는 이미 CreateUserDto의 인스턴스
// class-validator 검증도 통과한 상태
}
응답 직렬화 — ClassSerializerInterceptor
응답 데이터에도 class-transformer를 적용할 수 있다:
@Controller('users')
@UseInterceptors(ClassSerializerInterceptor)
export class UserController {
@Get(':id')
findOne(@Param('id') id: string) {
return this.userService.findOne(id);
// User 인스턴스가 반환되면, @Exclude()가 붙은 필드는 자동으로 제거됨
}
}
이 인터셉터는 instanceToPlain을 자동으로 호출한다. @Exclude({ toPlainOnly: true })로 마킹한 password 같은 필드가 API 응답에서 깔끔하게 빠진다.
옵션 정리
plainToInstance, instanceToPlain 등 모든 변환 함수의 세 번째 인자로 옵션 객체를 넘길 수 있다.
| 옵션 | 타입 | 기본값 | 설명 |
|---|---|---|---|
excludeExtraneousValues | boolean | false | @Expose() 없는 필드 무시 |
groups | string[] | — | 특정 그룹의 필드만 포함 |
version | number | — | 버전에 맞는 필드만 포함 |
strategy | string | — | 'exposeAll' 또는 'excludeAll' |
enableImplicitConversion | boolean | false | 타입 메타데이터 기반 자동 변환 |
exposeDefaultValues | boolean | false | 기본값이 있는 필드 노출 |
exposeUnsetFields | boolean | true | 값이 없는 필드를 undefined로 포함 |
ignoreDecorators | boolean | false | 모든 데코레이터 무시 |
enableCircularCheck | boolean | false | 순환 참조 감지 (성능 저하 있음) |
enableImplicitConversion
이 옵션을 켜면 TypeScript의 타입 메타데이터(reflect-metadata의 design:type)를 기반으로 자동 형변환이 일어난다:
class Settings {
isActive: boolean; // "true" → true
count: number; // "42" → 42
createdAt: Date; // "2026-01-01" → Date 객체
}
const settings = plainToInstance(Settings, {
isActive: 'true',
count: '42',
createdAt: '2026-01-01',
}, { enableImplicitConversion: true });
편리하지만 주의가 필요하다. class-validator와 함께 쓸 때 enableImplicitConversion을 켜면 검증 전에 형변환이 일어나서 @IsString() 같은 검증이 의도와 다르게 동작할 수 있다.
순환 참조 처리
클래스 간 순환 참조가 있으면 변환 시 무한 루프에 빠질 수 있다:
class User {
name: string;
@Type(() => Post)
posts: Post[];
}
class Post {
title: string;
@Type(() => User)
author: User; // User ↔ Post 순환
}
enableCircularCheck: true 옵션을 켜면 이미 변환한 객체를 추적해서 무한 루프를 방지한다. 다만 모든 객체를 추적해야 하므로 성능 오버헤드가 있다. 순환 참조가 확실히 있는 경우에만 켜는 게 좋다.
실전 패턴: Create/Update DTO 분리
하나의 엔티티에 대해 생성(Create)과 수정(Update) DTO를 분리하는 건 흔한 패턴이다. class-transformer의 데코레이터를 활용하면 깔끔하게 구성할 수 있다:
// create
class CreateProductDto {
@Expose() name: string;
@Expose() price: number;
@Expose() description: string;
@Type(() => Number)
@Expose() categoryId: number;
}
// update — 모든 필드가 optional
class UpdateProductDto {
@Expose() name?: string;
@Expose() price?: number;
@Expose() description?: string;
@Type(() => Number)
@Expose() categoryId?: number;
}
NestJS에서는 PartialType이나 OmitType 같은 유틸리티가 있어서 중복을 줄일 수 있지만, class-transformer 데코레이터의 동작 원리를 이해하는 것이 먼저다. PartialType은 내부적으로 원본 클래스의 메타데이터(class-transformer + class-validator 데코레이터)를 복사해서 새 클래스를 만든다.
reflect-metadata와의 관계
class-transformer는 reflect-metadata에 의존한다. @Type, @Expose, @Exclude 같은 데코레이터가 내부적으로 Reflect.defineMetadata를 사용해서 클래스에 변환 규칙을 저장한다.
enableImplicitConversion이 동작하는 원리도 reflect-metadata 덕분이다. TypeScript 컴파일러가 emitDecoratorMetadata: true 설정에 의해 design:type 메타데이터를 자동으로 방출하고, class-transformer가 이 메타데이터를 읽어서 String, Number, Boolean, Date 등으로 자동 변환을 수행한다.
주의사항
인터페이스는 사용할 수 없다. class-transformer는 런타임에 존재하는 클래스 생성자(constructor)가 필요하다. TypeScript 인터페이스는 컴파일 후 사라지기 때문에 변환 대상이 될 수 없다.
프로퍼티 초기화에 주의하라. plainToInstance는 new TargetClass()를 호출한 뒤 프로퍼티를 할당한다. 생성자에 필수 인자가 있으면 에러가 난다. DTO 클래스는 인자 없는 생성자를 유지해야 한다.
중첩 객체에는 반드시 @Type을 붙여라. 빼먹으면 중첩 객체가 plain object로 남는다. 런타임 에러가 즉시 나지 않아서 나중에 메서드 호출 시점에 터져서 디버깅이 어렵다.
배열의 타입도 @Type이 필요하다. TypeScript의 design:type 메타데이터는 배열을 Array로만 인식한다. 배열 안의 원소 타입은 알 수 없어서 @Type(() => ElementClass)를 명시해야 한다.
Map/Set은 기본 지원되지 않는다. @Transform으로 직접 변환 로직을 작성해야 한다:
class Config {
@Transform(({ value }) => new Map(Object.entries(value)), { toClassOnly: true })
@Transform(({ value }) => Object.fromEntries(value), { toPlainOnly: true })
settings: Map<string, string>;
}
왜 class-transformer인가
plain object → class 인스턴스 변환을 해결하는 방법은 여러 가지다. 수동 매핑 함수를 만들 수도 있고, Zod의 .transform()으로 스키마 변환을 할 수도 있다. class-transformer가 선택되는 이유는 NestJS 생태계와의 통합이다. ValidationPipe의 transform: true가 내부적으로 plainToInstance를 호출하고, ClassSerializerInterceptor가 instanceToPlain을 호출한다. class-validator의 데코레이터와 같은 클래스 위에 공존하기 때문에, 검증과 변환을 한 곳에서 선언적으로 관리할 수 있다. NestJS를 쓰지 않는 환경이라면 Zod의 스키마 기반 변환이 더 간결할 수 있다.
정리
- JSON.parse 결과는 plain object일 뿐이고, 클래스 메서드나 프로토타입이 없다. plainToInstance로 실제 인스턴스로 변환해야 런타임에서 의도대로 동작한다.
- @Expose/@Exclude로 필드 노출을 제어하고, @Type으로 중첩 객체와 Date를 변환하며, @Transform으로 커스텀 로직을 처리한다. 이 세 축이 거의 모든 케이스를 커버한다.
- NestJS에서는 ValidationPipe(transform: true)와 ClassSerializerInterceptor가 자동으로 변환을 수행하므로, 데코레이터만 선언하면 컨트롤러 코드에 변환 로직이 필요 없다.
관련 문서
- class-validator - 데코레이터 기반 런타임 검증, NestJS ValidationPipe 통합
- reflect-metadata - 데코레이터 메타프로그래밍 기반, design:type 메타데이터
- Zod 스키마 검증 - 스키마 빌더 방식의 대안 접근법