NestJS에서 응답객체에 어떻게 접근할까? (feat. Interceptor)

DatQueue·2023년 1월 26일
4

NestJS _TIL

목록 보기
5/12
post-thumbnail

시작하기에 앞서

간만에 글을 쓰게 되었다. 이번 글은 지식 전달의 목적이라기보단 nestJS를 통해 특정 기능을 구현하던 중 부딪히게 되었던 에러와 그 에러를 해결하기 위한 과정, 그리고 그때 느꼈던 "nest"란 프레임워크를 어떻게 잘 다룰수 있는가에 대한 생각들을 정리하는 글이 될 것이다.

생각을 주저리주저리 풀기에 앞서 항상 많은 도움을 주신 개발바닥 2사로 "허재"님을 shout out 하고 시작하겠다...


문제 상황


발생한 사건들을 단계적으로 알아보자.

구현하고자 하는 것

Entity로 만든 특정 클래스 객체에 대해 컨트롤러 라우터 응답 시 특정 프로퍼티를 제외하고 응답으로 보내주고자 한다.

유저 엔티티에서 password 프로퍼티를 라우터 응답 시에 보내주지 않을 것이다. password 같이 보안 상 민감한 일부 필드는 비지니스 로직에서는 사용하되, HTTP 응답에서는 제외해야할 필요성이 있기 때문이다.

위는 구현하고자 하는 상세내용일 것이고, 근본적 요구는 "어떻게 http 응답 객체에 접근해 변형 및 제어를 시키는가" 일 것이다. 해당 과정을 구현해보고자 한다.


User Entity

import { Exclude } from "class-transformer";
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  first_name: string;

  @Column()
  last_name: string;

  @Column({unique: true})
  email: string;

  @Column()
  @Exclude()         // class-transformer로 부터 불러온 @Exclude() 데코레이터 사용
  password: string;
}

제외하고자하는 특정 필드(프로퍼티)에 @Exclude() 데코레이터를 주입해준다.

여기서 끝난것이 아니다. 컨트롤러로 가서 해당 password를 제외한 유저에 대한 http 응답을 받기 위한 해당 요청으로 가서 필요한 데코레이터를 또 주입시켜야 한다.


auth-controller


import { (생략) ClassSerializerInterceptor, UseInterceptors } from '@nestjs/common';

	// 생략 

  @UseInterceptors(ClassSerializerInterceptor)  //  --> 이 데코레이터 추가
  @Get('user')
  async user(@Req() req: Request, @Res() res: Response): Promise<any> {
    try {
      const cookie = req.cookies['jwt'];

      const data = await this.jwtService.verifyAsync(cookie);

      const verifiedUser = await this.userService.findUserById({id: data['id']})

      return res.send(verifiedUser);   /// --> password를 제외한 응답 객체 기대 중
    } catch(err) {
      if (err.name === 'TokenExpiredError') {
        return res.status(419).json({
          code: 419,
          message: '토큰이 만료되었습니다.'
        })
      }
      return res.status(401).json({
        code: 401,
        message: "유효하지 않은 토큰입니다.",
      })
    }
    
  }

간단히 코드 설명을 하자면 쿠키 요청에 따른 토큰 인증을 한 유저의 id를 통해 verifiedUser 즉, 엔티티의 유저 객체를 찾고 return res.send(verifiedUser)를 통해 json 형태로 응답해주는 코드이다.

이때, 우리는 password를 제외한 응답 객체를 리터럴(json) 객체로 받길 기대하고 있고 그에 따라 해당 요청 라우터 앞에 ClassSerializerInterceptor를 인자로 받는 @UseInterceptors(ClassSerializerInterceptor) 해당 데코레이터를 주입시켜준다.

nestjs에선 "라이프 사이클"이란 중요한 개념이 존재한다. 해당 개념은 추후 따로 다루도록 하겠고 여기서 중요한것은 라이프 사이클중 "response object(응답 객체)"를 관리할 수 있는 위치가 어디냐는 것이다.

바로 우린 interceptor 차원에서 이를 관리해줄 수 있다.

"ClassSerializerInterceptor"는 이러한 역할을 도와주는 nestJS의 빌트인 기능이다.

작동 방식은 다음과 같다.


ClassSerializerInterceptor 클래스에서 HTTP 응답을 인터셉트하여(중간에서 낚아채어) class-transformerinstanceToPlain() 함수를 호출해 JSON 직렬화 후 반환하는 과정(Serialization)을 가지게 된다.
그 때, @Exclude 데코레이터가 주입된 필드를 읽게 되면 해당 필드를 제외하고 JSON 직렬화를 시키는 것이다.


이 방법은 nest 공식 문서에서 docs.nestjs __Serialziation 제시하는 방법이다.

결과 확인

postman에서 해당 요청 라우터로 접속해 응답을 날려봤을때 당연히 기대했던 결과가 나오길 원했지만 password 필드는 사라지지않고 전체 프로퍼티가 전부 담긴 유저객체가 찍히게 되었고, 로컬에선 아래와 같은 에러가 발생하였다.

해당 에러메시지의 경로에도 나오듯이 위 에러는 socket 통신에서 주로 나오게 되는 오류였고, 구글링을 해봤을때도 socket과 관련한 에러로 발생하는 경우가 많았다. 하지만, 난 socket은 전혀 사용하지도 않았고... 이게 뭐지... 하면서 몇 시간동안 삽질을 하였다.

또한 아래의 에러문구를 살펴보면 Node.js 내부의 잘못된 사용에 의한 Node.js의 버그라고 알려준다.

This is caused by either a bug in Node.js or incorrect usage of Node.js internals.

해당 문구에 대한 구글링을 한 결과 대부분 "버전" 충돌에 의한 문제란 말이 많았고, 일단 그것을 염두해둔 채 또 삽질을 시작해나갔다.


몇 가지 시도와 실패

먼저, 처음 해본 시도는 "글로벌 인터셉터 등록" 이다.
물론 이것이 근본적인 위의 에러에 대한 해결책은 아닐거지만 혹여나 데코레이터 주입 시의 에러일 경우도 존재하므로 전역으로 앱 초기화시에 인터셉터를 등록해보았다.

import { ClassSerializerInterceptor } from '@nestjs/common';
import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  // useGlobalInterceptors() 메서드 사용
  app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
  await app.listen(5000);
}
bootstrap();

전역으로 세팅한만큼 각 요청라우터에 @UseInterceptors(ClassSerializerInterceptor)를 의존성으로 주입해 줄 필요는 없다. 컨트롤러 자체에 주입시킬 필요또한 없다.

그렇게 다시 세팅 후 postman에서 해당 경로에 요청을 보내면 어떻게 될까?

뜨헉.... 처음 각 라우터에 적용했을때와 동일한 에러가 또 발생하였다.


사실 이것도 능력 한계이지만, 아직까지도 그렇고 코드 자체엔 도저히 어떠한 문제가 없다고 판단하여 이번엔 에러를 불러일으킨 UseInterceptors 함수와 ClassSerializerInterceptor 클래스가 담긴 @nestjs/common의 버전을 다운그레이드도 시켜보고 업그레이드도 시켜보았다. 현재 버전이 stable하지 않을수도 있기 때문이다.

하지만 ... 이 역시 동일한 에러발생을 막진 못하였다.


중간 생각정리

도저히 에러를 해결할 수 없었고, 결국 해당 기능(특정 필드 제외)을 구현하는데 있어 nest가 제공하는 데코레이터 주입 및 방식을 사용할 순 없다고 판단하였다. (이러한 판단을 하는데까지 위에서 언급한 "허재(은인님)"님의 도움이 있었다.)

결국 플랫폼 의존적인 경우엔 이런 문제가 일어날 수 있다는 것을 깨달았다. 물론, 본인이 제대로 nest가 제시하는 구현 법에 어긋나게 코드를 작성하였기에 때문에 그럴 수도 있다. 혹은 정말 nest와 nodejs의 버전 충돌 문제일 수도 있다.

( 해당 시점에선 직접적 원인에 대해 파악하지 못한 상황이다. 모든 상황을 고려하여 진행한다. )

여하튼 여기서 중요한 것은 해당 기능을 구현하는데 해당 기능을 nest에서 구현하는데있어 nest의 내장 기능을 사용하는 순간 편리해짐과 동시에 굉장히 "플랫폼 의존적"이된다. 이것이 의미하는 것은 "양날의 검"이다. 편리해진다는 점과 동시에 플랫폼이 제공하는 대로 정확히 지키지 못할 경우 혹은 버전이 어긋날경우 에러를 발생시킬 수 밖에 없다.

무조건 플랫폼-의존적인 방법을 지양하는 것이 좋다라고 말할 순 없다. 사실 그렇다면 플랫폼(프레임워크나 라이브러리)을 사용하는 이유가 없기 때문이다. 하지만 분명 거리를 두는 법을 익힐 필요는 있다 생각이 든다. 아래서 부터 시작될 문제 해결 과정에서는 nest의 방식에 국한되지 않는 조금은 원천적인 방법과 더불어 커스텀하게 인터셉터를 만드는 방식을 진행하도록 할 것이다.

( 커스텀 인터셉터 설명 과정에서 직접적 에러의 원인이 밝혀지게 됩니다. 그전까진 모든 사고가 열려있는 상태에서 진행합니다. )


문제 해결 과정 및 분석


접근 1)

먼저 우린 엔티티의 password 필드에 주입해준 @Exclude에 하나의 옵션을 줄 필요가 있다. 바로 { toPlainOnly: true } 라는 옵션을 추가해준다.

해당 toPlainOnly 옵션을 이용하게 되면 한 방향으로 가게끔만 특정 프로퍼티를 제한할 수 있다. 이 말이 의미하는 것은 DTO를 일반 json 리터럴 객체로 변환할 때 @Exclude에 의해 해당 속성은 제한하고 js object 로써만 의미있게 한다는 것이다.

정확하게 얘기하자면 js object 일 경우엔 해당하지 않지만, plain (json) object 로 convert할 경우에만 위의 @Exclude를 적용한다는 것이다. 조금 더 제한시키는 옵션이라 생각하면 좋다.

여기서 끝나는 것이 아니다. 우린 해당 필드 (password)를 json 직렬화 시 제외한다는 것을 컨트롤러의 요청 라우터에서 알게끔 해야한다. 이 부분을 바로 "플랫폼 의존적"에 구애받지 않고 구현하자는 것이다.

우리는 해당 가공(기능)또한 엔티티에서 메서드로써 작성해줄 수 있다.

instanceToPlain() 이란 함수를 이용할 것이다. 해당 함수역시 class-transformer에서 제공한다.
앞전에 언급했다시피 아얘 플랫폼(nest)이 제공해주지 않는 방법을 사용하자는 것이 아니다. 너무 의존적인 방법에서 벗어나자는 것을 지향한다는 뜻이다.

instanceToPlain() 함수는 아래와 같이 정의된다.

/**
 * Converts class (constructor) object to plain (literal) object. Also works with arrays.
 */
export declare function instanceToPlain<T>(object: T, options?: ClassTransformOptions): Record<string, any>;
export declare function instanceToPlain<T>(object: T[], options?: ClassTransformOptions): Record<string, any>[];

해당 함수는 클래스 객체를 리터럴 객체( json )로 컨버팅해준다.

그럼 한 번 위의 기능을 추가한 User Entity를 확인해보자.


// user.entity.ts

import { Exclude, instanceToPlain } from "class-transformer";
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  first_name: string;

  @Column()
  last_name: string;

  @Column({unique: true})
  email: string;

  @Column()
  @Exclude({toPlainOnly: true})  
  password: string;

  toJSON() {                       // 컨트롤러의 응답메서드의 결과에 첨가해줄 메서드
    return instanceToPlain(this)   // instanceToPlain() 함수 사용
  }
}

엔티티에서 해당 코드를 추가해준뒤 컨트롤러 해당 경로의 응답객체 리턴 시에 호출시켜주면 된다. (이것은 플랫폼에 의존적이지 않은 원천적인 접근 법이다.)

// auth.controller.ts 

// 생략

 @Get('user')
  async user(@Req() req: Request, @Res() res: Response): Promise<any> {
    try {
      const cookie = req.cookies['jwt'];

      const data = await this.jwtService.verifyAsync(cookie);

      const verifiedUser = await this.userService.findUserById({id: data['id']})
      console.log(verifiedUser);

      return res.send(verifiedUser.toJSON());  // toJSON() 메서드 호출
    }catch(err) {
      if (err.name === 'TokenExpiredError') {
        return res.status(419).json({
          code: 419,
          message: '토큰이 만료되었습니다.'
        })
      }
      return res.status(401).json({
        code: 401,
        message: "유효하지 않은 토큰입니다.",
      })
    }
    
  }

여기서 verifiedUser 객체 자체를 호출해보면 아래와 같다.

User {
  id: 5,
  first_name: 'c',
  last_name: 'c',
  email: 'cxxxxxxxx@gmail.com',
  password: '$2b$12$kp.Xr0Ga7si1YjIkYI155OFdL5/BslAUCy1WJhghAxMH5OWDYC4Ze'
}

이것은 User 토큰 검증을 받은 유저 클래스 자체이고 위와 같이 암호화가 된 password 속성까지 함께 포함되어있는 것을 알 수 있다. 물론 암호화가 되었지만 그렇다고 보안에 완벽한 것은 절대 아닐 것이다.

하지만, 우리가 엔티티에서 추가해준 toJSON()메서드를 호출해주면

응답 객체를 JSON 직렬화 시 password를 제외한 필드만 반환한 것을 볼 수 있다.

우린 이렇게 http 응답 시에 특정 필드를 내보내지 않는 방법을 구현할 수 있게 되었다.


접근 2) + "toJSON()" 에 대한 진실

두 번째 접근은 "접근 1"과 동일한 방법이다. 하지만 처음 접근법은 객체지향적으로 접근했을 때, 보일러 플레이트한 코드들을 발생시킬 수 있다. toJSON() {return instanceToPlain()} 해당 코드는 재사용 가능하게끔 추상화시켜서 따로 분리하면 더 좋을 것이다. 그렇게 하더라도 해당 추상화 클래스를 유저 클래스가 상속만 하게끔 한다면 상태와 가공을 분리시키진 않게끔 할 수 있다.

// user.entity.ts

import { Exclude } from "class-transformer";
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
import { ResponseModel } from "./user.response";

@Entity('users')
export class User extends ResponseModel{   // ResponseModel을 상속받음
  constructor() {
    super();
  }
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  first_name: string;

  @Column()
  last_name: string;

  @Column({unique: true})
  email: string;

  @Column()
  @Exclude({toPlainOnly: true})  
  password: string;

  // toJSON() {
  //   return instanceToPlain(this)
  // }
}

아래는 toJSON() 메서드를 분리시킨 클래스이다.

// user.response.ts

import { instanceToPlain } from "class-transformer";

export class ResponseModel {
  toJSON() {
    return instanceToPlain(this);
  }
}

위의 User 엔티티는 해당 ResponseModel을 확장받아 toJSON()을 가질 수 있게 된다.

컨트롤러는 동일하게 아래와 같이 작성해준다.

  @Get('user')
  async user(@Req() req: Request, @Res() res: Response): Promise<any> {
    try {
      const cookie = req.cookies['jwt'];

      const data = await this.jwtService.verifyAsync(cookie);

      const verifiedUser: User = await this.userService.findUserById({id: data['id']})

      return res.send(verifiedUser.toJSON());
    }catch(err) {
      if (err.name === 'TokenExpiredError') {
        return res.status(419).json({
          code: 419,
          message: '토큰이 만료되었습니다.'
        })
      }
      return res.status(401).json({
        code: 401,
        message: "유효하지 않은 토큰입니다.",
      })
    }
  }

동일하게 로그인을 진행 한 유저 데이터에 있어서 토큰 인증을 하게 되고, 그 때 응답 리터럴 객체로써 password를 제외하게 된다.

그런데 여기서 정말 하나의 단순환 변화를 일으켜 보았다.

기존에는 응답으로 아래와 같이 보냈었지만

return res.send(verifiedUser.toJSON());

이번엔 toJSON()메서드를 제외하고 아래와 같이 작성해보았다.

return res.send(verifiedUser);

toJSON()은 js 객체를 JSON-리터럴 객체로 컨버팅해주는 instanceToPlain()을 담고 있는 메서드이다. 그렇다면 이 메서드를 제거하면 우리가 얻고자 하는 "password를 제외한 나머지 필드에 대한 http 응답 객체"에 다가가지 못하지 않을까 생각했다.

하지만 결과는 동일했다. 이 역시 password 필드를 제거하고 응답을 띄웠다.

사실 위의 현상은 다른 분들께선 쉽게 이해하고 넘어가실 수 있지 모르겠지만, 본인으로썬 굉장히 당황스러웠다. 분명 toJSON()을 만든 이유가 있고 해당 메서드가 우리가 구현하고자 하는것에 대한 결정적 메서드인데 왜 제거해도 동일한 결과를 내는거지? 라는 생각에 잠시 멍해졌었다.

그냥 어? 굳이 toJSON()을 호출안해도 상관없네!! 하고 넘어갈 수도 있었겠지만 도저히 그럴 수가 없었다.
곰곰히 생각해보기로 하였다.

그럼 결국 "접근1"로 하였을때, 즉, 따로 메서드에 대한 추상화 클래스를 구현하지 않았을때도 동일하게 작용할 것이다.

// user.entity.ts

import { Exclude, instanceToPlain } from "class-transformer";
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  first_name: string;

  @Column()
  last_name: string;

  @Column({unique: true})
  email: string;

  @Column()
  @Exclude({toPlainOnly: true})  
  password: string;

  toJSON() {                       
    return instanceToPlain(this)  
  }
}

아! 참고로 @Exclude()에 option으로 지정해준 {toPlainOnly: true}는 말 그대로 옵션이다. 해당 옵션이 없다해서 우리가 원하는 결과를 보여주지 않는 것은 아니다.

여기에 대해 잠깐 알아보고 넘어가자.

import { ExcludeOptions } from '../interfaces';
/**
 * Marks the given class or property as excluded. By default the property is excluded in both
 * constructorToPlain and plainToConstructor transformations. It can be limited to only one direction
 * via using the `toPlainOnly` or `toClassOnly` option.
 *
 * Can be applied to class definitions and properties.
 */
export declare function Exclude(options?: ExcludeOptions): PropertyDecorator & ClassDecorator;

위의 주석(def)에도 나와있듯이 options의 프로퍼티로 쓰이는 toPlainOnlytoClassOnlyExcludeOptions를 타입으로 가지며 "Plain -> Class" or
"Class -> Plain"으로 가는 방향에 있어 제한을 걸 뿐이다.

우리는 구현하고자하는 것이 http 응답에 있어 특정 필드를 제외시키고자 하므로 "Class(instance) -> Plain"으로 가는것에 대한 방향만 지정한다는 의미에서 toPlainOnly를 사용한 것이라 생각하면 좋다.

아무생각없이 그냥 예제를 보고 옵션을 때려박는 것보단 정확한 사용이유를 찾아보는 과정도 중요하다 생각하기에 잠시 넣어보았다.


자, 다시 본론으로 넘어와 "응답을 내보내는 유저객체 호출에 있어 왜 toJSON()메서드를 호출해주지 않아도 동일한 결과(password 응답 제외)를 얻을까" 에 대해 알아보자.

💨toJSON()에 대한 진실

이 부분은 뭔가 어이가 없으면서도 동시에 처음알게 된 사실이었다. 타이틀에서도 말하듯이 "toJSON()의 진실"에 대해 설명해보고자 한다.

사실 toJSON()이란 메서드를 통한 방법은 이 글의 가장 처음에 언급했던 도움을 주신 분께서 제시해주셨던 방법이다. 하지만 toJSON() 메서드는

import { Exclude, instanceToPlain } from "class-transformer";
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";

@Entity('users')
export class User  {

  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  first_name: string;

  @Column()
  last_name: string;

  @Column({unique: true})
  email: string;

  @Column()
  @Exclude({toPlainOnly: true})  
  password: string;

  toJSON() {                    // 메서드일 뿐
    return instanceToPlain(this)
  }
}

단지 instanceToPlain()을 실행시키기 위해 엔티티 클래스에 정의해 준 "클래스 메서드"일 뿐 toJSON()은 특정한 js 혹은 nest에서 제공하는 아니다.
(물론 추후 밝혀지게 될 사실에 의하면 반은 맞고 반은 틀리다..)

단지 메서드(함수)명의 행위와 가독성에 따라 toJSON()이라 이름을 지어주었는 것 뿐이다. 하지만 신기하게도 해당 메서드를 호출하지 않아도 클래스를 호출할 시 자동으로 toJSON() 내부 로직 (반환문)이 실행되었다...

서론이 너무 길었다. 해당 원인을 밝혀보자.

해답은 mdn 공식문서에서 찾을 수 있었다.
<출처 : JSON.stringify() -- toJSON()>

If an object being stringified has a property named toJSON whose value is a function, then the toJSON() method customizes JSON stringification behavior: instead of the object being serialized, the value returned by the toJSON() method when called will be serialized. For example:

var obj = {
  foo: 'foo',
  toJSON: function() {
    return 'bar';
  }
};
JSON.stringify(obj);        // '"bar"'  <-- toJSON()에서 리턴된 값 직렬화되며 호출
JSON.stringify({ x: obj }); // '{"x":"bar"}'

간단히 해석해보자면 문자열화되는 객체가 있고, 그 객체에 toJSON이란 이름을 가지는 "함수" 프로퍼티가 존재할 경우, 해당 toJSON() 메서드는 JSON 문자열화를 수행하는데 있어 커스터마이징을 할 수 있게 된다는 것이다. 즉, JSON 리터럴 객체로 직렬화하는 과정에서 객체가 직렬화되는 대신 해당 toJSON() 내의 리턴문이 직렬화 시 불려지는 것이다.

여기서 한가지 특징이 있는데 toJSON()JSON.stringify() (javascript에서 JSON 직렬화를 하는 메서드)를 통해서 구현된다.

항상 모든 데이터 자체를 전역적으로 직렬화할 순 없을 것이다. 우리가 구현하고자 하는 "민감한 데이터 응답 시 제거" 또는 "계산된 속성 추가" 등과 같은 사용자 지정이 필요할 수 있는 상황이 존재한다. 이럴 때 toJSON()을 이용할 수 있는 것이다.

우리는 User 엔티티를 응답으로 보내주는 과정에서 JSON.stringify()를 직접 명시해주진 않았지만

const verifiedUser: User = await this.userService.findUserById({id: data['id']})

return res.send(verifiedUser);

위와 같이 res.send()를 통해서 보내주었다. nest에서 내부적으로 데이터를 send()를 통해 보내주면 JSON.stringify()를 실행해 문자열로 바꿔준다. 즉, 위에서 설명한 모든 과정이 이렇게 구현되는 것이다.

해당 구현체는 하나의 라우터 응답이 아니라 해당 객체를 JSON 직렬화하는 모든 경우에서 동일하게 적용된다.


접근 3) Custom Interceptor 생성

💨 소개

세 번째 접근은 1, 2번 접근과는 다른 방식이다.

이 포스팅을 작성하게 된 발단으로 돌아가자면 nest에서 제공하는 ClassSerialiazeInterceptor를 사용하는 과정에서 알 수 없는 에러가 계속하여 생겼고, 그로 인해 해당 인터셉터를 사용하는 것에 차질이 생겼기 때문이다.

넓은 관점에서 보면 인터셉터를 생성 후 라우터 혹은 컨트롤러 단위에 주입(Decorator로써)하는 것이 위에서 소개한 1, 2번 접근보다 조금 더 객체지향적으로 접근하게 된다. 조금 더 명시적으로 코드를 이해할 수 있고, 기능 구현을 아얘 외부(인터셉터 클래스)로 맡긴 후 DI(의존성 주입)를 통해 실현시켜 주므로 의존성 측면에서도 좋다고 본다.

하지만 더 중요한 관점은 "nest"의 시각에서 바라본 관점이라 생각한다. 애초에 응답 객체에서 특정 필드를 제거하는 법을 nest는 "인터셉터 주입"을 통해 해결하는 방법을 제시하였다.

위에서도 짧게 언급하였지만 nest엔 nest만의 "라이프 사이클" 이 존재하고 그 중 interceptor는 비즈니스 로직이 담겨있는 서비스와 컨트롤러가 실행되기 이전(Pre-Interceptor)과 이후(Post-Interceptor)를 거쳐가며 요청과 응답에 접근할 수 있게 된다. 이에 따라 nest는 "캐싱(Caching)", "로깅(Logging)", "직렬화(Serialization)", "응답-변환(Transforming)"등의 작업을 수행하는데 있어 인터셉트를 활용할 것을 제시(혹은 권장)한다.

위에서 우린 문제 발생을 통해 "프레임워크에 너무 의존적일 경우" 발생할(혹은 주의해야 할)문제점에 대해 얘기해보았다. 하지만 프레임워크를 사용하면서 해당 프레임워크가 제시하는 방법에서 최대한 자유로워지자!! 라는 뜻은 아니다. 그렇게 한다면 사실 "프레임워크"를 사용하는 이유가 없다고 생각한다. 뭐든지 적절한게 좋지 않을까 싶다. 상황에 따라 유연하게 구현을 해 볼 필요가 있다.

