왜 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은 출력을 지킨다.