NestJS에서 응답 객체에 Class Transform 적용하기

표지 사진

오늘의 #뚝딱뚝딱#NestJS 이야기입니다.

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를 따로 만들 일이 있을지도 모르니 그리 마음에 들지 않습니다. 그러니 plainToInstancestrategy를 기본값인 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