좀 주저리주저리 말이 많았는데 하고 싶은 말은 결국 "인터셉터(Interceptor)"를 사용하되 "커스텀"하게 인터셉터를 직접 만들어 사용할 필요가 있다는 것이다.

기존 요청 라우터에 주입시켰던 인터셉터를 통해 얘기하자면

@UseInterceptors(ClassSerializerInterceptor)

@nestjs/common 에서 받아온 ClassSerializerInterceptor 가 아닌, 직접 생성한 인터셉터를 넣어주는 것으로 구현할 수 있을 것이다.

시작해보자.

인터셉터 구현을 통해 해결해나가는 과정에서 발생한 에러와 해결과정 중심으로 진행한다.


💨 인터셉터 생성

먼저 코드부터 알아보자. (인터셉터에 대해 자세히 설명하진 않겠습니다.)

// serialize.inteceptor.ts

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { instanceToPlain } from "class-transformer";
import { Observable, timeout } from "rxjs";
import { map, tap } from "rxjs/operators";
import logger from "src/test-api/utils/log.util";
import { User } from "src/user/model/user.entity";

@Injectable()
export class ResponseSerializeInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> | Promise<Observable<any>> {
      logger.debug(`Request Accepted`);
      return next.handle().pipe(
        timeout(3000),
        map((data) => {
          if (isUser) {
            const alteredResponse = instanceToPlain(data);
            logger.debug(`Response Altered : ${JSON.stringify(alteredResponse)}`);
            return alteredResponse;
          }else {
            return data;
          }       
        }),
        tap(() => logger.debug(`After Handling, Response Sent`))
    )
  }
}

const isUser = (data: any): data is User => {
  return ('id' in data && typeof data.id === "number") 
    && ('first_name' in data && typeof data.first_name === "string")
    && ('last_name' in data && typeof data.last_name === "string") 
    && ('email' in data && typeof data.email === "string")
    && ('password' in data && typeof data.password === "string")
} 

위의 코드로써 ResponseSerializeInterceptor라는 커스텀 인터셉터를 구현하였다.

인터셉터는 nest에서 제공하는 NestInterceptor를 implements하게 되고 intercept()라는 함수를 구현한다. 해당 함수의 파라미터로는 ExecutionContextCallHandler를 받게 된다.

가드에서는 현재 실행중인 요청(request), 응답(response)에 접근하기 위해 canActivate 함수를 구현해 ExecutionContext에 접근하는 것을 알 것이다.

인터셉터는 요청, 응답 전 후로 접근하게 된다. 그렇기 때문에 두 번째 파라미터로 쓰인 CallHandler를 꼭 이용할 필요가 있다. CallHandler 인터페이스 구현채를 꺼내보면

export interface CallHandler<T = any> {
    /**
     * Returns an `Observable` representing the response stream from the route
     * handler.
     */
    handle(): Observable<T>;
}

다음과 같이 handle()이란 메서드를 지니고 있다. 해당 handle() 메서드를 통해 컨트롤러의 라우트 핸들러 메서드에 접근할 수 있게 되는 것이다. 만약, 작성하지 않는다면 접근 불가하다.

우리는 isUser일 경우에 우리가 원하는 기능을 수행하도록 로직을 구성하였다. isUser 함수 구현채는 Boolean값을 반환하며, 우리가 만든 User 객체(엔티티)에 필요한 데이터(필드)들이 전부 있는 경우(충족하는 경우), 더하여 모든 속성들이 원하는 타입을 만족하는 경우 true를 반환할 것을 기대한다. 그렇게 isUser이 true일 경우

const alteredResponse = instanceToPlain(data);
logger.debug(`Response Altered : ${JSON.stringify(alteredResponse)}`);
return alteredResponse;

dataclass-transformerinstanceToPlain() 함수를 통해 형 변환 시켜준다. (instanceToPlain()에 대한 설명은 생략하겠다.)

또한, 요청, 응답 전 후 그리고 handle() 메서드가 실행중인 경우, 인터셉터가 라우트 핸들러에 접근할때의 일련의 행위를 알아보기 위해 로깅 구문을 추가해주었다. 관찰 가능 스트림이 정상적으로 종료되거나 예외적으로 종료될 때 응답주기를 방해하지 않으면서 익명 로깅 기능을 호출하기 위해 tap() 연산자를 사용하였다.

이렇게 커스텀 인터셉터를 작성하였고 해당 인터셉터를 데코레이터로써 라우트 핸들러 메서드에 주입하였다.

@UseInterceptors(ResponseSerializeInterceptor)   // 인터셉터 주입
@UseGuards(AuthGuard) 
@Get('user')
async user(@Req() req: Request, @Res() res: Response): Promise<any> {
  try {
    const cookie = req.cookies['jwt'];
    const data = await this.jwtService.verifyAsync(cookie);
    const verifiedUser: User = await this.userService.findUserById({id: data['id']})

    return res.send(verifiedUser);
  } catch(err) {
      if (err.name === 'TokenExpiredError') {
        return res.status(419).json({
          code: 419,
          message: '토큰이 만료되었습니다.'
        })
      }
      return res.status(401).json({
        code: 401,
        message: "유효하지 않은 토큰입니다.",
     })
  }
}

💨 또다시 직면한 에러

이렇게 커스텀인터셉터를 @UseInterceptors()를 통해 주입시킨 후 포스트맨에서 해당 경로로 요청을 날려보니 예상했던 기대결과와는 다르게 password 필드는 제거 되지않았고, 로컬에선 아래와 같은 에러 메시지를 띄운다.

Error: This is caused by either a bug in Node.js or incorrect usage of Node.js internals.
Please open an issue with this stack trace at https://github.com/nodejs/node/issues

    at new NodeError (node:internal/errors:371:5)
    at assert (node:internal/assert:14:11)

"에러: Node.js의 버그 또는 Node.js의 잘못된 사용으로 인해 발생한다."

해당 에러를 접하고 원점으로 되돌아간 기분이었다.

가장 처음에 발생한 에러와 동일한 문구를 띄운것이다 !!!
(문제 상황 -- 결과확인 참고)

결국 가장 처음에 주입하였던 @UseInterceptors(ClassSerializerInterceptor)의 문제가 아니였던 것이다. nest에서 제공하는 ClassSerializerInterceptor를 쓰는 것에 있어 문제가 발생한 것이라 생각하였지만 커스텀 인터셉터를 직접 만들어 구현하여도 결국 동일한 에러를 발생시켰다.

원인은 생각보다 단순하고도 분명한곳에 있었다.

Nest_docs(공식문서)에선 다음과 같이 설명한다. (참조 Nest_docs - Interceptors)


Response mapping

We already know that handle() returns an Observable. The stream contains the value returned from the route handler, and thus we can easily mutate it using RxJS's map() operator.

( 우리는 이미 `handle()` 함수가 `Observable`을 반환한다는 것을 알고 있다. 스트림에는 라우트 핸들러에서 포함된 값이 포함되어있고, 우리는 그에따라 `RXJS`의 **`map()`** 연산자를 이용해 해당 값을 쉽게 변형시킬 수 있다. )

그리고 아래에 다음과 같은 경고 문구를 언급한다.

Warning:
The response mapping feature doesn't work with the library-specific response strategy (using the @Res() object directly is forbidden).

응답 매핑 기능은 library-specific 응답 전략과 함께 동작하지 않는다. ( Express의 `@Res()` 객체를 직접적으로 사용하는 것은 금지한다. )

( Library-specific approach 와 Standard approach에 대해 미리 보고 오는 것을 추천드립니다!! __[NestJS_docs--Controllers] )


이처럼 @Res() 객체를 사용하는 "Library-specific" 전략을 사용하게 될 경우 Express의 Response를 사용할 수 있게 되는 반면에, nest가 권장하는 "standard"한 방식을 사용하는 것에 제약이 생기게 된다. 인터셉터를 사용하지 못하게 되는 것도 이러한 이유이다.


💨 Standard 방식으로 수정해보기

"Standard" 방식을 사용하게 되면 @Res()를 사용할 수 없다. 즉, 컨트롤러에서 구현하였던 "try...catch 예외처리"문을 해당 경로에서 직접 사용할 순 없다고 판단하였다.

try {
    const cookie = req.cookies['jwt'];
    const data = await this.jwtService.verifyAsync(cookie);
    const verifiedUser: User = await this.userService.findUserById({id: data['id']})

    return res.send(verifiedUser);
} catch(err) {
  if (err.name === 'TokenExpiredError') {
    return res.status(419).json({
      code: 419,
      message: '토큰이 만료되었습니다.'
    })
  }
  return res.status(401).json({
    code: 401,
    message: "유효하지 않은 토큰입니다.",
  })
}

기존에 try...catch 문 내부에서 response 객체를 사용하였고, 이는 @Res()를 통해 불러온 것이다. 즉, 예외 처리문은 따로 실행 컨텍스트(Execution Context)를 불러올 수 있는 예외 필터로 따로 생성하기로 하였다.

// **수정된 컨트롤러**

@UseInterceptors(ResponseSerializeInterceptor)
@UseGuards(AuthGuard) 
@Get('user')
async user(@Req() req: Request): Promise<any> {
  const cookie = req.cookies['jwt'];
  const data = await this.jwtService.verifyAsync(cookie);
  const verifiedUser: User = await this.userService.findUserById({id: data['id']})

  return verifiedUser;
}

아래는 예외 필터(Exception Filters)이다.

import { ArgumentsHost, Catch, ExceptionFilter } from "@nestjs/common";

@Catch(Error) 
export class SerializeExceptionHandler implements ExceptionFilter {
  catch(exception: any, host: ArgumentsHost) {
      const ex = handlingException(exception)

      host.switchToHttp().getResponse()   // 응답 객체에 접근 가능
          .status(ex.code)
          .json({
              message: ex.message
          })
  }
}

interface ExceptionStatus {
  code: number;
  message: string;
}

const handlingException = (err: Error): ExceptionStatus => {
  if (err.name === 'TokenExpiredError') {
    return { code: 419, message: '토큰이 만료되었습니다.: ('}
  }else {
    return { code: 401, message: '유효하지 않은 토큰입니다: ('}
  }
}

토큰의 유효성에 대한 예외 필터를 작성 후 컨트롤러 단위 혹은 사용하고자 하는 라우터에 아래와 같이 적용할 수 있다.

@UseFilters(SerializeExceptionHandler)

💨 결과 및 동작 확인

  1. 로그인

    로그인 검증이 완료된 유저에 대해 응답을 보내주도록 할 것이다.


  1. 응답 객체 확인 (검증)
    위와 같이 password 프로퍼티는 제외된 User객체가 응답으로 보낸진 것을 확인할 수 있다.

  1. 로깅 확인 (검증)
    로컬에서 로깅을 통해 인터셉터의 일련의 동작을 확인해 보자.
    intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> | Promise<Observable<any>> {
        logger.debug(`Request Accepted`);
        return next.handle().pipe(
          timeout(3000),
          map((data) => {
            console.log(data);
            if (isUser) {
              const alteredResponse = instanceToPlain(data);
              logger.debug(`Response Altered : ${JSON.stringify(alteredResponse)}`);
              console.log(alteredResponse);
              return alteredResponse;
            }else {
              return data;
            }       
          }),
          tap(() => logger.debug(`After Handling, Response Sent`))
      )
    }
    로컬에 호출된 결과는 아래와 같다. ⬇⬇
    [Nest] 31856  - 2023. 01. 26. 오후 2:44:26   DEBUG [test-api] Request Accepted +1944ms
      User {
          id: 5,
          first_name: 'c',
          last_name: 'c',
          email: 'c01032762271@gmail.com',
          password: '$2b$12$kp.Xr0Ga7si1YjIkYI155OFdL5/BslAUCy1WJhghAxMH5OWDYC4Ze'
      }
      [Nest] 31856  - 2023. 01. 26. 오후 2:44:26   DEBUG [test-api] Response Altered : {"id":5,"first_name":"c","last_name":"c","email":"c01032762271@gmail.com"} +8ms
      {
          id: 5,
          first_name: 'c',
          last_name: 'c',
          email: 'c01032762271@gmail.com'
      }
      [Nest] 31856  - 2023. 01. 26. 오후 2:44:26   DEBUG [test-api] After Handling, Response Sent +3ms
    	

  1. 로깅 부가 설명

    라우트 핸들러에 접근하기 전과 접근(인터셉터 실행), 접근 후로 나뉘게 된다. 인터셉터 실행 전(instanceToPlain()문 실행 전) data를 호출해보면 User객체가 출력된 것을 확인할 수 있다.

    이것은 인터셉터가 응답 객체에 매핑된 것이라 볼 수 있다. 그 후 instanceToPlain() 함수가 실행이 되면
    password 필드를 제외한 유저 객체를 받아올 수 있게 된다. (위의 콘솔 창에서도 확인 가능하다.)


최종 생각 정리


nest에서 http 응답을 보낼 때, password와 같은 특정 필드를 제외하는 방법을 구현하고자 하였고 그런 와중에 마주하게 된 이슈들과 그것을 해결해나가는 과정이었다.

사실 테스트를 해보진 않았지만 처음 직면한 에러 또한 @Res() 객체를 사용한 "Library-specific" 전략을 사용했기 때문이라 생각한다.

이번 글은 모든 해결 과정을 다 마친 후에 쓴 것이 아닌 도중도중 기록하면서 작성해나갔다.
위의 해결 원인을 처음부터 알았다면 커스텀 인터셉터를 생성하는 과정을 겪어보지 못했을 수도 있고, 앞전의 다른 방법들도 구현해보지 못하였을 수도 있단 생각에 오히려 좋은것(?) 같다는 생각도 든다.

"중간 생각 정리" 에서도 언급하였지만 어떤 프레임워크나 라이브러리에 너무 의존된 사고를 가지는 것에 대해 한번쯤 생각할 필요가 있다고 다시 느꼈다. nest에서 응답 객체를 다룰 때 @Res()를 사용하게 되면 Express의 응답 객체를 그대로 사용할 수 있게 되지만 반면에 라이브러에 의존적이게 된다. nest의 standard한 기능을 사용하는데 제한이 걸릴 수 있다.

이러한 과정을 겪어보기 전엔 한번도 nest 프레임워크의 라이프 사이클 동안 어떠한 일련의 과정이 일어나고 그 과정안에서 프레임워크와 라이브러리들이 어떠한 의존성을 띄게 되고, 그것이 어떻게 독이 되거나 장점이 되거나 하는 것에 대한 생각을 깊게 해본 적이 없었다.

에러를 해결하는데 상당히 크게 돌아왔지만 덕분에 좋은 지식과 프레임워크를 대하는 올바른 사고에 대해 얻어가는거 같다.

더하여 코드에 있어서 일어나는 모든 문제 및 상황은 "공식문서" 속에 전부 담겨져 있을거라고 감히 생각도 해본다.

💢------ 끝 -----💢


정말 지루하게 긴 글 읽어주셔서 너무 감사합니다...

(허재님 감사합니다.)

profile
You better cool it off before you burn it out / 티스토리(Kotlin, Android): https://nemoo-dev.tistory.com

0개의 댓글