# Node.js 메모리 누수 완벽 해결 가이드: 프로덕션 힙 프로파일링과 디버깅 전략
Table of Contents
프로덕션 장애 시나리오: 수요일 새벽 3시, 갑자기 서버가 멈췄다
수요일 새벽 3시, 모니터링 알람이 울렸습니다. “Node.js API 서버 5대 중 3대가 응답하지 않습니다.” 급히 로그를 확인하니 FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory라는 메시지만 남기고 프로세스가 종료되어 있었습니다.
더 큰 문제는, 서버를 재시작해도 6시간마다 같은 현상이 반복된다는 것이었습니다. 메모리 사용량이 시간이 지날수록 계속 증가하다가 결국 힙 메모리 한계(heap limit)에 도달해 크래시되는 전형적인 메모리 누수(Memory Leak) 패턴이었습니다.
피해 규모:
- 서비스 다운타임: 총 4시간 23분 (3번의 크래시)
- 매출 손실: 약 $12,300 (API 호출 실패로 인한 거래 취소)
- 사용자 영향: 약 28,000명의 사용자가 500 에러 경험
- 대응 시간: 원인 파악까지 11시간 소요
이 글에서는 Node.js 프로덕션 환경에서 메모리 누수를 탐지하고 해결하는 실전 기법을 다룹니다.
메모리 누수란 무엇인가?
**메모리 누수(Memory Leak)**는 프로그램이 더 이상 사용하지 않는 메모리를 해제하지 않아, 시간이 지날수록 메모리 사용량이 계속 증가하는 현상입니다.
JavaScript는 가비지 컬렉터(Garbage Collector)가 자동으로 메모리를 관리하지만, 다음과 같은 상황에서는 여전히 메모리 누수가 발생할 수 있습니다:
흔한 메모리 누수 원인
1. 전역 변수에 데이터 계속 추가
// 나쁜 예: 전역 배열에 계속 추가
const userCache = [];
app.get('/api/users/:id', async (req, res) => {
const user = await db.getUser(req.params.id);
userCache.push(user); // 계속 추가만 하고 삭제는 안 함!
res.json(user);
});
// 1시간 후: userCache.length = 150,000개
// 6시간 후: userCache.length = 900,000개 → OOM Crash!
2. 이벤트 리스너 제거 안 함
// 나쁜 예: 이벤트 리스너가 계속 쌓임
class DataProcessor {
process(data) {
const emitter = new EventEmitter();
emitter.on('data', (chunk) => {
// 처리 로직...
});
// removeListener()를 호출하지 않음!
// 호출할 때마다 리스너가 메모리에 계속 남음
}
}
// 10,000번 호출 후: 10,000개의 좀비 리스너가 메모리 점유
3. 클로저가 큰 객체 참조
// 나쁜 예: 클로저가 큰 데이터 계속 참조
function processLargeData() {
const largeArray = new Array(1000000).fill('large data');
return function() {
// largeArray를 사용하지 않지만,
// 클로저가 계속 참조하여 GC되지 않음
console.log('Processing...');
};
}
const callbacks = [];
for (let i = 0; i < 1000; i++) {
callbacks.push(processLargeData());
}
// 1,000개 × 1,000,000개 = 10억 개의 문자열이 메모리에 남음!
4. 타이머/인터벌 정리 안 함
// 나쁜 예: setInterval이 계속 실행
app.get('/api/monitor', (req, res) => {
const intervalId = setInterval(() => {
console.log('Monitoring...');
}, 1000);
// clearInterval(intervalId)를 호출하지 않음!
res.json({ status: 'monitoring started' });
});
// 100번 호출 후: 100개의 interval이 동시에 실행 중
메모리 누수 탐지 방법
1. 프로덕션 모니터링으로 패턴 확인
첫 번째 단계는 메모리 사용 패턴을 모니터링하는 것입니다.
프로세스 메모리 모니터링 코드:
// memory-monitor.js
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` // C++ 객체
});
// 1분마다 메모리 체크
setInterval(() => {
const current = process.memoryUsage();
console.log(`Heap Used: ${Math.round(current.heapUsed / 1024 / 1024)} MB`);
}, 60000);
정상 패턴 vs 메모리 누수 패턴:
정상 패턴 (Sawtooth Pattern):
Heap Used: 120 MB → 180 MB → 140 MB → 200 MB → 150 MB (GC 작동)
↗️ ↘️ ↗️ ↘️
메모리 누수 패턴 (Ascending Pattern):
Heap Used: 120 MB → 180 MB → 240 MB → 320 MB → 450 MB → OOM Crash!
↗️ ↗️ ↗️ ↗️ ↗️
2. 힙 프로파일링 (Heap Profiling) 활성화
Node.js는 --heap-prof 플래그로 힙 프로파일링을 활성화할 수 있습니다.
힙 프로파일 수집:
# 프로파일링 활성화하여 Node.js 시작
node --heap-prof --heap-prof-interval=512 app.js
# 프로세스 종료 시 자동으로 힙 프로파일 생성됨:
# Heap.20250116.153045.12345.0.001.heapprofile
프로파일 분석 (Chrome DevTools):
- Chrome 브라우저 열기
chrome://inspect접속- “Open dedicated DevTools for Node” 클릭
- Memory 탭 → Load 버튼 →
.heapprofile파일 선택 - Chart 또는 Heavy (Bottom Up) 뷰로 메모리 사용량 확인
[주의] 프로덕션 환경에서 --heap-prof를 상시 실행하면 성능 저하(5-10%) 및 디스크 사용량 증가가 발생할 수 있습니다. 조건부로 활성화하는 것을 권장합니다.
if (process.env.ENABLE_HEAP_PROFILING === 'true') {
// 힙 프로파일링 활성화 로직
}
3. 힙 스냅샷 비교 (Heap Snapshot Comparison)
가장 강력한 메모리 누수 탐지 기법은 힙 스냅샷을 시간 간격을 두고 찍어서 비교하는 것입니다.
힙 스냅샷 수집 코드:
// heap-snapshot.js
const v8 = require('v8');
const fs = require('fs');
function takeHeapSnapshot(filename) {
const snapshotStream = v8.writeHeapSnapshot(filename);
console.log(`Heap snapshot saved: ${snapshotStream}`);
return snapshotStream;
}
// 사용 예시
takeHeapSnapshot('./snapshots/heap-1.heapsnapshot');
// 10분 후 다시 촬영
setTimeout(() => {
takeHeapSnapshot('./snapshots/heap-2.heapsnapshot');
}, 600000);
Express 엔드포인트로 노출:
const express = require('express');
const v8 = require('v8');
const fs = require('fs');
const app = express();
// [보안] 프로덕션에서는 인증 필수!
app.get('/admin/heap-snapshot', (req, res) => {
// 인증 체크
if (req.headers['x-admin-token'] !== process.env.ADMIN_TOKEN) {
return res.status(401).json({ error: 'Unauthorized' });
}
const filename = `./snapshots/heap-${Date.now()}.heapsnapshot`;
const snapshotPath = v8.writeHeapSnapshot(filename);
res.json({
success: true,
path: snapshotPath,
size: fs.statSync(snapshotPath).size
});
});
app.listen(3000);
Chrome DevTools에서 비교 분석:
- Chrome DevTools 열기 (
chrome://inspect) - Memory 탭 → “Load” 버튼
heap-1.heapsnapshot로드- 다시 “Load” 버튼 →
heap-2.heapsnapshot로드 - Comparison 뷰 선택
- Δ (Delta) 컬럼 확인 → 증가한 객체 확인
분석 예시:
Constructor | Δ Objects | Δ Size | Size Delta
-----------------|-----------|--------|------------
(array) | +15,234 | +45 MB | ▲ 큰 배열 증가!
(string) | +8,942 | +12 MB | ▲ 문자열 누수
EventEmitter | +1,203 | +3 MB | ▲ 이벤트 리스너 누수?
(closure) | +842 | +8 MB | ▲ 클로저 누수
4. Chrome DevTools 원격 디버깅 (개발/스테이징 환경)
개발 환경에서는 실시간 디버깅이 가능합니다.
디버깅 모드로 Node.js 시작:
# 9229 포트로 디버거 활성화
node --inspect app.js
[보안 주의] 프로덕션 환경에서 node --inspect=0.0.0.0:9229를 사용하면 누구나 원격으로 디버거에 접속할 수 있어 매우 위험합니다. 로컬호스트만 허용하고 SSH 터널링을 사용하세요.
# SSH 포트 포워딩으로 안전하게 접속
ssh -L 9229:localhost:9229 user@production-server
프로덕션 안전 메모리 누수 탐지: N|Solid
프로덕션 환경에서는 성능 저하 없이 안전하게 메모리를 모니터링해야 합니다. N|Solid는 NodeSource에서 제공하는 엔터프라이즈급 Node.js 런타임으로, **샘플링 힙 프로파일링(Sampling Heap Profiler)**을 지원합니다.
N|Solid의 장점
- 낮은 오버헤드 (<1%): 샘플링 방식으로 모든 할당을 기록하지 않고 일부만 샘플링
- 프로덕션 안전 설계: 힙 스냅샷 생성 시 블로킹 없음
- 통합 대시보드: 실시간 메모리 사용량 그래프, CPU 프로파일링, 이벤트 루프 모니터링
실전 디버깅 시나리오: 사용자 세션 캐시 누수
앞서 언급한 수요일 새벽 3시 장애의 실제 원인을 추적한 과정입니다.
1단계: 힙 스냅샷 비교
첫 번째 스냅샷 (서버 시작 직후):
curl -H "x-admin-token: $ADMIN_TOKEN" \
http://api-server-1:3000/admin/heap-snapshot
# heap-1699920000000.heapsnapshot 생성됨 (크기: 45 MB)
두 번째 스냅샷 (3시간 후):
curl -H "x-admin-token: $ADMIN_TOKEN" \
http://api-server-1:3000/admin/heap-snapshot
# heap-1699931600000.heapsnapshot 생성됨 (크기: 312 MB)
Chrome DevTools Comparison 결과:
Constructor | Δ Objects | Δ Size | Retained Size
-----------------|-----------|--------|---------------
(array) | +234,891 | +156 MB| ▲ 의심!
(object) | +234,891 | +98 MB | ▲ 의심!
(string) | +1,405,346| +45 MB |
UserSession | +234,891 | +23 MB | ▲ 확실한 범인!
→ UserSession 객체가 234,891개 증가! 명확한 메모리 누수입니다.
2단계: 코드 검토
문제의 코드 (session-manager.js):
// 나쁜 예: 세션을 전역 Map에 계속 추가만 함
const sessions = new Map();
class SessionManager {
createSession(userId) {
const session = {
userId,
createdAt: Date.now(),
data: {}
};
sessions.set(userId, session); // 추가만 하고 삭제는 안 함!
return session;
}
// ...
}
문제:
- 사용자 로그인할 때마다
createSession()호출 - 로그아웃 시
destroySession()호출되지 않음 - 3시간 동안 234,891명이 로그인 → 234,891개의 세션이 메모리에 영구히 남음
3단계: 수정 (LRU Cache 사용)
lru-cache 라이브러리를 사용하여 메모리 사용량을 자동으로 제한합니다.
const LRU = require('lru-cache');
// LRU 캐시로 자동 크기 제한
const sessions = new LRU({
max: 10000, // 최대 10,000개 세션
maxAge: 3600000, // 1시간 후 자동 만료
updateAgeOnGet: true, // 조회 시 TTL 갱신
dispose: (key, session) => {
console.log(`Session expired: ${session.userId}`);
}
});
class SessionManager {
createSession(userId) {
const session = { userId, createdAt: Date.now(), data: {} };
sessions.set(userId, session);
return session;
}
// ...
}
4단계: 배포 및 검증
수정 후 메모리 패턴 (정상):
# 배포 직후
Heap Used: 120 MB, Sessions: 1,234
# 6시간 후
Heap Used: 175 MB, Sessions: 5,123 (안정적!)
결과: 메모리 사용량이 120-180 MB 범위에서 안정적으로 유지되며 OOM Crash가 사라졌습니다.
메모리 누수 방지 체크리스트
코드 리뷰 시 확인 사항
- 전역 변수 사용 최소화:
Array대신LRU Cache사용 - 이벤트 리스너 정리:
removeListener또는once사용 - 타이머/인터벌 정리:
clearInterval,clearTimeout필수 호출 - 클로저 주의: 불필요하게 큰 스코프 참조 방지
- Stream 정리:
destroy()호출 확인
프로덕션 모니터링 설정
Prometheus + Grafana 메트릭 수집:
nodejs_heap_used_bytes 메트릭을 수집하고, 힙 사용률이 90%를 넘으면 알림이 오도록 설정하세요.
마치며
Node.js 메모리 누수는 조기에 탐지하지 못하면 심각한 프로덕션 장애로 이어집니다. 이 글에서 다룬 기법들을 정리하면:
핵심 요약:
- 모니터링 필수: 메모리 사용 패턴을 지속적으로 모니터링 (Prometheus, Datadog)
- 힙 스냅샷 비교: 가장 강력한 누수 탐지 기법 (Chrome DevTools)
- 프로덕션 안전: N|Solid, Clinic.js 등 오버헤드 낮은 도구 사용
- 예방 코드 패턴: LRU 캐시, 이벤트 리스너 정리, TTL 기반 만료
메모리 누수는 예방이 최선의 치료입니다. 코드 리뷰 시 위의 체크리스트를 참고하고, 프로덕션 환경에서는 지속적인 모니터링을 통해 조기에 발견하세요.