Search

8. 커스텀 데이터 직렬화

사용자 비밀번호를 응답에서 제외하는 방법

사용자 정보를 조회하는 GET 요청을 실행하면, 현재 응답에는 비밀번호가 포함된다.
이를 해결하기 위해 사용자 엔터티에서 비밀번호 속성을 제외해야 한다.
{ "id": 2, "email": "aaabbbb@aaa.com", "password": "test" }
JSON
복사

1. Exclude 데코레이터 + ClassSerializerInterceptor 사용

이 방법은 NestJS 공식 문서에서 추천하는 방법이다.
class-transformerExclude 데코레이터를 활용하여 엔터티 인스턴스를 JSON으로 변환할 때 특정 속성을 제외할 수 있다.
먼저, 사용자 엔터티에서 class-transformer 패키지를 임포트하고 @Exclude() 데코레이터를 적용한다.
user.entity.ts
import { Exclude } from 'class-transformer'; @Entity() export class User { @Column() @Exclude() password: string; }
TypeScript
복사
이제 password 속성은 엔터티 인스턴스를 일반 객체로 변환할 때 자동으로 제외된다.
컨트롤러에서 ClassSerializerInterceptor 를 사용하여 엔터티가 JSON으로 변환될 때 적용할 규칙을 지정한다.
@Controller('auth') export class UsersController { constructor(private usersService: UsersService) {} @Get('/:id') @UseInterceptors(ClassSerializerInterceptor) findUser(@Param('id') id: string) { return this.usersService.findOne(parseInt(id)); } }
TypeScript
복사
위 코드에서 @UseInterceptors(ClassSerializerInterceptor)를 적용하면, 엔터티의 @Exclude() 규칙이 자동으로 반영된다.
이제 동일한 GET 요청을 실행하면 응답에서 비밀번호가 제외된 것을 확인할 수 있다.
{ "id": 2, "email": "user@example.com" }
TypeScript
복사
이 방식은 간단하고 효과적이지만, 특정 조건에서만 속성을 숨기고 싶다면 추가적인 설정이 필요하다.
예를 들어, 사용자의 정보를 조회할 때 일반 유저가 요청할 때와 관리자가 요청할 때 다른 데이터를 반환해야 한다면 어떻게 구현할 수 있을까?

2. CustomInterceptor + DTO 사용

앞서 살펴본 문제는 사용자의 역할과 요청된 라우트에 따라 직렬화된 응답이 달라야 한다는 점이다.
기존의 NestJS의 @Exclude()@Expose() 방식은 하나의 엔터티에 직렬화 정보를 직접 정의하는 방식이므로, 여러 개의 라우트에서 다른 직렬화 로직을 적용할 때 확장성이 떨어지는 문제가 발생한다.
이를 해결하기 위해 커스텀 인터셉터와 DTO를 활용하는 방식을 도입해보자.

인터셉터란 무엇인가?

인터셉터(Interceptor)는 들어오는 HTTP 요청이나 나가는 응답을 가로채서 특정 로직을 수행할 수 있는 기능이다. 이를 통해 미리 정의한 로직을 요청 처리 전이나 후에 실행할 수 있다. (미들웨어와 비슷한 성질)
인터셉터는 다음과 같은 상황에서 많이 활용한다.
요청 및 응답 로깅: 요청이 들어올 때와 응답이 나갈 때, 로그를 기록할 수 있다.
데이터 직렬화 및 변환: 데이터를 원하는 형식으로 변환하여 응답에 포함시킬 수 있다.
성능 모니터링: 특정 API의 성능을 측정하거나 응답 시간을 기록할 수 있다.

인터셉터 클래스 만들기

