Node.js/NestJS

[NestJS] 9. 인터셉터

턴태 2022. 10. 1. 11:23

인터셉터

인터셉터는 @Injectable() 데코레이터와 함께 NestInterceptor 인터페이스를 구현하는 클래스입니다.

인터셉터는 관점 지향 프로그래밍(Aspect Oriented Programming, AOP) 기술로부터 영감을 받은 유용한 기능들을 가지고 있습니다.

  • 추가 로직을 메서드 실행 이전, 이후에 적용할 수 있다.
  • 함수로부터 반환된 값을 변환할 수 있다.
  • 함수로부터 발생한 예외를 변환할 수 있다.
  • 기초 함수의 동작을 확장할 수 있다.
  • 특정 조건에 따라 기능을 완전히 덮어쓸 수 있다.

기초

각 인터셉터는 두 개의 인자를 가지는 intercept() 메서드를 구현합니다. 첫 번째 인수는 ExecutionContext 인스턴스(가드에서 썼던 것과 정확히 동일한 객체입니다)입니다. ExecutionContext는 ArgumentsHost로부터 상속받습니다. ArgumentsHost는 이전에 예외 필터 챕터에서 본 적 있습니다. 거기에서 ArgumentsHost는 원본 핸들러로부터 전달받은 인수에 대한 기본적인 자료형이며, 어플리케이션의 타입에 기반한 각기 다른 인수 배열을 포함하고 있다는 것을 보았습니다.

실행 컨텍스트

ExecutionContext가 포함하는 추가적인 세부사항들은 인터셉터를 조금 더 일반적으로 만들 수 있게 도움을 제공합니다(여러 곳-컨트롤러, 메서드 등-에서 응용하기 좋게).

호출 핸들러

두 번째 인수는 CallHandler입니다. CallHandler 인터페이스는 인터셉터에서 특정 부분에 라우트 핸들러 메소드를 부를 수 있게 해주는 handle() 메서드를 구현합니다. handle() 메서드를 intecept() 메서드 안에서 호출하지 않는다면, 라우트 핸들러 메서드는 실행되지 않을 것입니다.

 

이러한 접근 방식은, intercept() 메서드가 효과적으로 요청/응답 스트림을 효과적으로 감싸준다는 것을 의미합니다. 결과적으로 사용자 지정 로직을 최종 라우트 핸들러 수행 이전 이후에 모두 구현할 수 있다는 것입니다. handle() 메서드를 호출하기 전에 실행되는 intercept() 메서드에서 코드를 작성할 수 있는 것은 분명하게 다가오지만, 이후 발생하는 일에는 어떤 영향을 미칠까요? handle() 메서드가 Observable(특정 인수를 받아서 비동기적으로 값을 반환하는 자료형)을 반환하기 때문에, 우리는 강력한 RxJS 연산자를 사용하여 더욱 잘 응답을 조작할 수 있습니다. AOP 프로그래밍 기술에서는 라우트 핸들러의 호출(handle() 호출)을 Pointcut 이라고 부르며, 이 위치에 우리의 로직이 삽입된다는 것을 나타냅니다.

 

예를 들어 POST /cats 요청이 온다고 가정해봅시다. 이 요청은 CatsController에서 정의된 create() 메서드로 향합니다. 만약 handle() 메서드를 호출하지 않는 인터셉터가 요청 어디에서든지 호출된다 하더라도, create() 메서드는 실행되지 않을 것입니다. 일단 handle() 메서드가 호출되면(그리고 Observable이 반환되면), create() 핸들러는 작동하기 시작할 것입니다. 요청 스트림이 Observable을 통해 전달되면, 추가적인 연산은 스트림에서 수행되며 마지막 결과가 호출에 대해 반환됩니다.

인터셉터 측면

우리가 살펴 볼 첫 번째 사용 예제는 인터셉터를 유저 상호작용의 로그를 찍는 데에 사용하는 것입니다(예를 들어, 사용자의 호출을 저장하거나, 비동기적으로 이벤트를 전달하거나, 타임스탬프를 계산하는 것 등). LoggingInterceptor를 봅시다

 

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

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

handle() 메서드가 RxJS의 Observable을 반환하기 때문에, 우리는 데이터 스트림을 조작하기 위해 여러 연산자를 사용할 수 있습니다. 예를 들어, 위의 예에서 tap() 연산자를 사용했습니다. 이 tap() 연산자는 Observable 데이터 스트림의 정상적인 혹은 예외적인 종료의 상황에서 익명 로그 기능을 호출하지만 응답 사이클은 방해하지 않습니다.

 

인터셉터 적용

인터셉터를 설정하기 위해서 @UseInterceptors() 데코레이터를 사용합니다. 파이프와 가드의 경우와 마찬가지로 인터셉터도 컨트롤러 스코프, 메서드 스코프, 전역 스코프로 사용 가능합니다.

@UseInterceptors(LoggingInterceptor)
export class CatsController {}

