NestJS Overview - Interceptors

Min Su Kwon·2021년 12월 6일
1
post-thumbnail

인터셉터는 @Injectable() 데코레이터가 적용된 클래스다. 인터셉터는 NestInterceptor 인터페이스를 implement 해야한다.

인터셉터들은 AOP 테크닉에 영감받은 몇가지 유용한 능력을 가지고 있다.

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

Basics

각각의 인터셉터는 intercept 메서드를 구현해야한다. 이 메서드는 두개의 인자를 받는다. 첫 번째 인자는 ExecutionContext 인스턴스를, 두번째 인자는 CallHandler를 받는다.

Execution context

ArgumentsHost 상속을 통해서, ExecutionContext는 여러가지 현재 실행 컨텍스트에 대한 유용한 정보들을 제공하는 새로운 헬퍼 메서드들도 추가해준다. 자세한 내용은 다음 문서에서 다룬다.

Call handler

CallHandler 인터페이스는 handle() 메서드 구현을 필요로하며, 인터셉터 내부에서 원하는 시기에 라우트 핸들러 메서드 호출을 하기 위해 사용한다. intercept() 메서드 내에서 handle() 메서드를 따로 호출하지 않으면, 라우트 핸들러는 아예 실행되지 않는다.

이 접근법은 intercept() 메서드가 효과적으로 요청/응답 스트림을 래핑한다는 것이다. 결과적으로, 커스텀 로직을 라우트 핸들러 전과 후 모두에 추가할 수 있게된다. handle() 메서드 호출 전에 로직을 추가하는 것은 명확한데, 호출 후의 로직은 어떻게 하면 좋을까? handle() 메서드의 반환값은 Observable이기 때문에, RxJS 연산자들을 사용해서 응답을 원하는대로 조작할 수 있다. AOP의 용어를 사용하면, 라우트 핸들러의 호출은 Pointcut이라고 불린다(추가 로직이 삽입되는 곳이라는 뜻)

예를 들어서, POST /cats로 오는 요청이 있다고 생각해보자. 요청은 CatsController 내부에 정의된 create() 핸들러에게 가야한다. 만약 handle() 메서드를 호출하지 않는 인터셉터가 도중에 호출됐다면, create() 핸들러가 트리거된다. 그리고 Observable을 통해 응답 스트림을 받으면, 추가적인 연산들이 스트림 위에서 실행된 뒤 마지막 결과가 호출자에게 반환된다.

Aspect Interception

첫번째 유즈케이스로 살펴볼 인터셉터의 사용은 사용자의 상호작용을 로깅하는 것이다. 아래의 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>의 타입을 의미하고, RObservable<R>로 감싸진 값의 타입을 의미한다.

인터셉트들은 다른 컨트롤러, 프로바이더, 가드들과 동일하게 생성자에서의 의존성 주입이 가능하다.

handle() 메서드가 RxJS Observable을 반환하기 때문에, 스트림을 조작하기 위해 다양한 연산자들을 사용할 수 있다. 위의 예시에서는 tap() 연산자를 사용해서 Observable 스트림이 graceful/exceptional하게 끝났을 때 익명 로깅 함수를 호출하지만, 아닌 경우에는 응답 사이클에 관여하지 않는다.

Binding Interceptors

인터셉터를 세팅하기 위해서, @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 {}

Response mapping

handle()Observable을 반환한다는 것은 우리가 이미 아는 사실이다. 스트림에는 라우트 핸들러가 반환한 값을 포함하게 되며, 따라서 RxJS의 map() 연산자를 통해서 그 내용을 바꿀 수 있다.

응답 매핑 기능은 라이브러리 specific한 응답 전략을 사용할 시에는 동작하지 않을 수 있다.

TransformInterceptor를 만들어보자. 이 인터셉터는 각 응답을 명확한 방법으로 변경할 것이다. RxJS의 map 연산자를 사용해서 응답 객체를 새로 만드는 객체의 data 프로퍼티에 넣고, 이 객체를 클라이언트에게 반환하도록 할 것이다.

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

네스트 인터셉터는 sync/async intercept() 메서드를 모두 지원한다. 원한다면 그냥 바꾸면 된다.

위처럼 코드를 작성한 후 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 ));
  }
}

Exception mapping

다른 흥미로운 유즈 케이스로는 RxJScatchError 연산자를 통해서 던져진 예외를 오버라이드 할 수 있다.

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

핸들러를 호출하기보다, 다른 값을 반환하고자 하는 케이스가 있을 수 있다. 예를 들어, 응답 시간을 줄이기 위해 캐시를 구현하는 경우가 있을 수 있다. 아래의 간단한 캐시 인터셉터를 살펴보자. 이 인터셉터는 캐싱되어 있으면 캐싱된 값을, 아니라면 핸들러를 호출한다.

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 연산자를 통해서 새로운 스트림을 반환하고 있다는 점으로, 따라서 라우트 핸들러는 아예 호출되지 않을 것이다. 누군가 캐시 인터셉터가 적용된 엔드포인트로 요청을 하게되면, 응답이 곧바로 반환될 것이다. 일반적인 해결법을 만들기 위해, 새로운 커스텀 데코레이터를 만들어서 내부에서 Reflector를 활용할 수 있다.

More operators

RxJS 연산자를 이용해서 스트림을 조작할 수 있는 가능성은 우리에게 많은 능력을 준다. 다른 일반적인 유즈케이스를 생각해보자. 라우트 요청에 타임아웃을 핸들링하고 싶다고 해보자. 특정 시간 내에 엔드포인트가 아무것도 반환하지 않으면, 요청을 끝내고 에러 응답을 반환하고자 한다. 아래와 같은 코드로 이를 구현할 수 있다.

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을 던지기 전에 더 많은 커스텀 로직을 넣을 수도 있다.

느낀 점

인터셉터를 활용해서 꽤 많은 코드중복을 제거할 수 있지 않을까싶다. 실제 라우트 핸들러 메서드를 호출하기 전은 물론 후까지, 거기다 추가적으로 타임아웃과 같은 것도 설정할 수 있다니, 놀랍지만 아직 안써봐서 얼마나 강력한 기능인지 상상이 안간다.

무엇보다 RxJS의 Observable과 연산자들에 대해서 잘 모르다보니, 이해하는데 한계가 있는 것 같다. 이 친구들도 스터디 해보고 나서 다시보면, 더 이해가 잘되고 활용도 잘 할 수 있을 것 같다.

profile
이제 막 커리어를 시작한 소프트웨어 엔지니어입니다. 배운 것을 정리하면서 조금 더 깊이 이해하려는 습관을 들이려고 합니다. 피드백은 언제나 환영입니다.

0개의 댓글