NestJS 공식문서 번역 겸 공부하는 글 입니다.
의역 및 오역이 있을 수 있습니다.
https://docs.nestjs.com/interceptors

Interceptors

Interceptors

interceptor는 @Injectable() 데코레이터로 주석된 클래스이다. interceptor는 NestInterceptor 인터페이스를 구현해야 한다.

interceptors

interceptor는 Aspect Oriented Programming(AOP)에서 영감을 받은 여러가지 기능을 갖고 있다.

  • method 수행 전/후 추가 로직을 bind
  • 함수로부터 반환된 값을 변형
  • 함수로부터 throw된 예외를 변형
  • 기본 함수를 확장
  • 특정 조건들에 따라 함수를 완전히 override(e.g, 캐싱)

Basics

각각의 interceptor는 intercept() method를 구현해야하며, 이 함수는 2개의 argument를 받는다. 첫번째는 ExecutionContext 인스턴스(guards에서 받는것과 같음)이다. ExecutionContext는 ArgumentsHost를 상속받았다. ArgumentsHost는 exception filter 챕터에서 이미 보았으며, 거기서 ArgumentsHost는 original handler로 가는 인자들의 wrapper이며, 애플리케이션 타입에 따라 다른 종류의 인자 배열을 포함하고 있음을 보았다. 좀 더 자세한 내용은 exception filters 챕터에서 볼 수 있다.

Execution context

ExecutionContext는 ArgumentsHost를 상속받아 몇개의 helper method를 추가하였다. 이 helper method는 현재 실행중인 프로세스에 몇가지 추가 세부사항을 제공한다. 이 세부사항들은 controller, method, execution context를 가로질러 작동할 수 있는 제네릭 interceptor를 구축하는데 도움을 준다. ExecutionContext에 대해 더 알고 싶으면 여기에서 확인하자.

Call handler

두번째 인자는 CallHandler이다. CallHandler 인터페이스는 handle() method를 구현한다. handle() method는 특정 시점에서 route handler method를 실행하기 위해 사용된다. 만약 intercept() mehotd에서 handle() method를 호출하지 않으면 route handler method도 실행되지 않는다.

이러한 접근법은 intercept() method가 효과적으로 request/response 스트림을 래핑하게 한다. 결과적으로, 최종 route handler 앞/뒤로 custom logic을 구현할 수 있다. intercept() method 내에서 handle()을 호출하기전에 코드를 작성 할 수 있지만, 그 후에는 어떻게 영향을 줄 수 있을까? handle() method는 Observable을 리턴하기때문에, 우리는 RxJS 오퍼레이터를 사용하여 추후에도 response를 가공할 수 있다. AOP 용어를 사용하면, handle()을 호출하여 route handler를 실행하는 것은 Pointcut이라고 부르며, 이것은 우리의 추가 로직이 삽입되는 지점을 가리킨다.

예를 들어, POST /cats로 들어오는 request를 생각해보자. 이 request는 CatsController에 정의된 create() handler를 목적지로 지정하고 있다. 도중에 handle() method를 호출하지 않는 interceptor가 호출되었다면, create() method는 실행되지 않을 것이다. handle() method가 호출되면 create() handler는 실행될 것이다. 그리고 response 스트림을 Observable로 받아, 추가적인 오퍼레이션을 수행하고 최종 결과를 caller에게 리턴할 수 있다.

Aspect interception

첫번째 이용사례는 사용자 상호작용을 로깅하기 위해 interceptor를 사용한 것이다.

// logging.interceptor.ts

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

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    console.log('Before...');

    const now = Date.now();
    return next
      .handle()
      .pipe(
        tap(() => console.log(`After... ${Date.now() - now}ms`)),
      );
  }
}

[hint]
NestInterceptor<T, R> 의 T는 Observable의 타입, R은 Observable에 의해 래핑된 값의 타입을 가리킨다.

[Notice]
interceptor도 controller, provider, guard 등과 마찬가지로, 생성자를 통해 의존성을 주입할 수 있다.

handle()이 RxJS Observable을 리턴하기 때문에, 스트림을 가공하기 위해 어떠한 오퍼레이터를 사용할지에 대해 많은 선택지를 갖고있다. 위의 예제에서는, tap() 오퍼레이터를 사용하여 observable stream의 정상적 혹은 예외적인 종료시 익명 logging 함수를 실행하였다. 그렇지 않은 경우 response cycle에 방해가 된다.

Binding interceptors

interceptor를 세팅하기 위해서는 @UseInterceptors() 데코리어터를 사용한다. pipe, gaurd와 마찬가지로 interceptor도 controller-scope, method-scope, global-scope 일 수 있다.

// cats.controller.ts

@UseInterceptors(LoggingInterceptor)
export class CatsController {}

위의 예제에서 CatsController에 정의된 각각의 route handler는 LoggingInterceptor를 사용한다. 누군가 GET /cats 엔드포인트를 호출하면, standard ouput에서 아래의 output을 보게 될것이다.

