Context Per Request in Node.js

Kay Hwang
직방 기술 블로그
14 min readJun 30, 2021

--

AsyncHooks이 무엇이고 Node.js에서 이 AsyncHooks를 통해 각 요청 마다 실행 context를 어떻게 갖게 할 수 있는지를 살펴봅니다.

일반적인 멀티 쓰레드 서버와 달리 Node.js는 메인 쓰레드가 싱글 쓰레드로 동작해 각 요청이 공유된 자원에 동시에 접근하더라도 쓰레드 세이프하다는 점이 Node.js로 웹 애플리케이션을 개발하는데 있어서 한 가지 장점이 될 수 있다고 생각합니다. 하지만, 멀티 쓰레드 서버는 요청 마다 각자의 쓰레드를 가지므로 요청이 수행되고 있는 context를 각 쓰레드에 쉽게 저장할 수 있겠지만 Node.js는 그렇지 못해 요청 마다 고유한 상태값 같은 것을 저장하거나 가져오기가 쉽지는 않습니다.

Node.js에서 각 요청마다 실행 context를 갖게할 방법이 결코 없는 것은 아닙니다. 이를 위해 여러가지 방법이 있겠지만 오늘은 Node.js의 AsyncHooks를 사용해 각 요청 마다 각자의 실행 context를 갖도록 하는 방법에 대해 말씀 드리고자 합니다.

들어가기 전에

컴퓨터공학에서 context라는 용어는 많이 사용 되는데, 상황에 따라 많이 다르게 사용될 수 있습니다. 이 글에서는 context란, “서버 애플리케이션에서 각 요청 마다 수행되고 있는 비동기 로직이 갖는 상태, 값 등을 의미한다”는 뜻으로 이해해 주셨으면 합니다.

AsyncHooks 란?

우선 AsyncHooks에 대해 간략히 살펴보겠습니다. AsyncHooks는 영어를 그대로 직역하면 “비동기 갈고리” 입니다. 개인적으로 “비동기적으로 동작하는 것을 갈고리처럼 그 순간을 잡아낼 수 있다”라고 의미를 부여하고 싶어서 이렇게 이름은 지은 것이 아닌가 추측합니다. 실제로 AsyncHooks를 통해 비동기적인 자원을 라이프 사이클별로 가져와 어떤 명령을 수행할 때 사용할 수 있습니다.

setTimeout 함수와 AsyncHooks

코드를 설명하기에 앞서 console.log 대신 fs.writeFileSync를 사용한 이유는 console.log는 터미널에 출력시 비동기적으로 동작하기 때문에 createHook콜백 함수 안에서 사용되면 무한루프가 발생하게 돼, 동기함수인 fs.writeFileSync를 사용했습니다. 앞으로 코드에서 print는 모두 동기적으로 문자열을 터미널에 출력하는 함수라고 보면 되겠습니다.

다음 코드를 봅시다. 다음 코드는 비동기적으로 동작하는 setTimeout 함수가 AsyncHooks의 콜백함수들을 통해 어떻게 동작하는지를 살펴보는 것입니다.

실행 결과를 터미널에 출력해보면 다음과 같습니다.

setTimeout 함수로 부터 AsyncHooks는 다음과 같은 특징을 가진다는 것을 알 수 있습니다.

  • 비동기 자원은 생성(init), 호출 전(before), 호출 후(after) 그리고 삭제(destroy) 라이프사이클을 가지는데 AsyncHooks 콜백함수를 통해 접근가능합니다.
  • 현재 실행 중인 context의 asyncId와 resource를 각각 executionAsyncId()executionAsyncResource() 로 가져올 수 있습니다.
  • init 함수에서 triggerAsyncId 는 현재 생성된 비동기 자원을 생성을 유발시킨 asyncId 입니다.

TCP 서버와 AsyncHooks

위에서 setTimeout 함수로 AsyncHooks가 무엇이고 어떻게 사용되는지를 간략히 보여드렸는데, 이번에는 TCP 서버를 통해 AsyncHooks를 조금 더 살펴보고자 합다. 백엔드 개발자라면 setTimeout 함수보다 TCP 서버를 통해 AsyncHooks를 살펴보는 것이 더 실용적일 것입니다.

아래는 코드가 실행되면 TCP 서버가 하나 생성됩니다. 생성 후 클라이언트가 연결을 요청하면 connection을 생성하고, 연결이 수립된 후 클라이언트가 소켓으로 버퍼를 전송하면 곧바로 connection이 종료되는 것을 알 수 있습니다.

실제로 TCP 서버를 실행시키면 다음과 같이 출력됩니다.

초기에 생성된 asyncId=1 에 의해 TCPSERVERWRAP 이 생성되었고, 이것의 asyncId=2 입니다. TCPSERVERWRAP 는 클라이언트로부터 connection을 받을 수 있는 서버입니다.

이제 클라이언트에서 netcat으로 소켓 연결을 해보겠습니다.

그러면 asyncId=2asyncId=4TCPWRAP 를 생성합니다. TCPWRAP 는 클라이언트와 연결된 새로운 connection 입니다.

동시에 다른 터미널 세션에서 또 다른 클라이언트를 하나를 더 연결해 보겠습니다.

마찬가지로, asyncId=2에 의해 asyncId=6TCPWRAP 가 생성되었습니다. 같은 asyncId=2 리소스가 type은 TCPWRAP 로 같지만 서로 다른 asyncId 를 가진 connection으로 서로 다른 비동기 자원이 생성된 것입니다.

참고로, 위에서 eid=0 임을 볼 수 있습니다. 이처럼 triggerAsyncIdeid 가 반드시 일치하는 것은 아닙니다. eid=0 인 이유는 c++과 같이 JavaScript 실행환경이 아닌 JavaScript 외부에서 수행된 것이기 때문입니다.

이제 client1에서 데이터를 전송시켜 보겠습니다. 데이터를 전송하면 connection은 shutdown 됩니다. 이때, TCP 서버에서 다음과 같이 출력됩니다.

클라이언트가 소켓에 버퍼를 전송하는 순간(1번째 줄) execAsyncId=4 이고 이 때 executionAsyncResourceTCP 임을 알 수 있습니다.

그 후 connection은 shutdown 되는데(4번째 줄), asyncId=4 에 의해 asyncId=10SHUTDOWNWRAP 이 생성되는 것을 볼 수 있습니다. SHUTDOWNWRAP은 connection의 shutdown 입니다.

최종적으로(16번째 줄) asyncId=4 인 resource가 destroy 되면서 asyncHook callback 함수의 destroy 가 실행됩니다.

이것을 조금 더 간단하게 다시 설명해보겠습니다. 설명을 위해 TCP 서버에서 connection이라는 비동기 자원의 라이프 사이클을 resource(asyncId) 관점 에서 보면, 다음과 같은 그래프를 그릴 수 있을 것 같습니다.

정리하자면, TCP 서버는 클라이언트가 연결을 요청해 비동기 자원인 client connection이 생성될 때, AsyncHooks의 init callback이 호출되고 비동기 자원인 각 connection에 asyncId가 부여됩니다. 후에 클라이언트로 소켓으로 부터 버퍼를 받으면 connection이 shutdown 되고 garbage collection에 의해 connection resource가 수집될 때, AsyncHooks의 destroy callback이 호출됩니다.

그렇다면, TCP 서버에서 client connection이 생성될 때 executionAsyncResource() 함수를 통해 비동기 자원인 client connection을 가져올 수 있기 때문에, 각 클라이언트가 매번 연결을 요청할 때 마다 각각 분리된 상태같은 것을 저장할 storage 같은 것을 만들 수 있지 않을까요? 즉, request 마다 독립된 실행 context를 갖게 할 수 있지 않을까요?

request 마다 자신만의 상태를 갖는 TCP 서버

아래 코드는 client가 연결된 후 소켓에 버퍼를 전송할 때 마다 전송횟수를 증가시키는 TCP 서버입니다. 다르게 구현할 수도 있겠지만(socket 객체를 key값으로 갖는 map 등) 각 connection의 context에서 asyncResource를 가져와 거기에 count 값을 추가하는 방식으로 구현해 봤습니다.