위와 같은 방법으로, CatsController에 정의된 각 라우트 핸들러는 LoggingInterceptor를 사용할 수 있습니다. 누군가 GET /cats 엔드포인트를 호출할 때, 다음과 같은 로그를 볼 수 있습니다.

Before...
After... 1ms

인스턴스 대신 LoggingInterceptor 타입을 사용하여, 인스턴스화에 대한 일은 프레임워크에게 전담하고 의존성 주입을 활성화 하였습니다. 파이프, 가드, 예외필터와 마찬가지로 내장 인스턴스를 사용할 수도 있습니다.

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

앞서 언급했듯이, 위의 설계는 이 컨트롤러에서 선언된 모든 핸들러에 인터셉터가 적용됩니다. 단일 메서드에 적용하고 싶다면 데코레이터를 메서드 단계에 적용하기만 하면 됩니다.

 

전역으로 설정하려면, useGlobalInterceptors() 메서드를 사용하세요.

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

전역 인터셉터는 모든 컨트롤러와 라우트 핸들러에 대하여 전체 어플리케이션에서 사용됩니다. 의존성 주입은 외부 모듈의 컨텍스트에서 처리되므로, 의존성 주입을 하기 위해서는 아래와 같이 사용합니다.

 

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

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

응답 매핑

우리는 이미 handle() 메서드가 Observable를 반환한다는 것을 알고 있습니다. 데이터 스트림은 라우트 핸들러로부터 반환된 값을 포함하며, 그래서 우리는 RxJS의 map() 연산자를 쉽게 사용할 수 있습니다.

 

단순한 방법으로 각 응답을 수정하는 TransformInterceptor를 만들어 봅시다. 여기서는 RxJS의 map() 연산자를 사용해 응답 객체를 새로 생성된 객체의 data 프로퍼티에 할당하고, 새 객체를 사용자에게 전달합니다.

 

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

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 })));
  }
}
더보기

Nest 인터셉터는 동기적 혹은 비동기적인 intercept() 메서드들과 잘 작동합니다. 필요하면 async로 메서드를 전환할 수 있습니다.

위의 코드에서, 누군가 GET /cats 엔드포인트를 호출할 때, 라우트 핸들러가 빈 배열을 반환한다고 가정했을 때)응답은 아래와 같습니다.

{
  "data": []
}

인터셉트는 전체 어플리케이션에서 발생하는 요구사항에 대해 재사용가능한 해결책을 생성한다는 점에서 의미가 있습니다. 예를 들어, 각 null 값의 발생을 빈 문자열인 ''로 바꿔야 한다고 생각해봅시다. 우리는 한 줄의 코드를 작성하여 이를 해결할 수 있고, 인터셉터를 전역으로 적용하여 자동으로 모든 등록된 핸들러에서 사용될 수 있게 할 수 있습니다.

 

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

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

예외 매핑

또 다른 흥미로운 사용 사례는 RxJS의 catchError() 연산자를 사용해 예외를 덮는 방법입니다.

 

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

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

스트림 덮어쓰기

종종 핸들러 호출을 완전히 방지하고 대신 다른 값을 반환하고 싶은 경우가 있습니다. 이에 대한 대표적인 예는, 캐시를 구현하여 응답 시간을 효과적으로 줄이는 것입니다. 캐시로부터 응답을 반환하는 캐시 인터셉터를 살펴봅시다. 실제 사례에서 TTL, 캐시 무효화, 캐시 사이즈 등의 다른 요소를 고려할 수 있지만, 이는 이번 주제를 넘어서니 패스하겠습니다. 그러면 한 번 캐시 인터셉터를 살펴봅시다.

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

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

우리의 CacheInterceptor는 하드코딩된 isCached 변수와 하드코딩된 [] 응답이 있습니다. 여기서 중요한 점은 RxJS의 of() 연산자로 생성된 새로운 데이터 스트림을 반환하므로 라우트 핸들러는 호출되지 않는다는 것입니다. 누군가 CacheInterceptor를 사용하는 엔드포인트를 호출할 때, 응답(빈 배열로 하드코딩된) 이 즉각적으로 전달될 겁니다. 일반적인 해결책을 만들기 위해서, Reflector를 사용하고 사용자 정의 데코레이터를 만들 수 있습니다. Reflector는 가드에서 설명했었습니다.

기타 연산자들

RxJS 연산자를 사용하는 데이터스트림을 조작할 수 있기 때문에 많은 기능을 사용할 수 있습니다. 다른 흔한 사례를 보겠습니다. 라우트 요청에 타임아웃을 처리하고 싶다고 상상해봅시다. 엔드포인트가 일정 시간 후에 아무 것도 반환하지 않을 때, 에러 응답과 함께 종결하길 원할 것입니다. 아래의 코드를 통해 구현해볼 수 있습니다.

 

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

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

5초 후에 요청의 처리가 취소됩니다. 또한, 사용자 정의 로직을 RequestTimeoutException 에러 이전에 추가할 수 있습니다.