NestJS Serialization 응답 제어

왜 Serialization이 중요한가?

API 응답에 엔티티를 그대로 반환하면 비밀번호, 내부 ID, 소프트 삭제 플래그 같은 민감한 필드가 노출된다. NestJS는 class-transformer 라이브러리와 내장 ClassSerializerInterceptor를 통해 응답 직렬화를 선언적으로 제어한다. 코드 한 줄 없이 데코레이터만으로 필드를 숨기고, 변환하고, 그룹별로 노출을 제어할 수 있다.

기본 설정

// main.ts — 글로벌 Interceptor 등록
import { ClassSerializerInterceptor } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

app.useGlobalInterceptors(
  new ClassSerializerInterceptor(app.get(Reflector), {
    // 기본 전략: excludeAll이면 @Expose()만 노출
    strategy: 'excludeAll',
    // undefined 필드 제외
    excludeExtraneousValues: true,
  }),
);

strategy: 'excludeAll'을 설정하면 화이트리스트 방식으로 동작한다. @Expose() 데코레이터가 붙은 필드만 응답에 포함된다. 이 방식이 보안상 훨씬 안전하다.

@Exclude와 @Expose

가장 기본적인 필드 제어 데코레이터다.

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

export class UserEntity {
  @Expose()
  id: number;

  @Expose()
  name: string;

  @Expose()
  email: string;

  @Exclude()
  password: string;

  @Exclude()
  deletedAt: Date | null;

  // strategy: 'excludeAll'이면 @Expose() 없는 필드도 자동 제외
  internalNote: string;

  constructor(partial: Partial<UserEntity>) {
    Object.assign(this, partial);
  }
}
// Controller
@Get(':id')
async findOne(@Param('id') id: number): Promise<UserEntity> {
  const user = await this.usersService.findOne(id);
  return new UserEntity(user);  // 반드시 클래스 인스턴스로 반환
}

// 응답: { "id": 1, "name": "홍길동", "email": "hong@example.com" }
// password, deletedAt, internalNote는 제외됨

핵심: 반환 값이 반드시 class-transformer 클래스의 인스턴스여야 한다. 일반 객체({})를 반환하면 직렬화가 적용되지 않는다.

@Transform: 값 변환

필드 값을 변환해서 노출하려면 @Transform을 사용한다.

import { Transform, Expose } from 'class-transformer';

export class UserEntity {
  @Expose()
  id: number;

  @Expose()
  @Transform(({ value }) => value?.toUpperCase())
  name: string;

  // 날짜 포맷 변환
  @Expose()
  @Transform(({ value }) => value?.toISOString().split('T')[0])
  createdAt: Date;

  // 중첩 객체에서 특정 값 추출
  @Expose()
  @Transform(({ obj }) => obj.profile?.avatarUrl ?? '/default-avatar.png')
  avatar: string;

  // 민감 정보 마스킹
  @Expose()
  @Transform(({ value }) => value?.replace(/(.{3}).*(@.*)/, '$1***$2'))
  email: string;  // "hong***@example.com"
}

@Transform의 콜백은 value(현재 필드 값), obj(원본 객체), key(필드명), type(변환 타입)을 받는다. obj를 활용하면 다른 필드 값을 참조한 파생 필드를 만들 수 있다.

@SerializeOptions와 그룹

같은 엔티티를 API마다 다르게 직렬화하려면 그룹을 사용한다. 관리자 API는 더 많은 필드를, 공개 API는 최소한의 필드만 노출하는 식이다.

export class UserEntity {
  @Expose()
  id: number;

  @Expose()
  name: string;

  @Expose({ groups: ['admin', 'self'] })
  email: string;

  @Expose({ groups: ['admin'] })
  role: string;

  @Expose({ groups: ['admin'] })
  createdAt: Date;

  @Expose({ groups: ['self'] })
  phone: string;
}

// Controller
@Get(':id')
@SerializeOptions({ groups: ['self'] })
findOne(@Param('id') id: number) {
  // 응답: id, name, email, phone (self 그룹)
  return new UserEntity(await this.usersService.findOne(id));
}

