Skip to content

9. Interceptors


인터셉터는 NestInterceptor 인터페이스를 구현하는, @Injectable() 데코레이터로 주석이 달린 클래스이다.


001


인터셉터에는 AOP(Aspect Oriented Programming) 기술에서 영감을 받은 유용한 기능들이 있다. 이를 통해 다음을 수행할 수 있다:


  • 메서드 실행 전후에 추가 로직 바인딩
  • 함수에서 반환된 결과를 변환
  • 함수에서 던진 예외를 변환
  • 기본 함수 동작 확장
  • 특정 조건에 따라 함수 재정의


1. Basics

각 인터셉터는 두 개의 인수를 취하는 intercept() 메서드를 구현한다. 첫 번째는 ExecutionContext 인스턴스(가드의 경우와 정확히 동일한 객체)이다. ExecutionContextArgumentsHost에서 상속된다.


2. Execution context

ArgumentsHost를 확장함으로써 ExecutionContext는 현재 실행 프로세스에 대한 추가 세부 정보를 제공하는 몇 가지 새로운 헬퍼 메서드도 추가한다. 이러한 세부 정보는 광범위한 컨트롤러, 메서드 및 실행 컨텍스트에서 작동할수 있는 보다 일반적인 인터셉터를 구축하는 데 도움이 될 수 있다.


3. Call handler

두 번째 인수는 CallHandler이다. CallHandler 인터페이스는 인터셉터의 특정 지점에서 라우트 핸들러 메서드를 호출하는 데 사용할 수 있는 handle() 메서드를 구현한다. intercept() 메서드 구현에서 handle() 메서드를 호출하지 않으면 라우트 핸들러 메서드가 전혀 실행되지 않는다.


이 접근 방식은 intercept() 메서드가 요청/응답 스트림을 효과적으로 래핑한다는 것을 의미한다. 결과적으로 최종 라우트 핸들러 실행 전후에 사용자 정의 로직을 구현할 수 있다. handle()을 호출하기 전에 실행되는 intercept() 메서드가 Observable을 반환하기 때문에 강력한 RxJS 연산자를 사용하여 응답을 추가로 조작할 수 있다.


예를 들어, 들어오는 POST /cat 요청을 생각해 보자. 이 요청은 CatsController 내부에 정의된 create() 핸들러를 대상으로 한다. handle() 메서드를 호출하지 않는 인터셉터가 도중에 어디에서나 호출되면 create() 메서드가 실행되지 않는다. handle()이 호출되어 Observable이 반환되면 create() 핸들러가 트리거된다. 그리고 Observable을 통해 응답 스트림을 수신하면 스트림에서 추가 작업을 수행할 수 있으며 최종 결과는 호출자에게 반환된다.


4. Aspect interception

