TechFeedTechFeed
Backend

프로덕션 Node.js 메모리 누수 추적기 — 힙 덤프에서 원인 발견까지

실제 프로덕션 환경에서 발생한 Node.js 메모리 누수를 추적한 사례. 증상 감지, 힙 스냅샷 분석, TTL 없는 인메모리 캐시 원인 특정, LRU 캐시 교체까지의 전 과정.

금요일 오후 5시, Slack에 알림이 울렸다. "API 서버 메모리 사용량 3.2GB 돌파, OOM Kill 임박." 평소 800MB 수준이던 Node.js 프로덕션 서버가 배포 3일 만에 메모리를 4배 먹고 있었다. 코드 변경은 겨우 PR 2개. 어디서 새는 건지 로그만 봐서는 알 수 없었다.

이 글은 실제 프로덕션 환경에서 Node.js 메모리 누수를 추적하고 해결한 과정을 기록한 것이다. 힙 스냅샷 촬영, Chrome DevTools 분석, 원인 특정, 그리고 수정 후 검증까지 — 문제 발생부터 해결까지의 전체 흐름을 따라간다.

※ Node.js 20 LTS 환경 기준. V8 힙 프로파일러와 --inspect 플래그를 사용합니다.

증상 — 서버가 천천히 죽어가고 있었다

모니터링 대시보드(Grafana + Prometheus)에서 처음 이상 징후를 포착한 건 수요일이었다. 서버 메모리가 리스타트 직후 400MB에서 시작해, 24시간마다 약 600~800MB씩 증가하는 패턴이 보였다. 하지만 트래픽 증가로 인한 자연스러운 현상이라고 생각했다.

금요일에 메모리가 3.2GB를 넘기면서 Kubernetes가 OOM Kill을 실행했고, 파드가 재시작됐다. 문제는 재시작 후에도 같은 패턴이 반복됐다는 것이다. 이건 트래픽 문제가 아니라 코드 문제였다.

당시 확인한 지표를 정리하면 다음과 같다.

시점RSS 메모리힙 사용량요청 수/분응답 시간 p95
수요일 09:00420MB310MB1,20045ms
목요일 09:001.1GB890MB1,25062ms
금요일 09:002.0GB1.6GB1,180130ms
금요일 17:00 (OOM)3.2GB2.7GB1,300890ms

요청 수는 거의 일정한데 메모리만 계속 올라갔다. 그리고 메모리가 2GB를 넘어가면서 GC(Garbage Collection) 빈도가 급증하고, 응답 시간이 느려지기 시작했다. 전형적인 메모리 누수 패턴이다.

Grafana 대시보드에서 Node.js 힙 메모리가 72시간에 걸쳐 선형 증가하는 그래프
메모리 사용량이 리스타트 후에도 동일한 기울기로 상승하는 패턴 (출처: Grafana 모니터링 예시)

조사 — 용의자 좁히기

먼저 최근 배포된 PR 2개를 확인했다.

  • PR #847: Redis 캐시 레이어 추가 — 자주 조회되는 사용자 프로필 데이터를 인메모리 캐시에 저장
  • PR #851: 웹훅 이벤트 큐 도입 — 외부 서비스 알림을 비동기 큐로 처리

두 PR 모두 메모리에 데이터를 적재하는 변경이었다. 롤백이 가장 빠른 해결책이었지만, 둘 중 어느 쪽이 원인인지 모르면 같은 문제가 반복될 수 있었다. 원인을 정확히 찾기로 했다.

프로덕션에서 직접 힙 스냅샷을 찍는 건 위험하다. 스냅샷 생성 중 서버가 멈추고, 메모리 사용량이 2배로 뛸 수 있다. 대신 스테이징 환경에서 프로덕션 트래픽을 리플레이하는 방식을 택했다.

  1. 프로덕션 access log에서 최근 24시간 요청 패턴을 추출
  2. 스테이징 서버에 --inspect 플래그를 붙여 시작
  3. Artillery로 추출한 패턴을 6시간 동안 리플레이
  4. 시작 직후, 2시간 후, 4시간 후, 6시간 후에 힙 스냅샷 촬영
