NestJS Interceptor와 Lifecycle

NestJS Interceptor와 Lifecycle

Introduction

이번 글은 Superb AI가 NestJS Interceptor를 어떻게 쓰고 있고, 사용하면서 겪었던 이슈들에 대해 공유하려고 합니다. NestJS를 도입을 결정하고 나서, 어떻게 하면 Logger를 구현할 때 코드 중복을 최소화하고 유지보수가 쉽도록 할 수 있을지에 대한 고민을 많이 했는데요. 그러던 중 Interceptor라는 기능에 대해 알게 되었고, 이를 적극 활용하여 Logger를 구현해 코드 중복을 최소화했습니다.

NestJS Interceptor를 이해하기 위해서는 먼저 RxJS에 대해 살펴봐야 합니다.

What is RxJS?

RxJS는 이벤트 스트림을 다루는 라이브러리입니다. 유저의 액션에 따른 이벤트 값, API 응답 결과 등 비동기적 값들이 들어올 수 있고, 이를 operator를 이용해 변환합니다. RxJS는 함수형, 이벤트 그리고 비동기 프로그래밍에 영향을 받아 만들어졌습니다.

RxJS

RxJS는 여러 개념들을 가지고 있지만, 이 글에서는 크게 4가지만 설명하려고 합니다.

Observable

Observable은 event가 흐르는 stream입니다. Observable은 누군가 구독(subscribe)을 해야 event를 발행(publish) 합니다. Observer가 Observable을 구독하면서 next, error, complete 키워드를 사용해 Observable에 흐르는 event를 처리합니다.

아래는 실제 Observable의 예시입니다.

observable에 1초에 한 번씩 4번 event를 발생 시킴

Observable 변수에 붙은 $(달러) 표시는 Observable을 나타내는 코드 컨벤션입니다. interval()은 정의된 시간마다 증가하는 연속값을 스트림에 발생시키고, pipe() operator를 사용하여 Observable stream 내부에서 적용할 operator를 처리하게 됩니다. take는 발생시킨 이벤트 중 처음부터 n개까지의 이벤트만 받습니다.

Operator

Operator는 Observable에서 각 이벤트들에 대해 연산을 할 수 있는 pure function입니다. 앞서 언급한 것처럼, RxJS는 함수형 프로그래밍에 영향을 많이 받아 이러한 pure function들이 많이 존재합니다. 대표적으로 tap(), filter(), min(), max()와 같은 operator가 존재합니다. 여기서 tab()은 Observable 중간의 값을 가져오는 함수입니다.

Observer

Observer는 Observable을 구독하는 대상입니다. Observer를 정의하고 next, error, complete 세 가지를 정의해 주고 Observable에 구독을 하면 완성입니다. next는 Observable에 들어오는 event를 처리합니다. error는 Observable에서 error가 발생했을 때 event를 처리해 줍니다. complete는 없어도 되는 옵션인데요. Observable이 종료되면 complete가 호출되게 됩니다. 마지막으로 Observable을 Observer가 구독하면 됩니다.

간단하게 1초에 한 번씩 4번 이벤트를 발행하는 Observable을 만들고 이를 구독(subscribe) 해보았는데요. 아래 그림의 오른쪽 부분처럼 0, 1, 2, 3, complete가 뜨면서 종료가 됩니다.

1초에 한번씩 event를 발행

NestJS Interceptor

What is NestJS Interceptor?

NestJS Interceptor는 request와 response시에 중간에서 값을 Intercept 한 뒤, 보내는 역할을 합니다. 그렇기에 Interceptor는 추가적인 로직이 필요한 경우 사용합니다. Interceptor를 쓰는 대표적 케이스가 바로 Logger인데요. Logger의 경우 request에 대한 정보, response에 대한 정보를 logging을 해야 하기에 Interceptor로 구현하기 알맞은 경우입니다.

NestJS Interceptor

Nestjs의 Interceptor는 AOP에 영감을 받아 만들어졌습니다. AOP는 Aspect Oriented Programming의 약자로 관점 지향 프로그래밍이라 불립니다. Aspect로 모듈화 하고 핵심적인 비즈니스 로직에서 분리하여 이를 재사용하겠다는 것이 AOP의 목적인데요. 아래 그림을 보면  class A, B, C는 공통적인 Aspect가 있고 이를 모듈화 시켜 재사용성을 높이고 있습니다. 이를 통해 코드의 유지 보수를 손쉽게 하고 비즈니스 로직과 그 외의 Aspect를 분리해서 볼 수 있습니다.

