NestJS - Interceptor


이 포스트는 NestJS 의 Interceptor 에 대해 알아본다.

소스는 example, user-service 에 있습니다.


1. Interceptor: @UseInterceptors

Interceptor 는 요청과 응답을 가로채서 변경을 가할 수 있는 컴포넌트이다.

<Interceptor 로 할 수 있는 기능>

  • 메서드 실행 전/후 추가 로직 바인딩
  • 함수에서 반환된 결과 변환
  • 함수에서 던져진 예외 변환
  • 특정 조건에 따라 기능 재정의 (예: 캐싱)

NestJS - Middleware 에서 본 Middleware 와 비슷하지만 수행 시점에 차이가 있다.

  • Middleware
    • 요청이 라우트 핸들러로 전달되기 전에 동작
    • 여러 개의 Middleware 를 조합하여 각기 다른 목적을 가진 Middleware 로직 수행 가능
    • 다음 Middleware 에게 제어권을 넘기지 않고 요청/응답 주기 종료 가능
  • Interceptor
    • 요청에 대한 하루트 핸들러의 처리 전/후 호출되어 요청과 응답을 다룰 수 있음

라우트 핸들러가 요청을 처리하기 전/후에 로그를 남기고 싶을 때 Interceptor 를 아래와 같이 활용할 수 있다.

$ nest new ch13

logging.interceptor.ts

import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {  // NestInterceptor 인터페이스 구현
  
  // NestInterceptor 인터페이스의 intercept 함수 구현
  intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> | Promise<Observable<any>> {
    
    // 요청이 전달되기 전 로그 출력
    console.log('Before log...');

    const now = Date.now();

    return next
      .handle()
      // 요청을 처리한 후 로그 출력
      .pipe(tap(() => console.log(`After log... ${Date.now() - now} ms`)));
  }
}

Interceptor 를 특정 컨트롤러나 메서드에 적용하고 싶다면 @UseInterceptors() 데커레이터를 이용하면 된다.

NestJS - Exception Filter2. 예외 필터: @Catch, @UseFilters 에 나오는 @UseFilters 데커레이터 사용법과 동일합니다.

지금은 전역으로 적용하여 로그를 확인해보도록 한다.

main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { LoggingInterceptor } from './logging.interceptor';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalInterceptors(new LoggingInterceptor());
  await app.listen(3000);
}
bootstrap();

이제 서버를 시작한 후 요청을 보내서 로그를 확인한다.

$ npm run start:dev

$ curl --location 'http://localhost:3000' | jq
curl --location 'http://localhost:3000' | jq

NestInterceptor 인터페이스의 시그니처는 아래와 같다.

export interface NestInterceptor<T = any, R = any> {
    /**
     * Method to implement a custom interceptor.
     *
     * @param context an `ExecutionContext` object providing methods to access the
     * route handler and class about to be invoked.
     * @param next a reference to the `CallHandler`, which provides access to an
     * `Observable` representing the response stream from the route handler.
     */
    intercept(context: ExecutionContext, next: CallHandler<T>): Observable<R> | Promise<Observable<R>>;
}

/**
 * Interface providing access to the response stream.
 *
 * @see [Interceptors](https://docs.nestjs.com/interceptors)
 *
 * @publicApi
 */
export interface CallHandler<T = any> {
  /**
   * Returns an `Observable` representing the response stream from the route
   * handler.
   */
  handle(): Observable<T>;
}

NestInterceptor 의 interceptor() 은 ExecutionContext, CallHandler 이렇게 2개의 인자를 받는다.

NestInterceptor 의 좀 더 상세한 내용은 Nest Interceptors 공식문서 를 참고하세요.

ExecutionContext 는 NestJS - Guard, JWT2.1. 실행 콘텍스트 에서 설명한 것과 동일한 컨텍스트이다.

CallHandler 인터페이스는 handle() 메서드를 구현해야 하는데 이 handle() 메서드의 역할은 라우트 핸들러에서 전달된 응답 스트림을 돌려주고 RxJS 의 Observable 을 리턴한다.
그렇기 때문에 만약에 Interceptor 에서 핸드러가 제공하는 handle() 메서드를 호출하지 않으면 라우터 핸들러가 동작하지 않는다.
handle() 을 호출하여 Observable 을 수신한 후 응답 스트림에 추가 작업을 수행할 수 있다.

logging.interceptor.ts