NestJS에서 인터셉터를 만들려면 클래스를 생성하고, NestInterceptor 인터페이스를 구현해야 한다.
먼저, 프로젝트 구조에서 interceptors 폴더를 생성하고 그 안에 serialize.interceptor.ts 라는 파일을 만든다. 이 파일은 직렬화 로직을 담당하는 인터셉터로 구현할 것이다.
interceptors/serialize.interceptor.ts
import { UseInterceptors, NestInterceptor, ExecutionContext, CallHandler, } from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs'; import { plainToInstance } from 'class-transformer'; export class SerializeInterceptor implements NestInterceptor { intercept( context: ExecutionContext, handler: CallHandler<any>, ): Observable<any> | Promise<Observable<any>> { // Run something before a request is handled // by the request handler console.log('Im running before the handler', context); return handler.handle().pipe( map((data) => { // Run something before the response is sent out console.log('Im running before response is sent out', data); }), ); } }
TypeScript
복사
intercept() 메서드
이 메서드는 인터셉터가 요청을 가로챌 때 호출된다.
이 메서드 안에서 요청 핸들러 실행 전후에 원하는 로직을 처리할 수 있다.
context
ExecutionContext는 현재 요청에 대한 정보를 담고 있다.
이 정보는 요청의 URL, HTTP 메서드, 사용자 정보 등을 포함한다.
handler
CallHandler는 실제 라우트 핸들러를 실행하는 객체이다.
handler.handle()을 호출하면 요청이 실제 핸들러로 전달된다.
map 연산자
응답이 나가기 전에 데이터를 변환하거나 직렬화할 때 사용한다.

인터셉트 사용하기

이제 이 인터셉터를 컨트롤러에 적용해보자. 인터셉터는 컨트롤러의 개별 핸들러나 전체 컨트롤러에 적용할 수 있다.
users.controller.ts
@Get('/:id') @UseInterceptors(SerializeInterceptor) findUser(@Param('id') id: string) { console.log('handler is running'); return this.usersService.findOne(parseInt(id)); }
TypeScript
복사

인터셉트 테스트하기

인터셉트가 제대로 동작하는지 요청을 보내보고 콘솔 로그가 어떻게 찍히는지 한번 확인해보자.
GET http://localhost:3000/auth/2 # 응답: Im running before the handler ExecutionContextHost {...} handler is running Im running before response is sent out User { id: 2, email: 'aaabbbb@aaa.com', password: 'test' }
Bash
복사

인터셉터를 활용한 직렬화 로직 구현

기본적인 인터셉트는 완성이됐으니, 인터셉터를 활용한 직렬화 로직을 구현해서 특정 DTO를 사용하여 응답 데이터를 자동으로 변화하도록 만들어보자.

DTO 생성

먼저, 응답 데이터를 직렬화할 DTO를 생성한다.
dtos/user.dto.ts
import { Expose } from 'class-transformer'; export class UserDto { @Expose() id: number; @Expose() email: string; }
TypeScript
복사
Expose() 데코레이터를 사용하면 해당 필드만 응답으로 반환한다.
password 필드는 포함하지 않았다.

인터셉터 수정

그리고 plainToInstance()를 이용해 DTO로 변환하는 로직을 추가하자.
interceptors/serialize.interceptor.ts
import { UseInterceptors, NestInterceptor, ExecutionContext, CallHandler, } from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs'; import { plainToInstance } from 'class-transformer'; export class SerializeInterceptor implements NestInterceptor { constructor(private readonly dto: any) {} intercept( context: ExecutionContext, handler: CallHandler<any>, ): Observable<any> | Promise<Observable<any>> { return handler.handle().pipe( map((data) => { return plainToInstance(this.dto, data, { excludeExtraneousValues: true, }); }), ); } }
TypeScript
복사
dto를 생성자로 받아서 직렬화할 클래스를 동적으로 지정할 수 있다.
excludeExtraneousValues: true 옵션을 사용하면, DTO에서 @Expose() 된 필드만 포함된다.

컨트롤러에 적용

사용자 정보를 조회할 때, 응답을 UserDto로 직렬화한다.
이때, 인터셉터에 DTO를 동적으로 주입하기 위해 new 키워드를 사용하여 인스턴스를 생성한다.
users.controller.ts
import { SerializeInterceptor } from 'src/interceptors/serialize.interceptor'; import { UserDto } from './dtos/user.dto'; import { UpdateUserDto } from './dtos/update-user.dto'; @Get('/:id') @UseInterceptors(new SerializeInterceptor(UserDto)) findUser(@Param('id') id: string) { console.log('handler is running'); return this.usersService.findOne(parseInt(id)); }
TypeScript
복사

