Back

Node.js 메모리 누수 디버깅: 실무 예제와 함께하는 완벽 트러블슈팅 가이드

처음엔 아무 문제 없어 보입니다. Node.js 서버가 몇 시간, 며칠 동안 잘 돌아가거든요. 그런데 어느 날 모니터링 대시보드를 보니, 메모리 사용량 그래프가 조금씩... 하지만 분명히 올라가고 있습니다. 서버 재시작이 주간 루틴이 됩니다. 그러다 매일 하게 됩니다. 급기야 몇 시간마다 자동 재시작하는 크론잡을 만들고 있는 자신을 발견하죠. 뭔가 심각하게 잘못됐습니다.

Node.js에서 메모리 누수는 정말 골치 아픈 버그예요. 문법 에러처럼 바로 터지는 게 아니라, 조용히 숨어서 서서히 문제를 키우거든요. 평소엔 멀쩡하다가 부하가 높아지면 슬금슬금 모습을 드러냅니다. 그리고 새벽 3시에 OOM(Out of Memory)으로 서버가 뻗으면... 이게 뭔가 싶은 힙 덤프 파일만 남아있죠.

이 글에서는 메모리 누수가 두려웠던 분들이 체계적으로 문제를 잡아낼 수 있도록 도와드릴게요. 기본 개념부터 시작해서, 실제 도구로 디버깅하는 과정을 따라가 보고, Node.js에서 자주 발생하는 누수 패턴들을 정리해 봅시다. 어떤 코드베이스에서든 써먹을 수 있는 디버깅 노하우를 알려드릴게요.


Node.js의 메모리 이해하기: 기초부터 시작해볼까요?

메모리 누수를 고치려면 먼저 Node.js가 어떻게 메모리를 관리하는지 이해해야 합니다. Node.js는 V8 JavaScript 엔진을 사용하며, 가비지 컬렉션을 통해 자동 메모리 관리를 구현합니다.

V8 메모리 모델

V8은 메모리를 여러 공간으로 나눕니다:

┌─────────────────────────────────────────────────────────────────┐
│                        V8 힙 메모리                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌─────────────────────┐    ┌─────────────────────────────────┐ │
│  │    NEW SPACE        │    │         OLD SPACE               │ │
│  │   (Young 세대)       │    │        (Old 세대)               │ │
│  │                     │    │                                 │ │
│  │  • 수명 짧은 객체    │───▶│  • 수명 긴 객체                 │ │
│  │  • 빠른 GC          │    │  • New Space에서 승격됨         │ │
│  │    (Scavenge)       │    │  • 느린 GC (Mark-Sweep)         │ │
│  └─────────────────────┘    └─────────────────────────────────┘ │
│                                                                 │
│  ┌─────────────────────┐    ┌─────────────────────────────────┐ │
│  │    LARGE OBJECT     │    │         CODE SPACE              │ │
│  │       SPACE         │    │   (컴파일된 JS 함수)            │ │
│  └─────────────────────┘    └─────────────────────────────────┘ │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

New Space (Young 세대): 새로운 객체가 할당되는 곳입니다. 작고 빠른 "Scavenger" 알고리즘을 사용해 자주 가비지 컬렉션됩니다.

Old Space (Old 세대): New Space에서 여러 번의 가비지 컬렉션을 살아남은 객체들이 여기로 승격됩니다. 더 느린 Mark-Sweep-Compact 알고리즘을 사용해 덜 자주 수집됩니다.

메모리 누수란 뭘까요?

간단히 말해서, 메모리 누수는 더 이상 안 쓰는 메모리를 해제하지 못하는 상황이에요. JavaScript는 GC(가비지 컬렉터)가 알아서 메모리를 정리해주지만, 안 쓰는 객체인데 어딘가에서 참조하고 있으면 정리를 못 합니다.

GC는 아무도 참조하지 않는 객체만 치워요. 그래서 실수로 객체 참조를 어딘가에 남겨두면, 절대 안 써도 그 객체는 메모리에 계속 살아있게 됩니다.

