새소식

반응형
Back-end/NestJS

[NestJS | Docs] Interceptor 개념정리 (+간단한 Logger)

2024.02.07
  • -
반응형

1. Interceptors란?

통상적으로 interceptor는 요청응답을 가로채서 중간에 로직에 변화를 줄 수 있도록 하는 컴포넌트를 말합니다.

 

Nest에서는 이를 @Injectable() 데코레이터가 붙은 클래스를 통해 만들 수 있고, Nest에서 제공하는 NestInterceptor 인터페이스를 상속받아 구현할 수 있습니다.

 

interceptor는 AOP 기법으로부터 영감을 받아 그와 관련된 몇 가지 유용한 기능들을 할 수 있습니다.

  • 부가적인 로직을 메서드 실행 전 후에 바인딩
  • 함수의 반환 결과변환
  • 함수의 에러변환
  • 함수의 기본 동작을 확장
  • 특정 조건에 의존하는 함수를 완전히 override(e.g., 캐싱 목적)

 

interceptor는 그 개념만 보면 middleware와 처리방식이 유사한 것 같으나 다음과 같은 차이점이 존재합니다.

  • Middleware
    • 요청이 라우트 핸들러로 전달되기 전에 동작합니다.
    • 심지어 어떤 라우트 핸들러와 매핑되었는지도 모릅니다.
    • Execution Context(실행 맥락; 메타데이터)에 대한 정보가 아예 없습니다.
    • 여러 개의 Middleware를 순서대로 조합하여 각기 다른 목적을 가진 Middleware 로직을 수행 가능합니다.
    • 다음 Middleware에게 제어권을 넘기지 않고 요청/응답 주기 종료할 수도 있습니다.
  • Interceptor
    • 요청에 대한 라루트 핸들러의 처리 전/후로 호출되어 요청과 응답을 다룰 수 있습니다.
    • Execution Context를 갖고 있습니다.

 

1-1. 기본 개념

각 interceptor에서는 2개의 인자를 취하는 intercept() 메서드를 구현합니다. 

 

1번째 인자는 ExecutionContext 인스턴스입니다.(이는 Guard에서도 똑같이 사용됩니다.)

2번째 인자는 CallHandler 입니다.

 

ExecutionContextArgumentHost를 상속합니다. ArgumentHost 클래스는 핸들러에 전달된 인자를 가져오는 메서드를 제공합니다. 따라서 이는 원래 핸들러에 전달된 인자를 감싸는 Wrapper이며, 애플리케이션 유형에 따라서 다른 인자 배열을 포함하고 있기도 합니다.

 

1-2. Execution context

1번째 인자인 ExecutionContext는 ArgumentHost를 상속한 것으로, 현재 실행 프로세스에 대한 추가적인 세부사항을 제공하는 몇 개의 새로운 helper 메서드를 추가하기도 합니다. 이러한 세부사항들은 다양한 컨트롤러, 메서드, execution context에 걸쳐 동작할 수 있는 보다 더 일반적으로 통하는 interceptors를 만드는데 유용할 수 있습니다.

 

1-3. Call handler

2번째 인자는 CallHandler입니다. CallHandler 인터페이스는 interceptor의 특정 지점에서 라우트 핸들러 함수를 호출하는데 사용할 수 있는 handle() 메서드를 구현합니다. 만약 handle() 메서드를 intercept() 메서드의 구현에서 호출하고 싶지 않다면 라우트 핸들러 메서드가 실행되는 일은 없을 것입니다.

 

이러한 접근은 intercept() 메서드가 요청/응답 stream을 효율적으로 감싸고 있음을 의미합니다. 결과적으로 최종 라우트 핸들러의 실행 전/후로 커스텀 로직을 구현할 수 있게 됩니다.

 

그런데 intercept() 메서드에서 handle() 메서드의 호출 이전에 실행되는 코드를 작성할 수 있는 건 분명하지만 그 이후에 일어나는 일에는 어떻게 영향을 미칠까요?

 

handle() 메서드가 Observable을 반환하기 때문에 응답의 추가적인 조작을 위해 강력한 RxJS 연산자를 사용할 수 있습니다. AOP 용어를 사용하자면, 라우트 핸들러의 호출은 Pointcut이라고 불립니다. 이는 추가적인 로직이 삽입된 지점을 가리키는 역할을 합니다.

 

