Node.js 성능을 `perf_hooks` 및 `AsyncLocalStorage`로 분석하기
Min-jun Kim
Dev Intern · Leapcell

소개
빠르게 변화하는 웹 개발 세계에서 Node.js 애플리케이션의 성능은 사용자 경험과 비즈니스 성공에 직접적인 영향을 미칩니다. 느린 응답 시간, 메모리 누수 또는 비효율적인 코드 경로는 사용자 좌절과 수익 손실로 이어질 수 있습니다. 일반적인 모니터링을 위한 다양한 도구가 있지만, 성능 병목 현상 뒤에 숨은 '이유'를 진정으로 이해하려면 특정 코드 실행 및 해당 작업 주변의 컨텍스트 정보에 대한 세분화된 통찰력이 필요한 경우가 많습니다. 바로 여기서 Node.js의 내장 모듈인 perf_hooks
와 AsyncLocalStorage
가 강력하고 가벼운 솔루션을 제공하여 애플리케이션 동작을 계측하고 관찰하는 데 사용됩니다. 이 글에서는 이 두 모듈을 함께 활용하여 심층적인 성능 가시성을 제공하고 개발자가 Node.js 애플리케이션의 중요한 영역을 정확히 파악하고 최적화하는 데 도움을 줄 수 있는 방법을 자세히 살펴보겠습니다.
성능 모니터링에 대한 심층 분석
실제 적용 사례에 들어가기 전에 사용할 핵심 도구에 대한 명확한 이해를 확립해 보겠습니다.
perf_hooks
: 이 Node.js 모듈은 Web Performance API의 구현을 제공합니다.performance.mark()
,performance.measure()
,performance.now()
와 같은 메서드를 통해 JavaScript 코드의 성능을 측정할 수 있습니다. 사용자 지정 성능 메트릭을 생성하고 특정 작업의 지연 시간을 관찰하는 데 매우 유용합니다.AsyncLocalStorage
: Node.js 12에 도입된AsyncLocalStorage
는 동일한 논리적 요청 또는 실행 컨텍스트 내에서 비동기 작업 전반에 걸쳐 데이터를 저장하고 검색하는 방법을 제공합니다. 비동기 호출 스택을 위한 스레드 로컬 스토리지라고 생각하면 됩니다. 이는 작업을 추적하고 작업이 다양한 비동기 콜백 및 프로미스에 걸쳐 분산되어 있더라도 컨텍스트 메타데이터(요청 ID 또는 사용자 ID와 같은)를 성능 측정값에 연결하는 데 중요합니다.
perf_hooks
와 AsyncLocalStorage
를 결합한 강력함은 상 보완적인 특성에 있습니다. perf_hooks
는 얼마나 오래 걸렸는지를 알려주고, AsyncLocalStorage
는 얼마나 오래 걸렸는지에 대한 컨텍스트를 제공합니다. 이를 통해 "이 특정 요청에 대해 이 사용자가 시작한 getUserData
실행은 얼마나 걸렸는가?"와 같은 질문에 내부 애플리케이션 계측만으로 답할 수 있습니다.
perf_hooks
를 사용한 함수 실행 측정
함수의 실행 시간을 측정하기 위해 perf_hooks
를 사용하는 기본적인 예제부터 시작하겠습니다.
const { performance, PerformanceObserver } = require('perf_hooks'); // 'measure' 이벤트를 수신 대기하는 PerformanceObserver 생성 const obs = new PerformanceObserver((items) => { items.getEntries().forEach((entry) => { console.log(`Measurement: ${entry.name} - Duration: ${entry.duration.toFixed(2)}ms`); }); // obs.disconnect(); // 한 번만 관찰하려는 경우 연결을 끊습니다. }); obs.observe({ entryTypes: ['measure'], buffered: true }); function expensiveOperation(iterations) { let sum = 0; for (let i = 0; i < iterations; i++) { sum += Math.sqrt(i); } return sum; } // 작업 시작 표시 performance.mark('startExpensiveOperation'); // 함수 실행 const result = expensiveOperation(10000000); // 작업 종료 표시 performance.mark('endExpensiveOperation'); // 두 표시 사이의 기간 측정 performance.measure('expensiveOperationDuration', 'startExpensiveOperation', 'endExpensiveOperation'); console.log('Operation complete. Result:', result);
이 코드를 실행하면 PerformanceObserver
가 expensiveOperationDuration
의 기간을 기록합니다. 이것은 성능 병목 현상을 이해하기 위한 기초 단계입니다.
AsyncLocalStorage
로 컨텍스트 추가
이제 AsyncLocalStorage
를 통합하여 성능 측정에 컨텍스트 정보를 추가해 보겠습니다. 일반적인 시나리오는 전체 비동기 흐름에 걸쳐 requestId
를 추적하는 것입니다.
const { AsyncLocalStorage } = require('async_hooks'); const { performance, PerformanceObserver } = require('perf_hooks'); const crypto = require('crypto'); // 요청 ID 생성용 const asyncLocalStorage = new AsyncLocalStorage(); // PerformanceObserver는 동일하게 유지됩니다. os = new PerformanceObserver((items) => { items.getEntries().forEach((entry) => { const context = asyncLocalStorage.getStore(); const requestId = context ? context.requestId : 'N/A'; console.log(`[Request ID: ${requestId}] Measurement: ${entry.name} - Duration: ${entry.duration.toFixed(2)}ms`); }); }); obs.observe({ entryTypes: ['measure'], buffered: true }); function simulateDatabaseCall(delay) { return new Promise(resolve => setTimeout(resolve, delay)); } async function processUserRequest(userId) { // 요청별 데이터 저장 const requestId = crypto.randomUUID(); asyncLocalStorage.enterWith({ requestId, userId }); performance.mark('startProcessUserRequest'); console.log(`[Request ID: ${requestId}] Processing request for user: ${userId}`); // 여러 비동기 단계 시뮬레이션 performance.mark('startDatabaseRead'); await simulateDatabaseCall(Math.random() * 100); // DB 읽기 시뮬레이션 performance.mark('endDatabaseRead'); performance.measure('DatabaseReadDuration', 'startDatabaseRead', 'endDatabaseRead'); performance.mark('startBusinessLogic'); // 일부 동기 또는 비동기 비즈니스 로직 await simulateDatabaseCall(Math.random() * 50); // 또 다른 비동기 작업 // AsyncLocalStorage의 컨텍스트는 여기서도 여전히 사용할 수 있습니다. const currentContext = asyncLocalStorage.getStore(); console.log(`[Request ID: ${currentContext.requestId}] Executing business logic.`); performance.mark('endBusinessLogic'); performance.measure('BusinessLogicDuration', 'startBusinessLogic', 'endBusinessLogic'); performance.mark('endProcessUserRequest'); performance.measure('TotalRequestProcessing', 'startProcessUserRequest', 'endProcessUserRequest'); console.log(`[Request ID: ${requestId}] Request processed.`); } // 동시 요청 시뮬레이션 processUserRequest('user-123'); setTimeout(() => processUserRequest('user-456'), 50); setTimeout(() => processUserRequest('user-789'), 100);
이 향상된 예제에서는 다음과 같습니다.
asyncLocalStorage
를 초기화합니다.processUserRequest
내에서 고유한requestId
를 생성하고asyncLocalStorage.enterWith()
을 사용하여userId
와 함께 저장합니다. 이 컨텍스트는 이제 이enterWith
블록 내에서 시작된 모든 후속 비동기 작업에서 암묵적으로 사용할 수 있습니다.PerformanceObserver
콜백은measure
이벤트가 발생할 때asyncLocalStorage.getStore()
에서requestId
를 검색하여 성능 메트릭을 특정 요청에 직접 연결합니다.- 여러
await
호출 후에도asyncLocalStorage.getStore()
를 통해 컨텍스트를 사용할 수 있다는 점에 유의하십시오. 이는 비동기 경계를 넘나드는 상태를 유지하는 기능을 보여줍니다.
이 패턴은 디버깅, A/B 테스트 성능, 마이크로서비스를 통한 추적(요청 ID를 전달하는 경우) 및 세그먼트별 또는 요청 유형별 상세 성능 보고서 생성에 매우 강력합니다.
애플리케이션 시나리오
- API 엔드포인트 지연 시간 추적: 각 수신 API 요청에 소요되는 총 시간을 측정하고 요청 경로, 사용자 ID 및 기타 관련 요청 매개변수에 연결합니다.
- 데이터베이스 쿼리 성능: 특정 데이터베이스 쿼리 또는 ORM 작업을 계측하여 느린 쿼리를 식별하고 이를 원본 요청에 연결합니다.
- 마이크로서비스 상호 통신: 서비스가 요청 ID와 함께 메시지를 교환하는 경우
AsyncLocalStorage
를 사용하여 처리 중인 들어오는 메시지에 해당 ID를 유지함으로써 엔드 투 엔드 성능 추적을 보장할 수 있습니다. - 백그라운드 작업 모니터링: 긴 실행 백그라운드 작업에 대한 실행 시간 및 컨텍스트 데이터(예: 작업 ID, 작업을 시작한 사용자)를 추적합니다.
- A/B 테스트 성능: 요청 컨텍스트를 알면 다른 기능 플래그 또는 사용자 그룹을 기반으로 성능 메트릭을 분석하여 새 기능의 성능 영향을 평가하는 데 도움이 될 수 있습니다.
결론
perf_hooks
와 AsyncLocalStorage
를 결합하면 Node.js 개발자에게 세분화된 성능 모니터링을 위한 강력하고 네이티브적인 도구 키트를 제공합니다. perf_hooks
를 사용하면 코드 실행 시간을 정확하게 측정할 수 있으며, AsyncLocalStorage
를 사용하면 복잡한 비동기 흐름에서 지능적으로 컨텍스트를 유지할 수 있습니다. 함께 사용하면 고수준 메트릭을 넘어 "누가", "무엇을", "언제" 애플리케이션 성능 뒤에 있는지 이해할 수 있게 하여 더욱 표적화되고 효과적인 최적화를 가능하게 하며, 궁극적으로 더 빠르고 안정적인 Node.js 애플리케이션을 만들 수 있습니다. 이 조합은 고성능 Node.js 서비스를 구축하는 데 관심이 있는 사람이라면 반드시 알아야 할 필수 요소입니다.