NestJS Logo

원문을 읽으며 정리합니다.

Table of Contents

  1. Interceptors

Interceptors

interceptor는 @Injectable() 데코레이터와 함께 annotated된 클래스 이다. interceptors는 NestInterceptor interface를 구현해야 한다.

interceptors

Image by: https://docs.nestjs.com/assets/Interceptors_1.png

interceptors는 Aspect Oriented Programmin(AOP) 기술에 영감을 받은 유용한 능력의 집합을 가지고 있다. 이들은 다음을 가능하게 한다.

  • 메서드 실행 전/후 에 추가적인 로직을 추가한다.
  • function으로부터 반환된 결과를 변환한다.
  • function으로부터 발생된 예외를 변환한다.
  • function의 기본적인 행동을 확장한다.
  • 특정 조건에 따라 function을 완전히 override한다(e.g., 캐싱 목적).

Basics

각 interceptor는 두개의 인자를 갖는 intercept() 메서드를 구현한다. 첫 번째 인자는 ExecutionContext instance(guards에서 나온거랑 똑같다.)이다. ExecutionContextArgumentsHost로부터 상속되었다. exception filters chapter에서 ArgumentsHost를 봤었다. 거기서, 원래의 핸들러에 전달될 인자에 대한 wrapper이며 application의 유형에 따라 다른 인자 배열을 포함하는 것을 봤다. 이 주제에 대한 자세한 내용은 이전의 exception filters로 돌아가 확인할 수 있다.

Execution context

ArgumentsHost를 확장함에 의해, ExecutionContext는 현재 실행 프로세스에 대한 추가 세부 사항을 제공하는 몇가지 헬퍼 메서드 또한 추가한다. 이러한 세부 사항은 광범위한 컨트롤러, 메서드, 실행 컨텍스트에서 작동할 수 있는 generic interceptors를 구축하는데 도움이 될 수 있다. ExecutionContext에 대해 이 링크에서 더 배워보자.

Call Handler

두 번째 인자는 CallHandler이다. CallHandler interface는 interceptor에서 특정 시점에 라우트 핸들러 메서드를 호출하는데 사용할 수 있는 handle() 메서드를 구현한다. 만약 intercept() 메서드를 구현할 때 handle() 메서드를 호출하지 않으면, 라우트 핸들러 메서드는 전혀 실행되지 않을 것이다.

이러한 접근의 의미는, intercept() 메서드는 효과적으로 request/response stream을 wrap 한다는 것이다. 그 결과, 최종 route handler의 실행 전후 둘다에 커스텀 로직을 구현할 수 있다. handle()을 호출하기 전에 intercept() method를 실행할 수 있도록 코드를 작성하는 것은 분명하지만, 이후 발생하는 작업에는 어떻게 영향을 줄 수 있을까? handle() 메서드는 Observable을 리턴하기 때문에, RxJS의 강력한 연산자를 사용하여 response를 추가로 조작할 수 있다. Aspect Oriented Progamming 용어를 사용하면, route handler(handle())의 호출을 Pointcut이라고 부르고, 이는 추가 로직이 삽입되는 지점을 나타낸다.

예를 들어 POST /cats request를 고려해보자. 이 요청은 CatsController안에 정의된 create() handler에 대한 것이다. 만약 interceptor가 도중에 handle() 메서드를 호출하지 않는다면, create() 메서드는 실행되지 않을 것이다. handle()이 호출되고(Observable이 리턴되면) create() 핸들러는 trigger 될 것이다. 그리고 Observable을 통해 response stream이 수신되면, stream에서 추가적인 작업을 수행할 수 있으며, 최종 결과는 caller에게 return 된다.

Aspect interception

첫 번째 use case는 사용자 interaction을 기록하기 위해 사용하는 것이다(e.g., users call 저장, 비동기 이벤트 dispatching, timestamp 계산). 아래에서 간단한 LoggingInterceptor를 보자.

// 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`)));
  }
}

NestInterceptor<T, R>은 generic interface이다. TObservable<T>를 나타내고(response stream을 도와줌) RObservable<R>로 wrap된 값의 타입이다.

interceptors는 controllers, providers, guards 처럼 그들의 생성자를 통해 inject dependencies가 가능하다.

handle()이 RxJS Observable을 리턴했기 때문에, 스트림 조작에 사용할 수 있는 다양한 operator를 선택할 수 있다. 위 예제에선, observable stream의 정상 또는 예외적 종료할 때 익명 로킹 function을 호출하는 tap() 명령어를 사용했는데, 그렇지 않으면 response cycle에 방해가 된다.

Binding interceptors

interceptor를 설정하기 위해서는, @UserInterceptors() 데코레이터를 사용한다. pipesguards 처럼, interceptors는 controller-scoped, method-scoped, or global-scoped가 될 수 있다.

// cats.controller.ts
@UseInterceptors(LoggingInterceptor)
export class CatsController {}

@UseInterceptors() 데코레이터는 @nestjs/common package에 있다.

위 구조로 사용하면, CatsController에 정의된 라우트 핸들러에 LoggingInterceptor를 사용할 수 있을 것이다. 누군가 GET /cats endpoint를 호출하게 된다면, 아래와 같은 표준 output을 따른다.