누수를 일으키는 흔한 패턴들:

  1. 전역 변수가 데이터를 계속 쌓는 경우
  2. 클로저가 의도치 않게 변수를 캡처하는 경우
  3. 이벤트 리스너가 추가되고 제거되지 않는 경우
  4. 캐시에 제거 정책이 없는 경우
  5. 타이머 (setInterval)가 절대 클리어되지 않는 경우
  6. 특정 상황에서의 순환 참조

메모리 누수 알아차리기: 이런 증상이 있으면 의심하세요

"이게 메모리 누수인가, 그냥 메모리를 많이 쓰는 건가?" 헷갈릴 수 있어요. 다음 패턴들이 보이면 누수를 의심해봐야 합니다:

증상 1: 잘못된 톱니 패턴

Node.js의 정상적인 메모리 사용량은 톱니 모양입니다: 객체가 할당되면서 메모리가 올라가고, 가비지 컬렉션 중에 급격히 떨어집니다.

정상적인 메모리 패턴:
     ▲
     │    /\    /\    /\    /\
     │   /  \  /  \  /  \  /  \
     │  /    \/    \/    \/    \
     └────────────────────────────▶
                  시간

메모리 누수는 다른 패턴을 보여줍니다: 톱니의 기준선이 계속 올라갑니다:

메모리 누수 패턴:
     ▲
     │                      /\
     │                 /\  /  \
     │            /\  /  \/
     │       /\  /  \/
     │  /\  /  \/
     │ /  \/
     └────────────────────────────▶
                  시간

증상 2: Old Space 증가

--expose-gc 플래그를 사용해서 주기적으로 가비지 컬렉션을 강제하면서 메모리를 로깅해보세요:

// memory-monitor.js if (global.gc) { setInterval(() => { global.gc(); const used = process.memoryUsage(); console.log({ heapUsed: Math.round(used.heapUsed / 1024 / 1024) + 'MB', heapTotal: Math.round(used.heapTotal / 1024 / 1024) + 'MB', external: Math.round(used.external / 1024 / 1024) + 'MB', rss: Math.round(used.rss / 1024 / 1024) + 'MB', }); }, 10000); }

이렇게 실행하세요:

node --expose-gc memory-monitor.js

GC 직후에도 heapUsed가 계속 증가한다면, 누수가 있는 겁니다.

증상 3: 응답 시간 증가

힙이 커지면서 가비지 컬렉션 사이클이 더 길어지고 더 자주 발생합니다. 이것은 지연 시간 증가와 요청 처리 중 주기적인 "멈춤"으로 나타납니다.


디버깅 도구: 무기들을 챙깁시다

Node.js에는 메모리 문제를 잡을 수 있는 좋은 도구들이 있어요. 하나씩 살펴볼게요.

1. Chrome DevTools (가장 강력한 도구)

Node.js는 강력한 힙 분석을 위해 Chrome DevTools와 통합됩니다.

inspect 모드로 앱을 시작하세요:

node --inspect server.js # 또는 첫 줄에서 브레이크하려면: node --inspect-brk server.js

Chrome에서 연결하기:

  1. Chrome에서 chrome://inspect 열기
  2. Node.js 타겟 아래의 "inspect" 클릭
  3. "Memory" 탭으로 이동

힙 스냅샷 찍기:

가장 강력한 기법은 시간에 따른 힙 스냅샷 비교입니다:

  1. 기준선에서 스냅샷 찍기 (서버 시작 후)
  2. 누수가 의심되는 액션 수행 (요청 보내기 등)
  3. 가비지 컬렉션 강제 (휴지통 아이콘 클릭)
  4. 다른 스냅샷 찍기
  5. 스냅샷 비교

비교 뷰는 스냅샷 사이에 할당되었지만 해제되지 않은 객체들을 보여줍니다.

2. Node.js 내장: process.memoryUsage()

빠르고 간단한 메모리 모니터링:

function logMemory(label = '') { const used = process.memoryUsage(); console.log(`Memory ${label}:`, { 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`, }); } // 의심스러운 작업을 감싸서 사용 logMemory('작업 전'); await suspiciousOperation(); logMemory('작업 후');

메트릭 이해하기:

  • rss (Resident Set Size): 프로세스에 할당된 총 메모리
  • heapTotal: 총 할당된 힙
  • heapUsed: 실제 사용 중인 힙 메모리
  • external: JavaScript에 바인딩된 C++ 객체가 사용하는 메모리 (Buffer 등)

3. 코드를 통한 힙 스냅샷

Chrome DevTools 없이 프로그래밍 방식으로 힙 스냅샷을 생성할 수 있습니다:

const v8 = require('v8'); const fs = require('fs'); const path = require('path'); function takeHeapSnapshot() { const filename = path.join( __dirname, `heap-${Date.now()}.heapsnapshot` ); const snapshotStream = v8.writeHeapSnapshot(filename); console.log(`힙 스냅샷 저장됨: ${filename}`); return filename; } // 프로덕션 디버깅을 위해 HTTP 엔드포인트로 트리거 app.get('/debug/heap-snapshot', (req, res) => { const file = takeHeapSnapshot(); res.json({ file }); });

.heapsnapshot 파일들을 Chrome DevTools에서 로드해서 분석할 수 있습니다.

4. Clinic.js: 프로덕션 준비된 도구 모음

Clinic.js는 Node.js 성능 분석을 위한 훌륭한 도구 모음입니다:

npm install -g clinic # 메모리 누수를 포함한 다양한 문제 감지 clinic doctor -- node server.js # 힙 프로파일링에 집중 clinic heap -- node server.js # CPU 프로파일링을 위한 플레임 그래프 clinic flame -- node server.js

Clinic Doctor는 부하 상태에서 서버를 실행하고 잠재적 문제를 강조하는 시각적 리포트를 생성합니다.

5. Memwatch-next: 코드 내 누수 감지

자동화된 누수 감지를 위해:

const memwatch = require('memwatch-next'); memwatch.on('leak', (info) => { console.error('메모리 누수 감지됨:', info); // info.growth: 누수된 바이트 // info.reason: 누수로 판단된 이유 }); memwatch.on('stats', (stats) => { console.log('GC 통계:', stats); // 상세한 가비지 컬렉션 통계 });

7가지 치명적인 누수 패턴 (그리고 해결 방법)

패턴 1: 무한 성장하는 캐시

문제:

// ❌ 메모리 누수: 캐시가 영원히 성장 const cache = {}; function getCachedData(key) { if (cache[key]) { return cache[key]; } const data = expensiveComputation(key); cache[key] = data; // 절대 제거되지 않음! return data; }

해결책: 최대 크기가 있는 LRU (Least Recently Used) 캐시를 사용하세요:

// ✅ 수정됨: 제거 정책이 있는 제한된 캐시 const LRU = require('lru-cache'); const cache = new LRU({ max: 500, // 최대 500개 항목 maxAge: 1000 * 60 * 5, // 5분 후 항목 만료 updateAgeOnGet: true, // 접근 시 수명 리셋 }); function getCachedData(key) { const cached = cache.get(key); if (cached !== undefined) { return cached; } const data = expensiveComputation(key); cache.set(key, data); return data; }

패턴 2: 이벤트 리스너 누적

문제:

// ❌ 메모리 누수: 핫 패스에서 리스너 추가하고 제거 안 함 function handleConnection(socket) { const onData = (data) => { processData(data); }; // 모든 연결이 새 리스너를 추가, 절대 제거되지 않음 eventEmitter.on('data', onData); }

해결책: 더 이상 필요하지 않을 때 항상 리스너를 제거하세요:

// ✅ 수정됨: 연결 해제 시 리스너 제거 function handleConnection(socket) { const onData = (data) => { processData(data); }; eventEmitter.on('data', onData); socket.on('close', () => { eventEmitter.removeListener('data', onData); }); }

또는 일회성 리스너용으로 once를 사용하세요:

eventEmitter.once('data', onData);

패턴 3: 컨텍스트를 캡처하는 클로저

문제:

// ❌ 메모리 누수: 클로저가 큰 데이터를 캡처 function processRequest(req, res) { const largePayload = req.body; // 10MB 데이터 // 이 클로저가 largePayload를 캡처 someAsyncOperation(() => { // req.body.id만 사용하지만, 전체 페이로드가 유지됨 console.log('처리됨:', req.body.id); res.send('done'); }); }

해결책: 필요한 것만 추출하세요:

// ✅ 수정됨: 필요한 데이터만 캡처 function processRequest(req, res) { const { id } = req.body; // 필요한 것만 추출 someAsyncOperation(() => { console.log('처리됨:', id); // 'id'만 캡처됨 res.send('done'); }); }

패턴 4: 고아가 된 타이머

문제:

// ❌ 메모리 누수: setInterval이 절대 클리어되지 않음 class DataPoller { constructor(url) { this.url = url; this.intervalId = setInterval(() => { this.poll(); }, 5000); } poll() { fetch(this.url).then(/* ... */); } // 정리 메서드가 없음! 인스턴스가 절대 가비지 컬렉션될 수 없음 }

해결책: 항상 정리 기능을 제공하세요:

// ✅ 수정됨: 명시적 정리 class DataPoller { constructor(url) { this.url = url; this.intervalId = setInterval(() => { this.poll(); }, 5000); } poll() { fetch(this.url).then(/* ... */); } destroy() { clearInterval(this.intervalId); } } // 사용법 const poller = new DataPoller('/api/data'); // 완료되면: poller.destroy();

패턴 5: 성장하는 전역 상태

문제:

// ❌ 메모리 누수: 전역 배열이 영원히 성장 const requestLog = []; app.use((req, res, next) => { requestLog.push({ timestamp: Date.now(), url: req.url, method: req.method, // ...잠재적으로 큰 헤더와 바디 }); next(); });

해결책: 데이터 구조에 경계를 설정하세요:

// ✅ 수정됨: 로테이션이 있는 제한된 로그 const MAX_LOG_SIZE = 1000; const requestLog = []; app.use((req, res, next) => { requestLog.push({ timestamp: Date.now(), url: req.url, method: req.method, }); // 오래된 항목 제거 while (requestLog.length > MAX_LOG_SIZE) { requestLog.shift(); } next(); });

또는 더 좋은 방법으로, 링 버퍼를 사용하거나 로그를 외부 스토리지로 스트리밍하세요.

패턴 6: 잊혀진 Promise

문제:

// ❌ 잠재적 누수: 에러 처리 없는 프라미스가 누적 function fetchAll(urls) { urls.forEach(url => { fetch(url).then(response => { process(response); }); // .catch() 없음 - 처리되지 않은 거부가 참조를 누적 }); }

해결책: 항상 프라미스 거부를 처리하세요:

// ✅ 수정됨: 적절한 에러 처리 async function fetchAll(urls) { const promises = urls.map(async url => { try { const response = await fetch(url); await process(response); } catch (error) { console.error(`${url} 가져오기 실패:`, error); // 적절히 처리됨, 누적 없음 } }); await Promise.all(promises); }

패턴 7: 클로저가 있는 순환 참조

문제:

// ❌ 까다로운 누수: 클로저를 통한 순환 참조 function createConnection() { const connection = { data: new Array(1000000).fill('x'), // 큰 데이터 }; connection.onClose = function() { // 이 클로저가 'connection'을 참조, 순환 참조 생성 console.log('연결 종료됨', connection.id); cleanup(connection); }; return connection; }

최신 V8은 대부분의 순환 참조를 잘 처리하지만, 클로저는 수집을 방해할 수 있습니다:

해결책: 명시적으로 사이클을 끊으세요:

// ✅ 수정됨: 정리 시 순환 참조 끊기 function createConnection() { const connection = { data: new Array(1000000).fill('x'), }; connection.onClose = function() { console.log('연결 종료됨', connection.id); cleanup(connection); connection.onClose = null; // 사이클 끊기 connection.data = null; // 큰 데이터 해제 }; return connection; }

실전 디버깅 세션

실제 메모리 누수 시나리오를 단계별로 디버깅해볼까요?

시나리오

WebSocket 연결을 처리하는 Express 서버가 있습니다. 메모리가 계속 증가합니다.

// server.js - 누수가 있는 코드 const express = require('express'); const WebSocket = require('ws'); const app = express(); const wss = new WebSocket.Server({ port: 8080 }); // 의심: 연결을 위한 전역 스토리지 const connections = new Map(); // 의심: 전역 메시지 히스토리 const messageHistory = []; wss.on('connection', (ws, req) => { const userId = req.url.split('?userId=')[1]; connections.set(userId, ws); ws.on('message', (message) => { // 모든 메시지를 영원히 저장 messageHistory.push({ userId, message: message.toString(), timestamp: Date.now(), }); // 모두에게 브로드캐스트 connections.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(message.toString()); } }); }); // 버그: 연결 종료 시 정리가 없음! }); app.listen(3000);

1단계: 누수 확인

메모리 모니터링을 추가합니다:

setInterval(() => { const used = process.memoryUsage(); console.log(`[Memory] Heap: ${Math.round(used.heapUsed / 1024 / 1024)}MB, ` + `Connections: ${connections.size}, ` + `Messages: ${messageHistory.length}`); }, 5000);

시뮬레이션된 트래픽으로 실행한 후, 다음을 확인합니다:

[Memory] Heap: 45MB, Connections: 100, Messages: 1000
[Memory] Heap: 67MB, Connections: 98, Messages: 2500
[Memory] Heap: 89MB, Connections: 102, Messages: 4200
[Memory] Heap: 112MB, Connections: 95, Messages: 6100

연결 수는 안정적인데 메모리가 증가합니다. 여기서 messageHistory 배열이 명백한 원인이지만, 연결도 확인해봅시다.

2단계: 힙 스냅샷 찍기

Chrome DevTools 사용:

  1. node --inspect server.js에 연결
  2. 시작 후 스냅샷 찍기
  3. 100개 연결 시뮬레이트, 연결 해제
  4. 다른 스냅샷 찍기
  5. 비교

비교 뷰에서 볼 수 있는 것들:

  • 많은 Array 객체 (메시지 히스토리)
  • Map에서 제거되지 않은 WebSocket 객체들

3단계: 수정 적용

// server.js - 수정된 버전 const express = require('express'); const WebSocket = require('ws'); const app = express(); const wss = new WebSocket.Server({ port: 8080 }); const connections = new Map(); // 수정됨: 제한된 메시지 히스토리 const MAX_HISTORY = 1000; const messageHistory = []; wss.on('connection', (ws, req) => { const userId = req.url.split('?userId=')[1]; connections.set(userId, ws); ws.on('message', (message) => { messageHistory.push({ userId, message: message.toString(), timestamp: Date.now(), }); // 오래된 메시지 제거 while (messageHistory.length > MAX_HISTORY) { messageHistory.shift(); } connections.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(message.toString()); } }); }); // 수정됨: 종료 시 정리 ws.on('close', () => { connections.delete(userId); }); ws.on('error', () => { connections.delete(userId); }); }); app.listen(3000);

4단계: 수정 검증

같은 부하 테스트를 실행합니다. 이제 메모리가 안정화되어야 합니다:

[Memory] Heap: 45MB, Connections: 100, Messages: 1000
[Memory] Heap: 52MB, Connections: 98, Messages: 1000
[Memory] Heap: 49MB, Connections: 102, Messages: 1000
[Memory] Heap: 51MB, Connections: 95, Messages: 1000

성공입니다!


프로덕션 전략

1. 메모리 제한과 알림

런어웨이 크래시를 방지하기 위해 명시적 메모리 제한을 설정하세요:

node --max-old-space-size=512 server.js # 512MB 제한

알림 구현:

const MEMORY_THRESHOLD = 450 * 1024 * 1024; // 450MB setInterval(() => { const used = process.memoryUsage(); if (used.heapUsed > MEMORY_THRESHOLD) { alertOperations('메모리 임계값 초과', { heapUsed: used.heapUsed, threshold: MEMORY_THRESHOLD, }); } }, 60000);

2. 그레이스풀 재시작

메모리가 너무 높아지면 우아하게 재시작하세요:

const RESTART_THRESHOLD = 500 * 1024 * 1024; setInterval(() => { if (process.memoryUsage().heapUsed > RESTART_THRESHOLD) { console.log('메모리 임계값 초과, 그레이스풀 셧다운 시작'); // 새 연결 수락 중단 server.close(() => { // 기존 요청이 완료되도록 허용 setTimeout(() => { process.exit(0); // PM2나 컨테이너 오케스트레이터가 재시작 }, 5000); }); } }, 60000);

3. 헬스 체크 엔드포인트

모니터링을 위해 메모리 메트릭을 노출하세요:

app.get('/health', (req, res) => { const memory = process.memoryUsage(); const uptime = process.uptime(); res.json({ status: 'ok', uptime: Math.round(uptime), memory: { heapUsed: Math.round(memory.heapUsed / 1024 / 1024), heapTotal: Math.round(memory.heapTotal / 1024 / 1024), rss: Math.round(memory.rss / 1024 / 1024), }, // 커스텀 메트릭 connections: connections.size, cacheSize: cache.size, }); });

4. 온디맨드 힙 덤프

사후 분석을 위한 프로덕션 힙 덤프 활성화:

app.post('/debug/heap', authenticateAdmin, (req, res) => { const v8 = require('v8'); const filename = v8.writeHeapSnapshot(); res.json({ filename }); });

예방: 누수에 강한 코드 작성하기

규칙 1: 항상 이벤트 리스너 정리하기

// 쉬운 정리를 위해 AbortController 사용 const controller = new AbortController(); element.addEventListener('click', handler, { signal: controller.signal }); // 나중에: 이 컨트롤러로 추가된 모든 리스너 제거 controller.abort();

규칙 2: 모든 컬렉션에 경계 설정

// 무제한 배열 대신 const items = []; items.push(newItem); // 제한된 컬렉션 사용 const MAX_ITEMS = 10000; if (items.length >= MAX_ITEMS) { items.shift(); } items.push(newItem);

규칙 3: 메타데이터용으로 WeakMap과 WeakSet 사용

객체에 메타데이터를 붙일 때:

// ❌ 강한 참조 생성, GC 방해 const metadata = new Map(); metadata.set(someObject, { extra: 'data' }); // ✅ 약한 참조, GC 방해 안 함 const metadata = new WeakMap(); metadata.set(someObject, { extra: 'data' }); // someObject가 다른 곳에서 더 이상 참조되지 않으면, // 메타데이터 항목이 자동으로 제거됨

규칙 4: Dispose 패턴 구현

class ResourceManager { #resources = []; #disposed = false; acquire(resource) { if (this.#disposed) { throw new Error('Manager가 disposed됨'); } this.#resources.push(resource); return resource; } dispose() { this.#disposed = true; for (const resource of this.#resources) { resource.close?.(); resource.destroy?.(); } this.#resources.length = 0; // 배열 비우기 } }

마무리: 생각의 전환이 필요해요

메모리 누수 디버깅은 코드를 보는 시각 자체를 바꿔야 합니다. "이거 동작해?"만 묻지 말고, 이런 질문들을 해보세요:

  1. 이 데이터는 어디로 가지? 모든 할당에는 명확한 생명주기가 있어야 해요.
  2. 언제 정리되지? 모든 add에는 대응하는 remove가 있어야 합니다.
  3. 상한선이 뭐지? 모든 컬렉션에는 최대 크기를 정해야 해요.
  4. 누가 이걸 참조하고 있지? 참조 그래프를 파악하세요.

메모리 누수는 신비한 문제가 아니에요. 그냥 필요 이상으로 오래 살아남은 객체일 뿐입니다. 이 글에서 다룬 도구와 패턴을 활용하면, 어떤 누수든 체계적으로 잡아낼 수 있어요. 재시작 없이 몇 달씩 안정적으로 돌아가는 서버를 만들 수 있습니다.

다음에 서버 메모리가 올라가기 시작하면, 어디를 봐야 하고 어떻게 고쳐야 하는지 이제 아실 겁니다.


행복한 디버깅 되세요. 힙은 작게, GC는 한가하게!

nodejsmemory-leakdebuggingperformancejavascriptbackenddevops

관련 도구 둘러보기

Pockit의 무료 개발자 도구를 사용해 보세요