Node.js 이벤트 이미터에서 숨겨진 메모리 누수 규명하기
Ethan Miller
Product Engineer · Leapcell

소개
Node.js의 비동기 세계에서 이벤트 이미터는 애플리케이션의 다른 부분 간의 통신을 관리하는 기본적인 패턴입니다. 이를 통해 모듈이 소스에 대한 직접적인 인식 없이 이벤트에 반응할 수 있는 디커플링된 아키텍처를 구현할 수 있습니다. 매우 강력하지만, 이벤트 이미터를 매우 유용하게 만드는 메커니즘, 즉 emitter.on(...)으로 리스너를 등록하는 기능은 Node.js의 가장 은밀한 성능 문제 중 하나인 메모리 누수의 일반적인 원인이기도 합니다. 이러한 누수는 종종 미묘하고 추적이 어려워 애플리케이션 성능을 서서히 저하시키고 충돌 및 전반적으로 좋지 않은 사용자 경험으로 이어질 수 있습니다. 이 글에서는 이러한 "숨겨진" 누수 뒤에 숨겨진 미스터리를 풀고, Node.js 애플리케이션이 가볍고 반응성이 뛰어나도록 유지하기 위해 효과적으로 이를 퇴치할 수 있는 지식과 도구를 제공할 것입니다.
핵심 개념 이해
메모리 누수 자체에 대해 자세히 알아보기 전에 Node.js의 이벤트 이미터와 관련된 몇 가지 핵심 개념을 간략하게 복습해 보겠습니다.
- EventEmitter: Node.js의 클래스로, 객체가 미리 등록된
Function객체를 호출하게 하는 명명된 이벤트를 발생시킬 수 있습니다. 스트림 및 HTTP 서버와 같은 많은 Node.js 내장 객체는EventEmitter인터페이스를 상속하거나 구현합니다. - Event:
EventEmitter가 발생시킬 수 있는 명명된 발생입니다. - Listener (또는 Handler): 특정 이벤트가 발생했을 때 호출되도록 등록된 함수입니다. 리스너는
emitter.on(eventName, listenerFunction)또는emitter.addListener(eventName, listenerFunction)과 같은 메서드를 사용하여 등록됩니다. - Memory Leak: 프로그램이 메모리를 할당했지만 더 이상 필요하지 않을 때 운영 체제에 다시 해제하지 못하는 경우 메모리 누수가 발생합니다. 시간이 지남에 따라 이 해제되지 않은 메모리가 축적되어 메모리 소비가 증가하고 결국 메모리 부족 오류가 발생합니다.
바인딩되지 않은 리스너의 함정
emitter.on(...)이 메모리 누수로 이어지는 가장 일반적인 방법은 리스너가 등록되었지만 제거되지 않는 경우입니다. emitter.on(...)을 호출할 때마다 EventEmitter 인스턴스가 해당 특정 이벤트 이름에 대해 유지하는 내부 배열에 새 리스너 함수가 추가됩니다. EventEmitter 인스턴스 또는 이를 수신 대기하는 객체의 수명이 리스너 자체보다 짧거나, 해당 리스너 제거에 해당하는 작업 없이 빈번하게 생성 및 파기되는 객체에 리스너가 연결된 경우, 이러한 리스너 배열은 무기한으로 늘어날 수 있습니다.
"캐시 지움"이라는 전역 이벤트를 수신해야 하는 "요청" 범위의 객체가 있다고 가정해 보겠습니다.
// Global cache manager const cacheManager = new (require('events').EventEmitter)(); let cache = {}; function clearCache() { cache = {}; cacheManager.emit('cache-cleared'); console.log('Cache cleared and event emitted.'); } setInterval(clearCache, 5000); // Clear cache every 5 seconds // Request handler example (conceptual, simplified) function handleRequest(req, res) { const requestId = Math.random().toString(36).substring(7); console.log(`Request ${requestId} started.`); // LEAKY CODE: Registering a listener without removing it const cacheClearedListener = () => { console.log(`Request ${requestId} detected cache clear.`); // Imagine some request-specific cleanup here }; cacheManager.on('cache-cleared', cacheClearedListener); // Simulate request processing setTimeout(() => { // If we don't remove the listener here, it will // persist even after the request is finished. console.log(`Request ${requestId} finished.`); res.end(`Request ${requestId} processed.`); }, 1000); } // Simulate numerous incoming requests let requestCounter = 0; setInterval(() => { requestCounter++; // In a real application, these would be HTTP requests console.log(`Simulating Request ${requestCounter}`); const mockRes = { end: (msg) => console.log(msg + '\n') }; handleRequest({}, mockRes); }, 200); // Simulate a new request every 200ms
이 예제에서 handleRequest가 호출될 때마다 새 익명 cacheClearedListener 함수가 생성되어 cacheManager에 등록됩니다. cacheClearedListener는 화살표 함수이고 requestId가 해당 범위에 있으므로 리스너는 잠재적으로 전체 handleRequest 클로저를 캡처합니다. 결정적으로 이 리스너는 제거되지 않습니다. 수천 건의 요청 후 cacheManager는 수천 개의 cache-cleared 리스너를 축적하게 되는데, 각 리스너는 해당 요청의 컨텍스트를 보유할 수 있습니다. requestId 문자열 자체가 작더라도, 좀비 리스너의 수가 많고 잠재적으로 형성하는 더 큰 클로저와 함께 상당한 메모리 누수로 이어질 것입니다.
Node.js는 기본적으로 단일 이벤트에 10개 이상의 리스너가 등록된 경우 경고를 발행하지만, 이는 누수를 방지하지는 않습니다.
누수 해결: 리스너 제거
주요 해결책은 emitter.on(...) 호출마다 리스너가 더 이상 필요하지 않을 때 해당 emitter.off(...) (또는 emitter.removeListener(...)) 호출이 이루어지도록 하는 것입니다.
// ... (cacheManager and clearCache are the same) ... function handleRequestFixed(req, res) { const requestId = Math.random().toString(36).substring(7); console.log(`Request ${requestId} started.`); const cacheClearedListener = () => { console.log(`Request ${requestId} detected cache clear.`); // Imagine request-specific cleanup here // IMPORTANT: Unsubscribe the listener *after* it has served its purpose // if it's meant to be a one-time event or tied to the request's lifecycle. // For events that might happen multiple times during a request, // you'd remove it when the request is fully completed. }; cacheManager.on('cache-cleared', cacheClearedListener); setTimeout(() => { console.log(`Request ${requestId} finished.`); // FIX: Remove the listener when the request (and its associated context) is done. cacheManager.off('cache-cleared', cacheClearedListener); res.end(`Request ${requestId} processed.`); }, 1000); } // Simulate numerous incoming requests (with handleRequestFixed) let requestCounterFixed = 0; setInterval(() => { requestCounterFixed++; console.log(`Simulating Fixed Request ${requestCounterFixed}`); const mockRes = { end: (msg) => console.log(msg + '\n') }; handleRequestFixed({}, mockRes); }, 200);
요청이 완료될 때 cacheManager.off('cache-cleared', cacheClearedListener);을 추가하여 리스너가 제대로 등록 해제되어 누적이 방지되도록 합니다.
대안 솔루션 및 모범 사례
-
emitter.once(...)단일 이벤트의 경우: 리스너가 한 번만 발생하면 된다면emitter.once(eventName, listenerFunction)을 사용하세요. 리스너는 호출된 후 자동으로 제거됩니다.// Example: A listener that only cares about the first cache clear after it's registered cacheManager.once('cache-cleared', () => { console.log('Detected *first* cache clear after registration, then removed.'); }); -
약한 참조 (고급/주의): 일부 매우 특수하고 복잡한 시나리오에서는 약한 참조를 활용하는 패턴을 고려할 수 있습니다(Node.js의
EventEmitter에서 함수에 직접적으로 네이티브로 제공되지는 않지만). 프레임워크나 사용자 정의EventEmitter구현에서는 다른 강력한 참조가 없는 경우 리스너가 가비지 수집될 수 있도록 이러한 참조를 사용할 수 있습니다. 그러나 이는 대부분 고급 주제이며, 이것이 유일한 솔루션이라면 설계상의 결함을 나타내는 경우가 많습니다. 리스너 제거를 직접 관리하는 것이 거의 항상 더 명확하고 안전합니다. -
캡슐화 및 범위: 모듈 및 클래스를 설계하여 이벤트 이미터와 해당 리스너의 수명을 명확하게 정의하십시오.
EventEmitter가 특정 구성 요소에 범위가 지정되면 해당 구성 요소가 파기될 때 관련 리스너도 모두 제거되도록 하십시오. 즉, 자체 리스너 또는 수신 대기하던 다른 이미터의 리스너를 제거해야 합니다. -
프로파일링 도구: 회피하기 어려운 메모리 누수를 다룰 때는 프로파일링 도구가 필수적입니다.
- Node.js
--inspect및 Chrome DevTools: 디버거를 연결하고 "메모리" 탭을 사용하여 힙 스냅샷을 찍으세요. 시간이 지남에 따라 스냅샷을 비교하고Closure객체 또는EventEmitter인스턴스 내의 배열과 같이 수 또는 크기가 지속적으로 증가하는 개체를 찾으세요. heapdump모듈: 프로덕션 환경의 경우heapdump는 메모리 임계값을 초과할 때 힙 스냅샷을 프로그래밍 방식으로 생성하는 데 유용할 수 있습니다.memwatch-next(또는 유사): 이러한 모듈은 시간이 지남에 따라 힙 성장을 모니터링하고 누수가 감지될 때 이벤트를 발생시켜 메모리 누수를 감지할 수 있습니다.
- Node.js
결론
이벤트 이미터는 Node.js의 초석으로, 유연하고 강력한 통신을 제공합니다. 그러나 emitter.on(...)을 사용한 리스너 수명 주기 관리를 무시하면 애플리케이션 성능을 저하시키는 은밀한 메모리 누수로 이어질 수 있습니다. 리스너가 더 이상 필요하지 않을 때 emitter.on(...)을 emitter.off(...)과 일관되게 쌍으로 사용하거나 단일 이벤트에 emitter.once(...)를 활용함으로써 이러한 일반적인 함정을 예방할 수 있습니다. 사전 예방적 리스너 관리와 프로파일링 도구의 신중한 사용이 결합되어 견고하고 메모리 효율적인 Node.js 애플리케이션을 구축하는 열쇠입니다.