Before...
After... 1ms

인스턴스 대신 LoggingInterceptor type을 전달했는데, 인스턴스화의 책임을 프레임워크에 맡기고 의존성 주입을 가능하게 함을 주의하자. pipes, guards, exception filters와 마찬가지로, in-place instance를 전달할 수 있다.

// cats.controller.ts
@UseInterceptors(new LoggingInterceptor())
export class CatsController {}

언급 했듯이, 위 구조는 모든 컨트롤러에 정의된 핸들러에 interceptor를 부착한다. 만약 interceptor의 scope를 단일 메서드로 제한하고 싶다면, 간단하게 method level에 데코레이터를 적용할 수 있다.

global interceptor를 설정하기 위해, NestJS application 인스턴스의 useGlobalInterceptors() 메서드를 사용한다.

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

global interceptors는 전체의 애플리케이션에서 사용된다. 의존성 주입 관점에서, global interceptors는 위 예제처럼 useGlobalInterceptors()를 사용해 모듈 밖에서 등록되었다. 따라서 모듈의 context 밖에서 수행되었기 때문에 의존성 주입을 할 수 없다. 이 이슈를 해결하기위해, 아래와 같은 구조로 모듈로부터 직접 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 {}

interceptor에 의존성 주입을 수행하기 위해 이러한 접근을 사용할 때, 이 구조가 사용되는 모듈에 관계없이 interceptor는 사실상 global임을 주의하자. 이 작업은 어디서 해야 할까? 위 예쩨에서 LoggingInterceptor가 정의된 곳의 모듈을 선택해라. 또한, useClass만이 custom provider 등록을 하기 위한 방법은 아니다. 자세한 건 이 링크에서 확인하자.

Response mapping

우리는 이미 handle()Observable을 리턴함을 알고 있다. stream은 route handler에서 반환된 값을 포함하므로, RxJS의 map() operator를 사용하여 쉽게 변이시킬 수 있다.

response mapping feature은 library-specific response 전략에서 작동하지 않는다(@Res() object를 직접 사용하는 것은 금지된다.).

TransformInterceptor를 만들어 보자. 사소한 방법으로 수정하여 process를 보여준다. RxJS의 map() operator를 사용하여 response objet를 새로운 객체의 data 속성에 할당하고 그 새로운 object를 client에게 return 한다.

// 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 })));
  }
}

NestJS interceptors는 비동기와 동기 intercept() 메서드와 함께 잘 작동한다. 만약 필요하다면, async 메서드로 바꿀 수 있다.

위와 같은 구조를 사용할때, 누군가 GET /cats 엔드포인트에 요청을 하면, response는 아래와 같이 보일 것이다(route handler는 빈 배열을 [] 반환한다고 가정하면).

{
  "data": []
}

interceptors는 전체 애플리케이션에서 발생하는 요구 사항에 대한 re-usable한 해결책을 만드는데 큰 가치를 가진다. 예를 들어, null이 발생할때 마다 '' 빈 문자열로 변환해야 한다고 상상해보자. 코드 한 줄을 사용하여 interceptor를 전역으로 바인딩하여 등록된 각 핸들러에서 자동으로 사용될 수 있을 것이다.

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

또 다른 use-case는 RxJS의 catchError() operator를 활용하여 예외를 재정의 하는 것이다.

// 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

가끔 핸들러 호출을 차단하고 대신 다른 값을 반환해야 하는 몇 가지 이유가 있다. 분명한 예로, response time을 향상시키기 위해 cache를 구현하는 것이다. 캐시에서 response를 반환하는 간단한 cache interceptor를 살펴보자. 현실적인 예에서는, TTL, cache invalidation, cachesize 등과 같은 다른 요인을 고려해야 하지만, 그것은 이 논의에서 벗어난다. 여기에 기본 개념을 증명하는 기초 예제를 보여줄 것이다.

// 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();
  }
}

CacheInterceptor는 하드코딩된 isCached 변수와 하드코딩된 response []을 가진다. 주목해야 할 점은, 여기서 RxJS of() operator에 의해 생성된 새로운 stream이 반환되었다. 그러므로 route handler가 전혀 호출되지 않는다. 누군가 CacheInterceptor를 사용하는 엔드포인트를 호출하면 response(a hardcoded, empty array)는 즉시 반환될 것이다. 일반적인 solution을 생성하기 위해, Reflector의 이점을 활용하고 커스텀 데코레이터를 만들 수 있다. Reflectorguards 챕터에서 잘 설명되어 있다.

More operators

RxJS operators를 사용하여 stream을 조작수 있음은 우리에게 많은 기능을 제공한다. 또다른 흔한 use case를 고려해보자. request route에 timeouts을 처리해야 한다고 가정해 보자. 일정 시간이 지나도 엔드포인트가 아무것도 반환하지 않는 경우, error 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초후에, 요청 처리가 취소된다. RequestTimeoutException을 발생하기 전에 custom logic을 추가할 수도 있다(e.g., release resources).

Next NestJS OVERVIEW(10) - Custom decorator
Intro NestJS OVERVIEW(0) - Intro