일반적인 누수 패턴 탐지 및 해결
시간이 지나며 힙을 증가시키는 누수 클로저, 제한 없는 캐시 및 남아 있는 리스너를 추적해 해결합니다.
일반적인 누수 패턴 탐지 및 해결은(는) CoddyKit의 무료 Node.js Backend Development Bootcamp 강의입니다. 이것은 4개 중 4번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 Node.js Backend Development Bootcamp 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. Node.js Backend Development Bootcamp 강의에는 총 4개의 강의가 포함되어 있습니다.
이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.
What a Real Leak Looks Like
A memory leak in Node.js is not when memory goes up — it is when memory goes up and never comes back down across GC cycles. The V8 heap grows, old-space stays retained, and eventually you hit the default --max-old-space-size ceiling and the process is OOM-killed.
- Normal: heap saws up and down as GC reclaims short-lived objects.
- Leak: the saw-tooth baseline trends upward over hours, even at steady traffic.
In this lesson we hunt the three classic backend offenders: leaking closures, unbounded caches, and lingering event listeners.
Reading the Heap with process.memoryUsage()
Your first cheap signal is process.memoryUsage(). Watch heapUsed over time. If it climbs monotonically under constant load, you have a retention problem.
The snippet below simulates a leak by pushing into a module-level array and logs heap growth every interval — a standalone reproduction you can run.
const leaky = [];
function tick(i) {
// Each call retains a 10k-element array forever.
leaky.push(new Array(10_000).fill(i));
const { heapUsed } = process.memoryUsage();
console.log(`tick ${i}: heapUsed=${(heapUsed / 1024 / 1024).toFixed(1)} MB, retained=${leaky.length}`);
}
for (let i = 1; i <= 5; i++) tick(i);
console.log('Heap keeps growing because `leaky` is never cleared.');Leak Pattern 1: The Capturing Closure
A closure keeps alive everything in its scope chain, even variables it does not use, as long as something holds a reference to the closure. Store such closures in a long-lived structure and you pin large objects forever.
Below, every registered handler closes over a multi-megabyte bigData buffer. The handlers live in a module array, so the buffers can never be collected.
const handlers = [];
function register(id) {
const bigData = Buffer.alloc(1024 * 1024); // 1 MB per registration
// The closure captures bigData even though it only logs id.
handlers.push(() => console.log(`handler ${id} fired`));
return bigData.length;
}
for (let i = 0; i < 3; i++) register(i);
console.log(`${handlers.length} handlers retained; each closure may pin its scope.`);Fixing the Closure Leak
Two fixes:
- Do not capture what you do not need. Pull the needed primitive out before creating the closure so the large object falls out of the closure's scope.
- Do not store closures in long-lived containers unless you also remove them.
Here the closure captures only the small id primitive; bigData is used and discarded, so GC frees it after register returns.
const handlers = [];
function register(id) {
const bigData = Buffer.alloc(1024 * 1024);
const summary = bigData.length; // use it now
// Closure captures only `id` and `summary` (primitives) — bigData is free to GC.
handlers.push(() => console.log(`handler ${id}: ${summary} bytes processed`));
}
register(1);
handlers[0]();
console.log('bigData is no longer reachable and will be collected.');Leak Pattern 2: The Unbounded Cache
The single most common Node leak: a Map or plain object used as an in-memory cache that only ever grows. Every unique key (user id, request hash, session token) adds an entry and nothing evicts it.
This looks innocent in code review but leaks linearly with traffic.
const cache = new Map();
function getUser(id) {
if (!cache.has(id)) {
cache.set(id, { id, profile: Buffer.alloc(50_000) }); // 50 KB each, never evicted
}
return cache.get(id);
}
for (let id = 0; id < 4; id++) getUser(id);
console.log(`cache size=${cache.size} — grows forever with unique ids.`);Fixing the Cache: Bounded LRU + TTL
An in-memory cache MUST have a bound. Use a battle-tested LRU (lru-cache) with max entries and a ttl, or implement a simple capacity-evicting Map. Insertion order in JS Maps makes a basic LRU trivial: delete-then-set bumps recency, and evict the oldest key when over capacity.
class LRU {
constructor(max) { this.max = max; this.map = new Map(); }
get(key) {
if (!this.map.has(key)) return undefined;
const v = this.map.get(key);
this.map.delete(key); this.map.set(key, v); // mark most-recent
return v;
}
set(key, val) {
if (this.map.has(key)) this.map.delete(key);
this.map.set(key, val);
if (this.map.size > this.max) this.map.delete(this.map.keys().next().value); // evict oldest
}
}
const cache = new LRU(2);
cache.set('a', 1); cache.set('b', 2); cache.set('c', 3); // 'a' evicted
console.log('keys:', [...cache.map.keys()]); // [ 'b', 'c' ]Leak Pattern 3: Lingering Event Listeners
Every emitter.on(...) stores a reference. If you attach in a hot path (per request, per socket, per timer) and never call removeListener/off, the emitter's listener array grows without bound — and each listener may close over request-scoped data.
Node warns you: MaxListenersExceededWarning once an emitter passes 10 listeners. Treat that warning as a leak alarm, not noise.
const { EventEmitter } = require('events');
const bus = new EventEmitter();
function handleRequest(reqId) {
// BUG: a new listener every request, never removed.
bus.on('shutdown', () => console.log(`drain req ${reqId}`));
}
for (let i = 0; i < 12; i++) handleRequest(i);
console.log('listenerCount(shutdown):', bus.listenerCount('shutdown'));Fixing Listeners: once, off, and AbortSignal
Match every on with an off, or avoid the accumulation entirely:
- Use
emitter.once(...)when the handler should fire a single time. - Keep the function reference so you can call
emitter.off(event, fn)on cleanup. - Modern APIs accept an
AbortSignal— abort the controller and all bound listeners are removed in one shot.
const { EventEmitter } = require('events');
const bus = new EventEmitter();
function handleRequest(reqId) {
const controller = new AbortController();
bus.on('shutdown', () => console.log(`drain req ${reqId}`), { signal: controller.signal });
// When the request finishes, abort once — listener auto-removed.
return () => controller.abort();
}
const cleanups = [];
for (let i = 0; i < 12; i++) cleanups.push(handleRequest(i));
cleanups.forEach((done) => done());
console.log('after cleanup, listenerCount:', bus.listenerCount('shutdown')); // 0Timers and Streams: The Hidden Retainers
Two retainers people forget:
- setInterval keeps its callback (and its closure) alive until
clearInterval. A per-connection interval that is never cleared leaks the whole connection scope. - Unconsumed streams and un-destroyed sockets buffer data in memory. Always
stream.destroy()on error and handle backpressure.
timer.unref() lets the process exit but does NOT free the callback — you still must clear it to stop retention.
function startWorker(job) {
const buf = Buffer.alloc(500_000); // retained by the interval closure
const t = setInterval(() => {
if (job.done) {
clearInterval(t); // releases the closure -> buf can be collected
console.log('worker stopped, scope released');
}
}, 10);
return t;
}
const job = { done: false };
startWorker(job);
setTimeout(() => { job.done = true; }, 30);WeakMap and WeakRef: Caches That Let Go
When a cache key is an object whose lifetime you do not control, use a WeakMap. Its entries do not prevent the key from being garbage-collected, so associated data disappears automatically when the key dies — no eviction policy needed.
Use WeakRef + FinalizationRegistry for advanced caches that hold values weakly. Note: you cannot iterate a WeakMap and it only accepts object keys, so it is unsuitable for primitive keys like string ids.
const meta = new WeakMap();
function attachMeta(obj) {
meta.set(obj, { seen: Date.now() });
}
let session = { id: 'abc' };
attachMeta(session);
console.log('has meta:', meta.has(session)); // true
// Once `session` is unreachable, its WeakMap entry is collected automatically.
session = null;
console.log('session dropped; WeakMap entry becomes eligible for GC.');Confirming a Leak: Heap Snapshots
Memory counters tell you THAT you leak; heap snapshots tell you WHAT leaks. Workflow:
- Start the process with
node --inspectand openchrome://inspect, OR callrequire('v8').writeHeapSnapshot()from code. - Take snapshot A, exercise the endpoint N times, take snapshot B.
- Use the Comparison view and sort by Delta. Objects whose count grows by exactly N are your leak.
- Inspect Retainers to see which root (a Map, a closure, a listener array) holds them.
For automated detection in CI, node --heapsnapshot-near-heap-limit=2 dumps a snapshot right before OOM.
const v8 = require('v8');
const fs = require('fs');
const file = v8.writeHeapSnapshot();
console.log('wrote heap snapshot:', file);
console.log('size bytes:', fs.statSync(file).size);
console.log('Load this .heapsnapshot in Chrome DevTools > Memory > Load.');Quick Check: Choosing the Right Cache
You add an in-memory cache keyed by string user id in a long-running API. Traffic is unbounded and you must guarantee memory stays bounded. Which approach is correct?
Recap: A Leak-Hunting Checklist
You can now find and fix the three dominant Node.js heap leaks:
- Closures: capture only the primitives you need; never park closures in module-level arrays without removal.
- Caches: every in-memory cache needs a bound — LRU
max+ttl, or aWeakMapwhen keys are objects you do not own. - Listeners & timers: pair every
onwithoff(or useonce/AbortSignal), andclearIntervalevery timer; treatMaxListenersExceededWarningas an alarm.
Workflow: watch heapUsed for a rising baseline, then take comparison heap snapshots and follow the retainer chain to the root. Measure, do not guess.
자주 묻는 질문
“일반적인 누수 패턴 탐지 및 해결” 강의는 무료인가요?
네 — “일반적인 누수 패턴 탐지 및 해결” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 Node.js Backend Development Bootcamp 강의 전체를 잠금 해제할 수 있습니다. Node.js Backend Development Bootcamp 강의에는 총 4개의 강의가 포함되어 있습니다.
“일반적인 누수 패턴 탐지 및 해결”에서 뭘 배우나요?
시간이 지나며 힙을 증가시키는 누수 클로저, 제한 없는 캐시 및 남아 있는 리스너를 추적해 해결합니다. 브라우저에서 직접 실행하는 실습 코드로 Node.js Backend Development Bootcamp을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.
Node.js Backend Development Bootcamp을(를) 시작하는 데 경험이 필요한가요?
사전 경험은 필요하지 않습니다. CoddyKit의 Node.js Backend Development Bootcamp은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 4번째 강의입니다.
“일반적인 누수 패턴 탐지 및 해결” 강의는 얼마나 걸리나요?
대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.
이 Node.js Backend Development Bootcamp 강의에서 코드를 작성하고 실행할 수 있나요?
네. 모든 Node.js Backend Development Bootcamp 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.
이 강의의 모든 강의
- V8 힙, 세대별 GC 및 객체 수명
- 힙 스냅샷 캡처 및 비교
- 핫 경로를 위한 CPU 프로파일링 및 플레임 그래프
- 일반적인 누수 패턴 탐지 및 해결