서로 다른 세션의 터미널에서 각 클라이언트를 서버에 netcat 으로 연결한 후 버퍼를 무작위로 전송시킵니다. 그러면 각 클라이언트는 서로 다른 asyncId와 count를 가지는 것을 볼 수 있습니다. 각 request는 서로 다른 상태 값을 갖는 분리된 storage 같은 것을 갖게 되었습니다.

사실 이런 storage를 직접 구현할 필요가 없습니다. 이미 Node.js가 AsyncLocalStorage 라는 것을 제공하고 있기 때문입니다.

NestJS는 어떻게 request 마다 context를 가질까요?

요즘 Node.js 웹 서버 프레임워크로 NestJS가 많이 쓰이고 있는 것 같습니다. 저희 직방에서도 서버 개발에 NestJS를 사용하고 있습니다. 개인적으로 NestJS의 장점은 프레임워크가 자체적으로 필요한 객체의 의존성주입(DI)을 모듈 단위로 해결해 줘, 애플리케이션을 개발하고 테스트하기 쉽게 해준다는 점이 아닐까 싶습니다.

그렇다면 NestJS는 request 마다 격리된 context를 갖도록 하기 위해 어떻게 하고 있을까요? NestJS는 기본적으로 Controller, Service 등과 같은 Provider 들이 singleton으로 생성됩니다. 하지만 request 마다 DI container가 생성되도록 해 격리된 객체들을 갖도록 할 수도 있는데, 이로써 request 마다 분리된 context를 가질 수 있게 됩니다.

하지만, 이렇게 매번 요청마다 DI container를 만들게 되면 성능적 측면에서 좋지 못 할 것으로 보입니다. container를 만들 때 마다 의존하는 많은 객체를 생성해 주입해야 될텐데, 애플리케이션의 규모가 커져 의존하는 객체가 많아질수록, 요청 수가 많아질 수록 객체의 생성과 소멸(garbage collection) 때문에 급격히 성능이 저하될 수 있습니다.

Node.js로 http server를 개발한다면 어떤 프레임워크를 사용하든 클라이언트로 부터 요청이 들어올 때 마다 request 객체 혹은 비슷한 것이 반드시 생성됩니다. 이런 request 객체를 각 실행 context 마다 이용할 수 있다면 위와 같은 성능적인 부담을 덜 수 있을 것 같습니다. 위에서 설명드린 AsyncHooks를 이용하면 현재 context에서 각각 클라이언트로부터 생성된 request 객체를 비교적 쉽게 가져올 수 있습니다.

NestJS 에서 request 마다 context 갖기

NestJS에서도 request 마다 각각의 context를 저장할 무언가를 위와 비슷하게 구현할 수 있을 것 같습니다. 클라이언트로부터 요청이 들어올 때 미들웨어에서 각 요청에 대한 contextStorage를 생성하고, 그곳에서 각 context로써 request 객체를 저장하도록 합니다.

이를 위해 다음과 같이 request의 context를 저장할 ContextStorageProvider 라는 것을 만들고 그것을 필요한 곳에서 주입받을 수 있게 만듭니다.

다음과 같이 room module에서 contextStorageProvider를 사용할 수 있도록 합니다.

실제로 다음과 같이 구현합니다. RoomModuleContextStorageProvider 를 어떻게 사용할지를 보여드릴 예시 모듈입니다.

ContextStorageMiddleware에서request 객체에 context라는 객체를 확장시키고 contextStorage에 request 객체를 저장함으로써 해당 request는 자체적인 context를 갖도록 합니다.

AccessTokenMiddleware는 jwt 같은 인증토큰을 파싱해서 context 객체에 값을 넣는 미들웨어라고 보시면 되겠습니다.

이제 RoomModule내에서 ContextStorageProvider를 주입한다면 request 대한 context에 접근할 수 있게 되는 것입니다.

ContextStorage 활용 예시

이렇게 request 마다 context storage를 갖도록 한다면 이것을 어떻게 활용할 수 있을지를 고민해 보았습니다.

