V8 힙 스냅샷으로 Node.js 메모리 누수 파헤치기
James Reed
Infrastructure Engineer · Leapcell

소개
Node.js 개발 세계에서 성능과 안정성은 무엇보다 중요합니다. 애플리케이션이 확장되고 더 복잡한 작업을 처리함에 따라, 제대로 관리되지 않은 메모리 문제는 사용자 경험을 빠르게 저하시켜 느린 응답, 예기치 않은 충돌 및 인프라 비용 증가로 이어질 수 있습니다. 이러한 문제 중 가장 교활한 형태 중 하나는 메모리 누수입니다. 메모리 누수는 애플리케이션이 필요 이상으로 지속적으로 메모리를 소비하고 더 이상 사용되지 않는 리소스를 절대 해제하지 않는 시나리오입니다. 적절한 진단 도구 없이는 이러한 파악하기 어려운 문제를 식별하고 수정하는 것이 건초 더미에서 바늘 찾기와 같을 수 있습니다. 이 글에서는 모든 진지한 Node.js 개발자에게 중요한 도구인 V8의 힙 스냅샷의 강력함을 활용하여 Node.js 애플리케이션의 메모리 누수를 효과적으로 진단하고 해결하는 데 필요한 지식과 기법을 제공할 것입니다.
V8 힙 스냅샷 및 메모리 관리 이해하기
실질적인 진단에 들어가기 전에 관련 핵심 개념에 대한 기본적인 이해를 확립해 봅시다.
핵심 용어
- V8 엔진: Google에서 Chrome 및 Node.js용으로 개발한 JavaScript 엔진입니다. JavaScript 코드를 실행하고 메모리 관리를 담당합니다.
- 힙: 객체가 동적으로 할당되는 메모리 영역입니다. 애플리케이션 데이터의 대부분이 이곳에 있습니다.
- 가비지 컬렉션(GC): 애플리케이션에서 더 이상 참조되지 않는 객체가 차지하는 메모리를 회수하는 V8의 자동화된 프로세스입니다. 매우 최적화되어 있지만 GC도 완벽하지는 않으며 메모리 누수로 인해 방해받을 수 있습니다.
- 메모리 누수: 애플리케이션에서 더 이상 필요하지 않은 객체가 여전히 어딘가에 참조되어 가비지 컬렉터가 메모리를 회수하지 못하는 경우 발생합니다. 시간이 지남에 따라 이는 지속적인 메모리 소비로 이어집니다.
- 힙 스냅샷: V8 JavaScript 힙의 특정 시점 "사진"으로, 모든 객체, 해당 유형, 크기 및 다른 객체에 대한 참조를 캡처합니다. 이것이 메모리 누수 탐지를 위한 우리의 주요 도구입니다.
- 유지된 크기(Retained Size): 객체 자체와 해당 객체에 의해서만 유지되는 모든 다른 객체의 크기를 합한 것입니다. 예상치 못한 객체의 큰 유지된 크기는 잠재적인 누수의 강력한 지표입니다.
- 얕은 크기(Shallow Size): 객체가 참조하는 객체의 크기를 제외한 객체 자체의 크기입니다.
힙 스냅샷 작동 방식
힙 스냅샷을 생성하면 V8은 애플리케이션 실행을 (일시적으로) 중지하고 JavaScript 힙 전체를 JSON과 유사한 형식으로 직렬화합니다. 이 스냅샷에는 풍부한 정보가 포함됩니다:
- 메모리에 현재 있는 모든 객체 목록.
- 해당 생성자 이름 및 대략적인 크기.
- 객체 간의 관계(참조).
애플리케이션의 수명 주기 동안 다른 시점에서, 특히 누수를 유발하는 것으로 의심되는 작업을 수행한 후에 생성된 여러 스냅샷을 비교함으로써, 우리는 예상보다 비정상적으로 증가하는 객체의 수나 크기를 식별할 수 있습니다.
실질적인 적용: 누수 진단
Node.js 애플리케이션이 메모리 누수를 겪을 수 있는 일반적인 시나리오와 힙 스냅샷을 사용하여 문제를 정확하게 찾아내는 방법을 살펴보겠습니다.
모든 들어오는 요청 객체를 항상 지우지 않고 전역 배열에 실수로 캐시하는 간단한 Node.js HTTP 서버를 생각해 봅시다.
// server.js const http = require('http'); const cachedRequests = []; // 이것이 잠재적 누수 지점입니다! const server = http.createServer((req, res) => { // 일부 처리 시뮬레이션 setTimeout(() => { // 요청 객체 저장 // 실제 앱에서는 클로저, 큰 데이터 구조 등을 저장할 수 있습니다. cachedRequests.push(req); res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('Hello from leaked server!\n'); // 시연을 위해 배열이 지워지지 않도록 누수를 더 두드러지게 만듭니다. // 실제 앱에서는 항목을 제거하는 것을 잊거나 잘못 구성된 캐시를 사용할 수 있습니다. if (cachedRequests.length > 1000) { console.log('Too many cached requests, memory usage might be high!'); } }, 100); }); const PORT = 3000; server.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); // 프로그래밍 방식으로 힙 스냅샷을 찍는 유틸리티 const v8 = require('v8'); const fs = require('fs'); let snapshotIndex = 0; setInterval(() => { const filename = `heap-snapshot-${snapshotIndex++}.heapsnapshot`; const snapshotStream = v8.getHeapSnapshot(); const fileStream = fs.createWriteStream(filename); snapshotStream.pipe(fileStream); console.log(`Heap snapshot written to ${filename}`); }, 30000); // 30초마다 스냅샷 찍기
누수 진단 단계:
-
애플리케이션 실행:
node server.js
-
로드 생성 (누수 시뮬레이션):
curl
또는ab
(ApacheBench)와 같은 도구를 사용하여 서버로 많은 요청을 보냅니다.# 몇백 개의 요청 보내기 for i in $(seq 1 500); do curl http://localhost:3000 & done
몇 분 동안 기다리거나 명령을 여러 번 실행하여
cachedRequests
배열이 커지고 여러 스냅샷이 찍히도록 합니다. -
Chrome DevTools에서 힙 스냅샷 검사:
- Chrome 브라우저를 엽니다.
- DevTools를 엽니다 (F12 또는 Ctrl+Shift+I).
- "Memory" 탭으로 이동합니다.
- "Load" 버튼(위쪽 화살표 아이콘)을 클릭하고 Node.js 애플리케이션에서 생성된 두 개 이상의
heapsnapshot
파일(예:heap-snapshot-0.heapsnapshot
및heap-snapshot-1.heapsnapshot
)을 선택합니다. - 로드되면 마지막 스냅샷을 선택합니다.
- 드롭다운에서 "Constructor" 보기를 선택한 후, 이전 스냅샷과 비교하기 위해 "Comparison"을 선택할 수 있습니다. 이는 개수가 늘어난 객체를 식별하는 데 매우 유용합니다.
DevTools에서 찾아야 할 것:
- "retained size" 및 "count"로 필터링: "Constructor" 목록을 "Size Delta" 또는 "Count Delta"(스냅샷 비교 시)로 정렬합니다. 예상치 못한
Retained Sizes
또는Counts
가 크게 증가하는 생성자를 찾습니다. - 의심스러운 객체 식별: "Constructors" 보기에서 의심스러운 객체를 클릭합니다. 아래의 "Retainers" 창에 이 객체에 참조를 유지하고 있는 것이 표시됩니다. 이것이 누수 소스를 찾는 결정적인 단계입니다. 우리 예시에서는
cachedRequests
배열까지 추적하게 됩니다.
보통 다음과 같은 항목을 보게 될 것입니다:
(array)
: 커지고 있는 JavaScript 배열.IncomingMessage
: Node.js 요청 객체 자체.
IncomingMessage
객체와 해당 retainers를 확장하면 결국cachedRequests
전역 변수로 추적되어 누수를 식별하게 됩니다. "Retainers" 섹션에는(array)
->cachedRequests
라고 표시될 것입니다.
고급 팁
- 여러 스냅샷 찍기: 항상 최소 두 개의 스냅샷을 찍습니다. 하나는 의심스러운 작업 전, 다른 하나는 작업 후, 또는 지속적인 부하 중에 여러 스냅샷을 찍습니다. 이들을 비교하는 것이 핵심입니다.
- 누수 격리: 특정 모듈이 의심되는 경우 코드 섹션을 주석 처리하거나 애플리케이션 복잡성을 줄여 문제를 좁혀보세요.
- 네이티브 누수 고려:
heapsnapshots
는 JavaScript 힙용이지만, 네이티브 메모리 누수(예: C++ 애드온, V8 제어 외부의 버퍼)는 직접 표시되지 않습니다. 이러한 경우에는perf
또는Valgrind
와 같은 도구가 필요할 수 있습니다. - 스냅샷 생성 자동화: 장기 실행 애플리케이션의 경우 프로그래밍 방식 스냅샷 생성(예시에 표시된 대로) 또는
heapdump
와 같은 모듈을 사용하는 것이 매우 중요할 수 있습니다. - 일반적인 누수 패턴 이해:
- 지워지지 않은 타이머/이벤트 리스너: 변수를 캡처하고 지워지지 않는
setInterval
,setTimeout
,EventEmitter.on()
콜백. - 전역 캐시: 제한이나 제거 정책 없이 객체를 저장하는 사전 또는 배열.
- 큰 범위를 캡처하는 클로저: 특히 비동기 작업에서 더 이상 필요하지 않은 변수에 대한 참조를 유지하는 함수.
- 순환 참조: 최신 GC에서는 덜 일반적이지만, 특히 DOM 조작이나 복잡한 객체 그래프에서는 여전히 가능합니다.
- 지워지지 않은 타이머/이벤트 리스너: 변수를 캡처하고 지워지지 않는
결론
메모리 누수는 Node.js 애플리케이션의 조용한 사형수이지만, 올바른 도구와 기법을 사용하면 전적으로 진단하고 수정할 수 있습니다. V8 힙 스냅샷과 Chrome DevTools는 애플리케이션의 메모리 환경을 들여다보고, 과도한 객체 유지를 식별하며, 궁극적으로 누수의 정확한 소스를 찾아내는 매우 강력하고 필수적인 메커니즘을 제공합니다. 힙 분석을 마스터함으로써 더 강력하고 성능이 뛰어나며 안정적인 Node.js 서비스를 구축할 수 있는 힘을 얻게 됩니다.