Async Context

참고자료 :

동기 컨택스트와 비동기 컨택스트의 차이

동기 코드들은 하나의 콜 스택에서 모두 실행됩니다. 명시적으로 인자를 넘겨주거나 혹은 클로저로 정보를 명시적으로 넘겨받을 수 있고(explicit), call stack으로 부터 정보를 암시적으로 넘겨받습니다.(implicit) 하지만 비동기 컨택스트는 그렇지 않습니다. 이벤트 루프로 인해서 비동기적으로 실행되는 코드들은 서로 다른 콜 스택에서 실행됩니다. 이로 인해 비동기 컨택스트는 명시적으로 인자를 넘겨주지 않는 이상, 정보를 넘겨받을 수 없습니다.

Promise 문법에서는 then 메소드 안이 다른 콜스택에서 실행된다는 것이 명시적으로 드러나지만, async/await 문법에서는 await 키워드가 붙은 부분이 다른 콜스택에서 실행된다는 것이 비교적 덜 드러납니다.

function program() {
  const value = { key: 123 };
 
  // Implicitly propagated via shared reference to an external variable.
  // The value is only available only for the _synchronous execution_ of
  // the try-finally code.
  try {
    shared = value;
    implicit();
  } finally {
    shared = undefined;
  }
}
 
let shared;
async function implicit() {
  // The shared reference is still set to the correct value.
  assert.equal(shared.key, 123);
 
  await 1;
 
  // After awaiting, the shared reference has been reset to `undefined`.
  // We've lost access to our original value. 
  assert.throws(() => {
    assert.equal(shared.key, 123);
  });
}
 
program();

아래 코드에서 명시적으로 인자를 넘겨주지 않는 이상 implicit함수에서 shared가 초기화되기 전 값을 사용하게 할 수 있는 방법은 없습니다.

function program() {
  const value = { key: 123 };
 
  // Implicitly propagated via shared reference to an external variable.
  // The value is only available only for the _synchronous execution_ of
  // the try-finally code.
  try {
    shared = value;
    setTimeout(implicit, 0);
  } finally {
    shared = undefined;
  }
}
 
let shared;
function implicit() {
  // By the time this code is executed, the shared reference has already
  // been reset. There is no way for `implicit` to solve this because
  // because the bug is caused (accidentally) by the `program` function.
  assert.throws(() => {
    assert.equal(shared.key, 123);
  });
}
 
program();

비동기 코드는 에러를 추적할 때도 문제가 됩니다. 아래 예시를 참고하세요.

function logStackTrace(label) {
  try {
    throw new Error();
  } catch (e) {
    console.log(`--- Stack Trace: ${label} ---`);
    // We clean up the stack trace a bit for readability
    // It shows the sequence of calls leading here.
    // Exact format/paths depend on the JS environment (Node/Browser)
    console.log(
      e.stack
        .split('\n')
        .slice(1) // Remove the "Error" line
        .filter(line => !line.includes('logStackTrace')) // Remove helper call
        .map(line => `  ${line.trim()}`) // Indent for clarity
        .join('\n')
    );
    console.log(`--- End Trace: ${label} ---`);
  }
}
 
// An async function that awaits briefly
async function middleAsyncFunction() {
  console.log("Entering middleAsyncFunction...");
  logStackTrace("Before await in middleAsyncFunction");
 
  // await briefly yields control, even for an already resolved promise
  await Promise.resolve();
  // *** Pause and resume happens here ***
  // The code after this runs as a new task scheduled by the event loop
 
  logStackTrace("After await in middleAsyncFunction");
  console.log("Exiting middleAsyncFunction...");
  return "Result from middle";
}
 
function topLevelFunction() {
  console.log("Entering topLevelFunction...");
  middleAsyncFunction().then(result => {
     console.log(`Received result in topLevelFunction: ${result}`);
     logStackTrace("Inside .then() callback in topLevelFunction");
  });
  console.log("Exiting topLevelFunction (middleAsyncFunction is running)...");
}
 
// --- Execution Start ---
console.log("Script Start");
topLevelFunction();
console.log("Script End (async operations may still be pending)");
 
/*
--- Expected Output Structure (details like line numbers will vary) ---
 
Script Start
Entering topLevelFunction...
Entering middleAsyncFunction...
--- Stack Trace: Before await in middleAsyncFunction ---
  at middleAsyncFunction (file:///.../your_script.js:...)  <-- Called by topLevelFunction
  at topLevelFunction (file:///.../your_script.js:...)     <-- Called by global scope
  at file:///.../your_script.js:...                        <-- Script execution start
--- End Trace: Before await in middleAsyncFunction ---
Exiting topLevelFunction (middleAsyncFunction is running)...
Script End (async operations may still be pending)
 
(Microtask queue runs, resuming the async function)
 
--- Stack Trace: After await in middleAsyncFunction ---
  at middleAsyncFunction (file:///.../your_script.js:...)  <-- Resumed execution. Notice topLevelFunction is GONE from the direct stack.
  // Stack below here might show internal async machinery of the JS engine
--- End Trace: After await in middleAsyncFunction ---
Exiting middleAsyncFunction...
Received result in topLevelFunction: Result from middle
--- Stack Trace: Inside .then() callback in topLevelFunction ---
  at file:///.../your_script.js:... (anonymous function) <-- The .then() callback
  // Stack below here might show internal Promise/async machinery
--- End Trace: Inside .then() callback in topLevelFunction ---
 
*/

Async Context

이를 해결하기위해 Async Context라는 개념이 등장했습니다. 비동기 코드에서 컨택스트를 비교적 쉽게 넘겨줄 수 있습니다. 이미 Node.js에서는 AsyncLocalStorage라는 API를 제공하고 있습니다. 이 API는 비동기적으로 실행되는 코드에서 컨택스트를 저장하고, 이를 쉽게 가져올 수 있도록 도와줍니다. tc39-proposal stage 2입니다.

import { AsyncLocalStorage } from 'node:async_hooks';
import assert from 'node:assert';
 
const asyncLocalStorage = new AsyncLocalStorage();
let shared; 
 
function program() {
  const value = { key: 123 }; // Context data
  console.log("Inside program.");
 
  try {
    shared = value;
    console.log(`[Try Block] Set global shared = ${JSON.stringify(shared)}`);
 
    asyncLocalStorage.run(value, () => {
      const store = asyncLocalStorage.getStore();
      console.log(`[asyncLocalStorage.run] Store is set: ${JSON.stringify(store)}`);
      setTimeout(implicit, 0); // Schedule implicit
    });
 
  } finally {
    // Reset the global variable (like original code)
    console.log(`[Finally Block] Resetting global shared from ${JSON.stringify(shared)} to undefined.`);
    shared = undefined;
  }
 
  console.log("Exiting program.");
}
 
function implicit() {
  console.log("\n--- Inside implicit function ---");
 
  // 콜스택이 교체되었지만 AsyncContext에서 값을 가져올 수 있다.
  const contextFromALS = asyncLocalStorage.getStore();
  assert.strictEqual(contextFromALS?.key, 123, "AsyncLocalStorage context should have key=123");
  console.log(`Value of global 'shared' variable: ${JSON.stringify(shared)}`);
}
 
program();