객체에 대한 RBAC 구현 -> context storage에 role을 저장

Room에 대해 다음과 같은 요구 조건을 만족시켜야 한다고 가정해 봅시다.

room의 name은 해당 room을 생성한 agent만 수정할 수 있어야 한다.

어떤 객체에 접근해 수정을 할 때 특정 유저만 접근 가능하도록(Role-Based Access Control, RBAC) 요구조건을 충족시키지 위해서는 user 객체를 controller layer에서 service layer로 넘겨줘야 그것을 구현할 수 있을 것입니다. service layer에서는 해당 요청이 어떤 user에 의한 request context에서 실행되는지를 알 수 없기 때문입니다.

만약 이렇게 특정 객채에 대해 RBAC를 Service에서 구현해야 한다면 모든 Service의 method는 user라는 argument를 갖도록 해야 할 것 입니다.

하지만, request 마다 context storage에 요청된 user의 role을 저장하고(컨트롤러 전 미들웨어 단에서 access token을 파싱해서 얻거나 role을 조회해서 얻을 수 있을 것입니다.), 해당 요청의 실행 context에서 필요할 때 가져오도록 하면 될 것 같습니다.

메소드들의 실행시간 로깅 -> context storage가 로깅 stack을 갖도록

아래와 같이 [GET] /rooms를 조회하는데 얼마나 오래 걸리는지를 알기 위해 로직이 수행되는 시간을 로깅하기로 했다고 가정해 봅시다. controller → service 흐름으로 호출되는 애플리케이션 로직을 어떻게 기록할 수 있을까요? 여러가지 해법이 있겠지만, 가장 먼저 드는 생각은 아마 controller → service로 logs stack과 같은 것을 만들어서 호출되는 메소드마다 계속해서 쌓고, 호출될 때마다 넘겨야 할 것 같습니다. 하지만, request 별로 context storage에 접근해서 logs 스택을 쌓아 간다면 호출될 때 마다 어떤 파라미터를 넘기지 않아도 이 문제를 해결할 수 있을 것 같습니다.

우선 로깅이라는 것은 애플리케이션 로직과 크게 관련이 없이 기록이라는 기능에 특화 되어 있는 것이라 decorator를 만들어 Proxy로 동작하도록 구현하는 것이 좋아 보입니다.

이렇게 ContextStorageProvider를 decorator가 호출되는 곳에서 inject 하도록 해, service layer에서 context storage의 존재를 모르도록 했습니다만, 이것은 좋은 방법일 수도 있고 좋지 못한 방법일 수도 있습니다. 그리고 ContextStorageProvider를 Inject 할 수 있는 provider에서만 사용가능 합니다.

어쨌든 이 LogTimeTaken이라는 decorator가 있는 메소드에서는 수행시간을 stack 처럼 계속 쌓아갈 수 있습니다. 아마 다른 종류의 로깅도 이와 비슷하게 활용할 수 있을 듯 합니다.

결론

setTimeout, TCP 서버 예시를 통해 AsyncHooks는 비동기적으로 동작하는 자원들이 자신만의 실행 context를 가질 수 있도록 해준다는 것을 알게 되었습니다.

NestJS에서 request마다 DI container가 생성되도록 해 request마다 context를 갖도록 설정할 수 있습니다. 하지만 request마다 DI container를 만드는 것은 성능상 좋지 못합니다. 그래서 AsyncLocalStorage를 활용해 NestJS에서 request마다 각자의 context를 갖기 위한 방법을 소개해 드렸고, 이것을 RBAC, 로깅 등에 활용해 보았습니다.

하지만, AsyncHooks을 프로덕트에 도입하는 것은 모험이 될 수 있습니다. 아직 Node.js에서는 experimental 단계이고, 어쩌면 프로덕트 애플리케이션이 request 마다 접근 가능한 context를 가질 필요가 없을 수도 있습니다. 그래서 오늘 말씀드린 context per request가 우리 애플리케이션에 필요할지를 고민해 보는 것이 우선일 것입니다.

참고

--

--

Kay Hwang
직방 기술 블로그

직방 부동산팀에서 백엔드 개발을 하고 있습니다.