스테이징 서버 시작 및 힙 스냅샷 촬영
# 디버그 포트를 열어 Node.js 시작 node --inspect=0.0.0.0:9229 --max-old-space-size=4096 dist/server.js # 다른 터미널에서 힙 스냅샷 촬영 (v8 내장 시그널 사용) kill -USR2 $(pgrep -f "dist/server.js") # 또는 프로그래밍 방식으로 촬영 node -e " const inspector = require('node:inspector'); const session = new inspector.Session(); session.connect(); session.post('HeapProfiler.takeHeapSnapshot', null, (err) => { if (err) console.error(err); else console.log('Snapshot saved'); session.disconnect(); }); "
⚠️ 주의: 프로덕션 서버에서 직접 v8.writeHeapSnapshot()을 호출하면 스냅샷 생성 동안 이벤트 루프가 완전히 멈춘다. 힙이 2GB면 스냅샷도 2GB이상이 되고, 생성에 수십 초가 걸린다. 반드시 스테이징에서 재현하거나, 프로덕션이라면 로드밸런서에서 해당 인스턴스를 빼고 진행해야 한다.

힙 스냅샷 분석 — 범인을 찾아라

Chrome DevTools의 Memory 탭에서 힙 스냅샷 4개를 순서대로 로드했다. 핵심은 Comparison 뷰다. 스냅샷 2개를 비교해서 "이전 스냅샷에는 없었는데 새로 생긴 객체"를 찾는다.

시작 직후 → 2시간 후 비교 결과:

  • (string) 타입: +42,000개, +38MB
  • (object) 타입: +18,000개, +22MB
  • (array) 타입: +6,200개, +14MB

문자열 객체가 가장 많이 증가했다. 문자열 내용을 확인해보니 JSON 직렬화된 사용자 프로필 데이터였다. PR #847의 인메모리 캐시가 용의자로 떠올랐다.

Retainers 탭에서 해당 문자열을 누가 참조하고 있는지 추적했다. 참조 체인은 이랬다:

(string) "{\"id\":12345,\"name\":\"...\"...}" └─ value in Map entry └─ table of Map └─ userCache in CacheManager └─ cacheManager in RequestHandler └─ context of handleRequest()

CacheManager 클래스 안의 userCache라는 Map이 원인이었다. 코드를 확인해보니 캐시에 넣는 로직은 있지만, 만료(TTL) 처리와 크기 제한이 전혀 없었다.

Chrome DevTools Memory 탭에서 힙 스냅샷 비교(Comparison) 뷰로 증가한 객체를 분석하는 화면
Comparison 뷰에서 두 스냅샷 간 증가한 객체 수와 크기를 한눈에 확인할 수 있다 (출처: Chrome DevTools)

원인 — TTL 없는 인메모리 캐시의 함정

문제의 코드를 단순화하면 이렇다.

문제의 CacheManager 코드 (수정 전)
class CacheManager { constructor() { this.userCache = new Map(); // TTL 없음, 크기 제한 없음 } async getUser(userId) { if (this.userCache.has(userId)) { return this.userCache.get(userId); } const user = await db.users.findById(userId); const serialized = JSON.stringify(user); // 직렬화해서 저장 this.userCache.set(userId, serialized); // 넣기만 하고 빼지 않음 return serialized; } } // 싱글톤으로 전역 사용 module.exports = new CacheManager();

코드 자체는 교과서적인 메모이제이션 패턴처럼 보인다. 하지만 프로덕션 환경에서 이 패턴이 위험한 이유가 세 가지 있다.