예를 들어, POST /cats 요청이 들어왔다고 가정해봅시다. 이 요청은 CatsController에서 정의된 create() 라는 핸들러로 경로가 지정되어 있습니다. 만약 도중에 handle() 메서드를 호출하지 않는 interceptor라면, create() 메서드는 실행되지 않을 것입니다. 즉, handle()이 호출되어야, create() 핸들러가 실행되는 것입니다. 또한 Observable을 통해 응답 stream을 받게될 때, stream에서 추가 작업을 수행하고 최종 결과를 호출한 곳에게 해당 값을 반환할 수 있습니다.

 

정리하면, CallHandler의 handle() 메서드는 요청의 처리 결과를 Observable로 반환하는데 사용되며, pipe() 메서드는 이 Observable에 대한 추가적인 처리나 변환을 적용하는데 사용됩니다. 

 

 

1-4. Aspect interceptor

가장 먼저 살펴볼 interceptor의 사용 예제는 유저 인터랙션을 로깅하는 것입니다.

  • 유저 호출 저장, 비동기 이벤트 dispatch, timestamp 계산 등..

아래는 간단한 LoggingInterceptor의 예시입니다.

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>에서 TObservable<T>의 타입을 의미하고, R은 Observable<R>로 감싸진 값의 타입을 의미합니다.

 

코드의 자세한 내용은 아래에서 다뤄보도록 하고, 그 전에 이 만든 Interceptor를 어떻게 적용하는지에 대해 먼저 알아보도록 하겠습니다.

 

interceptor도 controller나 provider, guard, ...와 같은 것들과 마찬가지로 생성자에서 의존성을 주입하는 것이 가능합니다.

 

handle() 메서드는 RxJS의 Observable을 반환하기 때문에 stream을 조작하는데 사용할 수 있는 다양한 선택지의 연산자를 갖습니다. 위의 예시에서는 tap() 연산자가 사용되었는데, tap()은 observable stream의 정상적/예외적 종료 시에 익명 로깅 함수(anonymous loggin function)을 호출하지만, 그 외에는 응답 주기를 방해하지 않도록 하는 역할을 합니다. 

 

1-5. Binding interceptors (interceptor 연결)

interceptor를 적용하기 위해서는, @nestjs/common 패키지의 @UseInterceptors() 데코레이터를 사용합니다. Pipes와 Guard와 마찬가지로, interceptor도 controller-scoped, method-scoped, 또는 global-scoped 에서 사용될 수 있습니다.

 

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

 

위와 같이 사용하면 CatsController에 정의된 매 라우트 핸들러는 LoggingInterceptor를 거치게 됩니다. GET /cats 엔드포인트로 요청이 오면, 아래와 같은 표준 출력을 볼 수 있을 겁니다.

Before...
After... 1ms

 

인스턴스 대신 LoggingInterceptor 타입을 전달하여 인스턴스화에 대한 책임을 프레임워크에 맡겨 의존성 주입이 가능하도록 했습니다.

 

물론 pipe, guard, exception filter처럼 인스턴스를 넘겨주는 방식도 존재합니다.

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

 

앞서 말했듯, 컨트롤러에 바인딩을 하게되면 해당 컨트롤러가 선언한 모든 핸들러에 대해 interceptor를 붙여줍니다. 만약 interceptor의 scope를 단일 메서드로 제한하고 싶다면 그 메서드에게 데코레이터를 붙여주기만 하면 됩니다. 

 

애플리케이션 전역에서 사용되는 interceptor로 설정하기 위해선 Nest application 인스턴스가 가진 useGlobalInterceptors() 메서드를 사용합니다.

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

 

전역(global) interceptor는 전체 애플리케이션에 걸쳐서 모든 컨트롤러와 모든 라우트 핸들러에 대해 적용됩니다. 의존성 주입 관점에서, 모듈 외부에서 등록된 전역 interceptor는 모든 모듈의 맥락(context)을 벗어나 수행되기 때문에 의존성을 주입할 수 없습니다.

 

이를 해결하기 위해서, 다음 구조를 사용하여 모든 모듈로부터 직접적으로 interceptor를 설정할 수 있습니다.

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

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

 

단, interceptor에 대한 의존성 주입을 수행하는 이 방식을 사용하면, 이 구조가 사용되는 모듈과 관계없이 interceptor는 사실상 전역이라는 점을 유의해야 합니다. 

 

1-6. Response mapping (feat. 응답 변형)

handle() 메서드는 Observable을 반환합니다. stream에는 라우트 핸들러로부터 반환된 값이 포함되기 때문에 그래서 RxJS의 map() 연산을 통해 해당 값을 쉽게 가져올 수 있습니다.

 

response mapping 특성은 라이브러리별 응답 전략에서는 작동하지 않습니다.(@Res() 객체를 직접 사용하는 금지되어 있기 때문임)

 

