들어가며: 프로덕션의 악몽, 메모리 누수
새벽 3시, 온콜 알람이 울립니다. 프로덕션 서버의 메모리 사용량이 90%를 넘어섰고, 애플리케이션이 느려지기 시작했습니다. PM2가 프로세스를 재시작했지만, 30분 후 같은 현상이 반복됩니다.
이것이 바로 메모리 누수(Memory Leak)의 전형적인 증상입니다.
Node.js 애플리케이션에서 메모리 누수는 가장 까다로운 문제 중 하나입니다. 로컬 개발 환경에서는 잘 작동하다가, 프로덕션에서 며칠 또는 몇 주 후에 갑자기 나타나곤 합니다. 더 큰 문제는 메모리 누수가 발생해도 애플리케이션이 즉시 멈추지 않는다는 점입니다. 점진적으로 성능이 저하되다가, 결국 OOM(Out of Memory) 에러로 크래시됩니다.
:::danger 메모리 누수의 영향
- 성능 저하: 응답 시간 증가, 처리량 감소
- 시스템 불안정: 예측 불가능한 크래시
- 비용 증가: 더 많은 서버 리소스 필요
- 사용자 경험 악화: 서비스 중단, 타임아웃
- 개발자 스트레스: 긴급 대응, 온콜 피로도 :::
이 글에서는 실제 프로덕션 환경에서 메모리 누수를 발견하고, 디버깅하고, 해결하는 전체 프로세스를 다룹니다. 이론이 아닌, 실무에서 바로 적용할 수 있는 실전 전략을 제공합니다.
1. 메모리 누수의 이해
1.1 메모리 누수란?
메모리 누수는 더 이상 사용하지 않는 메모리를 가비지 컬렉터(GC)가 회수하지 못하는 상황을 말합니다.
// 메모리 누수 예제let users = [];
function addUser(user) { users.push(user); // 계속 누적되지만 제거되지 않음 //... 사용자 처리 로직}
// 매 요청마다 호출되면 users 배열이 계속 커짐app.post('/api/user', (req, res) => { addUser(req.body); res.json({ success: true });});위 코드에서 users 배열은 전역 변수로, 한 번 추가된 사용자는 절대 제거되지 않습니다. 시간이 지날수록 배열이 커지고, 메모리가 계속 증가합니다.
1.2 Node.js 메모리 구조
Node.js는 V8 엔진을 사용하며, 메모리는 다음과 같이 구성됩니다:
┌─────────────────────────────────┐│ RSS (Resident Set) │├─────────────────────────────────┤│ ┌──────────────────────────┐ ││ │ V8 Heap Memory │ ││ ├──────────────────────────┤ ││ │ ├─ New Space (Young) │ ││ │ ├─ Old Space (Old) │ ││ │ ├─ Large Object Space │ ││ │ └─ Code Space │ ││ └──────────────────────────┘ ││ ┌──────────────────────────┐ ││ │ External Memory │ ││ │ (Buffers, C++ Addons) │ ││ └──────────────────────────┘ │└─────────────────────────────────┘주요 메모리 영역
- Heap Memory: JavaScript 객체가 저장되는 곳
- New Space: 새로 생성된 객체 (빠른 GC)
- Old Space: 오래된 객체 (느린 GC)
-
External Memory: Buffer, C++ 애드온 등
-
Code: 컴파일된 JavaScript 코드
:::important V8 메모리 제한
기본적으로 V8은 약 1.4GB (64비트) 또는 **512MB (32비트)**의 힙 메모리 제한이 있습니다. 이는 --max-old-space-size 플래그로 조정 가능합니다.
node --max-old-space-size=4096 app.js # 4GB로 설정:::
1.3 일반적인 메모리 누수 원인
1. 전역 변수 남용
// 나쁜 예: 전역 캐시가 계속 커짐global.cache = {};
function cacheUser(userId, userData) { global.cache[userId] = userData; // 삭제 로직 없음}2. 클로저와 이벤트 리스너
// 나쁜 예: 이벤트 리스너가 제거되지 않음function setupListener() { const hugeData = new Array(1000000).fill('x');
eventEmitter.on('data', () => { console.log(hugeData[0]); // 클로저가 hugeData 참조 유지 });}
// 여러 번 호출되면 메모리 누수setInterval(setupListener, 1000);3. 잊혀진 타이머와 콜백
// 나쁜 예: 타이머가 clear되지 않음function processData() { const data = fetchLargeData();
setInterval(() => { console.log(data.length); }, 1000);
// data가 계속 메모리에 유지됨}4. 무한 증가하는 배열/맵
// 나쁜 예: 요청 로그가 무한 증가const requestLogs = [];
app.use((req, res, next) => { requestLogs.push({ url: req.url, time: new Date(), headers: req.headers }); next();});2. 메모리 누수 감지 방법
2.1 메모리 사용량 모니터링
process.memoryUsage() 활용
function logMemoryUsage() { const usage = process.memoryUsage();
console.log({ rss: `${Math.round(usage.rss / 1024 / 1024)} MB`, // 총 메모리 heapTotal: `${Math.round(usage.heapTotal / 1024 / 1024)} MB`, // 할당된 힙 heapUsed: `${Math.round(usage.heapUsed / 1024 / 1024)} MB`, // 사용 중인 힙 external: `${Math.round(usage.external / 1024 / 1024)} MB`, // C++ 객체 arrayBuffers: `${Math.round(usage.arrayBuffers / 1024 / 1024)} MB` });}
// 매 10초마다 로깅setInterval(logMemoryUsage, 10000);PM2 메모리 모니터링
module.exports = { apps: [{ name: 'api', script: './app.js', max_memory_restart: '500M', // 500MB 도달 시 재시작 exec_mode: 'cluster', instances: 2, env: { NODE_ENV: 'production' } }]};# PM2 모니터링pm2 monit
# 메모리 사용량 확인pm2 list2.2 메모리 누수 징후
다음 증상이 나타나면 메모리 누수를 의심해야 합니다:
- 지속적인 메모리 증가: RSS가 계속 상승
- GC 빈도 증가: 가비지 컬렉션이 자주 발생
- 성능 저하: 응답 시간 증가
- 정기적 재시작 필요: PM2 자동 재시작 빈발
- heap 사용률 패턴: Old Space가 계속 증가
:::tip 메모리 누수 vs 정상적인 증가
- 정상: 메모리가 증가했다가 GC 후 감소하는 톱니 패턴
- 누수: 메모리가 계속 증가하고 GC 후에도 baseline이 상승
정상: /\ /\ /\ /\ / \/ \/ \/ \
누수: /\ /\ /\ / \ / \ / \ / \/ \/ \:::
3. Heap Snapshot을 활용한 디버깅
3.1 Heap Snapshot 생성
방법 1: 코드에서 생성
const v8 = require('v8');const fs = require('fs');
function takeHeapSnapshot() { const filename = `heap-${Date.now()}.heapsnapshot`; const snapshot = v8.writeHeapSnapshot(filename); console.log(`Heap snapshot written to ${snapshot}`); return snapshot;}
// HTTP 엔드포인트로 제공app.get('/debug/heap-snapshot', (req, res) => { const snapshot = takeHeapSnapshot(); res.download(snapshot);});방법 2: Node.js Inspector
# 1. 애플리케이션을 inspect 모드로 실행node --inspect app.js
# 2. Chrome에서 접속chrome://inspect
# 3. "Open dedicated DevTools for Node" 클릭# 4. Memory 탭에서 "Take heap snapshot" 클릭방법 3: Kill Signal 사용
const v8 = require('v8');
// SIGUSR2 시그널 받으면 스냅샷 생성process.on('SIGUSR2', () => { const filename = `heap-${process.pid}-${Date.now()}.heapsnapshot`; v8.writeHeapSnapshot(filename); console.log(`Heap snapshot saved: ${filename}`);});# 프로덕션에서 스냅샷 생성kill -USR2 <pid>3.2 Heap Snapshot 분석
Chrome DevTools로 분석
- 스냅샷 로드
Chrome DevTools > Memory > Load- 비교 분석
// 첫 번째 스냅샷takeHeapSnapshot(); // heap-1.heapsnapshot
// 부하 생성 (예: 1000 요청 처리)await processRequests(1000);
// 두 번째 스냅샷takeHeapSnapshot(); // heap-2.heapsnapshot- Comparison 뷰 사용
- DevTools에서 두 스냅샷 로드
- “Comparison” 뷰 선택
- “Size Delta” 열 확인 (메모리 증가량)
주요 분석 지표
| 지표 | 의미 | 누수 징후 |
|---|---|---|
| Shallow Size | 객체 자체 크기 | - |
| Retained Size | 객체가 참조하는 전체 크기 | 큰 값이 계속 증가 |
| #New | 새로 생성된 객체 수 | 지속적 증가 |
| #Deleted | 삭제된 객체 수 | 적거나 없음 |
| #Delta | 증가한 객체 수 | 양수가 계속 증가 |
3.3 실제 사례: EventEmitter 메모리 누수
문제 코드
const EventEmitter = require('events');const emitter = new EventEmitter();
class DataProcessor { constructor(id) { this.id = id; this.data = new Array(100000).fill(`data-${id}`);
// 리스너가 제거되지 않음 emitter.on('process', () => { console.log(`Processing ${this.id}`); }); }}
// 매번 새 인스턴스 생성app.post('/api/process', (req, res) => { new DataProcessor(req.body.id); res.json({ success: true });});Heap Snapshot 분석 결과
Comparison View:┌─────────────────┬─────────┬─────────┬─────────┐│ Constructor │ #New │ #Deleted│ #Delta │├─────────────────┼─────────┼─────────┼─────────┤│ DataProcessor │ 1000 │ 0 │ +1000 │ ← 누수!│ Array │ 1000 │ 0 │ +1000 │ ← 누수!│ (closure) │ 1000 │ 0 │ +1000 │ ← 이벤트 리스너└─────────────────┴─────────┴─────────┴─────────┘해결 방법
class DataProcessor { constructor(id) { this.id = id; this.data = new Array(100000).fill(`data-${id}`);
// bound function으로 참조 유지 this.handleProcess = () => { console.log(`Processing ${this.id}`); };
emitter.on('process', this.handleProcess); }
// 명시적으로 정리 cleanup() { emitter.off('process', this.handleProcess); this.data = null; }}
app.post('/api/process', (req, res) => { const processor = new DataProcessor(req.body.id);
// 작업 완료 후 정리 setTimeout(() => { processor.cleanup(); }, 5000);
res.json({ success: true });});4. Chrome DevTools 프로파일링
4.1 Allocation Timeline
Allocation Timeline은 시간에 따른 메모리 할당을 시각화합니다.
// 테스트 코드const leakyArray = [];
setInterval(() => { // 매번 큰 객체 추가 leakyArray.push({ data: new Array(10000).fill('x'), timestamp: Date.now() });}, 100);분석 방법:
- Chrome DevTools > Memory
- “Allocation instrumentation on timeline” 선택
- “Start” 클릭
- 애플리케이션 실행
- “Stop” 클릭
결과 해석:
- 파란 막대: 할당된 메모리 (살아있음)
- 회색 막대: 해제된 메모리
- 계속 파란색만 쌓이면 → 메모리 누수
4.2 Allocation Sampling
CPU 오버헤드가 적은 방식으로 메모리 할당을 샘플링합니다.
# Node.js에서 실행node --inspect --expose-gc app.js// 수동 GC 트리거 (테스트용)if (global.gc) { global.gc(); console.log('Manual GC triggered');}5. 실전: 프로덕션 디버깅 전략
5.1 안전한 프로덕션 디버깅
Step 1: 메모리 증가 확인
const os = require('os');
class MemoryMonitor { constructor(thresholdMB = 500) { this.threshold = thresholdMB * 1024 * 1024; this.samples = []; this.maxSamples = 100; }
check() { const usage = process.memoryUsage(); const sample = { timestamp: Date.now(), heapUsed: usage.heapUsed, heapTotal: usage.heapTotal, rss: usage.rss, external: usage.external };
this.samples.push(sample); if (this.samples.length > this.maxSamples) { this.samples.shift(); }
// 추세 분석 if (this.samples.length >= 10) { const trend = this.analyzeTrend(); if (trend.isIncreasing && usage.heapUsed > this.threshold) { this.alert('Memory leak suspected', { current: usage.heapUsed, trend: trend.rate }); } }
return sample; }
analyzeTrend() { const recent = this.samples.slice(-10); let increases = 0;
for (let i = 1; i < recent.length; i++) { if (recent[i].heapUsed > recent[i-1].heapUsed) { increases++; } }
return { isIncreasing: increases >= 7, // 10 중 7번 이상 증가 rate: (recent[recent.length-1].heapUsed - recent[0].heapUsed) / recent.length }; }
alert(message, data) { console.error(`[MEMORY ALERT] ${message}`, data); // Slack/PagerDuty 알림 전송 }}
const monitor = new MemoryMonitor(500);setInterval(() => monitor.check(), 30000); // 30초마다Step 2: 자동 스냅샷 생성
const v8 = require('v8');const fs = require('fs');const path = require('path');
class AutoSnapshotManager { constructor(options = {}) { this.threshold = options.threshold || 500 * 1024 * 1024; // 500MB this.maxSnapshots = options.maxSnapshots || 3; this.snapshotDir = options.dir || './snapshots'; this.lastSnapshot = 0; this.minInterval = 60000; // 최소 1분 간격
// 디렉토리 생성 if (!fs.existsSync(this.snapshotDir)) { fs.mkdirSync(this.snapshotDir, { recursive: true }); } }
shouldTakeSnapshot() { const usage = process.memoryUsage(); const now = Date.now();
return ( usage.heapUsed > this.threshold && now - this.lastSnapshot > this.minInterval ); }
take() { if (!this.shouldTakeSnapshot()) { return null; }
const timestamp = new Date().toISOString().replace(/:/g, '-'); const filename = `heap-${process.pid}-${timestamp}.heapsnapshot`; const filepath = path.join(this.snapshotDir, filename);
console.log(`Taking heap snapshot: ${filepath}`); v8.writeHeapSnapshot(filepath);
this.lastSnapshot = Date.now(); this.cleanup();
return filepath; }
cleanup() { const files = fs.readdirSync(this.snapshotDir).filter(f => f.endsWith('.heapsnapshot')).map(f => ({ name: f, path: path.join(this.snapshotDir, f), time: fs.statSync(path.join(this.snapshotDir, f)).mtime.getTime() })).sort((a, b) => b.time - a.time);
// 오래된 스냅샷 삭제 if (files.length > this.maxSnapshots) { files.slice(this.maxSnapshots).forEach(file => { console.log(`Removing old snapshot: ${file.name}`); fs.unlinkSync(file.path); }); } }}
// 사용 예const snapshotManager = new AutoSnapshotManager({ threshold: 500 * 1024 * 1024, // 500MB maxSnapshots: 5, dir: '/var/snapshots'});
setInterval(() => { snapshotManager.take();}, 60000); // 1분마다 체크Step 3: 점진적 분석
class LeakDetector { constructor() { this.references = new Map(); }
track(key, object) { if (!this.references.has(key)) { this.references.set(key, new WeakSet()); } this.references.get(key).add(object); }
getStats() { const stats = {}; for (const [key, refs] of this.references.entries()) { stats[key] = refs.size || 'Unknown (WeakSet)'; } return stats; }}
const detector = new LeakDetector();
// 사용 예class UserSession { constructor(userId) { this.userId = userId; detector.track('UserSession', this); }}
// 주기적 체크setInterval(() => { console.log('Tracked objects:', detector.getStats());}, 60000);5.2 일반적인 수정 패턴
패턴 1: WeakMap/WeakSet 사용
// 나쁜 예: 강한 참조const userCache = new Map();
function cacheUser(user) { userCache.set(user.id, user);}
// 좋은 예: 약한 참조const userCache = new WeakMap();const userObjects = new Map(); // id -> user object
function cacheUser(user) { userObjects.set(user.id, user); userCache.set(user, user.metadata);}
// user 객체가 GC되면 자동으로 WeakMap에서도 제거됨패턴 2: 명시적 정리
class RequestHandler { constructor() { this.activeRequests = new Map(); }
async handleRequest(requestId, data) { try { const context = { id: requestId, data: data, startTime: Date.now() };
this.activeRequests.set(requestId, context);
await this.process(context);
return { success: true }; } finally { // 항상 정리 this.activeRequests.delete(requestId); } }}패턴 3: 크기 제한이 있는 캐시
class LRUCache { constructor(maxSize = 1000) { this.maxSize = maxSize; this.cache = new Map(); }
set(key, value) { // 이미 존재하면 제거 후 재추가 (LRU) if (this.cache.has(key)) { this.cache.delete(key); }
this.cache.set(key, value);
// 크기 제한 if (this.cache.size > this.maxSize) { const firstKey = this.cache.keys().next().value; this.cache.delete(firstKey); } }
get(key) { if (!this.cache.has(key)) { return undefined; }
// LRU: 사용한 아이템을 끝으로 이동 const value = this.cache.get(key); this.cache.delete(key); this.cache.set(key, value);
return value; }}6. 메모리 누수 방지 모범 사례
6.1 코딩 가이드라인
1. 이벤트 리스너 관리
class Component { constructor() { this.listeners = new Set(); }
addEventListener(emitter, event, handler) { emitter.on(event, handler); this.listeners.add({ emitter, event, handler }); }
destroy() { // 모든 리스너 제거 for (const { emitter, event, handler } of this.listeners) { emitter.off(event, handler); } this.listeners.clear(); }}2. 타이머 관리
class Worker { constructor() { this.timers = new Set(); }
setInterval(fn, interval) { const timer = setInterval(fn, interval); this.timers.add(timer); return timer; }
setTimeout(fn, delay) { const timer = setTimeout(() => { fn(); this.timers.delete(timer); // 완료 후 제거 }, delay); this.timers.add(timer); return timer; }
cleanup() { // 모든 타이머 정리 for (const timer of this.timers) { clearTimeout(timer); clearInterval(timer); } this.timers.clear(); }}3. 스트림 처리
const fs = require('fs');
// 나쁜 예function badReadFile(path) { const stream = fs.createReadStream(path); stream.on('data', chunk => { //... 처리 }); // 스트림이 닫히지 않을 수 있음}
// 좋은 예function goodReadFile(path) { return new Promise((resolve, reject) => { const stream = fs.createReadStream(path); const chunks = [];
stream.on('data', chunk => chunks.push(chunk)); stream.on('end', () => resolve(Buffer.concat(chunks))); stream.on('error', reject);
// 정리 보장 stream.on('close', () => { stream.removeAllListeners(); }); });}6.2 테스트 및 모니터링
메모리 누수 테스트
const assert = require('assert');
async function testForMemoryLeak(fn, iterations = 1000) { // 초기 GC if (global.gc) global.gc();
const baseline = process.memoryUsage().heapUsed;
// 테스트 실행 for (let i = 0; i < iterations; i++) { await fn(); }
// 최종 GC if (global.gc) global.gc();
const final = process.memoryUsage().heapUsed; const increase = final - baseline; const increasePerIteration = increase / iterations;
console.log({ baseline: `${Math.round(baseline / 1024 / 1024)} MB`, final: `${Math.round(final / 1024 / 1024)} MB`, increase: `${Math.round(increase / 1024 / 1024)} MB`, perIteration: `${Math.round(increasePerIteration / 1024)} KB` });
// 반복당 10KB 이상 증가하면 누수 의심 assert(increasePerIteration < 10240, 'Potential memory leak detected');}
// 사용 예describe('Memory Leak Tests', () => { it('should not leak memory in request handler', async () => { await testForMemoryLeak(async () => { const handler = new RequestHandler(); await handler.process({ data: 'test' }); }, 1000); });});7. 프로덕션 체크리스트
프로덕션 배포 전 메모리 관리 체크리스트:
코드 레벨
- 전역 변수 사용 최소화
- 이벤트 리스너 정리 확인
- 타이머 정리 코드 작성
- 캐시 크기 제한 구현
- 스트림/연결 정리 보장
- 클로저 사용 주의
- WeakMap/WeakSet 적절히 사용
모니터링
- 메모리 사용량 대시보드 설정
- 알람 임계값 설정 (예: 80%)
- 자동 스냅샷 시스템 구현
- PM2 메모리 모니터링 활성화
- 로그 집계 시스템 구축
인프라
-
--max-old-space-size적절히 설정 - PM2 자동 재시작 설정
- Heap 크기 충분히 할당
- 메모리 임계값 기반 스케일링
마치며
메모리 누수는 Node.js 애플리케이션에서 가장 해결하기 어려운 문제 중 하나지만, 체계적인 접근으로 충분히 해결할 수 있습니다.
핵심 요약
- 예방이 최선: 코딩 단계에서 메모리 관리 고려
- 조기 발견: 모니터링과 알람으로 빠르게 감지
- 체계적 디버깅: Heap Snapshot으로 원인 분석
- 점진적 수정: 작은 변경으로 안전하게 해결
- 지속적 모니터링: 수정 후에도 계속 관찰
:::tip 최종 조언 메모리 누수는 한 번에 해결되지 않습니다. 반복적인 분석과 개선이 필요합니다.
- 모니터링 시스템을 먼저 구축하세요
- 문제가 발생하면 Heap Snapshot을 수집하세요
- 패턴을 찾아 근본 원인을 해결하세요
- 테스트를 작성해 재발을 방지하세요
- 팀과 지식을 공유하세요
메모리 관리는 기술이 아니라 습관입니다. :::
참고 자료
공식 문서
도구
- clinic.js - Node.js 성능 진단
- heapdump - Heap dump 생성
- memwatch-next - 메모리 누수 감지