AsyncLocalStorage를 활용한 컨텍스트 전파
AsyncLocalStorage를 사용해 prop drilling 없이 비동기 경계를 넘어 요청 범위 상태를 전달합니다.
AsyncLocalStorage를 활용한 컨텍스트 전파은(는) CoddyKit의 무료 Node.js Backend Development Bootcamp 강의입니다. 이것은 4개 중 4번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 Node.js Backend Development Bootcamp 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. Node.js Backend Development Bootcamp 강의에는 총 4개의 강의가 포함되어 있습니다.
이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.
The Prop-Drilling Problem
In a backend service, request-scoped data such as a request ID, the authenticated user, or a tenant ID is needed deep inside your call stack: in repositories, loggers, and outbound HTTP clients.
The naive fix is prop drilling — threading a ctx argument through every function:
- Every function signature gets polluted with a
ctxparameter. - One missed hand-off and a downstream call loses context.
- Library code you don't own can't receive your
ctxat all.
We need a way to carry per-request state implicitly, surviving every await and callback. That is exactly what AsyncLocalStorage provides.
async function handler(ctx, req) {
const user = await loadUser(ctx, req.userId);
return await renderPage(ctx, user);
}
async function loadUser(ctx, id) {
log(ctx, 'loading user'); // ctx passed again
return await db.find(ctx, id); // and again...
}
// Every layer must accept and forward ctx by hand.What AsyncLocalStorage Is
AsyncLocalStorage lives in the built-in node:async_hooks module. Think of it as thread-local storage for the async world: a store that stays attached to a logical chain of asynchronous operations.
You call als.run(store, callback) to establish a store, then anywhere inside that callback — no matter how many awaits, Promise chains, setTimeouts, or event emitters deep — als.getStore() returns the same store.
- Each concurrent request gets its own isolated store.
- No globals, no race conditions between requests.
- Powered by Node's async context tracking under the hood.
import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage();
als.run({ requestId: 'abc-123' }, () => {
setTimeout(() => {
const store = als.getStore();
console.log(store.requestId); // 'abc-123'
}, 10);
});run() Establishes a Context
The core API is als.run(store, fn, ...args). It executes fn synchronously, but binds store to the entire async tree spawned from it.
- The
storecan be any value — most often a plain object or aMap. run()returns whateverfnreturns (including a Promise).- Outside the callback,
getStore()returnsundefined.
This snippet shows that the store survives across an await on a real timer.
import { AsyncLocalStorage } from 'node:async_hooks';
import { setTimeout as sleep } from 'node:timers/promises';
const als = new AsyncLocalStorage();
async function deep() {
await sleep(5);
return als.getStore()?.requestId;
}
await als.run({ requestId: 'r-42' }, async () => {
const id = await deep();
console.log('inside run:', id); // 'r-42'
});
console.log('outside run:', als.getStore()); // undefinedWiring It Into an HTTP Server
The pattern in a real server: wrap the per-request work in als.run() at the very edge, seeding the store with a fresh request ID. Everything downstream can then read it.
Below uses only the built-in node:http module, so there is no framework dependency. Each incoming request gets its own store, isolated from concurrent requests.
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage();
function currentRequestId() {
return als.getStore()?.requestId ?? 'no-context';
}
const server = http.createServer((req, res) => {
als.run({ requestId: randomUUID() }, async () => {
// deep call needs no ctx argument
await Promise.resolve();
res.end('request id: ' + currentRequestId());
});
});
server.listen(3000, () => console.log('listening on 3000'));Express Middleware Pattern
In Express the idiomatic place to call als.run() is a middleware mounted first. It must call next() inside the callback so the rest of the chain inherits the store.
- Read an incoming
x-request-idheader if a proxy or upstream service supplied one; otherwise generate a fresh UUID. - Every later handler, service, and logger can read the store without receiving it as an argument.
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
export const als = new AsyncLocalStorage();
export function contextMiddleware(req, res, next) {
const store = {
requestId: req.headers['x-request-id'] || randomUUID(),
startedAt: Date.now(),
};
als.run(store, () => next()); // next() runs inside the context
}
export function getStore() {
const store = als.getStore();
if (!store) throw new Error('No request context');
return store;
}Context-Aware Structured Logging
The biggest payoff: a logger that automatically stamps every line with the request ID — no caller ever passes it.
The logger reads the store itself. If there is no active context (e.g. startup code), it degrades gracefully.
import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage();
function log(level, msg, extra = {}) {
const store = als.getStore();
const line = {
ts: new Date().toISOString(),
level,
msg,
requestId: store?.requestId ?? null,
...extra,
};
console.log(JSON.stringify(line));
}
als.run({ requestId: 'req-7' }, () => {
log('info', 'user fetched', { userId: 99 });
});
log('warn', 'no request context here');Mutating the Store Mid-Request
Because the store is a reference (object or Map), you can enrich it after authentication resolves. Later log lines automatically pick up the new fields.
- Seed with minimal data at the edge (request ID).
- After auth middleware runs, attach
userIdandtenantIdto the same store object. - Prefer a
Mapif you want a clear key-based API; a plain object is fine and slightly faster.
import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage();
function set(key, value) {
const store = als.getStore();
if (store) store.set(key, value);
}
function get(key) {
return als.getStore()?.get(key);
}
als.run(new Map([['requestId', 'r-1']]), () => {
// ... later, after authenticating:
set('userId', 42);
set('tenantId', 'acme');
console.log(get('requestId'), get('userId'), get('tenantId'));
});enterWith vs run
There are two ways to set a store:
als.run(store, fn)— scopes the store tofnand its async descendants. Whenfnsettles, the context is gone. Prefer this.als.enterWith(store)— sets the store for the current sync execution and everything after it in the same async resource, with no automatic exit.
enterWith is sharp: if you call it in a long-lived async resource it can leak into unrelated later work. Use run unless you have a specific reason (e.g. you cannot wrap a callback).
import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage();
// Scoped and safe — context ends with the callback:
als.run({ id: 'A' }, () => {
console.log(als.getStore().id); // 'A'
});
console.log(als.getStore()); // undefined
// enterWith persists with no clear boundary — easy to leak:
als.enterWith({ id: 'B' });
console.log(als.getStore().id); // 'B' (and stays set!)Where Context Can Break
AsyncLocalStorage follows native promises, async/await, timers, and most event emitters. But context can be lost in a few situations:
- Work scheduled before
run()— e.g. a connection pool or queue created at startup runs its callbacks outside any request store. - Some older libraries that pool or reuse resources across requests can carry a stale store.
- Manually detached callbacks stored in a global array and invoked later.
The fix when integrating such code is als.bind(fn) (or AsyncResource.bind), which snapshots the current context and re-applies it whenever the function is later called.
import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage();
const queue = [];
als.run({ requestId: 'r-9' }, () => {
// bind captures the current store for later execution
queue.push(als.bind(() => {
console.log('later:', als.getStore()?.requestId);
}));
});
// Runs outside the run() callback, but context is preserved:
queue.forEach((fn) => fn()); // later: r-9A Reusable Context Module
In practice you centralize the store in one small module so the rest of the codebase only imports helpers — never touching the AsyncLocalStorage instance directly.
This keeps the API tidy: runWithContext() at the edge, requestId() / getUser() everywhere else.
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
const als = new AsyncLocalStorage();
export function runWithContext(seed, fn) {
const store = { requestId: randomUUID(), ...seed };
return als.run(store, fn);
}
export function context() {
return als.getStore() ?? null;
}
export function requestId() {
return context()?.requestId ?? null;
}
export function setUser(user) {
const store = als.getStore();
if (store) store.user = user;
}Performance & Good Hygiene
AsyncLocalStorage in modern Node (v16+) is backed by an efficient native implementation and the overhead is small — acceptable for virtually all web workloads. Still, follow some hygiene:
- Use one long-lived
AsyncLocalStorageinstance per concern, not one per request. - Keep the store small; it is request-scoped state, not a cache.
- Don't store secrets you wouldn't want appearing in logs that read the store.
- Prefer
run()overenterWith()to get automatic cleanup. - Always handle the
undefinedstore case for code that may run outside a request.
Quick Check
Test your understanding of how to establish request context safely.
Recap
You learned how to propagate request-scoped state across async boundaries without prop drilling:
AsyncLocalStoragefromnode:async_hooksis thread-local storage for the async world.als.run(store, fn)binds a store tofnand all its async descendants;als.getStore()reads it anywhere; outside the run it isundefined.- Wire it once at the edge (HTTP server or first middleware), seeding a request ID, then enrich the store after auth.
- A context-aware logger can stamp every line with the request ID automatically.
- Prefer
run()overenterWith()for automatic cleanup, and useals.bind()/AsyncResource.bind()to preserve context across detached callbacks. - Keep the store small, handle the no-context case, and centralize access in one module.
자주 묻는 질문
“AsyncLocalStorage를 활용한 컨텍스트 전파” 강의는 무료인가요?
네 — “AsyncLocalStorage를 활용한 컨텍스트 전파” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 Node.js Backend Development Bootcamp 강의 전체를 잠금 해제할 수 있습니다. Node.js Backend Development Bootcamp 강의에는 총 4개의 강의가 포함되어 있습니다.
“AsyncLocalStorage를 활용한 컨텍스트 전파”에서 뭘 배우나요?
AsyncLocalStorage를 사용해 prop drilling 없이 비동기 경계를 넘어 요청 범위 상태를 전달합니다. 브라우저에서 직접 실행하는 실습 코드로 Node.js Backend Development Bootcamp을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.
Node.js Backend Development Bootcamp을(를) 시작하는 데 경험이 필요한가요?
사전 경험은 필요하지 않습니다. CoddyKit의 Node.js Backend Development Bootcamp은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 4번째 강의입니다.
“AsyncLocalStorage를 활용한 컨텍스트 전파” 강의는 얼마나 걸리나요?
대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.
이 Node.js Backend Development Bootcamp 강의에서 코드를 작성하고 실행할 수 있나요?
네. 모든 Node.js Backend Development Bootcamp 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.
이 강의의 모든 강의
- 상관관계 ID를 활용한 구조화된 로깅
- OpenTelemetry 스팬을 활용한 분산 추적
- 애플리케이션 메트릭 노출 및 RED 방법
- AsyncLocalStorage를 활용한 컨텍스트 전파