제목 : NestJS 의 Interceptor
프로그래머스 풀스택 부트캠프에서 팀 프로젝트를 진행했을 때,
Express 와 NestJS 의 선택지가 존재했는데,
나는 Spring CRUD 정도는 경험 해 본 지라,
NestJS 에서의 데코레이터를 통한 메타 프로그래밍 방식을 익히고,
파일과 코드 컨벤션을 통한 팀 프로젝트 협업을 배우고자 하였다.
특히, Express 의 경우 디렉토리 구조, 클라이언트와 컨트롤러 사이의 로직이 너무나 자유로워
팀 프로젝트에서 큰 혼란을 줄 수도 있다는 판단이 들었기 때문에 NestJS 를 선택했다.
아예 NestJS 를 처음 사용해 보기 때문에, 공식문서와 팀원분의 코드를 읽으며 빠르게 구현하는 법을 학습했다.
하지만, 모든 프레임워크가 그렇듯, 구현을 우선시하다가 이해를 넘겨버리게 되었다.
지금에 와서, 나는 새로이 udemy NestJS 강의를 들으며 숙련도를 높이고 있다.
그리고, 궁금한 것은 모두 직접 조사 및 공부하여 포스팅하고 있다.
그렇다면, 왜 NestJS 의 interceptor 를 포스팅하냐 하면,
팀 프로젝트 당시 인터셉터로 나눌 수 있었던 비즈니스 로직을 전부 Middleware 로 만들었기 때문이다.
물론 대부분의 기능을 미들웨어로 만들 수 있었지만, 쉽게 로직을 추가하기 위해 인터셉터를 사용했으면
더 좋지 않았을까 생각이 든다.
NestJS Interceptor 와 AOP 의 관계
NestJS 는 AOP (Aspect Oriented Programming) 을 지원하는 인터셉터를 만들었다.
우선, 요청을 검수할 수 있는 다른 기능들과 달리,
NestInterceptor
는 클라이언트와 요청 사이, 클라이언트와 응답 사이
이 두 개의 로직을 추가하거나, 세분화 할 수 있다는 장점이 있다.
그래서 AOP 라는 개념을 어떻게 적용시킬까?
AOP (Aspect Oriented Programming)
프로그래밍 기술이 발달함에 따라, 단순히 웹과 서버, 데이터베이스 서버를 관리하는 것이 아니라,
프로그래밍 과정에서 나오는 산출물이 매우 중요해지는 시기가 이미 왔다.
이러한 산출물들을 "어떤 시점으로 바라보냐" 에 따라서, 프로그램 코드가 추가되는 것을
AOP 라고 부르지 않나 생각된다.
밑은 AOP 의 예시다.
1. 고객이 요청을 했는데, 특정 비즈니스 구간에서 느려지는 경향이 있다고 보고한다
- 그렇다면, 우리가 제공하고 있는 API 들의 수행 시간을 모두 검사 해 보자.
2. 고객이 어떤 기능을 자주 사용하는지 알기 위해 외부로 데이터를 로깅 할 수 있도록 송출해야겠다
- 글로벌(전역) 혹은, 어떠한 컨트롤러, 혹은 메서드에 직접 적용하여 중간에 로직을 추가해야겠다.
3. 어떠한 요청 혹은 다수를 처리하는 데 있어 일정 시간이 지난다면, 특정 에러를 반환하고 싶다
- 그렇다면 응답 구간에 특정 로직을 추가해야겠다.
한번 내가 상상 해 본 AOP 의 예시들이다.
우리는 프로그램에 로직을 추가하거나, 최적화 하기 위해 다양한 관점으로 프로그램을 볼 수 밖에 없다.
기존의 방향성이 옳은지, 혹은 틀린지를 판단하기 위해서라도 시각을 달리 볼 수 밖에 없다고 생각한다.
그리고, 로직의 어떤 부분, 과정에 넣을지도 선택 할 수 있다.
요청, 응답 둘 다 처리 하는 것인지, 혹은 둘 중 하나만을 검사하고 처리하는 것인지도 선택할 수 있다.
그리고 특히, 이미 만들어진 로직에 핵심적인 영향을 주지 않는다는 것이 중요하다.
따라서, NestInterceptor 의 개념과 사용법을 파헤쳐 볼 이유는 충분하다고 생각한다.
NestInterceptor 와 RxJS?
우선, NestInterceptor
는 RxJS
라이브러리와 상호작용한다.
단순히 구현만 하다보면, 그냥 프레임워크에 잡아먹힌다.
특히나 외부 모듈에 굉장히 의존적인 Node.js 의 특성상, 하나를 놓치면 나머지는 잡을 수도 없다.
그래서, RxJS
에 대해서 짚고 넘어가기로 결정했다.
RxJS 문서 를 참조하여 작성한다. (내 지식과 의견도 함께)
아... RxJS 의 공식문서를 보았는데, RxJS 는 단순히 내가 작성하고 있는 Article 의 일부분으로서만
작성하기에는 굉장히 다양한 사용법과, 응용법이 존재했다.
따라서, RxJS 의 기본 Philosophy 를 다루며,
간단한 예제를 통한 의미를 살펴 볼 것이다.
RxJS 의 관찰자(Observer) 와 사용법
NestJS 에서 NestInterceptor
를 사용하기 위해서는 rxjs
를 사용해야 한다.
즉, 내부 프레임워크에서 자체적으로 인터셉터를 모두 구현 해 놓은 것이 아니라, rxjs
라이브러리와
상호작용하는 것이다.
Example :
import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
export class DeleteInterceptor implements NestInterceptor {
intercept(
context: ExecutionContext,
next: CallHandler<any>,
): Observable<any> | Promise<Observable<any>> {
return next.handle();
}
}
이것이 NestInterceptor
의 기본 골자이다.
보면, 인터페이스를 구현하는 과정에서, 반드시 rxjs
의 라이브러리의 객체인 Observable
을 반환하고 있다.
인터셉터는 로직을 끼우거나 커스텀 할 수 있다면서, 무엇인지도 모를 Observable
을 반환하고 있는가?
이에 대해서 알아볼 시간이다.
나는 프레임워크가 왜 외부 라이브러리인 rxjs
를 사용하는지 이해가 되지 않았다.
특히나, 인터셉터라는 Nest 자신만의 객체를 외부 라이브러리의 힘까지 가지고 와서 구현 할 필요가 있었을까?
왜 그랬는지 알아내기 위해 직접 공식문서에서 rxjs
의 사용법을 보기 시작했다.
rxjs 공식문서 예시
// 순수 JS
document.addEventListener("click", () => console.log("Clicked"));
// RxJS 사용하는 JS
import {fromEvent} from "rxjs";
fronEvent(document, "click").subscribe(() => console.log("Clicked"));
위의 예시는 DOM
(Document Object Model) 에 탑재된 "기본 이벤트" 를 이용한 예제이다.
document
객체는 DOM
으로서, 유저 interaction(클릭) 에 대한 정보를 가지고 있다.
RxJS
에서 보여주고자 한 것은, DOM
오브젝트가 가지고 있는 기본 이벤트 리스너를 이용하여
특정 이벤트가 실행되었을 때 실행 할 함수를 Callback 함수로 지정 해 놓은 것이다.
즉, 특정 이벤트를 "구독"(subscribe) 한다는 것이다.
하지만, NestJS 의 오브젝트는 DOM
오브젝트는 아니므로, 좀 더 탐구하기로 했다.
RxJS 공식사이트 예제 2
// 순수 JS
let count = 0;
document.addEventListener('click', () => console.log(`Clicked ${++count} times`));
// RxJS
import { fromEvent, scan } from 'rxjs';
fromEvent(document, 'click')
.pipe(scan((count) => count + 1, 0))
.subscribe((count) => console.log(`Clicked ${count} times`));
NestInterceptor
에서 사용되는 pipe
에 대한 정보를 볼 수 있다.
그렇다면, 위에서 보여주고자 하는 정보를 요약 해 보자.
pipe(...)
를 통해 해당 이벤트가 일어났을 때,
subscribe
에 전달 해 주기 위한 결과물을 특정 처리한다.subscribe
를 통해, 내부에 저장된count
를 이용하여 콜백 함수를 실행한다.count
는scan
함수를 통해 저장된 변수이며, 이는 리액트의 커스텀 디스패치와 유사하다.
그렇다면, NestJS 의 인터셉터와 비교하기 전에, 마지막으로 rxjs 의 map
을 알아보자.
여타 다른 언어와 비슷하게, map
은 기존 객체 혹은 변수의 값들을 특정 조건에 의해 변환하는 데 사용한다.
EX
import { fromEvent, map } from 'rxjs';
const clicks = fromEvent<PointerEvent>(document, 'click');
const positions = clicks.pipe(map(ev => ev.clientX));
positions.subscribe(x => console.log(x));
그런데, fromEvent
를 살펴보면 알 수 있는 것은,
"click"
이라는 이벤트에 대해서 구독을 신청 한 것은 document
객체 하나뿐이다.
즉, "click"
이라는 이벤트가 일어났을 때,
clicks
이벤트 객체 ev
에서 clientX
로 꺼내 Observable
타입으로 래핑하는 것이다.
그리고 나서, 마지막 줄인 subscribe
에서 x
는 매핑된 ev.clientX
를 출력하게 된다.
NestJS 의 인터셉터와 비교 해 보자.
import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
export class DeleteInterceptor implements NestInterceptor {
intercept(
context: ExecutionContext,
next: CallHandler<any>,
): Observable<any> | Promise<Observable<any>> {
return next.handle();
}
}
context
는 여기서 사용되지 않는다. next
에 신경쓰면 된다.
그런데, 재밌는 것은, context
와 next
둘 다 타입으로만 선언되어 있다.
즉, NestInterceptor
라고 선언하는 것 자체가, 형식에 맞춘 객체를 생성한다고 생각하면 된다.
이는 이따가 설명하겠다.
중요 한 것은, next
가 응답 스트림에 반응한다는 것이다.
그리고, next.handle()
은 Observable
을 반환한다.
NestJS 는 rxjs
라이브러리의 타입을 반환한다.
요약하자면
next
는 응답 스트림에 반응하는 타입이다.handle()
은 그런 응답 스트림에 반응하는Observable
을 반환한다.
그렇다면, rxjs
는 NestJS 의 타입 지원과 함께, 응답 스트림에 반응하며,
실제 응답 시 반환되는 데이터를 어떻게 처리 할 것인지 만들 수 있는 것이다.
그런데, NestInterceptor 를 실제로 구성하는 로직은 찾을 수 없었다.
여기서 드는 의문점 : NestInterceptor 는 도대체 무슨 객체지?
context : ExecutionContext
와, next : CallHandler
이 둘의 실제 로직을 살펴보기 위해 정의 파일을 뒤졌다.
그런데, 이들은 interface
나 type
으로 구성되어 있을 뿐,
실제로 컨텍스트를 조작하거나, 응답에 반응할 수 있는 로직을 반환하지 않았다.
그렇다면 도대체 NestInterceptor
는 무엇인가?
나는 잠시 잊고 있었던 NestJS 프로젝트의 구성 과정을 떠올렸다.
NestJS 는 메타 프로그래밍 언어로 구성된다는 걸.
즉, NestInterceptor
를 implements
한다는 것은,
NestJS 가 인터셉터로서 로직을 수행하기 위해,
알맞은 타입을 입력하여 로직을 구성하고,
NestJS 는 데코레이터를 통해 함수나 클래스의 메타데이터를 읽은 후,
context
와 next
에 걸맞는 데이터를 주입한다는 것이었다.
이 때, @UseInterceptor()
를 사용한다면,
괄호 내부에 들어간 인터셉터들이 MethodDecorator
혹은 ClassDecorator
로 변환되어,
메서드나 클래스 내부의 로직을 중간에 가로채는 것이다.
이 과정에서, rxjs
를 사용하여 요청 스트림, 응답 스트림을 가로챌 수 있게 만들어 둔 것이다.
여기까지 이해하는 데 조금 시간이 걸렸다.
요약하여, NestInterceptor
는 요청, 응답 스트림 중간에 정보를 변환할 수 있는,
일종의 메서드 데코레이터, 클래스 데코레이터라는 것이다.
우리는 NestJS 에서 주입하는 정보의 형태를 맞추기 위해 NestInterceptor
를 implements
하는 것이다.
자, 이제 구현을 해 보자.
NestInterceptor
사용법
반환의 개념은 위에서 먼저 보여주었으니, context
를 의미하는 ExecutionContext
를 먼저 시작해보자.
ExecutionContext
는 다루게 될 요청, 응답 객체를 조정할 수 있다.
그리고, NestInterceptor
가 사용된 메서드 이름, 그리고 클래스 타입도 알 수 있다.
또한, context
는 해당 메서드에 할당된 인자를 배열로 가져올 수 있으며,
index 와 함께 특정 인자만을 가져올 수도 있다.
이에 대한 메서드나 변형 방식은 NestJS 공식문서 Execution context 를 참조하면 된다.
상황 1. 특정 api 들이 요청하고 응답하는 시간을 측정하고 싶다.
export class TestInterceptor implements NestInterceptor {
intercept(
context: ExecutionContext,
next: CallHandler<any>,
) : Observable<any> | Promise<Observable<any>> {
// 요청 받은 시각을 기록 - ms 측정 단위를 가진 숫자
const reqTime = Date.now();
// 결과에 출력할 컨트롤러와 메서드 정보 기록
const classWithMethod = context.getClass().name + " - " + context.getHandler().name;
return next.handle().pipe(
tap(() => console.log(`${classWithMethod} 요청과 응답 사이의 시간 : ${Date.now() - reqTime} ms`))
)
}
}
context
에 주입된 메타데이터와, 인터페이스가 가지는 고유의 변수 저장을 이용하여
인터셉터가 적용된 컨트롤러와, 메서드 이름, 현재 시각을 기록하고,
반환 시 그 때의 시간과 이전 시각의 차이를 이용하여 실제 소비 시간을 기록할 수 있었다.
Result :
UsersController - findUser 요청과 응답 사이의 시간 : 8 ms
상황 2. 유저가 보낸 요청에 권한이 있는지 확인해야겠다.
import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
export class AuthInterceptor implements NestInterceptor {
intercept(
context: ExecutionContext,
next: CallHandler<any>
): Observable<any> | Promise<Observable<any>> {
// 요청 받은 시각을 기록 - ms 측정 단위를 가진 숫자
const httpCtx = context.switchToHttp();
const request = httpCtx.getRequest<Request>();
// Redis, 혹은 JWT 토큰을 파싱하여 헤더의 권한을 인증
console.log(request.headers);
return next.handle();
}
}
Result :
{
authorization: 'user',
'user-agent': 'IntelliJ HTTP Client/IntelliJ IDEA 2024.3.5',
'accept-encoding': 'br, deflate, gzip, x-gzip',
accept: '*/*',
host: 'localhost:3000'
}
간단하게 .http
파일을 이용하여 전달하였으며,
Authorization
헤더에 user
값을 넣었다.
여기에는 보통 컨벤션으로 Bearer xxxxxx
를 넣어 전송하지만,
JWT 페이로드의 의미와 파싱, 그리고 암호화 부분은 여기서 다룰 부분은 아니라 생각하여 user
로 대체했다.
먼저, ExecutionContext
는 http
객체로 바꾸는데,
여기서 Express
를 사용했기 때문에, req
, res
를 얻을 수 있다.
들어온 요청에 대한 Headers
혹은 Cookies
를 가져올 수 있다.
사용자의 보안 인가 및 인증 절차는 프로그램의 요구 사항에 따라 매우 달라진다.
이를 감안하여 보면 된다.
상황 3. 해당 api 요청이 5 초 넘게 걸린다면, 에러를 즉시 내보낸다.
export class TimeoutInterceptor implements NextInterceptor {
intercept(
context: ExecutionContext,
next: CallHandler<any>
): Observable<any> | Promise<Observable<any>> {
return next.handle()
.pipe(
// 인터셉터가 적용되는 api 들은 5 초의 제한시간을 가진다.
timeout(5000),
// 만약에 에러가 발견되었을 때,
catchError(err => {
// 던져진 에러가 "TimeoutError" 객체의 인스턴스라면,
if(err instanceof TimeoutError) {
// 클라이언트에게 요청시간 예외를 보낸다.
return throwError(() => new RequestTimeoutException());
}
// 사실상 else --> 그렇지 않은 에러라면, 그대로 예외를 내보낸다.
return throwError(() => err);
})
)
}
}
위의 인터셉터는 RxJS
의 catchError
를 이용하여 에러를 처리하는데,
시간에 대한 에러만 특별히 처리하고, 그렇지 않은 에러는 그대로 반환한다.
배운 점
강의를 듣다가, 클라이언트와 서버 사이의 로직을 처리하는 인터셉터의 원리가 궁금하여 파헤쳐 보았다.
NestInterceptor
라는 형태가 존재하기에, 이는 따로 객체 형태로 존재하여 구현된 줄 알았다.
하지만, 이는 메타프로그래밍 언어로서의 NestJS 가 의존성 주입을 위해 어떠한 형태를 잡아놓은 것이었다.
즉, NestInterceptor
는 클래스가 아니고, 인터페이스이다.
그것도, interceptor()
라는 메서드를 가지며, rxjs
의 Observable
을 반환하는 인터페이스.
그렇다면, 인터페이스는 interceptor
메서드 내부 인자가
ExecutionContext
CallHandler
이 둘을 가지는데, 이 값은 어떻게 인지되는가? 하면,
이 또한 모두 인터페이스로서 구현 된 것이다.
실질적으로, 우리는 NestJS 에서 제공한 NestInterceptor
의 실질적인 로직(클래스)를 제작 한 것이 아니라,
데코레이터 및 컨테이너 IoC 에 메타정보를 주입하기 위한 어떠한 틀을, 다시 NestJS 에게 제공 한 것이다.
그리고 또한 배운 것은, NestJS 는 interceptor
, filter
, guard
와 같은 컴포넌트들을
단순히 내부적인 프레임워크에서 독립적으로 처리하지 않고, rxjs
라는 외부 라이브러리와 깊이 연동시켜 놓았다.
물론, rxjs
의 요청, 응답 스트림에 반응하여 데이터를 조작할 수 있는 것은 좋지만,
기본적인 rxjs
라이브러리의 작성 형태를 알아야 한다는 말이기도 하다.
간단한 RxJS 의 사용법 - 진짜 단순하게
RxJS 는 스스로 구독하거나, 감시 할 수 있는 객체인 Observable
에
로직을 끼워넣거나, 추가 이벤트를 넣을 수 있다.
그리고, 이러한 Observable
을 처음 만들기 위해서는,
일반 객체는 EventTarget
을 계승하거나, 그 자체여야 한다.
예시
const {fromEvent} = require("rxjs")
const ob = new EventTarget();
ob.addEventListener("test-event", () => {
console.log("테스트 이벤트 작동");
})
fromEvent(ob, "test-event").subscribe(
(event) => {
console.log("rxjs 가 'test-event' 에 따라 이벤트 발동");
console.log(event);
}
)
const ev = new Event('test-event');
ob.dispatchEvent(ev);
console.log(ev);
Result :
$ node rxjs-exam-1.js
테스트 이벤트 작동 # ob 에 이미 등록해 둔 기본 이벤트 action
rxjs 가 'test-event' 에 따라 이벤트 발동 # rxjs 를 이용하여 구독 해 둔 "test-event" 에 반응하여 발동
Event { # `Observable` 이 내부 콜백 함수에 전달해 주는 event 값을 출력
type: 'test-event',
defaultPrevented: false,
cancelable: false,
timeStamp: 101.452125
}
Event { # JS 의 실제 `Event` - `Observable` 에서 다뤄지는 이벤트와 동일하다.
type: 'test-event',
defaultPrevented: false,
cancelable: false,
timeStamp: 105.842542
}
JS 의 실제 Event
와 동일한 속성을 가지고 있는 것을 확인 할 수 있다.
참고 사이트
https://docs.nestjs.com/interceptors
'Node.js > 잡다 지식' 카테고리의 다른 글
Jest 와 유닛 테스트 (0) | 2025.05.05 |
---|---|
비밀번호는 왜 해싱할까? - With Node.js (0) | 2025.04.15 |
WebAssembly 와 Node.js (0) | 2025.04.08 |
node.js 로 멀티 스레드 구현하기 (Worker) (0) | 2025.04.07 |
package.json 과 package-lock.json 은 무엇일까? (0) | 2025.04.03 |