NESTJS를 배워보자(10) - Interceptors

yoon·2023년 8월 1일
0

NESTJS를 배워보자

목록 보기
10/21
post-thumbnail

Interceptors

nest의 공식문서를 토대로 작성합니다.

인터셉터는 @Injectable() 데코레이터가 달린 클래스이며 NestInterceptor 인터페이스를 구현합니다.

인터셉터에는 AOP(Aspect Oriented Programming) 기법에서 영감을 얻은 유용한 기능들이 있습니다. 인터셉터는 다음을 가능하게 합니다:

  • 메소드 실행 전/후에 추가 로직 바인딩.
  • 함수에서 반환된 결과 변환.
  • 함수에서 던져진 예외를 변환.
  • 기본 함수 동작 확장.
  • 특정 조건에 따라(예: 캐싱 목적) 함수를 완전히 재정의.

AOP?
저도 처음 보는 개념이라 찾아봤습니다. 감사합니다.👍
https://3months.tistory.com/74

Basics

각 인터셉터는 intercept() 메소드를 구현하는데 이 메소드는 두 개의 인수를 받습니다. 첫 번째는 ExecutionContext 인스턴스입니다(Guard와 정확히 동일한 객체). ExecutionContextArgumentsHost를 상속합니다. 앞서 예외 필터 챕터에서 ArgumentsHost를 살펴봤습니다.

Execution context

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

Call handler

두 번째 인자는 CallHandler입니다. CallHandler 인터페이스는 handle() 메소드를 구현하며 인터셉터의 특정 지점에서 route handler 메소드를 호출하는 데 사용할 수 있습니다. intercept() 메소드 구현에서 handle() 메소드를 호출하지 않으면 route handler 메소드가 전혀 실행되지 않습니다.

이 방식은 intercept() 메소드가 요청/응답 스트림을 효과적으로 wrapping 한다는 것을 의미합니다. 따라서 최종 route handler 실행 전후에 사용자 정의 로직을 구현할 수 있습니다. intercept() 메소드에 handle() 호출 전에 실행되는 코드를 작성할 수 있지만 그 이후에 일어나는 일에 어떤 영향을 미칠까요? handle() 메소드는 Observable을 반환하므로 강력한 RxJS 연산자를 사용하여 응답을 추가로 조작할 수 있습니다. 객체지향 프로그래밍 용어를 사용하면 route handler의 호출 즉 handle()의 호출을 PointCut이라고 하며 이는 추가 로직이 삽입되는 지점임을 나타냅니다.

예를 들어 들어오는 POST /cats 요청을 보면 CatsController 내부에 정의된 create() 핸들러로 향합니다. 도중에 handle() 메소드를 호출하지 않는 인터셉터가 호출되면 create() 메소드는 실행되지 않습니다. handle() 메소드가 호출되면(그리고 해당 Observable이 반환되면) create() 핸들러가 트리거됩니다. 그리고 Obervable을 통해 응답 스트림이 수신되면 스트림에서 추가 작업을 수행하고 최종 결과를 호출자에게 반환할 수 있습니다.

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

HINT
NestInterceptor<T, R>에서 T는 응답 스트림을 지원하는 Observable<T>의 유형, R은 Observable<R>로 래핑된 값의 유형.

NOTICE
인터셉터는 컨트롤러, providers, 가드 등과 같이 constructor를 통해 종속성을 주입.

handle()은 RxJS Observable을 반환하므로 스트림을 조작하는 데 사용할 수 있는 연산자를 폭넓게 선택 가능합니다. 위의 예에서는 observable 스트림이 정상적으로 종료되거나 예외적으로 종료될 때 익명 로깅 함수를 호출하지만 그 외에는 응답 주기를 방해하지 않는 tap() 연산자를 사용했습니다.

Binding interceptors

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

# cats.controller.ts

@UseInterceptors(LoggingInterceptor)
export class CatsController {}

위처럼 작성하면 CatsController에 정의된 각 route handler는 LogginInterceptor를 사용합니다. 누군가 Get /cats 엔드포인트를 호출하면 표준 출력에서 다음을 볼 수 있습니다.

Before...
After... 1ms

인스턴스 대신 LoggingInterceptor 타입을 전달하여 인스턴스화에 대한 책임을 프레임워크에 맡기고 의존성 주입을 활성화했습니다. 파이프, 가드, 예외 필터와 마찬가지로 in-place 인스턴스도 전달 가능합니다:

# cats.controller.ts

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

앞서 언급했듯이 위의 구조는 이 컨트롤러가 선언한 모든 핸들러에 인터셉터를 적용합니다. 인터셉터의 범위를 단일 메소드로 제한하려면 메소드 수준에서 데코레이터를 적용하기만 하면 됩니다.

