# Node.js 메모리 누수 디버깅 완벽 가이드: 프로덕션에서 힙 덤프 분석하기
Table of Contents
“서버 재시작 없이 일주일 이상 버티질 못해요.”
Node.js 서비스를 운영하다 보면 한 번쯤 겪는 증상입니다. 처음엔 500MB였던 메모리 사용량이 며칠 지나면 2GB를 넘기고, 결국 OOM(Out of Memory)으로 프로세스가 죽습니다. 재시작하면 다시 정상으로 돌아오지만, 근본적인 해결책은 아닙니다.
이 글에서는 Node.js 메모리 누수를 프로덕션 환경에서 진단하고 해결하는 방법을 다룹니다. 힙 덤프를 생성하는 방법부터 Chrome DevTools로 분석하는 법, 그리고 흔한 누수 패턴과 해결책까지 실전 중심으로 설명합니다.
메모리 누수란?
메모리 누수는 더 이상 필요하지 않은 객체가 가비지 컬렉션(GC)에 의해 수거되지 않고 계속 메모리를 점유하는 현상입니다.
JavaScript는 자동 메모리 관리를 하지만, 참조가 남아있으면 GC가 수거하지 못합니다. 문제는 이 참조가 개발자의 의도와 다르게 유지되는 경우입니다.
V8 힙 메모리 구조
Node.js는 V8 엔진을 사용하며, 힙 메모리는 다음과 같이 구성됩니다:
┌─────────────────────────────────────────┐
│ V8 Heap │
├─────────────────────────────────────────┤
│ New Space (Young Generation) │
│ - 새로 생성된 객체 │
│ - Minor GC (Scavenge)로 빠르게 수거 │
├─────────────────────────────────────────┤
│ Old Space (Old Generation) │
│ - New Space에서 살아남은 객체 │
│ - Major GC (Mark-Sweep)로 수거 │
├─────────────────────────────────────────┤
│ Large Object Space │
│ - 큰 객체 (별도 관리) │
├─────────────────────────────────────────┤
│ Code Space │
│ - 컴파일된 코드 │
└─────────────────────────────────────────┘
메모리 누수는 주로 Old Space에서 발생합니다. 객체가 계속 참조되어 GC 대상이 되지 않으면, Old Space가 계속 증가합니다.
메모리 누수 증상 확인
1. 메모리 사용량 모니터링
가장 기본적인 방법은 process.memoryUsage()를 주기적으로 확인하는 것입니다.
setInterval(() => {
const used = process.memoryUsage();
console.log({
rss: `${Math.round(used.rss / 1024 / 1024)}MB`,
heapTotal: `${Math.round(used.heapTotal / 1024 / 1024)}MB`,
heapUsed: `${Math.round(used.heapUsed / 1024 / 1024)}MB`,
external: `${Math.round(used.external / 1024 / 1024)}MB`,
});
}, 30000);
- rss: 전체 메모리 사용량 (Resident Set Size)
- heapTotal: V8이 할당받은 힙 크기
- heapUsed: 실제 사용 중인 힙 크기
- external: V8 외부의 C++ 객체가 사용하는 메모리
정상적인 패턴: heapUsed가 증가했다가 GC 후 감소하는 톱니바퀴 모양 누수 패턴: heapUsed의 최저점이 계속 상승하는 우상향 패턴
2. GC 로그 활성화
node --trace-gc app.js
출력 예시:
[44308:0x7f9b8a004000] 65432 ms: Scavenge 123.4 (150.2) -> 110.5 (150.2) MB, 2.3 / 0.0 ms
[44308:0x7f9b8a004000] 67891 ms: Mark-sweep 234.5 (280.1) -> 220.3 (285.0) MB, 45.6 / 0.0 ms
Mark-sweep(Major GC) 후에도 메모리가 줄어들지 않으면 누수를 의심해야 합니다.
3. Prometheus 메트릭 수집
프로덕션에서는 prom-client를 사용해 메트릭을 수집합니다.
const client = require('prom-client');
// 기본 메트릭 수집 (메모리, GC 등 포함)
client.collectDefaultMetrics();
// Express에서 메트릭 노출
app.get('/metrics', async (req, res) => {
res.set('Content-Type', client.register.contentType);
res.end(await client.register.metrics());
});
Grafana 대시보드에서 nodejs_heap_size_used_bytes를 시계열로 보면 누수 패턴을 쉽게 확인할 수 있습니다.
힙 덤프 생성하기
메모리 누수를 정확히 진단하려면 **힙 덤프(Heap Dump)**를 생성해야 합니다. 힙 덤프는 특정 시점의 메모리 상태를 스냅샷으로 저장한 것입니다.
방법 1: v8 모듈 사용 (Node.js 12+)
const v8 = require('v8');
function writeHeapSnapshot() {
const filename = v8.writeHeapSnapshot();
console.log(`Heap snapshot written to ${filename}`);
return filename;
}
// API 엔드포인트로 노출
app.get('/debug/heapdump', (req, res) => {
const filename = writeHeapSnapshot();
res.json({ filename });
});
방법 2: heapdump 패키지
npm install heapdump
const heapdump = require('heapdump');
// SIGUSR2 시그널로 힙 덤프 생성
process.on('SIGUSR2', () => {
const filename = `/tmp/heapdump-${Date.now()}.heapsnapshot`;
heapdump.writeSnapshot(filename, (err) => {
if (err) console.error(err);
else console.log(`Heap dump written to ${filename}`);
});
});
프로덕션에서 힙 덤프 생성:
kill -USR2 <PID>
방법 3: Chrome DevTools 원격 연결
node --inspect=0.0.0.0:9229 app.js
SSH 터널링:
ssh -L 9229:localhost:9229 user@production-server
Chrome에서 chrome://inspect로 접속하여 Memory 탭에서 힙 스냅샷을 생성할 수 있습니다.
:::warning 힙 덤프 생성은 서비스를 일시 중지시킵니다. 프로덕션에서는 트래픽이 적은 시간대에 진행하거나, 로드밸런서에서 해당 인스턴스를 제외한 후 진행하세요. :::
비교를 위한 힙 덤프 전략
메모리 누수를 찾으려면 두 개 이상의 힙 덤프를 비교해야 합니다.
- 서비스 시작 직후 첫 번째 덤프
- 일정 시간 경과 후 (또는 메모리 증가 후) 두 번째 덤프
- 두 덤프 사이에 증가한 객체를 분석
Chrome DevTools로 힙 덤프 분석
.heapsnapshot 파일을 Chrome DevTools에서 열어 분석합니다.
1. 파일 로드
- Chrome DevTools 열기 (F12)
- Memory 탭 선택
- Load 버튼 클릭
.heapsnapshot파일 선택
2. Summary 뷰 이해하기
| 컬럼 | 의미 |
|---|---|
| Constructor | 객체 생성자 이름 |
| Distance | GC 루트로부터의 거리 |
| Shallow Size | 객체 자체의 크기 |
| Retained Size | 객체가 참조하는 모든 것을 포함한 크기 |
Retained Size가 큰 객체부터 살펴보세요. 이 객체가 해제되면 그만큼의 메모리가 회수됩니다.
3. Comparison 뷰로 증가분 확인
두 힙 덤프를 로드한 후 Comparison 뷰를 선택하면:
- # New: 새로 생성된 객체 수
- # Deleted: 삭제된 객체 수
- # Delta: 순 증가량
Delta가 양수인 객체가 누수 후보입니다.
4. Retainers 패널로 참조 추적
객체를 선택하면 하단에 Retainers(보유자) 패널이 나타납니다. 이 객체를 누가 참조하고 있는지 역추적할 수 있습니다.
Object @123456
└─ property in Object @789012
└─ element in Array @345678
└─ cache in Map @901234
└─ instance in AppService
이 경로를 따라가면 누수의 근본 원인을 찾을 수 있습니다.
5. Allocation Timeline으로 실시간 추적
Memory 탭에서 “Allocation instrumentation on timeline”을 선택하고 Record를 시작하면, 언제 어떤 객체가 할당되었는지 시간순으로 볼 수 있습니다.
특정 작업(API 호출, 이벤트 처리 등)을 수행하면서 기록하면 해당 작업으로 인한 메모리 할당을 정확히 파악할 수 있습니다.
흔한 메모리 누수 패턴
패턴 1: 클로저가 예상보다 많은 것을 참조
// 문제 코드
function createHandler(largeData) {
return function handler(req, res) {
// largeData를 사용하지 않지만, 클로저가 참조를 유지
res.json({ status: 'ok' });
};
}
const bigArray = new Array(1000000).fill('x');
app.get('/api', createHandler(bigArray));
클로저는 외부 스코프의 모든 변수를 캡처합니다. 실제로 사용하지 않아도 참조가 유지됩니다.
해결책:
function createHandler() {
return function handler(req, res) {
res.json({ status: 'ok' });
};
}
// 또는 필요한 것만 명시적으로 전달
function createHandler(specificValue) {
return function handler(req, res) {
res.json({ value: specificValue });
};
}
패턴 2: 이벤트 리스너 미해제
// 문제 코드
class UserSession {
constructor(socket) {
this.socket = socket;
this.data = new Array(10000).fill('session data');
// 리스너 등록
socket.on('message', this.handleMessage.bind(this));
}
// destroy()가 호출되지 않으면 누수 발생
}
socket이 닫혀도 리스너가 UserSession을 참조하므로 GC되지 않습니다.
해결책:
class UserSession {
constructor(socket) {
this.socket = socket;
this.data = new Array(10000).fill('session data');
// bound 함수를 저장하여 나중에 해제 가능
this.boundHandleMessage = this.handleMessage.bind(this);
socket.on('message', this.boundHandleMessage);
}
destroy() {
this.socket.off('message', this.boundHandleMessage);
this.socket = null;
this.data = null;
}
}
패턴 3: 전역 캐시의 무한 성장
// 문제 코드
const cache = {};
function getUserData(userId) {
if (!cache[userId]) {
cache[userId] = fetchUserFromDB(userId);
}
return cache[userId];
}
사용자가 늘어날수록 캐시가 무한히 커집니다.
해결책: LRU 캐시 사용
const LRU = require('lru-cache');
const cache = new LRU({
max: 500, // 최대 항목 수
ttl: 1000 * 60 * 5, // 5분 TTL
});
패턴 4: 타이머 미정리
// 문제 코드
class Poller {
start() {
this.interval = setInterval(() => {
this.poll();
}, 5000);
}
poll() {
// this를 참조하므로 Poller 인스턴스가 GC되지 않음
}
}
해결책: clearInterval을 반드시 호출해야 합니다.
패턴 5: Promise 체인의 미완료
// 문제 코드
function processQueue() {
return queue.getNext()
.then(item => processItem(item))
.then(() => processQueue()); // 무한 재귀
}
각 Promise가 이전 Promise를 참조하여 체인이 쌓입니다.
해결책: 재귀 대신 루프 사용
async function processQueue() {
while (true) {
const item = await queue.getNext();
if (!item) break;
await processItem(item);
}
}
패턴 6: 콘솔 로그의 객체 참조
// 개발 환경에서 문제될 수 있음
function handleRequest(req) {
const largeResponse = generateLargeResponse();
console.log('Response:', largeResponse); // DevTools가 참조 유지
return largeResponse;
}
Chrome DevTools 콘솔이 로그된 객체의 참조를 유지합니다.
해결책: 프로덕션에서는 구조화된 로깅(pino, winston 등)을 사용하세요.
실전 디버깅 워크플로우
- 문제 재현:
autocannon등으로 부하를 주어 메모리 증가를 재현합니다. - 힙 덤프 수집: 메모리 임계값 도달 시 자동으로 덤프를 생성하거나 수동으로 생성합니다.
- 힙 덤프 비교: Chrome DevTools에서 초기 덤프와 증가 후 덤프를 비교합니다.
- 원인 수정: Retainers 경로를 따라 코드에서 참조를 찾아 수정합니다.
- 검증: 수정 후 다시 부하 테스트를 실행합니다.
정리
Node.js 메모리 누수 디버깅의 핵심 포인트:
- 모니터링을 먼저 설정하세요. 누수를 빨리 발견할수록 디버깅이 쉽습니다.
- 힙 덤프 비교가 핵심입니다. 단일 덤프보다 시간 간격을 둔 두 덤프를 비교하세요.
- Retained Size와 Retainers를 집중적으로 보세요. 이것이 누수 객체와 원인을 알려줍니다.
- 흔한 패턴을 숙지하세요. 클로저, 이벤트 리스너, 전역 캐시, 타이머가 주요 원인입니다.
- LRU 캐시와 WeakMap을 활용하세요. 자동으로 메모리를 관리해 줍니다.
메모리 누수는 찾기 어렵지만, 체계적인 접근법을 따르면 반드시 원인을 찾을 수 있습니다. 힙 덤프와 Chrome DevTools는 가장 강력한 도구이니, 익숙해지면 대부분의 누수를 해결할 수 있습니다.