Before...
After... 1ms

LoggingInterceptor 인스턴스가 아닌, LoggingInterceptor 타입을 전달하여 프레임워크가 의존성 주입을 하도록 하였는데, pipe, guard, exception filter와 마찬가지로 인스턴스를 직접 전달할 수도 있다.

// cats.controllers.ts

@UseInterceptors(new LoggingInterceptor())
export class CatsController {}

앞서 말했듯, 위의 구조에서 해당 controller에 정의된 모든 handler에 interceptor가 붙는다. 단일 method로 interceptor를 제한하고 싶다면 데코레이터를 method level로 적용하면 된다.

global interceptor를 사용하기 위해서는 useGlobalInterceptors() method를 사용한다.

const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new LoggingInterceptor());

global interceptor는 애플리케이션 전체를 통해 모든 controller와 route handler에 사용된다. 이 경우 의존성 주입을 통한 것은 아니기때문에, 이를 해결하기 위해서는 module에 직접 interceptor를 세팅한다.

// app.module.ts

import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';

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

Response mapping

우리는 이미 handle() method가 Observable을 리턴하는것을 알고 있다. 이 스트림은 route handler로부터 반환된 값을 포함하고 있어서, RxJS map() 오퍼레이터를 사용해 쉽게 변형 가능하다.

[warning]
response mapping 특성은 library-specific strategy에서는 동작하지 않는다.

TransformInterceptor를 만들어보자. 이는 각각의 response를 가볍게 수정하여 프로세스를 보여준다. 이것은 RxJS의 map() 오퍼레이터를 사용하여 response 오브젝트를 새로운 오브젝트의 data 프로퍼티에 할당하고, 그 새로운 오브젝트를 클라이언트에 리턴한다.

// transform.interceptor.ts

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } 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): Observable<Response<T>> {
    return next.handle().pipe(map(data => ({ data })));
  }
}

[hint]
intercept() method는 동기 혹은 비동기일 수 있다.

위의 구조에서 GET /cats 엔드포인트가 호출되면, response는 아래와 같은 형태가 될 것이다.

{
  "data": []
}

interceptor는 애플리케이션 전체에 걸쳐 요구사항에 대한 재사용 가능한 해결책을 만들수 있다는 것에서 큰 가치를 가진다. 예를 들어, null 값에 대해 항상 ‘'(빈 string 값)으로 변환하고 싶다고 생각해보자. 우리는 단 한줄의 코드를 전역 interceptor에 사용하여 각각의 handler에 적용할 수 있다.

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

@Injectable()
export class ExcludeNullInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next
      .handle()
      .pipe(map(value => value === null ? '' : value ));
  }
}

Exception mapping

또 다른 흥미로운 사용사례는 RxJS의 catchError() 오퍼레이터를 사용한 예외를 override 하는 것이다.

// errors.interceptor.ts

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

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

Stream overriding

가끔 handler의 호출을 완전히 막고 대신해서 다른 값을 리턴해야 하는 경우가 있다. 하나의 명확한 예로 response time을 개선하기 위해 캐시를 구현하는 것이다. 캐시로부터 값을 리턴하는 간단한 cache interceptor를 살펴보자. 실제로는, TTL, cache invalidation, cache size 등등 여러가지 변수를 고려해야 하지만, 이번에는 무시하기로 한다.

// cache.interceptor.ts

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

@Injectable()
export class CacheInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const isCached = true;
    if (isCached) {
      return of([]);
    }
    return next.handle();
  }
}

위의 예에서는 isCahced와 리턴값 []를 하드코딩 하였다. 여기서 주요 포인트는 RxJS의 of() 오퍼레이터로 새로운 스트림이 리턴되고, route handler는 호출되지 않았다. 누군가 CacheInterceptor를 사용하는 엔드포인트를 호출하면 하드코딩된 빈 배열이 즉시 response 된다. 제네릭한 솔루션을 만들기 위해, Reflector를 사용하거나 커스텀 데코레이터를 만들 수 있다. Reflector는 Guards챕터에 잘 설명되어 있다.

More operators

RxJS 오퍼레이터를 사용해 스트림을 조작할 수 있다는 것은 많은 기능을 가져온다. 또 다른 사용사례를 생각해보자. route request에 timeout을 설정하고 싶다고 생각해보자. 특정 시간동안 response를 반환하지 못하면, 대신 에러를 반환할 것이다.

// timeout.interceptor.ts

import { Injectable, NestInterceptor, ExecutionContext, CallHandler, RequestTimeoutException } from '@nestjs/common';
import { Observable, throwError, TimeoutError } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      timeout(5000),
      catchError(err => {
        if (err instanceof TimeoutError) {
          return throwError(new RequestTimeoutException());
        }
        return throwError(err);
      }),
    );
  };
};

5초가 지나면, request 프로세스는 종료된다. 또한 RequestTimeoutException을 throw 하기전 커스텀 로직을 추가할 수도 있다.