AOP(Aspect Oriented Programming

Logger

첫 번째 use case는 Logger입니다. NestJS Interceptor는 두 가지 parameter를 받습니다. 첫 번째는 ExecutionContext로 현재 상태에 대한 정보를 가지고 있는 utility class(helper class)입니다. 두 번째는 CallHandler이며 Observable 객체입니다. 저희는 Observable에서 200번대 status code의 경우, this.logNext()라는 함수를 호출하도록 하였고 400, 500번대 status code의 경우에는 this.logError()라는 함수를 호출하게 했습니다. 여기서 this.logError() 함수 내부에서 status code에 따라 400번대 status code는 warn으로 500번대 status code는 error로 처리했습니다.

logger example

transform

두 번째 use case로는 request나 response에 대한 transformation이 있습니다. 아래 코드는 response body value가 null일 경우 response body를 transform 하는 코드입니다.

transform example

timeout

Request시 timeout이 필요할 때도 Interceptor가 유용합니다. 아래 코드는 Interceptor를 활용하여 timeout을 구현한 것인데요. 1초가 지나도 response가 오지 않는다면 에러를 발생시키는 코드입니다. 이처럼 Interceptor는 RxJS의 다양한 operator들을 사용하여 다양한 기능들을 구현할 수 있습니다.

timeout example

NestJS Middleware와 Interceptor

NestJS Middleware는 request와 response시 중간에서 값을 가져갔다가 다시 원래 목적지로 되돌려줍니다. 그렇기 때문에 Middleware는 추가적인 로직이 필요한 경우 사용합니다.

Middleware

겉으로 보기엔 NestJS Middleware와 Interceptor의 차이는 없어 보일 수 있는데요. 실제로 NestJS 공식 docs에서 Logger를 만들때 Middleware, Interceptor 두 가지 방식 모두 다 예제로 확인할 수 있습니다. Interceptor와 Middleware 왜 이렇게 나눠졌는지 궁금하여 NestJS 디스코드 채널에 질문을 올리기도 했습니다.

“Interceptor와 Middleware의 가장 큰 차이는 무엇인가요?”라고요.

현재 NestJS 디스코드 채널은 Trilon이 운영 중이며, 해당 회사에서 NestJS를 개발 및 유지 보수 하고 있습니다. Trilon 직원이자 NestJS 유지보수 담당자인 Jay McDoniel(jmcdo29)이 다음과 같이 답변해 주었습니다.

Middleware는 파라미터로 request, response, next 이 세 가지를 받는데요, NestJS에서 request와 response가 HTTP 위에서 동작하게 설계되어 있기 때문에 HTTP 통신이 아니면 사용이 불가합니다. 반면에 Interceptor는 파라미터로 execution context라는 helper class를 받아 처리하기 때문에 HTTP 이외에도 WebSocket, GraphQL, RPC(Remote procedure call) 위에서도 동작 가능합니다.

Trilon 직원의 답변

이 외에도 Middleware, Guards, Interceptors, Pipes, Filters는 기술적으로 모두 NodeJS에서 말하는 Middleware에 속하지만, NestJS에선 Guards, Interceptors, Pipes, Filters를 enhancer라고 부르며, 꼭 Middleware가 필요한 경우가 아니라면 enhancer 쓰길 권장하고 있습니다.

NestJS Lifecycle

그렇다면 Interceptor를 어떤 상황에서 써야 하고 혹은, 어떤 상황에서는 쓰지 말아야 할까요? 이를 알기 위해선 NestJS Lifecycle에 대해 자세히 알고 있어야 합니다. NestJS Lifecycle은 다음과 같습니다.

1. Request가 들어옵니다.

2. Middleware를 타게 됩니다. Middleware는 앞서 말씀드린 것과 같이 request와 response 중간에 로직을 추가합니다.

3. Guard를 지나게 됩니다. Guard에선 authorization과 authentication을 해주게 됩니다. Guard는 허용된 유저가 아니면 요청 자체를 막아줍니다. 예를 들어 모든 사용자가 민감한 정보 혹은, 본인의 계정이 아닌 다른 계정에 접근할 수 있다면 보안상 굉장히 위험할 텐데요.  웹사이트의 관리자 권한을 아무나 사용 가능하다면 해당 웹 사이트는 많은 문제가 생길 것입니다. 이런 불상사를 막기 위해서 HTTP Header에 User의 정보가 담긴 Token을 보내면, 서버의 Guard가 유효한 유저인지 권한이 있는 유저인지 체크합니다.

4. Pre Interceptor를 거치게 됩니다. Interceptor는 request와 response 중간에 로직을 추가합니다.

5. Pipe를 거치게 됩니다. Pipe는 request가 왔을 때 body나 params, query에 대해 validation, transformation을 해줍니다. Pipe에는 두 가지 일반적인 사용 사례가 있는데요. 첫 번째로 transformation은 입력데이터를 원하는 출력으로 변환시킵니다. 두 번째로 validation은 입력 데이터를 검증합니다. 만약 오류가 있다면 에러가 발생됩니다.

6. Controller를 과정을 지납니다. Controller는 앱에 대한 특정 request를 수신해 라우팅을 해줍니다. 일반적으론 service에 라우팅을 해주는 역할로 쓰입니다.

7. Service layer가 있다면 service layer가 실행됩니다.

8. Post Interceptor를 거칩니다.

9. Exception filter를 거치고 여기서 400, 500번대 에러에 대한 에러 처리를 합니다. 보통 global exception filter를 선언해 에러에 대한 핸들링을 합니다. 이후 server response가 나가게 되며 Lifecycle이 종료됩니다.

NestJS Lifecycle

우리가 마주했던 이슈들

1. Logger

저희의 경우 Interceptor를 이용해 Logger를 구현하려 했으나, 문제는 node 진영에서 많이 쓰이는 pinowinston을 Nest로 패키징 한 nest-pinonest-winston은 저희가 원하는 기능을 충분히 제공하지 못했고, logging을 할 때 포매팅이 깨지는 이슈가 발생하여 custom logger를 구현하였습니다. 특히나 custom header 파싱을 해야 하는 경우 custom logger로 구현이 편했습니다.

2% 부족한 느낌..?

2. Sentry

다음 이슈로는 Sentry였습니다. Sentry는 단순 logging 목적보다는 400, 500번대 error에 집중하여 다양한 정보들을 제공합니다. 이를 이용하여 개발할 때 생기는 여러 버그나 에러를 로깅할 수 있도록 해주는 SaaS 툴입니다. 저희 팀에서는 Logger + Sentry 둘 다 사용하는데요. 문제는 Sentry는 몽키 패칭방식 즉, 소프트웨어가 시스템을 개별적으로 확장 구현으로 구현되어 있다 보니, custom logger에서 error를 발생시킬 시, 자동으로 Sentry가 exception을 감지하지 못합니다. 그래서 captureException(error)를 통해 exception을 발생시켜 Sentry에 error message를 전달해줘야 합니다.

Sentry를 이용해 exception capture를 해주는 것이 필요

3. monorepo

마지막 이슈는 monorepo인데요. 간단히 monorepo에 대해 설명하면,

아래 그림과 같이 monolithic한 프로젝트를 여러 개의 repo로 나누면 multi-repo의 형태가 됩니다. 여기서 한 발짝 더 나아가면, 여러 프로젝트가 한 repository안에 같이 존재하는 monorepo가 됩니다. monorepo를 통해 중복되는 코드를 묶어 코드 재사용성을 높이고, 보다 편리하게 프로젝트를 관리할 수 있습니다.

monolith, multi-repo and mono-repo

저희는 yarn Workspace를 사용한 monorepo를 구성했는데요. 문제는 NestJS Interceptor를 공유 프로젝트로 사용하려 하면 dependency 이슈가 생겼습니다. 대표적으로 Validation pipe가 두 번 사용되는 이슈가 있었습니다. 이를 해결하기 위해서 peerDependency를 설정하거나 혹은 package의 version을 맞추거나 하는 등의 갖가지 방법이 존재했지만 결국 다 실패했습니다.

yarn workspace와 NestJS package와는 dependency 이슈 존재

이유를 알고자 이번에도 디스코드 채널에 질문을 했더니 yarn Workspace의 package.json 관리 방식과 NestJS monorepo의 package.json 관리방식이 다르기 때문에 dependency issue가 발생했습니다.

NestJS의 monorepo는 Nx의 철학을 따르기에 이슈가 발생

Summary

저희는 NestJS Interceptor 구현을 통해 다음과 같은 기술적 경험을 했습니다.

- RxJS

- RxJS를 이용해 구현된 NestJS Interceptor와 그 예시

- NestJS와 Middleware의 차이점

- NestJS의 Lifecycle

- NestJS Interceptor를 구현하면서 겪었던 Logger, Sentry, monorepo 이슈들

이 경험을 통해 NestJS에 대해 더 깊이 이해하게 되었고 덕분에 NestJS meetup에 나가 발표도 하게 되었는데요.

앞으로 NestJS 도입을 고민하시는 분들이나, 같은 이슈로 고민하고 계시는 분들에게 도움이 되었기를 바랍니다!