  1. 사용자 수가 무한정 늘어난다. 서비스 사용자가 10만 명이면 캐시 엔트리도 최대 10만 개까지 증가한다. 각 프로필이 평균 2KB라면 캐시만으로 200MB를 먹는다.
  2. 삭제 경로가 없다. 캐시에 한번 들어간 데이터는 프로세스가 죽기 전까지 절대 나오지 않는다. V8 GC는 참조가 있는 객체를 수집하지 않으므로, Map이 살아있는 한 모든 엔트리가 유지된다.
  3. JSON.stringify로 불필요한 복사가 발생한다. 원본 객체 + 직렬화된 문자열이 모두 메모리에 남는다. 그리고 getUser()가 호출될 때마다 캐시에서 꺼낸 문자열을 다시 JSON.parse()하므로 또 객체가 생긴다.

이 세 가지가 결합되면서 메모리가 단조 증가한 것이다. GC가 아무리 열심히 돌아도, 살아있는 참조를 끊지 못하기 때문에 회수할 수 있는 건 임시 파싱 결과뿐이었다.

💡 핵심 교훈: Node.js에서 new Map()으로 인메모리 캐시를 만들면, 반드시 TTL(만료 시간)과 maxSize(최대 엔트리 수)를 함께 구현해야 한다. 또는 lru-cache 같은 검증된 라이브러리를 사용하라.

수정 — LRU 캐시로 교체하고 검증하기

수정 방향은 두 가지를 동시에 적용했다.

  • Maplru-cache로 교체 — 최대 10,000개 엔트리, TTL 5분
  • JSON 직렬화 제거 — 객체를 그대로 캐시에 저장하고, 방어적 복사가 필요한 곳에서만 structuredClone() 사용
수정된 CacheManager 코드
const { LRUCache } = require('lru-cache'); class CacheManager { constructor() { this.userCache = new LRUCache({ max: 10_000, // 최대 10,000 엔트리 ttl: 1000 * 60 * 5, // 5분 후 자동 만료 ttlAutopurge: true, // 만료된 항목 자동 정리 allowStale: false, updateAgeOnGet: true, // 조회 시 TTL 리셋 }); } async getUser(userId) { const cached = this.userCache.get(userId); if (cached) return cached; const user = await db.users.findById(userId); this.userCache.set(userId, user); // 객체 그대로 저장 return user; } // 모니터링용 메트릭 노출 getStats() { return { size: this.userCache.size, calculatedSize: this.userCache.calculatedSize, hitRate: this._hits / (this._hits + this._misses) || 0, }; } } module.exports = new CacheManager();

수정 후 같은 방식으로 스테이징에서 6시간 리플레이 테스트를 돌렸다. 결과는 극적이었다.

시점수정 전 힙수정 후 힙
시작 직후310MB305MB
2시간 후890MB340MB
4시간 후1.6GB345MB
6시간 후2.7GB342MB

수정 후에는 메모리가 초반에 약간 오르다가 340MB 부근에서 안정화됐다. LRU 캐시의 max 설정이 상한 역할을 제대로 하고 있었다.

재발 방지 — 메모리 누수를 사전에 잡는 장치들

한 번 겪은 후 팀에서 도입한 방어 장치를 정리한다.

1. process.memoryUsage() 메트릭 수집

Express/Fastify 미들웨어에서 1분마다 process.memoryUsage()를 Prometheus에 보낸다. heapUsed가 특정 임계치를 넘으면 PagerDuty 알림이 발동한다. 기존에는 RSS만 감시했는데, 힙 사용량을 별도로 추적해야 V8 내부의 메모리 증가를 조기에 감지할 수 있다.

2. 코드 리뷰에서 Map/Set 인스턴스 체크

전역 또는 싱글톤 스코프에서 new Map(), new Set(), 배열 push만 있고 delete/clear가 없는 코드는 리뷰에서 플래그한다. ESLint 커스텀 룰로 자동 탐지하는 것도 가능하지만, 오탐이 많아서 리뷰어 체크리스트에 항목으로 추가하는 게 현실적이었다.

3. 부하 테스트에 메모리 증가율 검증 추가

CI/CD 파이프라인의 부하 테스트(Artillery) 단계에서, 테스트 시작과 종료 시점의 힙 사용량을 비교한다. 증가율이 설정한 임계치(예: 테스트 동안 100MB 이상 증가)를 넘으면 파이프라인이 실패한다.

Prometheus 메트릭으로 힙 메모리 노출
const client = require('prom-client'); // V8 힙 메모리 게이지 const heapUsedGauge = new client.Gauge({ name: 'nodejs_heap_used_bytes', help: 'V8 heap used in bytes', }); const heapTotalGauge = new client.Gauge({ name: 'nodejs_heap_total_bytes', help: 'V8 heap total in bytes', }); // 30초마다 갱신 setInterval(() => { const mem = process.memoryUsage(); heapUsedGauge.set(mem.heapUsed); heapTotalGauge.set(mem.heapTotal); }, 30_000); // /metrics 엔드포인트에서 Prometheus가 수집 // Grafana 알림 조건: rate(nodejs_heap_used_bytes[1h]) > 50MB/h
Node.js 메모리 누수 디버깅 워크플로우 다이어그램 — 증상 감지부터 힙 분석, 수정, 검증까지의 흐름도
메모리 누수 추적 워크플로우: 모니터링 → 재현 → 힙 분석 → 수정 → 검증
💡 팁: Node.js 19+에서는 --diagnostic-dir 플래그로 힙 스냅샷 저장 경로를 지정할 수 있다. --heapsnapshot-signal=SIGUSR2를 함께 쓰면 시그널 한 번으로 스냅샷을 촬영할 수 있어 프로덕션 디버깅이 훨씬 수월해진다.

Node.js 메모리 누수를 일으키는 흔한 패턴 5가지

이번 사례를 포함해, Node.js에서 반복적으로 발생하는 메모리 누수 패턴을 정리한다.

패턴원인해결
무제한 캐시Map/Object에 TTL·크기 제한 없이 적재LRU 캐시 또는 Redis로 교체
이벤트 리스너 누적요청마다 EventEmitter에 리스너 추가, 제거 안 함once() 사용 또는 명시적 removeListener()
클로저 참조 유지콜백이 외부 스코프의 큰 객체를 캡처필요한 값만 추출해서 전달
스트림 미해제에러 발생 시 스트림 destroy() 누락pipeline() 사용, 에러 핸들러에서 명시적 정리
타이머 미정리setInterval() 정리 안 함, 콜백이 외부 참조 유지clearInterval() 필수, WeakRef 고려

이 중 "무제한 캐시"와 "이벤트 리스너 누적"이 실무에서 가장 자주 발생한다. Node.js가 MaxListenersExceededWarning을 출력하면 이벤트 리스너 누수를 의심해야 한다. 이 경고를 무시하고 setMaxListeners(0)으로 끄는 코드를 종종 보는데, 그건 화재경보기를 끄는 것과 같다.

이 사건에서 남은 것

돌이켜보면, 이 버그가 3일이나 살아남은 건 모니터링의 사각지대 때문이었다. RSS(Resident Set Size)만 보고 힙 사용량을 별도로 추적하지 않았고, 메모리 "증가율"에 대한 알림이 없었다. 절대값이 아니라 기울기를 봤어야 했다.

코드 수준에서는 단순한 실수였다. Map에 넣기만 하고 빼지 않은 것. 하지만 이런 단순한 실수가 프로덕션에서 3일 동안 감지되지 않고 서비스 장애로 이어졌다.

메모리 누수 디버깅은 화려한 기술이 필요한 게 아니다. 힙 스냅샷을 찍고, 비교하고, 참조 체인을 따라가면 된다. 어려운 건 "이게 메모리 누수다"라고 인식하는 것이다. 지표가 천천히 올라가면 사람은 그걸 정상으로 받아들인다. 그래서 자동화된 증가율 알림이 사람의 눈보다 먼저 잡아야 한다.

Node.js메모리누수힙덤프프로덕션디버깅V8LRU캐시성능최적화모니터링

관련 포스트

Node.js 22 LTS 새 기능 총정리2026-03-12Node.js vs Bun vs Deno — 2026년 런타임 비교 실무 가이드2026-03-20PostgreSQL 성능 튜닝 실전 가이드2026-02-20REST API 설계 모범 사례 20262026-02-22