본문으로 건너뛰기
Node.js 프로덕션 메모리 누수 디버깅 가이드 - Heap Snapshot과 실전 해결 전략

# Node.js 프로덕션 메모리 누수 디버깅: 실전 가이드와 해결 전략

Table of Contents

들어가며: 프로덕션의 악몽, 메모리 누수

새벽 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) │ │
│ └──────────────────────────┘ │
└─────────────────────────────────┘

주요 메모리 영역

  1. Heap Memory: JavaScript 객체가 저장되는 곳
  • New Space: 새로 생성된 객체 (빠른 GC)
  • Old Space: 오래된 객체 (느린 GC)
  1. External Memory: Buffer, C++ 애드온 등

  2. 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 메모리 모니터링

// ecosystem.config.js
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 list

2.2 메모리 누수 징후

다음 증상이 나타나면 메모리 누수를 의심해야 합니다:

  1. 지속적인 메모리 증가: RSS가 계속 상승
  2. GC 빈도 증가: 가비지 컬렉션이 자주 발생
  3. 성능 저하: 응답 시간 증가
  4. 정기적 재시작 필요: PM2 자동 재시작 빈발
  5. 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로 분석

  1. 스냅샷 로드
Chrome DevTools > Memory > Load
  1. 비교 분석
// 첫 번째 스냅샷
takeHeapSnapshot(); // heap-1.heapsnapshot

// 부하 생성 (예: 1000 요청 처리)
await processRequests(1000);

// 두 번째 스냅샷
takeHeapSnapshot(); // heap-2.heapsnapshot
  1. 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);

분석 방법:

  1. Chrome DevTools > Memory
  2. “Allocation instrumentation on timeline” 선택
  3. “Start” 클릭
  4. 애플리케이션 실행
  5. “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: 메모리 증가 확인

// monitoring.js
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: 자동 스냅샷 생성

// auto-snapshot.js
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: 점진적 분석

// leak-detector.js
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 테스트 및 모니터링

메모리 누수 테스트

// memory-leak-test.js
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 애플리케이션에서 가장 해결하기 어려운 문제 중 하나지만, 체계적인 접근으로 충분히 해결할 수 있습니다.

핵심 요약

  1. 예방이 최선: 코딩 단계에서 메모리 관리 고려
  2. 조기 발견: 모니터링과 알람으로 빠르게 감지
  3. 체계적 디버깅: Heap Snapshot으로 원인 분석
  4. 점진적 수정: 작은 변경으로 안전하게 해결
  5. 지속적 모니터링: 수정 후에도 계속 관찰

:::tip 최종 조언 메모리 누수는 한 번에 해결되지 않습니다. 반복적인 분석과 개선이 필요합니다.

  1. 모니터링 시스템을 먼저 구축하세요
  2. 문제가 발생하면 Heap Snapshot을 수집하세요
  3. 패턴을 찾아 근본 원인을 해결하세요
  4. 테스트를 작성해 재발을 방지하세요
  5. 팀과 지식을 공유하세요

메모리 관리는 기술이 아니라 습관입니다. :::

참고 자료

공식 문서

도구

추가 학습


이 글 공유하기:
My avatar

글을 마치며

이 글이 도움이 되었기를 바랍니다. 궁금한 점이나 의견이 있다면 댓글로 남겨주세요.

더 많은 기술 인사이트와 개발 경험을 공유하고 있으니, 다른 포스트도 확인해보세요.

유럽살며 여행하며 코딩하는 노마드의 여정을 함께 나누며, 함께 성장하는 개발자 커뮤니티를 만들어가요! 🚀


관련 포스트

# Database Replication Lag 완벽 가이드: 프로덕션 30초 임계값과 2025 최신 모니터링 전략

게시:

Database Replication Lag 프로덕션 모니터링 완벽 가이드입니다. Primary-Replica 동기화 지연, Stale Read, Failover 실패 문제를 진단하고 해결하는 방법부터 PostgreSQL, MySQL, Azure SQL의 2025년 최신 모니터링 메트릭과 Multi-Threaded Replication 최적화까지 실전 예제와 함께 설명합니다.

읽기