프론트엔드와 백엔드가 통신할 때, 데이터는 네트워크를 통해 문자열(String) 형태로 오고 갑니다. 프론트엔드에서 아무리 정교하게 조립된 객체(Object)를 보내더라도, 인터넷 선을 통과하기 위해서는 JSON.stringify()를 통해 납작한 텍스트로 변환되어야 하죠.
백엔드인 NestJS 서버가 이 텍스트 데이터를 받으면 문제가 생깁니다. 우리는 이 데이터를 단순한 문자열 덩어리가 아니라, TypeScript가 제공하는 강력한 타입 추론과 클래스의 메서드를 사용할 수 있는 '객체' 로 다루고 싶기 때문입니다. 반대로 클라이언트에게 데이터를 응답할 때는, 데이터베이스에서 꺼낸 복잡한 엔티티나 객체에서 불필요한 정보(예: 비밀번호)를 제외하고 다시 예쁜 JSON 문자열로 포장해서 내보내야 합니다.
Express 같은 원시적인 Node.js 프레임워크에서는 JSON.parse()로 문자열을 JavaScript 객체(Literal Object)로 바꾸는 선에서 그칩니다. 하지만 이는 형태만 객체일 뿐, 우리가 정의한 TypeScript 클래스의 인스턴스는 아닙니다. 즉, 클래스에 정의된 메서드나 데코레이터 기능을 전혀 사용할 수 없다는 뜻입니다.
NestJS는 이 문제를 우아하게 해결하기 위해 class-transformer와 class-validator 라는 두 가지 강력한 라이브러리를 기본 생태계로 채택하고, 이를 파이프(Pipe)와 인터셉터(Interceptor)라는 구조에 녹여냈습니다.
NestJS에서는 보통 요청(Request) 시에는 ValidationPipe 가 역직렬화를 담당하고, 응답(Response) 시에는 ClassSerializerInterceptor 가 직렬화를 담당합니다.
먼저, 클라이언트의 요청 데이터를 받을 DTO를 정의합니다.
// create-user.dto.ts import { IsString, IsInt, Min } from 'class-validator'; export class CreateUserDto { @IsString() name: string; @IsInt() @Min(18) age: number; // 일반 객체라면 호출할 수 없는 클래스 메서드 printInfo() { return `${this.name}은(는) ${this.age}살 입니다.`; } }
이제 main.ts에 전역 파이프를 설정합니다. 여기서 가장 중요한 옵션이 transform: true 입니다.
// main.ts import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); // 핵심: transform 옵션을 true로 켜야 역직렬화가 동작합니다! app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); await app.listen(3000); } bootstrap();
이 옵션이 켜져 있으면, 컨트롤러에 도달하기 전에 JSON 데이터가 CreateUserDto의 진짜 인스턴스로 변환(역직렬화) 됩니다. 따라서 컨트롤러에서 dto.printInfo() 같은 메서드를 안전하게 호출할 수 있게 됩니다.
사용자 정보를 응답으로 내려줄 때 비밀번호 같은 민감한 데이터는 빼고 싶을 것입니다. 이때 class-transformer의 @Exclude() 데코레이터와 인터셉터를 활용합니다.
// user.entity.ts import { Exclude, Expose } from 'class-transformer'; export class UserEntity { id: number; name: string; // 직렬화될 때 이 필드는 제외됩니다! (응답 JSON에 포함 안 됨) @Exclude() password: string; constructor(partial: Partial<UserEntity>) { Object.assign(this, partial); } // 가상의 필드를 만들어 응답에 포함시킬 수도 있습니다. @Expose() get isAdult(): boolean { return this.age >= 18; } }
컨트롤러에서는 @UseInterceptors(ClassSerializerInterceptor)를 달아주면 끝입니다.
// users.controller.ts import { Controller, Get, UseInterceptors, ClassSerializerInterceptor } from '@nestjs/common'; import { UserEntity } from './user.entity'; @Controller('users') // 이 컨트롤러의 응답은 직렬화 인터셉터를 거칩니다. @UseInterceptors(ClassSerializerInterceptor) export class UsersController { @Get() getUser(): UserEntity { // DB에서 꺼내왔다고 가정하는 엔티티 인스턴스 return new UserEntity({ id: 1, name: 'Hooney', password: 'supersecret', age: 30 }); } // 응답 결과: { "id": 1, "name": "Hooney", "isAdult": true } (비밀번호는 숨겨짐!) }
ValidationPipe와 ClassSerializerInterceptor를 통해 지루하고 반복적인 데이터 검증 로직과 데이터 가공 로직(비밀번호 숨기기 등)을 서비스 계층(Business Logic)에서 완전히 분리할 수 있습니다. 코드가 훨씬 우아해지고 클린 아키텍처에 가까워집니다.transform: true 옵션은 내부적으로 Reflection과 class-transformer 객체 생성 로직을 거치기 때문에, 매우 트래픽이 높은 극단적인 환경에서는 미세한 성능 오버헤드가 발생할 수 있습니다. 하지만 일반적인 엔터프라이즈 환경에서는 개발 생산성과 안정성이라는 이점이 이를 아득히 상회합니다.프론트엔드와 백엔드를 오가며 개발하다 보면 '이 데이터가 단순한 객체인지, 아니면 클래스의 인스턴스인지' 헷갈릴 때가 많습니다. NestJS가 제공하는 직렬화/역직렬화 생태계를 정확히 이해하면, 외부 세계의 불확실한 데이터를 우리 시스템 내부의 견고하고 안전한 객체로 탈바꿈시키는 마법을 자유자재로 다룰 수 있게 될 것입니다.