junyeokk
Blog
TypeScript·2025. 11. 15

class-transformer

API에서 JSON을 받아와서 JSON.parse로 파싱하면 뭘 얻는가? 평범한 JavaScript 객체다. TypeScript에서 User 타입이라고 선언해도, 런타임에서 그 객체는 User 클래스의 인스턴스가 아니다. 메서드도 없고, getter도 없고, 프로토타입 체인도 연결되지 않는다.

typescript
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를 실제 클래스 인스턴스로 변환해야 한다.

수동으로 하면 이렇게 된다:

typescript
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를 클래스 인스턴스로 변환한다.

typescript
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"

배열도 그대로 넘기면 된다:

typescript
const users = plainToInstance(User, [
  { id: 1, firstName: 'John', lastName: 'Doe' },
  { id: 2, firstName: 'Jane', lastName: 'Smith' },
]);
// User[] — 각 원소가 User 인스턴스

첫 번째 인자가 대상 클래스, 두 번째 인자가 원본 데이터다. 타입 추론도 자동으로 되기 때문에 제네릭 파라미터를 명시할 필요가 없다.

instanceToPlain — class → plain

반대 방향이다. 클래스 인스턴스를 plain object로 변환한다. API에 데이터를 보내기 전, 민감한 필드를 제거하거나 이름을 바꿀 때 유용하다.

typescript
import { instanceToPlain } from 'class-transformer';

const plain = instanceToPlain(user);
console.log(plain); // { id: 1, firstName: 'John', lastName: 'Doe' }

이 함수가 진짜 힘을 발휘하는 건 @Exclude@Expose 데코레이터와 조합할 때다. 나중에 자세히 다룬다.

instanceToInstance — class → class (딥 클론)

클래스 인스턴스를 새로운 인스턴스로 복제한다. 데코레이터 규칙이 적용된 딥 클론이라고 보면 된다.

typescript
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를 한 번에 해준다:

typescript
import { serialize, deserialize } from 'class-transformer';

const json = serialize(user);              // string
const restored = deserialize(User, json);  // User 인스턴스

실무에서는 plainToInstanceinstanceToPlain을 직접 쓰는 경우가 더 많다. HTTP 클라이언트가 이미 JSON 파싱을 해주기 때문이다.


@Expose와 @Exclude — 필드 노출 제어

class-transformer의 핵심 데코레이터 두 가지다.

기본 동작: 모든 프로퍼티가 포함된다

아무 데코레이터도 안 붙이면, plain object의 모든 프로퍼티가 그대로 매핑된다. 심지어 클래스에 정의되지 않은 프로퍼티도 포함된다.

typescript
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()를 명시한 필드만 허용하고 나머지는 전부 무시하는 방식이다:

typescript
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를 다룰 때 이 패턴이 거의 필수다. ValidationPipetransform: true 옵션과 함께 쓰면 자동으로 plainToInstance가 적용된다.

@Exclude — 특정 필드 제외

출력 시 민감한 필드를 숨길 때 쓴다:

typescript
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

"기본적으로 전부 숨기고, 명시한 것만 노출" 패턴이다. 화이트리스트 방식으로 가장 안전하다:

typescript
import { Exclude, Expose } from 'class-transformer';

@Exclude()
class User {
  @Expose() id: number;
  @Expose() name: string;
  @Expose() email: string;

  password: string;      // 자동 제외
  refreshToken: string;  // 자동 제외
}

방향별 제어 — toPlainOnly / toClassOnly

변환 방향에 따라 다르게 동작하게 할 수 있다:

typescript
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 데코레이터로 명시적으로 알려줘야 한다.

typescript
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으로 자동 변환할 수 있다:

typescript
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)를 지정한다:

typescript
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으로 직접 로직을 작성한다.

typescript
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가 유용하다:

typescript
class Order {
  quantity: number;
  unitPrice: number;

  @Transform(({ obj }) => obj.quantity * obj.unitPrice)
  totalPrice: number;
}

방향별 Transform

변환 방향에 따라 다른 로직을 적용할 수 있다:

typescript
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 응답의 필드 이름과 클래스의 프로퍼티 이름이 다를 때:

typescript
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에 포함시킬 수 있다:

typescript
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 엔드포인트에서 다르게 직렬화하고 싶을 때:

typescript
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 버전에 따라 필드를 다르게 노출할 수도 있다:

typescript
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

typescript
// main.ts
app.useGlobalPipes(
  new ValidationPipe({
    transform: true,               // plainToInstance 자동 적용
    whitelist: true,                // 데코레이터 없는 필드 제거
    forbidNonWhitelisted: true,     // 허용되지 않은 필드가 있으면 400 에러
  }),
);

이 설정이면 컨트롤러에서 DTO 클래스를 파라미터 타입으로 지정하는 것만으로 자동 변환된다:

typescript
@Post('users')
create(@Body() dto: CreateUserDto) {
  // dto는 이미 CreateUserDto의 인스턴스
  // class-validator 검증도 통과한 상태
}

응답 직렬화 — ClassSerializerInterceptor

응답 데이터에도 class-transformer를 적용할 수 있다:

typescript
@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 등 모든 변환 함수의 세 번째 인자로 옵션 객체를 넘길 수 있다.

옵션타입기본값설명
excludeExtraneousValuesbooleanfalse@Expose() 없는 필드 무시
groupsstring[]특정 그룹의 필드만 포함
versionnumber버전에 맞는 필드만 포함
strategystring'exposeAll' 또는 'excludeAll'
enableImplicitConversionbooleanfalse타입 메타데이터 기반 자동 변환
exposeDefaultValuesbooleanfalse기본값이 있는 필드 노출
exposeUnsetFieldsbooleantrue값이 없는 필드를 undefined로 포함
ignoreDecoratorsbooleanfalse모든 데코레이터 무시
enableCircularCheckbooleanfalse순환 참조 감지 (성능 저하 있음)

enableImplicitConversion

이 옵션을 켜면 TypeScript의 타입 메타데이터(reflect-metadatadesign:type)를 기반으로 자동 형변환이 일어난다:

typescript
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() 같은 검증이 의도와 다르게 동작할 수 있다.


순환 참조 처리

클래스 간 순환 참조가 있으면 변환 시 무한 루프에 빠질 수 있다:

typescript
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의 데코레이터를 활용하면 깔끔하게 구성할 수 있다:

typescript
// 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 인터페이스는 컴파일 후 사라지기 때문에 변환 대상이 될 수 없다.

프로퍼티 초기화에 주의하라. plainToInstancenew TargetClass()를 호출한 뒤 프로퍼티를 할당한다. 생성자에 필수 인자가 있으면 에러가 난다. DTO 클래스는 인자 없는 생성자를 유지해야 한다.

중첩 객체에는 반드시 @Type을 붙여라. 빼먹으면 중첩 객체가 plain object로 남는다. 런타임 에러가 즉시 나지 않아서 나중에 메서드 호출 시점에 터져서 디버깅이 어렵다.

배열의 타입도 @Type이 필요하다. TypeScript의 design:type 메타데이터는 배열을 Array로만 인식한다. 배열 안의 원소 타입은 알 수 없어서 @Type(() => ElementClass)를 명시해야 한다.

Map/Set은 기본 지원되지 않는다. @Transform으로 직접 변환 로직을 작성해야 한다:

typescript
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 생태계와의 통합이다. ValidationPipetransform: true가 내부적으로 plainToInstance를 호출하고, ClassSerializerInterceptorinstanceToPlain을 호출한다. class-validator의 데코레이터와 같은 클래스 위에 공존하기 때문에, 검증과 변환을 한 곳에서 선언적으로 관리할 수 있다. NestJS를 쓰지 않는 환경이라면 Zod의 스키마 기반 변환이 더 간결할 수 있다.


정리

  • JSON.parse 결과는 plain object일 뿐이고, 클래스 메서드나 프로토타입이 없다. plainToInstance로 실제 인스턴스로 변환해야 런타임에서 의도대로 동작한다.
  • @Expose/@Exclude로 필드 노출을 제어하고, @Type으로 중첩 객체와 Date를 변환하며, @Transform으로 커스텀 로직을 처리한다. 이 세 축이 거의 모든 케이스를 커버한다.
  • NestJS에서는 ValidationPipe(transform: true)와 ClassSerializerInterceptor가 자동으로 변환을 수행하므로, 데코레이터만 선언하면 컨트롤러 코드에 변환 로직이 필요 없다.

관련 문서