Node.js AsyncLocalStorage.snapshot 가이드: 콜백 경계에서도 요청 컨텍스트를 안전하게 넘기는 법
Node.js 서버에서 요청 ID, 사용자 ID, trace 정보를 AsyncLocalStorage에 넣어 두면 로그 상관관계가 훨씬 좋아집니다.
그런데 실제 운영에서는 지금은 컨텍스트가 있는데, 나중에 실행되는 콜백에서는 그 값이 비어 버리는 문제가 자주 생깁니다.
이럴 때 눈여겨볼 기능이 AsyncLocalStorage.snapshot()입니다.
결론부터 말하면 이 API는 현재 실행 컨텍스트를 함수처럼 캡처해 두고, 나중에 다른 시점의 콜백을 그 컨텍스트 안에서 다시 실행하게 도와줍니다.
AsyncLocalStorage.snapshot이 필요한 이유
H3. 콜백을 저장해 뒀다가 나중에 실행하면 컨텍스트가 끊길 수 있다
AsyncLocalStorage는 비동기 흐름을 따라가지만, 모든 지연 실행 구조에서 자동으로 의도가 보존되는 것은 아닙니다.
예를 들어 이벤트 핸들러를 배열에 저장해 두었다가 나중에 실행하거나, 라이브러리 콜백을 별도 큐에 넘기는 경우 현재 요청 컨텍스트를 자연스럽게 잃기 쉽습니다.
import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage();
const tasks = [];
function registerTask(fn) {
tasks.push(fn);
}
als.run({ requestId: 'req-123' }, () => {
registerTask(() => {
console.log(als.getStore());
});
});
setTimeout(() => {
for (const task of tasks) task();
}, 100);
이 코드는 구조에 따라 requestId가 기대와 다르게 비어 보일 수 있습니다.
핵심은 콜백 자체를 저장할 때 어떤 컨텍스트를 묶어 둘지 명시하는 것입니다.
H3. bind와 다른 점은 현재 문맥을 다시 실행하는 래퍼를 만든다는 데 있다
snapshot()은 현재 컨텍스트를 캡처한 뒤, 나중에 원하는 함수를 그 문맥 안에서 실행할 수 있는 래퍼를 돌려줍니다.
즉 “이 콜백은 지금의 요청 맥락에서 다시 실행해 달라”는 의도를 코드로 남길 수 있습니다.
Node.js AsyncLocalStorage.snapshot 기본 사용법
H3. 현재 요청 컨텍스트를 캡처해 두고 나중에 실행한다
가장 기본적인 패턴은 아래와 같습니다.
import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage();
const tasks = [];
function registerTask(fn) {
tasks.push(fn);
}
als.run({ requestId: 'req-123' }, () => {
const runInCapturedContext = AsyncLocalStorage.snapshot();
registerTask(() => {
runInCapturedContext(() => {
console.log(als.getStore());
// { requestId: 'req-123' }
});
});
});
이 패턴의 장점은 분명합니다.
- 현재 요청 컨텍스트를 명시적으로 보존할 수 있음
- 나중에 실행되는 콜백에도 같은 로그 상관관계를 유지할 수 있음
- 이벤트 큐, 지연 실행, 후처리 훅 같은 구조에서 재사용하기 쉬움
- 컨텍스트 전달 책임이 코드에 드러나서 유지보수가 쉬워짐
기본적인 요청 컨텍스트 설계 자체는 Node.js AsyncResource 가이드: 요청 컨텍스트를 백그라운드 작업까지 이어 가는 법과 함께 보면 더 이해가 쉽습니다.
H3. 캡처한 함수를 헬퍼로 감싸 두면 반복 코드를 줄일 수 있다
실무에서는 매번 snapshot() 호출부를 노출하기보다 헬퍼 함수로 감싸는 편이 깔끔합니다.
function withCurrentContext(fn) {
const runInCapturedContext = AsyncLocalStorage.snapshot();
return (...args) => runInCapturedContext(() => fn(...args));
}
als.run({ requestId: 'req-456' }, () => {
registerTask(
withCurrentContext(() => {
logger.info({ store: als.getStore() }, 'delayed task');
})
);
});
이렇게 해 두면 “나중에 실행될 함수는 등록 시점의 컨텍스트를 유지한다”는 팀 규칙을 재사용 가능한 형태로 만들 수 있습니다.
어떤 상황에서 특히 유용한가
H3. 이벤트 리스너 등록 시점의 요청 문맥을 보존할 때 유용하다
예를 들어 요청 처리 중 리스너를 등록하고, 실제 실행은 이후 다른 타이밍에 일어나는 구조가 있습니다.
이때 snapshot()이 없으면 로그에서 어떤 요청이 이 리스너를 등록했는지 끊기기 쉽습니다.
- 커스텀 이벤트 버스
- 후처리 훅
- 플러그인 콜백
- 작업 큐에 적재되는 지연 함수
이런 구조에서는 “실행 시점”보다 “등록 시점”의 문맥이 더 중요할 때가 많습니다.
H3. 관측성 로그와 메트릭 태깅 품질을 높일 수 있다
요청 ID, tenant ID, trace ID를 AsyncLocalStorage에 저장해 두었다면 snapshot()은 관측성 품질에도 직접 영향을 줍니다.
콜백이 늦게 실행되더라도 같은 컨텍스트로 로그를 찍을 수 있기 때문입니다.
특히 Node.js diagnostics_channel 가이드: 관측성 이벤트를 낮은 결합도로 수집하는 법처럼 이벤트 기반 계측을 설계할 때 함께 쓰면 추적성이 더 좋아집니다.
사용할 때 주의할 점
H3. 너무 오래 보관되는 콜백에는 큰 객체를 넣지 않는 편이 안전하다
snapshot() 자체는 편리하지만, 컨텍스트에 무거운 객체를 넣은 상태로 장시간 콜백을 보관하면 메모리 사용량을 키울 수 있습니다.
그래서 store에는 보통 아래처럼 작은 식별자 위주로 두는 편이 좋습니다.
- requestId
- userId
- tenantId
- traceId
- feature flag 상태
반대로 대형 응답 객체, 버퍼, ORM 엔티티 전체를 넣는 패턴은 피하는 편이 안전합니다.
H3. 모든 비동기 문제를 snapshot 하나로 해결할 수 있는 것은 아니다
snapshot()은 “현재 문맥을 나중에 다시 실행한다”는 문제에 강합니다.
하지만 백그라운드 잡 워커, 스레드 경계, 프로세스 경계처럼 아예 실행 단위가 달라지는 상황은 별도 전달 전략이 필요합니다.
예를 들어 작업 큐에 job을 넣는다면 requestId를 payload에 함께 싣고, 워커가 그 값으로 새 컨텍스트를 만드는 편이 더 명확합니다.
취소와 타임아웃까지 함께 다뤄야 한다면 Node.js AbortSignal.any 가이드: timeout과 사용자 취소를 함께 처리하는 법도 같이 보는 것을 권장합니다.
실무 적용 체크리스트
H3. 팀 코드베이스에는 이런 기준으로 도입하면 안전하다
- 지연 실행되는 콜백 등록 지점을 먼저 찾기
- 그 콜백이 등록 시점의 요청 문맥을 필요로 하는지 구분하기
- 필요한 경우
snapshot()또는 헬퍼 래퍼로 컨텍스트를 명시적으로 캡처하기 - store에는 작은 식별자만 넣고 무거운 객체는 제외하기
- 로그/메트릭에서 실제로 requestId가 이어지는지 검증하기
마무리
AsyncLocalStorage.snapshot()은 화려한 기능처럼 보이지 않지만, 실무에서는 지금의 요청 컨텍스트를 나중의 콜백까지 잃지 않고 넘기는 데 아주 실용적인 도구입니다.
특히 이벤트 기반 구조, 지연 실행 훅, 플러그인 시스템처럼 호출 시점과 실행 시점이 어긋나는 코드에서는 차이가 크게 납니다.
AsyncLocalStorage를 이미 쓰고 있는데 가끔 로그 상관관계가 끊긴다면, 문제는 저장소가 아니라 컨텍스트를 캡처해야 하는 경계가 빠져 있는 것일 가능성이 큽니다.
함께 보면 좋은 글
이 글은 AI가 작성/정리하고, 오너가 방향을 결정한 프로젝트 운영 로그입니다.