Interceptors

Interceptors

NestInterceptor 인터페이스를 구현하고 @Injectable() 데코레이터가 어노테이션으로 붙는 클래스이다.

interceptor 는 AOP 관점에서 탁월한 능력을 몇가지 갖고 있다.

  • method 가 실행되기 전과 후에 외부 로직을 bind 할 수 있다.
  • 함수에 return 을 변형시킬 수 있다.
  • 함수에 예외를 변형시킨다.
  • 함수에 기본동작을 확장한다.
  • 특정 조건에 따라 완전히 함수를 오버라이딩 할 수 있다.

Basics

Interceptor 는 intercept() 메서드를 구현한다. intercept() 메서드는 두개의 매개변수를 갖는다. 하나는 ExecutionContext 인스턴이다( guard 와 정확히 같은 객체). ExecutionContextArgumentsHost 를 상속한다. 두번째 매개변수는 Call handler 이다.

Execution context

ArgumentHost 를 확장했고, ExecutionContext 는 현재 실행 프로세스를 세부적인 사항을 제공하는 헬퍼 메서드를 추가했다.

Call handler

CallHandler 인터페이스는 route handler method 를 interceptor 에 특정 시점에 호출하여 사용할 수 있는 handler() 메서드를 구현한다. 만약 interceptor() 메서드에서 handler() 메서드를 호출하지 않는다면 route handler method 는 실행되지 않을 것이다.

이것은 interceptor() 메서드가 효과적으로 request/response 스트립을 wrap 했다는 것을 의미한다. 결과적으로 route handler method 가 호출되기 전과 후에 호출되는 커스텀 로직을 구현할 수 있게된다. handler() 가 호출되기 전에 로직을 작성하면 route handler method 가 호출되기 전 실행되는 로직을 만들 수 있는 것은 분명한데, 호출된 이후에는 어떻게 알 수 있을까? 답은 handler() 메서드가 Observable 를 반환하기 때문에 알 수 있다. RxJs 기능을 사용하여 응답을 조작하는 작업을 할 수 있다. AOP 관점에서 handler() 메서드는 추가 로직이 추가되어야 하는 지점을 나타내는 Pointcut 이라고 불린다.

handler() 가 호출되는 시점에서 Controller 에 메서드가 트리거 되고 Observable 를 반환하기 때문에 stream 안에서 추가작인 작업을 수행할 수 있다.

Aspect interception

첫번째 use case 는 사용자의 intercation을 로그로 남기는 LoggingInterceptor 이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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> 는 generic interface 이다. T 는 응답 스트림 Observable<T> R 은 Wrapping 된 Observable<R> 을 나타낸다.

interceptors 또한, constructor 를 통해서 주입될 수 있다.

Binding interceptors

인터셉터를 세팅하기 위해서 @UseInterceptors() 를 사용한다. pipesguards 와 마찬가지로 controller-scoped, method-scoped, global-scoped 모두 가능하다.

1
2
@UseInterceptors(LoggingInterceptor)
export class CatsController {}

이제 컨트롤러에 요청마다 다음과 같은 로그를 확인할 수 있다.

1
2
Before...
After... 1ms

위에 예에서 Type을 전달하여 프레임워크에 인스턴스화에 대한 책임을 맡기고 종속성을 주입했다. 또한, 인스턴스를 넘기는 것도 가능하다.

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

Controller 레벨에서 설정했기 때문에 모든 메서드마다 Logging 이 실행된다. 만약 특정 함수로 제한하고 싶다면 method 레벨에서 설정해주면 된다.

전역으로 설정하고 싶다면 app 에 useGlobalInterceptors() 를 사용하여 등록해주면 된다.

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

이렇게 작성하면 dependnecy injection 측면에서 좋지 않기 때문에 module 에 주입하도록 작성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';

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

Response mapping

handler()Observable 를 반환한다. 따라서 RxJS 의 map() 기능을 사용할 수 있다.

응답 매핑 기능은 라이브러리별 응답전략에서는 작동하지 않는다. (@Res 에서 작동 안함)

RxJS 의 map 을 사용하여data property 를 응답에 추가해 클라이언트에게 전달하는 TransformInterceptor 를 구현해 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 })));
}
}

Nest interceptors 는 동기와 비동기 모두 동작한다. 필요하면 async 키워드를 사용할 수 있다.

interceptor 는 애플리케이션 전체를 가로지르는 좋은 해결책이 된다. 만약 모든 응답에서 null 인 값을 빈 스트링으로 바꿔야 한다고 가정해보자. 이 경우 interceptor 구현하고 전역으로 사용하도록 주입하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
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

RxJS 의 catchError() 를 사용하여 예외를 override 할수도 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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

응답 시간을 줄이기 위해 캐시를 사용해 응답을 완전히 바꾸는 등의 작업도 할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
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();
}
}

여기서는 하드 코딩 된 isCached 와 [] 를 사용했지만 중요한 점은 RxJS 의 of 를 사용하여 생성된 스트림을 여기에서 반환하므로 route handler 가 호출되지 않는다는 것이다. 만약 CacheInterceptor 를 사용하는 메서드에서는 응답이 빈 리스트로 바로 반환될 것이다.

More operators

route request 에 timouts 를 주고 싶다고 가정해보자. 일정 기간동안 응답 하지 않으면 error 를 반한하게 한다.