Node.js 24 AsyncLocalStorage로 요청 컨텍스트를 미들웨어 없이 전파하기 — 트레이스 ID·사용자 정보를 호출 스택 전체에 흘리는 법
프로덕션 Node.js 서버를 운영하다 보면 반드시 부딪히는 순간이 있습니다. 로그를 뒤지는데 트레이스 ID가 없어서 특정 요청의 흐름을 추적할 수가 없거나, 서비스 레이어 깊숙이 들어간 함수가 사용자 정보를 필요로 해서 호출 스택 위에서부터 아래까지 userId를 줄줄이 넘기는 상황 말입니다. 저도 처음엔 그냥 Express 미들웨어에서 req 객체에 붙여서 쓰는 게 맞는 방식인 줄 알았는데, 어느 순간 미들웨어 바깥에서 호출되는 서비스 함수가 생기면서 그 방식이 한계에 부딪혔습니다.
AsyncLocalStorage는 이 문제를 런타임 레벨에서 해결합니다. 함수 인자를 추가하거나 프레임워크에 의존하지 않고, 비동기 실행 체인 전체에 요청별 컨텍스트를 자동으로 흘려줍니다. node:async_hooks에 내장된 API라 외부 패키지도 필요 없습니다. 그리고 Node.js 24부터는 내부 구현이 AsyncContextFrame으로 바뀌면서 성능 부담까지 크게 줄었습니다.
이 글에서는 AsyncLocalStorage가 내부적으로 어떻게 동작하는지, 트레이스 ID·사용자 정보·멀티테넌트 라우팅 같은 실제 시나리오에서 어떻게 쓰는지, 그리고 실무에서 흔히 저지르는 실수가 무엇인지를 정리해봤습니다.
왜 지금 AsyncLocalStorage인가
prop drilling의 피로감
서비스 레이어가 조금만 깊어져도 아래와 같은 코드가 생깁니다.
// 라우터
router.get('/order/:id', (req, res) => {
orderService.getOrder(req.params.id, req.user, req.headers['x-trace-id']);
});
// 서비스
async function getOrder(orderId: string, user: User, traceId: string) {
return paymentService.getPayment(orderId, user, traceId);
}
// 결제 서비스
async function getPayment(orderId: string, user: User, traceId: string) {
logger.info({ traceId, userId: user.id }, '결제 조회');
// ...
}traceId와 user가 함수마다 파라미터로 붙어 다닙니다. 함수 시그니처를 바꿀 때마다 연쇄적으로 수정이 필요하고, 나중에 tenantId가 추가되면 또 처음부터 반복입니다.
Node.js 24와 AsyncContextFrame
AsyncLocalStorage는 Node.js 16.4.0부터 Stable 상태였지만, 내부적으로는 async_hooks 훅 기반으로 동작하면서 모든 비동기 경계마다 훅을 실행하는 오버헤드가 있었습니다. 2026년 기준으로 Node.js 24(2025년 4월 출시)부터는 기본 구현이 AsyncContextFrame으로 전환됐습니다. V8이 async context를 네이티브로 추적하는 방식은 아니고(그건 TC39 proposal-async-context가 표준화를 진행하는 이유이기도 합니다), Node.js 런타임의 실행 컨텍스트 전파 방식을 재구현한 것입니다. 컨텍스트 추적이 필요한 워크로드 기준 15~40% 처리량 향상이 보고됐습니다.
레거시 동작으로 되돌리는 롤백 플래그도 제공되지만 정확한 스위치 이름은 Node.js 24.0.0 릴리스 노트에서 최신 표기를 확인한 뒤 쓰는 것을 권합니다. 신규 프로젝트라면 굳이 되돌릴 이유는 없습니다.
한편 생태계 차원에서도 수렴이 일어나고 있습니다. Cloudflare Workers, Deno, Bun 모두 AsyncLocalStorage API를 지원하기 시작했고, TC39에서는 브라우저를 포함한 모든 JS 런타임에서 쓸 수 있도록 AsyncContext를 ECMAScript 표준으로 추가하는 제안이 진행 중입니다. Node.js의 AsyncLocalStorage는 그 과도기적 구현으로 역할하고 있습니다.
AsyncLocalStorage가 컨텍스트를 흘리는 방식
핵심은 세 줄입니다.
import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage();
als.run({ traceId: '123', userId: null }, () => {
// 이 콜백 안에서 생성된 모든 Promise, setTimeout, I/O 콜백은
// 자동으로 동일한 store를 상속합니다
someAsyncFunction();
});
async function someAsyncFunction() {
await fetch('https://api.example.com');
// fetch 이후에도, 어느 깊이에서든
const store = als.getStore(); // { traceId: '123', userId: null }
}als.run(store, callback)이 호출되는 순간 store가 현재 실행 컨텍스트에 바인딩되고, 그 콜백에서 파생된 모든 비동기 실행은 동일한 store를 자동으로 물려받습니다. als.getStore()는 어디서 호출해도 같은 객체를 돌려줍니다.
아래 다이어그램이 이 흐름을 보여줍니다.
미들웨어 체인과의 차이가 여기서 드러납니다. Express/Fastify 미들웨어는 프레임워크 레이어 안에서만 req, res를 넘길 수 있습니다. 반면 AsyncLocalStorage는 프레임워크 밖의 순수 함수, 독립 모듈, 심지어 setTimeout 안에서도 컨텍스트를 유지합니다.
실제 시나리오별 코드
시나리오 1: 구조화 로깅에 traceId 자동 주입
매 로그 호출에 traceId를 수동으로 넣는 대신, pino의 mixin 옵션으로 스토어 값을 모든 로그에 자동으로 병합할 수 있습니다.
// context.js
import { AsyncLocalStorage } from 'node:async_hooks';
export const als = new AsyncLocalStorage();
// logger.js
import pino from 'pino';
import { als } from './context.js';
export const logger = pino({
mixin() {
const store = als.getStore();
return store ? { traceId: store.traceId, userId: store.userId } : {};
},
});
// app.js — Express 기준, als.run()은 여기서 딱 한 번
import crypto from 'node:crypto';
import { als } from './context.js';
app.use((req, res, next) => {
als.run(
{
traceId: req.headers['x-trace-id'] ?? crypto.randomUUID(),
userId: null,
},
next
);
});
// emailService.js — import만 하면 traceId 자동 포함
import { logger } from './logger.js';
export function sendEmail(to, subject) {
logger.info({ to, subject }, '이메일 발송');
// 로그 출력: { traceId: 'abc-123', userId: null, to: '...', subject: '...' }
}sendEmail이 호출 스택 어디서 불려도, traceId를 인자로 받을 필요가 전혀 없습니다.
시나리오 2: JWT 검증 후 사용자 정보 적재
여기서 store에 값을 얹는 방식은 두 가지가 있는데, 이 글에서는 각 미들웨어가 새 store로 als.run()을 다시 여는 방식을 권장합니다. 이유는 조금 뒤에 설명하고, 먼저 권장 코드부터 봅니다.
// authMiddleware.js
import jwt from 'jsonwebtoken';
import { als } from './context.js';
export function authMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).end();
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET);
} catch (err) {
return res.status(401).json({ error: 'invalid token' });
}
const prev = als.getStore() ?? {};
als.run({ ...prev, user: decoded }, next);
}
// auditService.js — user 파라미터 없이도 접근 가능
import { als } from './context.js';
import { db } from './db.js';
export function auditLog(action) {
const { traceId, user } = als.getStore() ?? {};
db.insert('audit_logs', { action, userId: user?.id, traceId });
}jwt.verify는 검증 실패 시 동기 예외를 던지므로, 이 try-catch를 빼면 미들웨어가 그대로 터집니다. 그리고 store에 값을 얹을 때는 기존 store를 스프레드로 복사한 새 객체로 als.run()을 다시 엽니다. Express 미들웨어 체인에서 next를 run의 콜백으로 넘기면 이후 미들웨어와 라우트 핸들러가 새 컨텍스트 안에서 실행되므로 자연스럽게 이어집니다.
그럼 왜 store.user = decoded처럼 기존 객체를 직접 변이하는 방식은 피해야 할까요. 겉으로는 간편해 보이지만, 같은 store 참조가 어디선가 재사용되거나 store 객체를 다른 스코프에 흘려버리는 순간 요청 간 데이터가 섞일 수 있습니다. 새 객체로 run()을 여는 방식은 이 리스크를 원천 차단하고, 스택을 거슬러 올라가면서 어느 미들웨어가 어떤 필드를 얹었는지가 코드에 그대로 드러난다는 이점도 있습니다.
시나리오 3: 멀티테넌트 DB 커넥션 라우팅
// db.js
import { als } from './context.js';
const connectionPool = new Map(); // tenantId -> db client
export function getDbClient() {
const store = als.getStore();
const tenantId = store?.tenantId;
if (!tenantId) throw new Error('테넌트 컨텍스트 없음');
const client = connectionPool.get(tenantId);
if (!client) throw new Error(`알 수 없는 테넌트: ${tenantId}`);
return client;
}요청 진입점에서 tenantId를 한 번만 store에 넣어두면, 이후 DB 쿼리 함수들이 어디서 호출되든 getDbClient()가 올바른 커넥션을 꺼내 옵니다. 함수 인자로 tenantId를 넘길 필요가 없습니다. 컨텍스트가 없거나 풀에 없는 테넌트일 때 즉시 던지는 방어 코드를 붙여두면, 나중에 이상한 자리에서 undefined.query is not a function 같은 스택을 만나 원인을 역추적하는 시간을 아낄 수 있습니다.
시나리오 4: Fastify에서 훅 하나로 진입점 구성
import Fastify from 'fastify';
import { als } from './context.js';
import crypto from 'node:crypto';
const fastify = Fastify();
fastify.addHook('onRequest', (req, reply, done) => {
const traceId = req.headers['x-trace-id'] ?? crypto.randomUUID();
als.run(
{ traceId, userId: null },
done // done이 als.run 컨텍스트 안에서 실행됩니다
);
});Fastify의 req.id는 기본값이 단일 프로세스 내에서 증분되는 정수라, 프로세스가 재시작되면 다시 1부터 시작합니다. 즉 분산 환경에서 전역 유니크성은 보장하지 않습니다. 따라서 여러 인스턴스나 마이크로서비스가 얽힌 환경이라면 위처럼 외부에서 넘어온 x-trace-id를 우선 사용하고, 없을 때만 crypto.randomUUID()로 생성하는 방식이 안전합니다.
시나리오 5: OpenTelemetry와의 자연스러운 통합
솔직히 이 부분은 별도 작업이 거의 없습니다. OpenTelemetry Node.js SDK는 내부적으로 AsyncLocalStorageContextManager를 사용해 현재 span을 관리하도록 이미 전환이 완료됐습니다. 그래서 앱 레이어에서 별도 설정 없이 활성 span의 traceId/spanId를 자동으로 로그에 연결할 수 있습니다.
// otel 초기화 (별도 파일)
import { NodeSDK } from '@opentelemetry/sdk-node';
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
const sdk = new NodeSDK({
contextManager: new AsyncLocalStorageContextManager(),
// ... 기타 설정
});
sdk.start();이 초기화 이후에는 OTel이 관리하는 span 컨텍스트와 앱이 관리하는 als 스토어가 같은 메커니즘 위에서 동작하므로 충돌이 없습니다.
컨텍스트 흐름 전체 그림
요청이 들어와서 응답이 나갈 때까지 컨텍스트가 어떻게 흐르는지 시퀀스로 보면 이렇습니다.
미들웨어는 진입점에서 딱 한 번 run()을 호출할 뿐, 이후 모든 비동기 호출은 자동으로 동일한 store를 가지고 동작합니다.
장단점 비교와 실무에서 흔한 실수
먼저 장점과 단점을 한 표로 정리합니다.
| 항목 | 장점 | 단점·주의점 |
|---|---|---|
| 인터페이스 단순성 | 함수 인자에 컨텍스트를 넘길 필요 없음 | 컨텍스트가 암묵적으로 흘러 코드만 봐서는 흐름 파악이 어려움 |
| 프레임워크 결합도 | Express, Fastify, NestJS, 순수 http 모두 동일 패턴 |
진입점에서 als.run()을 반드시 호출해야 함 |
| 성능 (Node.js 24+) | AsyncContextFrame으로 15~40% 처리량 향상 보고 | 여전히 완전 무비용은 아님. 워크로드별 실측 권장 |
| 외부 의존성 | node:async_hooks 내장, 설치 불필요 |
앱 어디서든 참조되는 싱글턴이 되므로 관리 규율 필요 |
| OTel 호환 | SDK 내부가 같은 메커니즘을 씀 | worker_threads 경계에서는 자동 전파 안 됨 |
주의해야 할 실수들:
1. worker_threads 경계에서 전파 안 됨
워커 스레드는 별도 V8 컨텍스트를 가집니다. 부모 스레드의 store가 자동으로 전파되지 않으므로, MessagePort를 통해 직렬화해서 전달해야 합니다.
2. als.run() 없이 getStore() 호출
als.run() 바깥에서 getStore()를 호출하면 undefined를 반환합니다. 방어 코드를 항상 넣어두는 게 좋습니다.
// 나쁜 예
const { traceId } = als.getStore(); // run() 바깥이면 TypeError
// 나은 예
const { traceId } = als.getStore() ?? { traceId: 'unknown' };3. 스토어 객체 공유로 인한 데이터 오염
이게 가장 치명적입니다. als.run()마다 새 객체를 생성하지 않고 같은 객체 참조를 재사용하면, 요청 간 데이터가 섞일 수 있습니다.
// 절대 하면 안 되는 예
const sharedStore = { traceId: null }; // 모든 요청이 이걸 공유
app.use((req, res, next) => {
sharedStore.traceId = req.headers['x-trace-id']; // 다른 요청이 덮어씁니다
als.run(sharedStore, next);
});
// 올바른 예 — 항상 새 객체
app.use((req, res, next) => {
als.run({ traceId: req.headers['x-trace-id'] ?? crypto.randomUUID() }, next);
});4. 인스턴스를 여러 개 만들어 관리가 복잡해지는 경우
AsyncLocalStorage 인스턴스는 프로세스당 하나로 유지하는 것이 권장됩니다. 모듈마다 각자 new AsyncLocalStorage()를 생성하면 스토어가 파편화되어 추적이 어려워집니다. 하나의 context.js에서 싱글턴으로 export해서 쓰는 패턴이 깔끔합니다.
5. 컨텍스트가 "보이지 않게" 흐르는 디버깅 난이도
장점이 곧 단점이 되는 경우입니다. 스토어가 어디서 설정됐는지 코드만 봐서는 바로 안 보입니다. 팀 컨벤션으로 als.run()을 호출하는 지점을 문서화하거나, 스토어 타입을 TypeScript로 명시하면 많이 나아집니다.
// context.ts
interface RequestStore {
traceId: string;
userId: string | null;
tenantId?: string;
}
export const als = new AsyncLocalStorage<RequestStore>();타입을 붙이면 getStore()의 반환값도 타입 추론이 되어 IDE 지원을 제대로 받을 수 있습니다.
의사결정 흐름을 도식으로 정리하면 이렇습니다.
마무리 — 지금이 도입을 앞당길 시점인 이유
AsyncLocalStorage 자체는 새로운 API가 아닙니다. 다만 지금까지 "좋은데 오버헤드가 걸린다"는 이유로 도입을 미뤄왔던 팀이라면, Node.js 24의 AsyncContextFrame 전환이 그 결정을 다시 꺼내볼 만한 계기가 됩니다. 컨텍스트 추적이 필요한 워크로드에서 15~40% 처리량 향상이 보고됐다는 점, TC39 AsyncContext 표준화로 런타임 간 이식성 방향이 잡히고 있다는 점, 그리고 OpenTelemetry SDK가 이미 같은 메커니즘 위에서 돌고 있다는 점이 모두 같은 방향을 가리킵니다.
가장 가성비 높은 첫 단계는 구조화 로깅에 traceId를 자동 주입하는 것입니다. pino의 mixin에 store를 연결하는 몇 줄만으로 이후 모든 로그가 트레이스 ID를 달고 나오기 시작하고, 그 위에 멀티테넌트 라우팅이나 감사 로그 같은 레이어를 단계적으로 얹을 수 있습니다. 요청 진입점에서 als.run() 한 번, 미들웨어에서 값을 얹을 때는 새 객체로 다시 run(). 이 두 규칙만 팀 안에서 합의되면 나머지는 자연스럽게 굴러갑니다.
참고: NestJS를 쓰는 팀이라면
nestjs-cls패키지가 이 패턴을 Guard, Interceptor, Service 계층까지 통합해 추상화해두었으니 도입 비용을 더 줄일 수 있습니다.
참고 자료
- Node.js 공식 문서 — Asynchronous context tracking
- Node.js 24.0.0 릴리스 노트 — AsyncContextFrame 기본화
- What's New in Node.js 24 — AppSignal Blog
- Contextual Logging Done Right in Node.js with AsyncLocalStorage — Dash0
- The Hidden Cost of Async Context in Node.js — Platformatic Blog
- Node.js AsyncLocalStorage: Pass Context Without Prop Drilling — Trevor Lasn
- @opentelemetry/context-async-hooks — npm
- AsyncLocalStorage — Cloudflare Workers Docs
- TC39 AsyncContext proposal
- WinterCG AsyncLocalStorage 이식 가능 서브셋 명세