Node.js async_hooks를 이용한 비동기 리소스 생명주기 이해
Daniel Hayes
Full-Stack Engineer · Leapcell

소개
Node.js 세계에서 비동기 작업은 근본적입니다. 파일 I/O부터 네트워크 요청까지, 거의 모든 중요한 상호 작용은 논블로킹 실행을 포함합니다. 이 패러다임은 엄청난 성능 이점을 제공하지만 복잡성을 야기하기도 합니다. 여러 비동기 호출에 걸쳐 실행 흐름을 추적하는 것은 특히 메모리 누수, 잡히지 않은 오류 또는 예상치 못한 이벤트 시퀀스에 뿌리를 둔 성능 병목 현상과 같은 까다로운 문제를 디버깅할 때 어려운 과제가 될 수 있습니다. 개발자들은 종종 콜 스택이 갑자기 변경되거나 리소스가 예상보다 오래 지속되거나 사라지는 것처럼 보이는 상황에 직면합니다. 바로 이 지점에서 Node.js async_hooks
가 중요한 역할을 합니다. 이러한 기능은 비동기 리소스의 전체 생명주기를 관찰할 수 있는 비할 데 없는 메커니즘을 제공하여 비동기 작업이 어떻게 연결되고 관리되는지에 대한 깊고 세분화된 이해를 제공합니다. 이 글에서는 async_hooks
의 실제 응용 사례를 살펴보고 이를 활용하여 애플리케이션의 비동기 동작에 대한 중요한 통찰력을 얻는 방법을 보여드리겠습니다.
async_hooks
의 핵심 개념
실제 예제로 들어가기 전에 async_hooks
와 관련된 핵심 개념 및 용어에 대한 기본 이해를 확립해 보겠습니다.
-
async_hooks
모듈: 이 내장 Node.js 모듈은 비동기 리소스의 수명을 추적하는 API를 제공합니다. 이를 통해 비동기 작업 수명의 다양한 단계에 대한 콜백을 등록할 수 있습니다. -
비동기 리소스: 나중에 호출되는 콜백이 관련된 모든 객체. 예로는
setTimeout
타이머, 네트워크 소켓, 파일 시스템 작업,Promise
등이 있습니다.async_hooks
는 이러한 각 리소스에 고유한asyncId
를 할당합니다. -
asyncId
: 각 비동기 리소스에 할당된 고유 식별자입니다. 이 ID를 통해 전체 수명 주기 동안 특정 리소스를 추적할 수 있습니다. -
triggerAsyncId
: 현재 비동기 리소스를 생성한 비동기 리소스의asyncId
입니다. 이 개념은 비동기 작업의 완전한 인과 체인을 구축하는 데 중요합니다. -
AsyncHook
클래스: 비동기 훅을 생성하기 위한 기본 인터페이스입니다. 이 클래스를 인스턴스화하고 다양한 이벤트 유형에 대한 콜백 함수를 포함하는 객체를 제공합니다. -
생명주기 이벤트:
async_hooks
는 네 가지 주요 생명주기 이벤트를 노출합니다.init(asyncId, type, triggerAsyncId, resource)
: 비동기 리소스가 초기화될 때 호출됩니다. 여기서asyncId
, 리소스의type
(예:'Timeout'
,'TCPWRAP'
,'Promise'
), 이를 시작한triggerAsyncId
, 그리고resource
객체 자체에 대한 참조를 얻습니다.before(asyncId)
:asyncId
에 연결된 콜백이 실행되기 직전에 호출됩니다.after(asyncId)
:asyncId
에 연결된 콜백이 완료된 직후에 호출됩니다.destroy(asyncId)
: 비동기 리소스가 파괴되거나, 가비지 컬렉션되거나, 더 이상 필요하지 않게 될 때 호출됩니다.
-
executionAsyncId()
:async_hooks
의 정적 메서드로, 현재 실행 중인 콜백의 리소스asyncId
를 반환합니다. 이는 비동기 콜백 내에서 실행되는 동기 코드의 컨텍스트를 이해하는 데 매우 유용합니다. -
executionAsyncResource()
: 현재 실행 컨텍스트와 관련된resource
객체를 반환합니다.
비동기 흐름 추적
async_hooks
를 사용하여 비동기 작업의 생명주기를 추적하는 방법을 설명해 보겠습니다. setTimeout
및 Promise
를 포함하는 간단한 예제부터 시작하겠습니다.
const async_hooks = require('async_hooks'); const fs = require('fs'); // 활성 비동기 리소스에 대한 정보를 저장하는 간단한 맵 const activeResources = new Map(); // async ID와 함께 로깅하는 도우미 function logWithAsyncId(message, asyncId = async_hooks.executionAsyncId()) { const resourceInfo = activeResources.get(asyncId); console.log(`[ID: ${asyncId}${resourceInfo ? `, Type: ${resourceInfo.type}` : ''}] ${message}`); } // 새 AsyncHook 인스턴스 생성 const asyncHook = async_hooks.createHook({ init(asyncId, type, triggerAsyncId, resource) { activeResources.set(asyncId, { type, triggerAsyncId, resource }); logWithAsyncId(`INIT ${type} (triggered by ${triggerAsyncId})`, asyncId); }, before(asyncId) { logWithAsyncId(`BEFORE callback`); }, after(asyncId) { logWithAsyncId(`AFTER callback`); }, destroy(asyncId) { const resourceInfo = activeResources.get(asyncId); if (resourceInfo) { logWithAsyncId(`DESTROY ${resourceInfo.type}`, asyncId); activeResources.delete(asyncId); } }, }); // 이벤트를 추적하기 시작하도록 훅 활성화 asyncHook.enable(); // --- 애플리케이션 로직 --- console.log('--- Start of Application ---'); // 예제 1: 기본 setTimeout setTimeout(() => { logWithAsyncId('Timeout callback executed'); }, 100); // 예제 2: Promise 체인 const myPromise = new Promise((resolve) => { logWithAsyncId('Inside Promise constructor (synchronous part)'); setTimeout(() => { logWithAsyncId('Resolving promise after timeout'); resolve('Promise Fulfillerd'); }, 50); }); myPromise.then((value) => { logWithAsyncId(`Promise then() callback: ${value}`); fs.readFile(__filename, 'utf8', (err, data) => { if (err) throw err; logWithAsyncId(`File read completed. First 20 chars: ${data.substring(0, 20)}`); }); }); // 예제 3: 즉시 비동기 작업 setImmediate(() => { logWithAsyncId('SetImmediate callback executed'); }); console.log('--- End of Application (synchronous part finished) ---'); // 애플리케이션이 종료되거나 추적이 더 이상 필요하지 않을 때 훅 비활성화 // asyncHook.disable();
이 코드를 실행하면 다음과 같은 이벤트 로그를 볼 수 있습니다.
--- Start of Application ---
[ID: 1, Type: Timeout] INIT Timeout (triggered by 1) // 글로벌 컨텍스트 ID는 일반적으로 1입니다
[ID: 1] Inside Promise constructor (synchronous part)
[ID: 1, Type: Promise] INIT Promise (triggered by 1)
[ID: 1, Type: Timeout] INIT Timeout (triggered by 1)
[ID: 1, Type: Immediate] INIT Immediate (triggered by 1)
--- End of Application (synchronous part finished) ---
[ID: 4] BEFORE callback // setImmediate의 콜백
[ID: 4, Type: Immediate] DESTROY Immediate
[ID: 4] AFTER callback
[ID: 3] BEFORE callback // 이것은 Promise의 타이머입니다
[ID: 3] Resolving promise after timeout
[ID: 5, Type: Promise] INIT Promise (triggered by 3) // Promise.then()은 `then` 내부에서 새 Promise를 생성합니다
[ID: 3] AFTER callback
[ID: 2] BEFORE callback // 이것은 첫 번째 setTimeout입니다
[ID: 2] Timeout callback executed
[ID: 2, Type: Timeout] DESTROY Timeout
[ID: 2] AFTER callback
[ID: 5] BEFORE callback // 이것은 Promise.then() 콜백입니다
[ID: 6, Type: FSREQCALLBACK] INIT FSREQCALLBACK (triggered by 5) // fs.readFile은 FSREQCALLBACK을 생성합니다
[ID: 5] AFTER callback
[ID: 6] BEFORE callback // 이것은 fs.readFile 콜백입니다
[ID: 6] File read completed. First 20 chars: const async_hooks =
[ID: 6, Type: FSREQCALLBACK] DESTROY FSREQCALLBACK
[ID: 6] AFTER callback
[ID: 5, Type: Promise] DESTROY Promise
[ID: 3, Type: Timeout] DESTROY Timeout
[ID: 1, Type: Promise] DESTROY Promise
이 출력은 비동기 작업의 중첩된 특성과 async_hooks
가 생성, 실행 및 파괴를 조명하는 방법을 명확하게 보여줍니다. triggerAsyncId
가 인과 관계를 이해하는 데 어떻게 도움이 되는지 주목하세요. 예를 들어 Promise.then()
해석기(ID: 5
)는 초기 Promise
를 해석한 Timeout
(ID: 3
)에 의해 트리거되었습니다.
고급 응용
인과 체인/콜 스택 재구성 구축
async_hooks
의 가장 강력한 응용 프로그램 중 하나는 비동기 콜 스택, 즉 인과 체인을 재구성하는 것입니다. 표준 Error.stack
은 오류 발생 시점까지의 동기 콜 경로만 보여줍니다. async_hooks
는 비동기 경계를 가로질러 이러한 동기 세그먼트를 연결할 수 있습니다.
const async_hooks = require('async_hooks'); const util = require('util'); const asyncIdToStack = new Map(); const asyncIdToResource = new Map(); const asyncHook = async_hooks.createHook({ init(asyncId, type, triggerAsyncId, resource) { asyncIdToResource.set(asyncId, { type, triggerAsyncId }); // 비동기 리소스 생성 시점에 스택 트레이스 캡처 asyncIdToStack.set(asyncId, AsyncLocalStorage.currentStore ? AsyncLocalStorage.currentStore.get('stack') : new Error().stack); }, destroy(asyncId) { asyncIdToStack.delete(asyncId); asyncIdToResource.delete(asyncId); } }).enable(); function getCausalChain(rootAsyncId) { let currentId = rootAsyncId; const chain = []; while (currentId !== null && currentId !== undefined && currentId !== 0) { // 0은 종종 루트 ID입니다. const resourceInfo = asyncIdToResource.get(currentId); if (!resourceInfo) break; // 알 수 없거나 파괴된 리소스에 도달했습니다. chain.unshift({ asyncId: currentId, type: resourceInfo.type, creationStack: asyncIdToStack.get(currentId) // 리소스 생성 시점의 스택 }); currentId = resourceInfo.triggerAsyncId; } return chain; } // "논리적" 스택 컨텍스트를 유지하기 위해 AsyncLocalStorage 사용 const { AsyncLocalStorage } = require('async_hooks'); const als = new AsyncLocalStorage(); function operationA() { return new Promise(resolve => { setTimeout(() => { console.log('Operation A completed.'); resolve(); }, 50); }); } function operationB() { return new Promise(resolve => { setTimeout(() => { console.log('Operation B completed.'); resolve(); }, 20); }); } async function mainFlow() { console.log('Starting main flow'); await operationA(); await operationB(); console.log('Main flow completed.'); // 인과 체인을 설명하기 위해 의도적으로 오류를 발생시킵니다. const error = new Error('Something went wrong in the main flow!'); const currentAsyncId = async_hooks.executionAsyncId(); console.error(' --- Tracing Error Context --- '); console.error('Original Error Stack:', error.stack); console.error(' Causal Chain for current execution context:'); const causalChain = getCausalChain(currentAsyncId); causalChain.forEach((entry, index) => { console.error(` ${' '.repeat(index * 2)}-> [Async ID: ${entry.asyncId}, Type: ${entry.type}] Created at:\n${util.inspect(entry.creationStack, { colors: true, depth: 3 }).replace(/^Error:\s*(\n)?/, '')}`); }); } als.run(new Map([['stack', new Error().stack]]), () => { mainFlow(); });
이 예제는 비동기 경계를 가로질러 "논리적" 스택 추적을 전파하기 위해 AsyncLocalStorage
(async_hooks
의 일부)를 도입합니다. 오류가 발생하면 triggerAsyncId
체인을 탐색하여 각 작업의 생성 시점 스택을 포함하여 현재 실행에 이른 비동기 작업 시퀀스를 볼 수 있습니다. 이는 복잡한 비동기 상호 작용을 디버깅하는 데 매우 강력합니다.
성능 모니터링 및 리소스 누수 감지
init
및 destroy
이벤트를 추적함으로써 애플리케이션에서 활성 비동기 리소스의 수를 모니터링할 수 있습니다. 해당 destroy
이벤트가 없는 특정 리소스 유형의 수가 계속 증가하는 것은 리소스 누수(예: 잊혀진 타이머, 닫히지 않은 연결)를 나타낼 수 있습니다.
const async_hooks = require('async_hooks'); const resourceCount = new Map(); const leakDetectorHook = async_hooks.createHook({ init(asyncId, type) { resourceCount.set(type, (resourceCount.get(type) || 0) + 1); // console.log(`INIT: ${type}, Active: ${resourceCount.get(type)}`); }, destroy(asyncId) { const resourceInfo = asyncIdToResource.get(asyncId); // 이전 예제의 asyncIdToResource를 가정 if (resourceInfo && resourceInfo.type) { resourceCount.set(resourceInfo.type, resourceCount.get(resourceInfo.type) - 1); // console.log(`DESTROY: ${resourceInfo.type}, Active: ${resourceCount.get(resourceInfo.type)}`); } } }).enable(); setInterval(() => { console.log('\n--- Active Async Resources Snapshot ---'); resourceCount.forEach((count, type) => { if (count > 0) { console.log(`${type}: ${count}`); } }); // 잠재적인 누수 시뮬레이션: // if (Math.random() > 0.8) { // setTimeout(() => {}, 10 * 60 * 1000); // 매우 오래 지속되는 타이머 // } }, 2000); // 여기에 애플리케이션 로직을 넣어 비동기 리소스를 생성합니다. setTimeout(() => console.log('Short timeout finished'), 100); Promise.resolve().then(() => console.log('Promise resolved')); new Promise(() => {}); // 해결되지 않는 Promise로 "누수"를 시뮬레이션합니다 (관리되지 않는 경우).
이 단순화된 예제는 활성 리소스를 계산하는 방법을 보여줍니다. 실제 시나리오에서는 다음을 개선할 수 있습니다.
asyncId
대resource
매핑을 저장하여destroy
에서 더 많은 컨텍스트를 얻습니다.- 특정 리소스 유형에 대한 임계값 및 경고를 설정합니다.
- 관찰 가능성 도구와 통합하여 추세를 시각화합니다.
고려 사항 및 모범 사례
- 성능 오버헤드:
async_hooks
는 강력하지만 성능 비용이 따릅니다. 전체 프로덕션 환경에서 고처리량 애플리케이션에 글로벌하게 활성화하는 것은 눈에 띄는 오버헤드를 초래할 수 있습니다. 현명하게 사용하고 필요하지 않을 때는 비활성화하십시오. Node.js 코어는async_hooks
를 최적화하기 위해 상당한 노력을 기울였지만 컨텍스트 전환 및 콜백 실행은 여전히 일부 비용을 발생시킵니다. - 컨텍스트 손실:
async_hooks
의before
및after
콜백은 애플리케이션 코드와 별도의 특별한 컨텍스트에서 실행됩니다. 이러한 훅 내에서 무거운 작업을 수행하거나 애플리케이션별 상태와 상호 작용하는 것은 신중한 관리를 하지 않으면 피해야 합니다. - 오류 처리:
async_hooks
콜백 내에서 발생하는 오류는 Node.js 프로세스를 중단시킬 수 있습니다. 훅 콜백이 견고한지 확인하세요. - 디버깅 vs. 모니터링:
async_hooks
는 복잡한 흐름을 이해하고 심층 디버깅하는 데 탁월합니다. 일반적인 성능 모니터링의 경우 더 높은 수준의 지표가 더 적절할 수 있습니다. 그러나 복잡한 문제를 식별하는 데는async_hooks
가 필수적입니다. - 추적 라이브러리와의 통합: OpenTelemetry와 같은 라이브러리는
async_hooks
를 기반으로 하여 비동기 경계를 가로질러 추적 컨텍스트를 자동으로 전파합니다.async_hooks
를 이해하면 이러한 도구와 함께 작업하는 데 강력한 기반을 제공합니다.
결론
Node.js async_hooks
는 애플리케이션의 비동기 런타임을 관찰하고 상호 작용하는 강력한 저수준 메커니즘을 제공합니다. 비동기 리소스의 생명주기 이벤트를 노출함으로써 실행 흐름에 대한 비할 데 없는 통찰력을 제공하여 개발자가 견고한 디버깅 도구를 구축하고 고급 성능 분석을 수행하며 리소스 누수를 감지할 수 있도록 합니다. 성능 비용이 따르지만, 복잡한 비동기 작업의 얽힌 망을 푸는 능력은 복잡한 Node.js 애플리케이션을 이해하고 최적화하는 귀중한 자산입니다. async_hooks
를 마스터하면 Node.js의 비동기적 심장을 진정으로 이해할 수 있습니다.