살펴볼 첫 번째 사용 사례는 인터셉터를 사용하여 사용자 상호작용을 로깅하는 것이다. 아래에 간단한 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`)));
  }
}


Note

NestInterceptor<T, R>TObservable<T>(응답 스트림 지원)의 타입을 나타내고 RObservable<R>에 의해 래핑된 값의 유형을 나타내는 일반 인터페이스이다.


컨트롤러, 프로바이더, 가드 등과 같은 인터셉터는 생성자를 통해 종속성을 주입할 수 있다.


handle()은 RxJS Observable을 반환하므로 스트림을 조작하는 데 사용할 수 있는 다양한 연산자를 선택할 수 있다. 위의 예제에서 옵저버블 스트림의 정상적이거나 예외적인 종료 시 익명 로깅 함수를 호출하지만 응답 주기를 방해하지 않는 tap() 연산자를 사용했다.


5. Binding interceptors

인터셉터를 설정하기 위해 @nestjs/common 패키지에서 가져온 @UseInterceptors() 데코레이터를 사용한다. 파이프 및 가드와 마찬가지로 인터셉터는 컨트롤러 범위, 메서드 범위 또는 글로벌 범위일 수 있다.


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


위의 구성을 사용하여 CatsController에 정의된 각 라우트 핸들러는 LoggingInterceptor를 사용한다. 누군가 GET /cats 엔드포인트를 호출하면 표준 출력에 다음 출력이 표시된다.


Before...
After... 1ms


인스턴스 대신 LoggingInterceptor 유형을 전달하여 프레임워크에 인스턴스화에 대한 책임을 남겨두고 종속성 주입을 활성화했다. 파이프, 가드 및 예외 필터와 마찬가지로 내부 인스턴스를 전달할 수도 있다.


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


언급했듯이 위의 구성은 이 컨트롤러가 선언한 모든 핸들러에 인터셉터를 연결한다. 인터셉터의 범위를 단일 메서드로 제한하려면 메서드 레벨에서 데코레이터를 적용하기만 하면 된다.


글로벌 인터셉터를 설정하기 위해 Nest 애플리케이션 인스턴스의 useGlobalInterceptors() 메서드를 사용한다.


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


글로벌 인터셉터는 모든 컨트롤러와 모든 라우트 핸들러에 대해 전체 애플리케이션에서 사용된다. 의존성 주입의 관점에서, 모듈 외부에서 등록된 글로벌 인터셉터는 모든 모듈의 컨텍스트 외부에서 수행되기 때문에 의존성을 주입할 수 없다. 이 문제를 해결하기 위해 다음 구성을 사용하여 모든 모듈에서 직접 인터셉터를 설정할 수 있다.


app.module.ts
import { Module } from "@nestjs/common";
import { APP_INTERCEPTOR } from "@nestjs/core";

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


6. Response mapping

handle()Observable을 반환한다. 스트림에는 라우트 핸들러에서 반환된 값이 포함되어 있으므로 RxJS의 map() 연산자를 사용하여 쉽게 변경할 수 있다.


프로세스를 보여주기 위해 간단한 방법으로 각 응답을 수정하는 TransformInterceptor를 만들어보자. RxJS의 map() 연산자를 사용하여 새로 생성된 객체의 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 })));
  }
}


위의 구성에서 누군가 GET /cats 엔드포인트를 호출하면 응답은 다음과 같다(라우트 핸들러가 빈 배열 []을 반환한다고 가정).


{
  "data": []
}


인터셉터는 전체 애플리케이션에서 발생하는 요구 사항에 대해 재사용 가능한 솔루션을 만드는 데 큰 가치가 있다. 예를 들어, null 값이 나타날 때마다 빈 문자열 ''로 변환해야 하는 경우를 생각해 보자. 한 줄의 코드를 사용하여 이를 수행하고 인터셉터를 글로벌로 바인딩하여 등록된 각 핸들러에서 자동으로 사용하도록 할 수 있다.


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


7. Exception mapping

또 다른 사용 사례는 RxJS의 catchError() 연산자를 활용하여 던진 예외를 재정의하는 것이다.


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


8. Stream overriding

때떄로 핸들러 호출을 완전히 방지하고 대신 다른 값을 반환하려는 몇 가지 경우가 있다. 예를 들어 응답 시간을 개선하기 위해 캐시를 구현하는 것이다. 캐시에서 응답을 반환하는 간단한 캐시 인터셉터를 살펴보자. 현실적인 예에서 TTL, 캐시 무효화, 캐시 크기 등과 같은 다른 요소를 고려하고 싶지만 여기에서는 주요 개념을 보여주는 기본 예제를 제공한다.


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 변수와 응답 []도 있다. 주목해야 할 요점은 RxJS of() 연산자에 의해 생성된 새 스트림을 여기에서 반환하므로 라우트 핸들러가 전혀 호출되지 않는다는 것이다. 누군가 CacheInterceptor를 사용하는 엔드포인트를 호출하면 응답이 즉시 반환된다. 일반 솔루션을 생성하기 위해 Reflector를 활용하고 사용자 정의 데코레이터를 생성할 수 있다.


9. More operators

RxJS 연산자를 사용하여 스트림을 조작할 수 있는 가능성은 많은 기능을 제공한다. 또 다른 일반적인 사용 사례를 생각해 보자. 예를 들어 라우트 요청에 대한 시간 초과를 처리하는 경우를 살펴보자. 일정 시간이 지나도 엔드포인트가 아무 것도 반환하지 않으면 오류 응답으로 종료해야 한다. 다음 구성을 통해 이를 가능하게 한다.


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을 던지기 전에 사용자 정의 로직을 추가할 수도 있다.


References