실행 결과

GET http://localhost:3000/auth/2 # 기존 응답: { "id": 2, "email": "aaabbbb@aaa.com", "password": "test" } # 인터셉터 적용 후: { "id": 2, "email": "aaabbbb@aaa.com" }
Bash
복사

데코레이터에 인터셉터 포함하기

우리는 컨트롤러에서 다음과 같이 @UseInterceptors(new SerializeInterceptor(UserDto)) 를 사용해서 응답을 직렬화 하고 있다.
@Get('/:id') @UseInterceptors(new SerializeInterceptor(UserDto)) findUser(@Param('id') id: string) { console.log('handler is running'); return this.usersService.findOne(parseInt(id)); }
TypeScript
복사
하지만, 이렇게 하면 매번 SerializeInterceptor를 직접 생성해야 하므로 중복이 발생하고 코드가 길어진다.
이를 개선하기 위해 커스텀 데코레이터를 만들어보자.
decorators/serialize.decorator.ts
export function Serialize(dto: any) { return UseInterceptors(new SerializeInterceptor(dto)); }
TypeScript
복사
이제 SerializeInterceptor를 컨트롤러에서 간단한 데코레이터 호출만으로 적용할 수 있다.
users.controller.ts
import { Serialize } from 'src/interceptors/serialize.interceptor'; import { UserDto } from './dtos/user.dto'; @Get('/:id') @Serialize(UserDto) // DTO 직렬화를 적용하는 커스텀 데코레이터 사용 findUser(@Param('id') id: string) { console.log('handler is running'); return this.usersService.findOne(parseInt(id)); }
TypeScript
복사
만약 직렬화 인터셉터를 개별 핸들러가 아니라 컨트롤러 전체에 적용하고 싶다면, @Controller() 데코레이터 바로 아래에 @Serialize()를 배치하면 된다.
@Controller('auth') @Serialize(UserDto) // 모든 핸들러에 직렬화 적용 export class UsersController { constructor(private usersService: UsersService) {} ... }
TypeScript
복사
핸들러마다 @Serialize(UserDto)를 반복해서 붙일 필요가 없다.
만약 특정 핸들러에서만 다른 DTO를 사용하고 싶다면 개별적으로 @Serialize(AnotherDto)를 추가하면 된다.

인터셉터 타입 안정성 개선하기

기존 코드에서 DTO의 타입이 any로 되어 있어 타입 안정성이 보장되지 않는 문제가 있었다.
타입 검증이 제대로 이루어지지 않으면 런타임 오류 발생 가능성이 증가하며, 잘못된 값을 전달해도 컴파일 단계에서 에러를 감지할 수 없다.
@Serialize(UserDto) // 가능 (정상적인 사용) @Serialize('쀌베브렞ㄷ레ㅏㅔㅈㄷㄹ') // 가능 (잘못된 사용)
TypeScript
복사
이처럼 문자열이나 엉뚱한 값이 들어가도 타입 에러가 발생하지 않는다.
최소한의 타입 검증을 추가하여 잘못된 값이 전달되지 않도록 개선해보자.
import { UseInterceptors, NestInterceptor, ExecutionContext, CallHandler, } from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs'; import { plainToInstance } from 'class-transformer'; interface ClassConstructor { new (...args: any[]): {}; } export function Serialize(dto: ClassConstructor) { return UseInterceptors(new SerializeInterceptor(dto)); } export class SerializeInterceptor implements NestInterceptor { constructor(private readonly dto: ClassConstructor) {} ... }
TypeScript
복사
new (...args: any[]): T;를 사용해 DTO가 클래스 형태임을 보장
잘못된 값(stringnumber 등)이 들어오면 컴파일러가 즉시 에러를 감지
@Serialize(UserDto) // 가능 ✅ @Serialize('쀌베브렞ㄷ레ㅏㅔㅈㄷㄹ') // 오류 발생! 🔴 (DTO 클래스가 아님)
TypeScript
복사