전역 인터셉터를 설정하기 위해서는 Nest 애플리케이션 인스턴스의 useGlobalInterceptors() 메소드를 사용하면 됩니다.

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

전역 인터셉터는 모든 컨트롤러와 모든 route handler에 적용됩니다. 종속성 주입과 관련하여 모듈 외부에서 등록한 전역 인터셉터는 모듈 컨텍스트 외부에서 수행되므로 종속성을 주입할 수 없습니다. 이 문제를 해결하기 위해 다음 구성을 사용하여 모든 모듈에서 직접 인터셉터를 설정할 수 있습니다:

# app.module.ts

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

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

HINT
이 방식을 사용하면 모듈에 관계없이 인터셉터는 전역임.

Response mapping

우리는 이미 handle()Observable을 반환한다는 것을 알고 있습니다. 스트림에는 route handler에서 반환된 값이 포함되어 있으므로 RxJS의 map() 연산자를 사용하여 쉽게 변경할 수 있습니다.

WARNING
응답 매핑 기능은 라이브러리별 응답 전략에서는 작동하지 않음
(@Res() 객체를 직접 사용하는 것은 금지되어 있음).

과정을 보여주기 위해 각 응답을 간단한 방식으로 수정하는 TransformInterceptor를 만들어 봅시다. 이 함수는 RxJS의 map() 연산자를 사용하여 응답 객체를 새로 생성된 객체의 데이터 프로퍼티에 할당하고 새 객체를 클라이언트에 반환합니다.

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

HINT
Nest 인터셉터는 동기 및 비동기 메소드에서 모두 작동. 필요한 경우 메소드를 비동기로 전환하기만 하면 됨.

위처럼 작성하면 누군가 GET /cats 엔드포인트를 호출하면 다음과 같은 응답이 표시됩니다(route handler가 빈 배열을 반환한다고 가정):

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

저는 이렇게 작성했습니다.

# excludeNull.interceptor.ts

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) => {
        console.log(value);
        return value === null ? 'not null' : value;
      }),
    );
  }
}
# cats.controller.ts

...
@Get('/test')
  async test() {
    const a = null;
    return this.catsService.test(a);
  }
}
# cats.service.ts

...
test(a) {
    console.log('service.test');
    return a;
  }
}

GET /cats/test 호출해보시면

이렇게 null 대신에 'not null'이라는 문자열이 출력되는 것을 확인할 수 있습니다.

그리고 제가 콘솔로 찍은 걸 확인하면 순서는 controller -> service -> interceptor 인 것을 확인할 수 있습니다.

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

Catscontroller에서 test()를 이렇게 바꿔줍니다.

# cats.controller.ts

...
@Get('/test')
  async test() {
    throw new ForbiddenException();
  }
}

이대로 Get /cats/test를 호출해보면

이렇게 출력되는데 인터셉터를 적용하면

이렇게 바뀌어서 출력되는 것을 확인할 수 있습니다.

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() 연산자에 의해 생성된 새 스트림을 반환하므로 route handler가 전혀 호출되지 않았다는 것입니다. 누군가 CacheInterceptor를 사용하는 엔드포인트를 호출하면 응답(하드코딩된 빈 배열)이 즉시 반환됩니다. 일반적인 솔루션을 만들기 위해 Reflector를 활용하고 사용자 정의 데코레이터를 만들 수 있습니다. Reflector가드 챕터에서 잘 설명되어 있습니다.

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초가 지나면 요청 처리가 취소됩니다. 요청 시간 초과 예외가 발생하기 전에 사용자 지정 로직을 추가할 수도 있습니다.

이것도 직접 한번 해볼까요?

# cats.controller.ts

...
@Get('/test')
  async test() {
    return await this.catsService.test();
  }
}
# cats.service.ts

...
async test() {
    let ms = 10000;
    return new Promise((r) => setTimeout(r, ms));
  }
}

이렇게 10초를 딜레이 해보겠습니다.
해당 엔드포인트를 호출하면

이렇게 시간이 가다가 5초가 지나면

에러를 출력하는 것을 확인할 수 있습니다.

제 코드입니다. 😎
https://github.com/cxzaqq/cxzaqq-velog/tree/2.9-interceptors

고생하셨습니다!
다음 글에서 만나요~~😀


저도 아직 배우는 단계입니다. 지적 감사히 받겠습니다. 함께 열심히 공부해요!!

profile
백엔드 개발자 지망생

1개의 댓글

comment-user-thumbnail
2023년 8월 1일

잘 봤습니다. 좋은 글 감사합니다.

답글 달기