NestJS class-transformer 심화

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 RxJSmap 연산자와 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의 요청/응답 양쪽 모두에서 작동한다. 요청에서는 타입 변환과 정규화를, 응답에서는 필드 제어와 포맷팅을 담당하여 컨트롤러 코드를 깔끔하게 유지할 수 있다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux