NestJS에서 응답 객체에 Class Transform 적용하기
DB에서 사용자 정보를 가져와서 전송하는 API가 필요하다고 생각해 보세요. return await this.userRepository.findOne({where: {id: userId}});
정도를 작성하고 커밋을 누르기만 하면 된다면 얼마나 좋을까요?
하지만 그렇게 했다가는 응답 객체에 들어가면 안 되는 내용까지 들어가게 되겠죠.
{
"id": "heartade",
"name": "Dani",
"email": "soohan@heartade.dev",
"secret": "진짜진짜비밀"
}
물론 return await this.userRepository.findOne({where: {id: userId}}) as Pick<User, "id"|"name"|"email">;
도 소용이 없습니다. 타입스크립트의 타입 체크는 컴파일 타임에만 동작하니까요.
이런 상황을 예방하는 가장 직관적인 방법은 아래와 같은 모양일 거예요.
// UserResponseDto extends PickType(User, ["id", "name", "email"])
async getUser(userId: string): Promise<UserResponseDto> {
const user = await this.userRepository.findOne({where: {id: userId}});
// 클라이언트에게 보여줘도 되는 정보만 한땀한땀 골라서 전달
// 코드에 다른 필드를 넣으면 "Object literal may only specify known properties" 오류 발생
return {id: user.id, name: user.name, email: user.email};
}
하지만 응답에 들어가는 필드가 많아질수록 이런 방식은 귀찮아집니다. 심지어 Object literal을 반환하는 게 아니라면 추가로 들어간 필드가 있어도 타입 체크에 걸리지도 않죠. 이왕 UserResponseDto
를 정의한 김에 그냥 알아서 반환값을 끼워맞춰 주는, 좀 더 예쁘고 깔끔한 방법은 없는 걸까요?
그래서 class-transformer
를 사용해 보기로 했습니다.
class-transformer
를 적용하는 인터셉터 구현
NestInterceptor
에서 class-transformer
를 사용하면 컨트롤러가 응답을 반환하기 전에 응답 객체를 특정한 타입으로 변환하도록 할 수 있습니다. 그리고 우리가 클라이언트에게 보낼 필드만 담긴 타입을 정의해서 인터셉터 생성자의 인수로 주면 되는 거죠.
// response.interceptor.ts
import {
CallHandler,
ExecutionContext,
Injectable,
InternalServerErrorException,
NestInterceptor,
} from '@nestjs/common'
import { instanceToPlain, plainToInstance } from 'class-transformer'
import { validate } from 'class-validator'
import { switchMap } from 'rxjs'
@Injectable()
export class ResponseInterceptor<T extends object>
implements NestInterceptor<any, T>
{
constructor(private readonly dto: new () => T) {}
intercept(context: ExecutionContext, next: CallHandler) {
// 컨트롤러가 handle된 뒤에 pipe를 실행합니다.
return next.handle().pipe(
switchMap(async (data) => {
// instanceToPlain(data)를 실행해서 plain object로 변환한 다음,
// plainToInstance(this.dto, ...)를 실행해서 plain object로 변환된 data를 dto 타입으로 변환합니다.
const transformedData = plainToInstance(this.dto, instanceToPlain(data))
// class-validator로 이상한 값이 없는지 점검도 한 번 해 줍니다.
const errors = await validate(transformedData)
if (errors.length > 0) {
throw new InternalServerErrorException(
"RESPONSE VALIDATION FAILED",
)
}
return transformedData
}),
)
}
}
이제 클라이언트에게 보여줄 사용자 정보가 담긴 UserResponseDto
를 정의하고...
// user-response.dto.ts
import { PickType } from '@nestjs/swagger'
import { User } from 'src/entities/user.entity'
export class UserResponseDto extends PickType(User, [
'id',
'name',
'email',
] as const) {}
컨트롤러에 인터셉터를 적용하고...
// user.controller.ts
// ...
export class UserController {
// ...
@Get(':id')
@UseInterceptors(new ResponseInterceptor(UserResponseDto))
async getUser(@Param('id') userId: string) {
await this.userService.getUser(userId);
}
}
실행해 보면... 아까랑 같은 결과가 나옵니다. 왠지 여전히 secret
필드를 넣어서 보내 주고 있는 거죠.
@Expose()
사용하기
이런 문제가 생기는 원인은 plainToInstance
가 변환 대상이 되는 클래스에 없는 필드가 plain object에 있다고 해서 삭제하지는 않기 때문입니다.
특정 필드를 변환 과정에서 삭제하려면 그 필드를 exclude
해야 합니다. class-transformer
는 기본적으로 클래스나 필드에 달 수 있는 @Exclude()
와 @Expose()
데코레이터를 제공하고, plainToInstance
의 설정 기본값은 @Exclude()
가 붙어 있지 않은 모든 필드를 노출하는 거죠.
이 기본 설정을 그대로 둔 상태로 User
클래스의 secret
에 @Exclude()
를 적용하는 방법도 있겠지만, secret
을 반환해야 하는 API를 따로 만들 일이 있을지도 모르니 그리 마음에 들지 않습니다. 그러니 plainToInstance
의 strategy
를 기본값인 exposeAll
대신 excludeAll
로 바꿔 주고, 각 DTO 클래스에서 어떤 필드를 클라이언트에게 노출할지 @Expose()
로 알려 주는 게 좋겠어요.
// response.interceptor.ts
// ...
@Injectable()
export class ResponseInterceptor<T extends object>
implements NestInterceptor<any, T>
{
constructor(private readonly dto: new () => T) {}
intercept(context: ExecutionContext, next: CallHandler) {
// ...
const transformedData = plainToInstance(
this.dto,
instanceToPlain(data),
// 변환 시 @Expose()가 붙어 있지 않은 모든 필드를 삭제합니다.
{ strategy: 'excludeAll' }
)
// ...
// user-response.dto.ts
import { PickType } from '@nestjs/swagger'
import { Expose } from 'class-transformer'
import { User } from 'src/entities/user.entity'
export class UserResponseDto extends PickType(User, [
'id',
'name',
'email',
] as const) {
@Expose()
id: string;
@Expose()
name: string;
@Expose()
email: string;
}
여기까지 하고 나니 계획대로 userRepository
에서 반환된 User
객체를 컨트롤러에서 그대로 반환해도 클라이언트에는 id
, name
, email
만 전송되게 되었습니다. 하지만 이번엔 UserResponseDto
가 너무 길어져서 처음이 더 나을 지경이네요.
그래서 정해진 필드 몇 개만 모아서 @Expose()
할 수 있는 데코레이터를 하나 더 만들었습니다.
@ExposeFields()
데코레이터
간단하게 필드 이름의 목록을 인수로 넣으면 그 필드에 @Expose()
를 붙여 주는 데코레이터입니다.
// expose-fields.decorator.ts
import { Expose } from 'class-transformer'
export default function ExposeFields<
T extends object,
// 필드 이름이 아닌 값을 넣지 못하도록 keyof T를 extend해 줍니다.
// 단순히 keyof T를 사용하면 숫자 인덱스 접근자도 포함하는데,
// PropertyDecorator는 (target: Object, propertyKey: string | symbol) => void로 정의되기 때문에
// 필드에 Expose()를 적용하는 과정에서 타입이 충돌합니다.
K extends keyof T & (string | symbol),
>(fields: readonly K[]) {
return function (constructor: new (...args: any[]) => T) {
fields.forEach((field) => {
Expose()(constructor.prototype, field)
})
}
}
그러면 이제 UserResponseDto
를 훨씬 간결하게 만들 수 있죠.
// user-response.dto.ts
import { PickType } from '@nestjs/swagger'
import { ExposeFields } from 'src/decorators/expose-fields.decorator'
import { User } from 'src/entities/user.entity'
@ExposeFields(['id', 'name', 'email'] as const)
export class UserResponseDto extends PickType(User, [
'id',
'name',
'email',
] as const) {}
사실 이제는 PickType
을 쓰지 않고 바로 User
를 extend해도 알아서 id, name, field
만 걸러져서 응답 데이터로 전송됩니다! 이젠 DTO에 @ExposeFields
를 달고 요청 핸들러에 @UseInterceptors
를 달기만 하면 서비스 구현체에서 User
를 바로 반환하더라도 문제없어요. 다만 그렇게 하면 Swagger 문서에 나오는 필드와 실제 반환되는 필드가 서로 달라질테니 저는 PickType
을 그대로 두기로 했습니다.
// user-response.dto.ts
import { PickType } from '@nestjs/swagger'
import { ExposeFields } from 'src/decorators/expose-fields.decorator'
import { User } from 'src/entities/user.entity'
@ExposeFields(['id', 'name', 'email'] as const)
export class UserResponseDto extends User {}
Dani Soohan Park (@heartade)
Follow this blog at Fediverse: @heartade@blog.heartade.dev
Follow my shorter shoutouts at Fediverse: @heartade@social.silicon.moe
Follow me at Bluesky: @heartade.dev