Skip to content

Instantly share code, notes, and snippets.

@DublinCity
Last active April 22, 2024 00:13
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save DublinCity/95df459847e743cc7071d929e5e29b1c to your computer and use it in GitHub Desktop.
Save DublinCity/95df459847e743cc7071d929e5e29b1c to your computer and use it in GitHub Desktop.
AsyncLocalStorage

AsyncLocalStorage

어떤 문제가 있나요

우리의 서버로 하나의 request가 들어오면 서버는 응답을 처리하기 위해 필요한 여러 API를 호출하고 데이터를 가공하여 필요한 응답을 돌려줍니다. requset는 동시에 여러개 들어오기 때문에 우리는 request마다 어떤 작업을 수행했는지 그룹핑하여 모니터링하는 것이 필요합니다. 가장 일반적인 방법으로는 요청마다 requestID 를 생성해 할당하고 수행하는 작업마다 requestID 를 함께 기록하여 requestID 단위로 그룹핑해 확인하는 것입니다.

이러한 패턴은 Ruby on rails 나 Python Django 같은 플랫폼에서 매우 간단하게 구현할 수 있는데, 각 request는 하나의 thread에 할당되어 동기로 처리되기 때문에 requestID 와 같은 요청 정보는 thread context 에 저장하여 필요할 때 꺼내 사용하면 되기 때문입니다.

하지만 Node.js 에서는 이 단순한 방법을 구현하기가 쉽지 않습니다. 왜냐하면 Node.js 는 single-thread 로 동작하기 때문인데 즉 모든 request가 하나의 thread에서 처리되며 request별 별도의 thread context가 없다는 뜻입니다.

따라서 우리는 다른 방법을 찾아야 합니다.

전통적인 방식

부모함수에서 자식함수까지 데이터를 공유할 수 있는 가장 간단한 방법은 정의된 변수를 매개변수로 넘기는 것 입니다.

function parent() {
  var requestID = uuid()
  child(requestID)
}
function child(requestID) {
  console.log(requestID)
}

또 다른 방법은 상위함수와 하위함수에서 모두 접근 가능한 스코프에 변수를 두고 참조하는 것 입니다.

var requestID = null
function parent() {
  requestID = uuid()
  child()
}
function child() {
  console.log(requestID)
}

후자는 몇가지 주의해야할 점이 있는데,

  • 전역스코프를 오염시키게 됩니다. requestID 라는 전역변수는 다른 함수에서 참조하고 있을지도 모릅니다.
  • 비동기 상황에서 함수의 실행 순서를 보장할 수 없습니다.

2번에 대해서 조금 더 자세히 설명하기 위해 비동기로직을 추가해보면 두가지 요청에도 불구하고 동일한 결과가 두번 찍힌 것을 볼 수 있습니다. 비동기 로직은 실행 순서를 보장할 수 없기 때문입니다.

var requestID = null 
function parent() {
  requestID = uuid()
  child()
}
function child() {
  setTimeout(() => console.log(requestID), Math.random()*10)
}

parent(); // b3kjdksjf3ks...
parent(); // b3kjdksjf3ks...

비동기 상황에서 동일한 전역변수로의 할당과 접근은 매우 심각한 문제로 이어질 수 있기 때문에 주의해야합니다.

그렇다면 전자의 경우에는 어떤가요?

함수의 깊이가 앝을 때는 매개변수를 전달하는 것이 무리가 없어보이지만 실제 서비스를 하다보면

  • 비지니즈 로직은 굉장히 복잡하며 호출 구조가 깊어집니다.
  • 데이터를 제공하는 함수부터 사용되는 함수에 이르기까지 모든 함수에 매개변수를 추가하는 것은 확장성이 없고 유지보수의 어려움을 크게 증가시키게 됩니다.

만일 작성한 모든 함수에 매개변수를 추가하는 고통을 감수한다 하더라도 사용하고 있는 라이브러리, 프레임워크를 이용한 레이어에서 매개변수를 전달할 수 없는 경우도 있습니다.

좋은 방법은 없을까요?

잠깐 눈을 돌려보면

우리는 React에서 이미 비슷한 경험을 한 적 있습니다. 상위에서 생성한 props를 여러 레이어를 거쳐 필요한 컴포넌트까지 전달하는 prop-drilling 이라 불리는 고생스런 작업을 크게 아래의 두가지 방식으로 해결할 수 있었습니다.

  • 전역으로 상태를 관리하는 라이브러리를 사용(mobx, redux, ...)
  • Context API를 사용

두가지 방식 중 전역으로 상태를 관리하는 방법은 비동기 상황에서 문제를 야기할 수 있으니 제외하는 것이 좋겠습니다. 그렇다면 남은 옵션인 Context API 가 문제해결에 도움을 줄 수 있을까요?

AsyncLocalStorage

Node.js에는 Context API는 없지만 AsyncLocalStorage 라는 API가 있습니다. 언뜻 이름만 보면 브라우저의 LocalStorage 와 비슷해보이지만 목적이 전혀 다릅니다. AsyncLocalStorage 는 비동기 작업의 일관성을 유지하는 저장소를 만드는 클래스로 thread-local storage 와 비슷합니다. 이 API를 사용하면 Context API와 비슷한 방법으로 문제를 해결할 수 있습니다.

Node.js 공식문서의 예제를 확인해보겠습니다.

import { AsyncLocalStorage } from 'async_hooks';

const asyncLocalStorage = new AsyncLocalStorage();
const store = { id: 2 };
asyncLocalStorage.run(store, () => {
    asyncLocalStorage.getStore(); // Returns the store object
    setTimeout(() => {
      asyncLocalStorage.getStore(); // Returns the store object
    }, 200);
});

코드를 살펴보면

  • asyncLocalStorage 인스턴스를 생성하고
  • asyncLocalStorage.run 의 첫번째 인자로 데이터를 전달하면
  • 두번째 인자인 함수내부에서 실행되는 모든 함수는 asyncLocalStorage.getStore 를 통해 데이터에 접근 가능합니다.

React 에서 Context를 사용하기 위해 Provider로 감싸고 내부에서 Consumer 또는 useContext 를 사용해서 Context 에 접근하는 것과 비슷해보이지 않나요?

AsyncLocalStorage 를 사용해서 처음의 예제를 구현해본다면 아래처럼 구현해 볼 수 있습니다.

import {AsyncLocalStorage} from 'async_hooks'
const asyncLocalStorage = new AsyncLocalStorage()

function parent() {
  asyncLocalStorage.run(uuid(), child)
}

function child() {
  const requestID = asyncLocalStorage.getStore()
  setTimeout(() => console.log(requestID), Math.random()*10)
}

parent(); // 7c33d6d2...
parent(); // d9e9f863...

실예제 - Koa.js

koa.js 를 사용한 서버에서는 requestID 를 남기기 위해 AsyncLocalStorage 를 아래와 같이 활용할 수 있습니다.

import {AsyncLocalStorage} from 'async_hooks'
import Koa from 'koa'
import {v4 as uuidv4} from 'uuid'

const app = new Koa()
const asyncLocalStorage = new AsyncLocalStorage()

app.use(async (ctx, next) => {
    asyncLocalStorage.run(uuidv4(), async () => {
        await next()
    })
})

const getResponse = () => `requestID: ${asyncLocalStorage.getStore()}`
app.use(async (ctx) => {
    ctx.body = getResponse()
})

app.listen(3000)

결론

사실 예전부터 이와 같은 문제들은 존재했고 해결하기 위한 시도들이 있었습니다. AsyncWrap 이나 async_hooks 같은 API들 말이죠. 하지만 두 API는 Experimental 기능으로 제공되어 불안했고 구현하기 번거로워 cls-hooked 과 같은 패키지를 통해 도움을 받아 사용하곤 했습니다.

Node.js 16.4 버전부터는 손쉽게 사용할 수 있는 AsyncLocalStorage 를 Stable로 지원합니다. 이제 Node.js에서도 AsyncLocalStorage 를 사용해 thread-local storage 를 마음껏 사용해보면 어떨까요!


참고:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment