본문으로 건너뛰기
Node.js 메모리 누수 해결 가이드 - 힙 프로파일링

# 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):

  1. Chrome 브라우저 열기
  2. chrome://inspect 접속
  3. “Open dedicated DevTools for Node” 클릭
  4. Memory 탭 → Load 버튼 → .heapprofile 파일 선택
  5. 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에서 비교 분석:

  1. Chrome DevTools 열기 (chrome://inspect)
  2. Memory 탭 → “Load” 버튼
  3. heap-1.heapsnapshot 로드
  4. 다시 “Load” 버튼 → heap-2.heapsnapshot 로드
  5. Comparison 뷰 선택
  6. Δ (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. 낮은 오버헤드 (<1%): 샘플링 방식으로 모든 할당을 기록하지 않고 일부만 샘플링
  2. 프로덕션 안전 설계: 힙 스냅샷 생성 시 블로킹 없음
  3. 통합 대시보드: 실시간 메모리 사용량 그래프, 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가 사라졌습니다.

메모리 누수 방지 체크리스트

코드 리뷰 시 확인 사항

  1. 전역 변수 사용 최소화: Array 대신 LRU Cache 사용
  2. 이벤트 리스너 정리: removeListener 또는 once 사용
  3. 타이머/인터벌 정리: clearInterval, clearTimeout 필수 호출
  4. 클로저 주의: 불필요하게 큰 스코프 참조 방지
  5. Stream 정리: destroy() 호출 확인

프로덕션 모니터링 설정

Prometheus + Grafana 메트릭 수집: nodejs_heap_used_bytes 메트릭을 수집하고, 힙 사용률이 90%를 넘으면 알림이 오도록 설정하세요.

마치며

Node.js 메모리 누수는 조기에 탐지하지 못하면 심각한 프로덕션 장애로 이어집니다. 이 글에서 다룬 기법들을 정리하면:

핵심 요약:

  1. 모니터링 필수: 메모리 사용 패턴을 지속적으로 모니터링 (Prometheus, Datadog)
  2. 힙 스냅샷 비교: 가장 강력한 누수 탐지 기법 (Chrome DevTools)
  3. 프로덕션 안전: N|Solid, Clinic.js 등 오버헤드 낮은 도구 사용
  4. 예방 코드 패턴: LRU 캐시, 이벤트 리스너 정리, TTL 기반 만료

메모리 누수는 예방이 최선의 치료입니다. 코드 리뷰 시 위의 체크리스트를 참고하고, 프로덕션 환경에서는 지속적인 모니터링을 통해 조기에 발견하세요.

이 글 공유하기:
My avatar

글을 마치며

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

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

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


관련 포스트