...
return (
    next
      .handle() // handle() 메서드 호출
      // 요청을 처리한 후 로그 출력
      .pipe(tap(() => console.log(`After log... ${Date.now() - now} ms`)))
  );

응답을 다루는 방법은 RxJS 에서 제공하는 다양한 메서드로 구현이 가능한데 위에선 tap() 을 사용함


2. 응답과 예외 매핑

Interceptor 를 통해 응답과 예외에 변형을 가하는 예시를 보자.

2.1. 응답 변형

아래는 라우터 핸들러에서 전달한 응답을 객체로 감싸서 전달하도록 하는 TransformInterceptor 예시이다.

transform.interceptor.ts

import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

export interface Response<T> {
  data: T;
}

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>>
{
  intercept(context: ExecutionContext, next: CallHandler<T>): Observable<Response<T>> | Promise<Observable<Response<T>>> {
    return next
        .handle()
        .pipe(map((data) => {
          return { data };
      }),
    );
  }
}

위에서 예시로 들었던 LoggingInterceptor 와 비교해보자.

logging.interceptor.ts

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  // NestInterceptor 인터페이스 구현

  // NestInterceptor 인터페이스의 intercept 함수 구현
  intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> | Promise<Observable<any>> {
...
  }
}

TransformInterceptor 는 LoggingInterceptor 와 다르게 TransformInterceptor 이렇게 Generic 으로 타입 T 를 선언하고 있다.

NestInterceptor 인터페이스의 시그니처를 보면 Generic 으로 T, R 타입 2개를 선언하도록 되어 있는데 둘 다 기본이 any 타입이라 어떤 타입이 와도 상관없다.

export interface NestInterceptor<T = any, R = any> {
    intercept(context: ExecutionContext, next: CallHandler<T>): Observable<R> | Promise<Observable<R>>;
}

T 는 응답 스트림을 지원하는 Observable 타입이어야 하고,
R 은 응답의 값을 Observable 로 감싼 타입을 정해주어야 한다. (타입스크립트를 통해 타입을 명확히 지정해주면 더 안전함)

TransformInterceptor 에선 T 는 any 타입이고, R 은 Response 를 지정하였다.

위의 Response 는 요구 사항에 맞게 정의한 타입인 data 속성을 갖는 객체가 되도록 강제하는 역할이다.

이 TransformInterceptor 을 useGlobalInterceptors 데커레이터를 통해 전역으로 적용한다.

main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { LoggingInterceptor } from './logging.interceptor';
import { TransformInterceptor } from './transform.initerceptor';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalInterceptors(
    new LoggingInterceptor(),
    new TransformInterceptor(),
  );
  await app.listen(3000);
}
bootstrap();

이제 서버 시작 후 요청을 보내본다.

$ npm run start:dev

$ curl --location 'http://localhost:3000' | jq
{
  "data": "Hello World!"
}
Before log...
After log... 2 ms

2.2. 예외 매핑

라우트 핸들링 중 발생한 예외를 잡아 모두 400 BadRequest 로 변환해보도록 한다. 예외를 변환하는 것은 ExceptionFilter 를 사용하는 것이 좋지만 Interceptor 를 통해서도 가능하다는 정도만 알아두자.

error.interceptor.ts

import {
  BadRequestException,
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { catchError, Observable, throwError } from 'rxjs';

@Injectable()
export class ErrorInterceptor implements NestInterceptor {
    intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> | Promise<Observable<any>> {
        return next
            .handle()
            .pipe(
                catchError(err => throwError(() => new BadRequestException()))
            )
    }
}

위 Interceptor 는 전역이 아닌 GET / 엔드포인트에 적용해본다.

@UseInterceptors(ErrorInterceptor)
@Get()
getHello(): string {
  throw new InternalServerErrorException();
  return this.appService.getHello();
}
$ curl --location 'http://localhost:3000/' | jq

{
  "statusCode": 400,
  "message": "Bad Request"
}
Before log...

3. 유저 서비스

요청을 처리하기 전 HTTP 메서드와 URL 를 로그로 남기고, 응답 시 HTTP 메서드와 URL, 응답 결과를 로그로 남기는 Interceptor 를 적용해본다.


3.1. Interceptor 적용

/src/logging/logging.interceptor.ts

import {
  CallHandler,
  ExecutionContext,
  Injectable,
  Logger,
  NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  constructor(private logger: Logger) {}

  intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> | Promise<Observable<any>> {
    // 실행 콘텍스트에 포함된 첫 번째 객체를 가져옴 (이 객체에로부터 요청 정보 얻을 수 있음)
    const { method, url, body } = context.getArgByIndex(0);
    this.logger.log(`Request to ${method} ${url}`);

    return next
      .handle()
      .pipe(
        tap((data) =>
          this.logger.log(
            `Response from ${method} ${url} \n response: ${JSON.stringify(data)}`,
          ),
        ),
      );
  }
}

위 Interceptor 를 main.ts 에 바로 적용하는 것이 아니라 LoggingModule 로 분리하여 AppModule 에 적용하도록 한다.

/src/logging/logging.module.ts

import { Logger, Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { LoggingInterceptor } from './logging.interceptor';

@Module({
  providers: [
    Logger,
    { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
  ],
})
export class LoggingModule {}

app.module.ts

import { LoggingModule } from './logging/logging.module';

@Module({
  imports: [
    ...
    LoggingModule,
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

이제 유저 정보 조회 요청을 해본다.

오류 발생 시

$ curl --location 'http://localhost:3000/users/01GXN39KWVPKV7WZR5XFD0A5FH' | jq
{
  "statusCode": 500,
  "message": "Internal Server Error"
}
{
  "timestamp": "2023-04-23T07:15:01.645Z",
  "url": "/users/01GXN39KWVPKV7WZR5XFD0A5FH",
  "response": {
    "statusCode": 500,
    "message": "Internal Server Error"
  },
  "stack": "TypeError: Cannot read properties of undefined (reading 'split')\n    at AuthGuard.validateRequest (/Users/05_nestjs/me/user-service/src/auth.guard.ts:17:53)\n    at AuthGuard.canActivate (/Users/user-service/src/auth.guard.ts:12:17)\n    at GuardsConsumer.tryActivate (/Users/Developer/05_nestjs/me/user-service/node_modules/@nestjs/core/guards/guards-consumer.js:15:34)\n    at canActivateFn (/Users/Developer/05_nestjs/me/user-service/node_modules/@nestjs/core/router/router-execution-context.js:134:59)\n    at /Users/Developer/05_nestjs/me/user-service/node_modules/@nestjs/core/router/router-execution-context.js:42:37\n    at /Users/Developer/05_nestjs/me/user-service/node_modules/@nestjs/core/router/router-proxy.js:9:23\n    at Layer.handle [as handle_request] (/Users/Developer/05_nestjs/me/user-service/node_modules/express/lib/router/layer.js:95:5)\n    at next (/Users/Developer/05_nestjs/me/user-service/node_modules/express/lib/router/route.js:144:13)\n    at Route.dispatch (/Users/Developer/05_nestjs/me/user-service/node_modules/express/lib/router/route.js:114:3)\n    at Layer.handle [as handle_request] (/Users/Developer/05_nestjs/me/user-service/node_modules/express/lib/router/layer.js:95:5)"
}

정상 요청 시

$ curl --location 'http://localhost:3000/users/01GXN39KWVPKV7WZR5XFD0A5FH' \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjAxR1hOMzlLV1ZQS1Y3V1pSNVhGRDBBNUZIIiwibmFtZSI6ImFzc3UiLCJlbWFpbCI6InRlc3RAdGVzdC5jb20iLCJpYXQiOjE2ODIyMzQ3MjksImV4cCI6MTY4MjMyMTEyOSwiYXVkIjoidGVzdC5jb20iLCJpc3MiOiJ0ZXN0LmNvbSJ9.uXZmMGu4ynWQxfpAh-2iNbcJMrMh4iBdztoXZX2dwoA' | jq

{
  "id": "01GXN39KWVPKV7WZR5XFD0A5FH",
  "name": "assu",
  "email": "test@test.com"
}
[MyApp] Info    4/23/2023, 4:27:54 PM Request to GET /users/01GXN39KWVPKV7WZR5XFD0A5FH - {}
[MyApp] Info    4/23/2023, 4:27:54 PM Response from GET /users/01GXN39KWVPKV7WZR5XFD0A5FH 
 response: {"id":"01GXN39KWVPKV7WZR5XFD0A5FH","name":"assu","email":"test@test.com"} - {}

참고 사이트 & 함께 보면 좋은 사이트

본 포스트는 한용재 저자의 NestJS로 배우는 백엔드 프로그래밍을 기반으로 스터디하며 정리한 내용들입니다.






© 2020.08. by assu10

Powered by assu10