이를 검증하기 위해 제일 간단한 방식으로 각 응답을 수정하는 TransformInterceptor를 만들어봅시다. RxJS의 map() 연산자를 사용하여 새로 만들어진 object의 data 속성으로 response 객체를 할당하고 클라이언트에게 그 새로운 object를 반환하는 코드입니다.

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

위 예제는 앞서 봤던 LoggingInterceptor와 타입이 지정되었다는 점에서 약간 다릅니다. TransformInterceptor에서는 T는 any 타입이고, R은 Response를 지정하였습니다. Response는 요구사항에 맞게 정의한 타입인 data 속성을 갖는 객체가 되도록 강제하는 역할입니다.

 

Nest의 interceptor는 동기/비동기 intercept() 모두 작동합니다.

 

위와 같은 구조에서 GET /cats 엔드포인트를 요청하면, 응답은 다음과 같을 것입니다.

  • 라우트 핸들러는 빈 배열 [] 을 반환한다고 가정합니다.
{
  "data": []
}

 

 

inteceptor는 애플리케이션 전체에서 발생하는 요구사항에 대해 재사용가능한 해결책을 만드는 훌륭한 가치를 가지고 있습니다. 예를 들어, 매번 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 ));
  }
}

 

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

 

라우트 핸들링 중 발생한 예외를 잡아 모두 BadGatewayException 으로 변환하는 동작을 합니다. 예외를 변환하는 것은 Exception Filter를 사용하는 것이 좋지만 Interceptor를 통해서 가능은 하다는 것을 보여주는 예시입니다.

 

1-8. Stream overriding

가끔가다 보면 핸들러 호출을 완전히 막고 그 대신에 다른 값을 반환해야 하는 상황을 볼 수 있습니다. 대표적인 예로 응답 시간을 개선하기 위해 캐싱 기능에 대한 구현을 들 수 있습니다.

 

캐시로부터 받은 응답을 반환하는 간단한 cache interceptor를 살펴봅시다.

 

현실적인 상황에서 발생할 수 있는 TTL(Time-to-Leave), cache invalidation, 캐시 사이즈 등 여러 요소를 고려할 수도 있겠지만, 그러한 것들은 잠시 제쳐두고 우선 가장 간단한 예시를 먼저 알아보도록 하겠습니다.

// 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() 연산자에 의해 생성된 새로운 stream을 반환하기 때문에 라우트 핸들러가 아예 호출되지 않을 것이라는 점입니다.

 

누군가 이 CacheInterceptor를 이용하는 엔드포인트로 요청을 날리면, (빈 배열을 반환하는) 응답은 즉시 반환됩니다.

 

일반적으로 적용되는 해결책은 Reflector를 활용하여 custom 데코레이터를 만드는 것입니다.

 

1-9. 그 외의 연산자들

RxJS 연산자들을 사용하여 stream을 조작할 수 있어 많은 기능을 구현할 수 있습니다.

 

이에 대한 또 다른 대표적인 예시를 보도록 합시다. 라우트 요청에 timeout(시간초과) 기능을 넣고 싶다고 합시다. 엔드포인트가 특정 기간 동안 어떠한 것도 반환하지 않을 때, 에러 응답을 통해 종료하고자 합니다.

 

다음은 이 기능을 구현한 예제입니다.

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

 

timeout(5000)으로 인해 5초의 시간 초과가 되도록 하여 에러 핸들러가 이를 받아 처리 중이던 요청이 취소되게끔 하였습니다.

 

물론 위 코드에서 RequestTimeoutException을 던지기 전에 커스텀 로직을 추가할 수도 있습니다.

 

2. 유저 서비스 실습

요청을 처리하기 전 HTTP 메서드와 URL을 로그로 남기고, 응답 할 때도 HTTP 메서드와 URL, 응답 결과를 로그로 남기는 interceptor를 만들어보겠습니다.

 

2-1. Interceptor 구현

// 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) {} // 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에 적용하도록 합니다.

// 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을 통해서 요청을 날려봅시다.

 

<오류 발생 시>

$ curl --location 'http://localhost:3000/users/01GXN39KWVPKV7WZR5XFD0A5FH' | jq
{
  "statusCode": 500,
  "message": "Internal Server Error"
}

 

{
  "statusCode": 500,
  "message": "Internal Server Error"
}

 

 

<정상적인 처리 시>

$ 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"} - {}

 

 

이상으로 Interceptor에 대한 설명이었습니다. 감사합니다.

반응형
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.