Async Context
참고자료 :
- tc39/proposal-async-context: Async Context for JavaScript
- Async Context
- Zero-config Debugging with Deno and OpenTelemetry
동기 컨택스트와 비동기 컨택스트의 차이
동기 코드들은 하나의 콜 스택에서 모두 실행됩니다. 명시적으로 인자를 넘겨주거나 혹은 클로저로 정보를 명시적으로 넘겨받을 수 있고(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();