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) │ │
│ └──────────────────────────┘ │
└─────────────────────────────────┘
주요 메모리 영역
- 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 메모리 모니터링
// 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 메모리 누수 징후
다음 증상이 나타나면 메모리 누수를 의심해야 합니다:
- 지속적인 메모리 증가: 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: 메모리 증가 확인
// 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 애플리케이션에서 가장 해결하기 어려운 문제 중 하나지만, 체계적인 접근으로 충분히 해결할 수 있습니다.
핵심 요약
- 예방이 최선: 코딩 단계에서 메모리 관리 고려
- 조기 발견: 모니터링과 알람으로 빠르게 감지
- 체계적 디버깅: Heap Snapshot으로 원인 분석
- 점진적 수정: 작은 변경으로 안전하게 해결
- 지속적 모니터링: 수정 후에도 계속 관찰
:::tip 최종 조언 메모리 누수는 한 번에 해결되지 않습니다. 반복적인 분석과 개선이 필요합니다.
- 모니터링 시스템을 먼저 구축하세요
- 문제가 발생하면 Heap Snapshot을 수집하세요
- 패턴을 찾아 근본 원인을 해결하세요
- 테스트를 작성해 재발을 방지하세요
- 팀과 지식을 공유하세요
메모리 관리는 기술이 아니라 습관입니다. :::
참고 자료
공식 문서
도구
- clinic.js - Node.js 성능 진단
- heapdump - Heap dump 생성
- memwatch-next - 메모리 누수 감지