@Get('admin/:id')
@SerializeOptions({ groups: ['admin'] })
findOneAdmin(@Param('id') id: number) {
  // 응답: id, name, email, role, createdAt (admin 그룹)
  return new UserEntity(await this.usersService.findOne(id));
}

@Get('public/:id')
// 그룹 미지정 → @Expose()만 있고 groups 없는 필드만 노출
findOnePublic(@Param('id') id: number) {
  // 응답: id, name
  return new UserEntity(await this.usersService.findOne(id));
}

그룹은 Guard RBAC과 자연스럽게 조합된다. Guard에서 역할을 확인하고, 역할에 맞는 그룹으로 직렬화하면 된다.

중첩 객체 직렬화

연관 엔티티도 별도 직렬화 클래스를 적용할 수 있다.

import { Type, Expose } from 'class-transformer';

export class OrderEntity {
  @Expose()
  id: number;

  @Expose()
  amount: number;

  @Expose()
  status: string;

  @Expose()
  @Type(() => UserEntity)  // 중첩 객체에 UserEntity 직렬화 적용
  user: UserEntity;

  @Expose()
  @Type(() => OrderItemEntity)
  items: OrderItemEntity[];

  constructor(partial: Partial<OrderEntity>) {
    Object.assign(this, partial);
  }
}

export class OrderItemEntity {
  @Expose()
  productName: string;

  @Expose()
  quantity: number;

  @Expose()
  @Transform(({ value }) => `₩${value.toLocaleString()}`)
  price: number;
}

@Type() 데코레이터가 핵심이다. 이를 빠뜨리면 중첩 객체의 @Exclude/@Expose가 무시되어 모든 필드가 그대로 노출된다.

배열·페이지네이션 응답

배열이나 페이지네이션 래퍼를 사용할 때도 직렬화가 적용되려면 클래스 인스턴스 변환이 필요하다.

// 페이지네이션 래퍼
export class PaginatedResponse<T> {
  @Expose()
  @Type(() => Object) // 동적 타입은 수동 처리
  data: T[];

  @Expose()
  total: number;

  @Expose()
  page: number;

  @Expose()
  limit: number;
}

// Service에서 변환
async findAll(page: number, limit: number): Promise<PaginatedResponse<UserEntity>> {
  const [users, total] = await this.userRepo.findAndCount({
    skip: (page - 1) * limit,
    take: limit,
  });

  const response = new PaginatedResponse<UserEntity>();
  response.data = users.map(u => new UserEntity(u));  // 각 아이템도 인스턴스화
  response.total = total;
  response.page = page;
  response.limit = limit;
  return response;
}

커스텀 Interceptor로 확장

기본 ClassSerializerInterceptor를 확장해 동적 그룹 할당 같은 로직을 추가할 수 있다.

@Injectable()
export class RoleBasedSerializerInterceptor extends ClassSerializerInterceptor {
  serialize(response: any, options: ClassTransformOptions) {
    // request에서 사용자 역할을 가져와 그룹에 자동 추가
    const request = this.getRequest();
    const userRole = request?.user?.role;

    const groups = [...(options.groups || [])];
    if (userRole === 'admin') groups.push('admin');
    if (userRole) groups.push('self');

    return super.serialize(response, { ...options, groups });
  }
}

흔한 실수와 해결

실수 증상 해결
일반 객체 반환 직렬화 미적용, 모든 필드 노출 new Entity(obj)로 인스턴스화
@Type() 누락 중첩 객체 직렬화 무시 중첩 필드에 반드시 @Type() 추가
constructor 누락 Object.assign 안 됨 partial 생성자 패턴 사용
excludeAll + @Expose 없음 빈 객체 {} 반환 노출할 필드에 @Expose() 추가
순환 참조 무한 루프, 스택 오버플로우 한쪽에 @Exclude() 또는 @Transform으로 ID만 노출

정리

NestJS Serialization은 보안과 API 설계의 교차점이다. @Exclude/@Expose로 필드를 제어하고, @Transform으로 값을 변환하며, 그룹으로 역할별 응답을 분리한다. strategy: 'excludeAll' 화이트리스트 방식을 기본으로 채택하면 실수로 민감 정보가 노출되는 사고를 원천 차단할 수 있다. Pipe 검증이 입력을 지키듯, Serialization은 출력을 지킨다.

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