컴퓨터 공학/JavaScript

Node.js - async_hooks 소개

혼새미로 2022. 7. 24. 13:46
반응형

"A Raphael painting of The Hulk playing table tennis with Spider-Man" from DALL-E 2

프롤로그

클라이언트-서버 모델에서 클라이언트는 서버에게 다양한 요청을 보냅니다. 일반적으로 서버는 클라이언트의 요청 정보를 식별하기 위해 클라이언트가 적재하는 헤더의 식별 정보를 사용합니다. 예를 들어, HTTP 통신에서 클라이언트는 헤더 ‘[x-request-id]’에 요청 고유 식별자를 적재하여 서버에 보냅니다.

 

일반적으로 서버는 여러 클라이언트가 보내는 동시다발적인 요청을 시간 순서에 따라 로그에 기록합니다. 이때, 서버는 각 요청에서 발생한 로그에 헤더 ‘x-request-id’를 함께 기록해두는데, 이는 나중에 특정 요청에 대한 로그 내역을 찾기 위해 요청의 고유 식별자인 ‘x-request-id’로 필터링을 걸면 해당 요청 로그만 손쉽게 열람할 수 있습니다.

async_hooks가 생긴 이유

async_hooks가 생긴 이유를 설명하려면 우선 스레드를 얘기해야 합니다. 스레드는 프로세스 내에서 실행되는 흐름의 단위를 말합니다. Node.js는 싱글스레드로 동작하는 이벤트 주도 (Event-driven)의 비동기 런타임으로, 주로 웹 서버 구축에 사용됩니다. 문제는 클라이언트가 전달한 요청들이 비동기 방식으로 처리되는 과정에서 순서 개념이 사라진다는 점 입니다. 예로, A 요청의 처리가 끝난 후에 B 요청을 처리하지 않고, A 요청을 처리하다가 IO가 발생하는 순간 일시 중지되고 다음으로 유입된 B 요청이 이벤트 큐에서 꺼내 처리됩니다. 이때 A 요청과 B 요청에 대한 로그가 기록되는데, 로그에 각 요청을 식별할 정보가 기록되어 있지 않다면, 어떤 요청에 대한 로그인지 확인하기 어렵습니다.

 

일반적인 HTTP 통신에서 클라이언트가 헤더 ‘x-request-id’에 요청 식별자를 포함하여 전달합니다. 요청 정보 로그에 ‘x-request-id’를 함께 기록한다면 원하는 로그를 쉽게 찾을 수 있겠죠? 문제는 Node.js가 싱글스레드로 동작하다보니 서로 다른 요청을 처리하는 스레드가 모두 동일하다는 점 입니다. C++와 같은 멀티스레드 지원 언어에서 HTTP 서버를 구축한다면, 클라이언트가 요청을 보낼 때 마다 스레드 풀에서 스레드를 꺼내 각 요청에 할당했을 것 입니다. 그리고 스레드는 고유 식별자인 ‘thread_id’ 를 갖기 때문에 이 식별자를 통해 요청 정보를 보관하거나 조회하기 수월합니다. 반면, Node.js는 싱글스레드이기 때문에 각 요청의 흐름 단위를 구분할 방법이 없었습니다.

 

그래서 나온 기능이 async_hooks 입니다. async_hooks를 사용하면 싱글스레드 기반이라도 여러 요청에 대한 핸들러들을 식별할 수 있으며, 이를 통해 로그에 각 요청의 식별자를 함께 기록할 수 있습니다.

async_hooks 모듈

async_hooks는 스레드에 식별자를 부여하는 대신 비동기 함수에 식별자를 부여합니다. 작업 흐름 단위가 비동기 함수에 진입하면 'async_id’ 라는 비동기 식별자가 생성됩니다. 비동기 식별자는 비동기 함수가 종료되는 시점에 소멸되며 destroy(asyncId) 후크 함수가 호출됩니다.

 

비동기 식별자는 말 그대로 비동기 함수가 호출되었을 때 생성되며, 동기 함수를 호출하면 생성되지 않습니다. 아시다시피 비동기 함수는 함수 정의에서 async 키워드가 붙습니다. 아래 그림은 비동기 함수 내에서 다시 동기 및 비동기 함수를 호출했을 때 생성되는 비동기 식별자를 나타냅니다.

그림 1. 비동기 함수 호출 예시

루트 비동기 함수 A에서 동기 함수 sync1, sync2와 비동기 함수 async1, async2를 호출한 결과를 보여주고 있습니다. 호출된 동기 함수에서는 호출한 비동기 함수 A와 같은 비동기 식별자를 공유하기 때문에 비동기 식별자 ‘1’로 등록된 리소스에 접근할 수 있습니다. 반면, 호출된 비동기 함수에서는 독립적인 비동기 식별자가 생성되었기 때문에 직접적으로 비동기 식별자 ‘1’로 등록된 리소스에 접근할 수 없습니다. 대신, 호출한 비동기 함수의 비동기 식별자를 연동하면 접근할 수 있습니다.

 

비동기 함수가 호출되면 init(asyncId, type, triggerAsyncId, resource) 후크 함수가 호출됩니다.

  • asyncId <number> : 호출된 비동기 함수의 식별자
  • type <string> : 비동기 리소스 타입
  • triggerAsyncId <number> : 호출한 비동기 함수의 식별자
  • resource <object> : 리소스 레퍼런스

여기서 asyncId와 triggerAsyncId를 이용하여 두 비동기 함수를 연동할 수 있습니다.

const async_hooks = require('async_hooks');
const contexts = {};
async_hooks.createHook({
	init: (asyncId, _, triggerAsyncId) => {
		if(context[triggerAsyncId]){
			contexts[asyncId] = contexts[triggerAsyncId];
		}
	},
	destroy: asyncId => {
		delete contexts[asyncId];
	}
}).enable();

위와 같이 작성함으로써 호출된 비동기 함수에서도 호출한 비동기 함수에서 등록된 리소스에 접근할 수 있습니다.

AsyncLocalStorage 모듈

async_hooks 모듈에서 제공하는 createHook() 함수는 AsyncHook 클래스를 사용하는데, 해당 클래스는 Node.js v18에서도 아직 <Stability: 1 - Experimental> 인 상태입니다. 대신, Node.js v16부터 동일한 기능을 수행하는 AsyncLocalStorage 모듈이 <Stable> 상태로 변경되었습니다. AsyncLocalStorage 모듈도 비동기 연산에서 리소스를 보관하는 용도로 사용되며, 메모리 세이프를 보장하고 성능이 더 뛰어나다고 합니다.

const {AsyncLocalStorage} = require('async_hooks');
const asyncLocalStorage = new AsyncLocalStorage();

function logWithId(msg) {
  const id = asyncLocalStorage.getStore();
  console.log(`${id !== undefined ? id : '-'}:`, msg);
}

let idSeq = 0;
http.createServer((req, res) => {
  asyncLocalStorage.run(idSeq++, () => {
    logWithId('start');
    // Imagine any chain of async operations here
    setImmediate(() => {
      logWithId('finish');
      res.end();
    });
  });
}).listen(8080);

http.get('http://localhost:8080');
http.get('http://localhost:8080');
// Prints:
//   0: start
//   1: start
//   0: finish
//   1: finish

AsyncLocalStorage 클래스에서 run(store, callback) 메서드를 통해 보관할 리소스와 호출할 비동기 함수를 지정할 수 있습니다.

  • store <any> : 보관할 리소스
  • callback <Function> : 리소스가 유지되는 동안 호출될 함수. 함수가 종료되면 리소스도 함께 소멸됩니다.

또한, 루트 함수에서 호출된 동기 및 비동기 함수에서 등록된 리소스에 접근하고 싶을 때는 단지 getStore() 메서드만 호출하면 됩니다.

성능 비교

AsyncHook와 AsyncLocalStorage의 성능을 비교하기 위해 간단한 웹 서버를 구축하여 테스트를 진행해해보았습니다. 웹 서버 구축을 위해 HyperExpress 모듈을 사용하였으며, GET 메서드로 요청을 보내면 0부터 1000 사이의 임의의 정수를 보내도록 설계하였습니다.

AsyncHook 모듈 정의

const asyncHooks = require('async_hooks');
const contexts = {};
asyncHooks
    .createHook({
        init: (asyncId, type, triggerAsyncId) => {
            if (contexts[triggerAsyncId]) {
                contexts[asyncId] = contexts[triggerAsyncId];
            }
        },
        destroy: asyncId => {
            delete contexts[asyncId];
        },
    })
    .enable();

function getContext() {
    return contexts[asyncHooks.executionAsyncId()] || {};
}

function initContext(data, fn) {
    const asyncResource = new asyncHooks.AsyncResource('initContext');
    return asyncResource.runInAsyncScope(async () => {
        const asyncId = asyncHooks.executionAsyncId();
        contexts[asyncId] = data;
        return await fn(contexts[asyncId]);
    });
}

AsyncLocalStorage 모듈 정의

const { AsyncLocalStorage } = require('async_hooks');
const asyncLocalStorage = new AsyncLocalStorage();

function getContext() {
    return asyncLocalStorage.getStore();
}

function initContext(data, fn) {
    asyncLocalStorage.run(data, fn);
}

index.js

const HyperExpress = require('hyper-express');
//const { initContext, getContext } = require('./async-hook');
// 또는
//const { initContext, getContext } = require('./async-local-storage');

const webserver = new HyperExpress.Server();

webserver.get('/', (req, res) => {
    initContext(`${Math.round(Math.random() * 1000)}`, async () => {
        res.send(getContext());
    });
});

webserver
    .listen(3000)
    .then(socket => console.log('Listening on port 3000'))
    .catch(err => console.error(err));

테스트 방법

로컬 데스크탑을 사용하였으며, 16개의 스레드가 각자 10,000번씩 총 160,000번의 요청을 보낼 때, 초당 처리성능을 비교합니다.

테스트 결과

당연하게도, 아무것도 사용하지 않았을 때 가장 높은 초당 처리성능을 보였습니다. 다음으로 AsyncLocalStorage는 미사용의 95% 정도의 초당 처리성능을 보여주었습니다. 다음으로, 가장 낮은 성능을 보인 AsyncHook은 AsyncLocalStorage와 비교하여 72%의 성능을, 미사용과 비교하여 69%의 성능을 보여주었습니다.

그림 2. 성능 비교

마무리

지금까지 Node.js의 모듈인 async_hooks에 대해 살펴보았는데요, 초반에 설명드린 케이스처럼 웹 서버에서 각 요청에 대한 로그를 식별하고자 할 때 AsyncLocalStorage를 사용하는 것을 추천드립니다. 이는 Node.js v16부터 <Stable> 상태로 변경되었기 때문에 안정적으로, 그리고 큰 성능저하 없이 사용할 수 있습니다. 그리고 AsyncHook은 아직 <Experimental> 상태인 만큼, 조심해서 사용해야 겠습니다.

반응형