클로저와 메모리 누수의 관계는?

클로저로 인해 의도치 않은 참조가 유지되어 메모리 누수가 발생하는 패턴을 파악하고 예방하는 방법을 학습합니다

심화 15분 메모리 누수 클로저 의도치 않은 참조 성능 최적화

클로저는 JavaScript의 강력한 기능이지만, 잘못 사용하면 의도치 않은 메모리 누수를 발생시킬 수 있습니다. 클로저는 외부 스코프의 변수를 참조하며, 이러한 참조는 클로저가 존재하는 한 메모리에 유지됩니다. 문제는 개발자가 의도하지 않았던 참조가 살아있어서 가비지 컬렉션이 되지 않고 메모리에 계속 남아있는 경우입니다. 특히 이벤트 리스너, 타이머, 대용량 데이터를 참조하는 클로저 등에서 이러한 문제가 자주 발생합니다. 이 주제를 통해 클로저로 인한 메모리 누수 패턴을 이해하고, 실무에서 안전하게 클로저를 사용하는 방법을 배울 수 있습니다.

💥 핵심 문제점

  • 이벤트 리스너나 타이머에서 클로저가 불필요한 데이터를 계속 참조하여 메모리가 해제되지 않음
  • 순환 참조 구조에서 클로저가 관련되면 가비지 컬렉션이 제대로 작동하지 않을 수 있음
  • 대용량 객체나 DOM 요소를 클로저가 참조하면 해당 메모리가 장시간 유지됨
  • 클로저 체인이 길어지면 예상보다 많은 변수가 메모리에 유지되어 성능 저하 발생
  • 의도치 않은 전역 변수 참조로 인해 메모리 누수가 발생할 수 있음

🎯 실무에서의 영향

싱글 페이지 애플리케이션(SPA)이나 장시간 실행되는 웹 애플리케이션에서 클로저 관련 메모리 누수는 치명적인 성능 문제를 야기합니다. 사용자가 페이지를 오래 사용할수록 메모리 사용량이 계속 증가하여 브라우저가 느려지거나 멈추는 현상이 발생할 수 있습니다. 특히 React, Vue 같은 프레임워크에서 컴포넌트 언마운트 시 이벤트 리스너나 타이머를 정리하지 않으면 메모리 누수가 누적됩니다. 실시간 데이터 처리, 무한 스크롤, 채팅 애플리케이션처럼 동적으로 많은 이벤트를 처리하는 경우 더욱 주의가 필요합니다. 이러한 문제를 예방하려면 클로저의 생명주기를 명확히 이해하고, 불필요한 참조를 적절히 제거하며, 개발자 도구의 메모리 프로파일러를 활용하여 메모리 누수를 사전에 탐지하는 습관이 중요합니다.


핵심 개념

Unintended Reference Retention

입문

클로저는 함수 안에서 사용한 변수를 기억하는데, 가끔 우리가 의도하지 않은 큰 데이터까지 기억해서 메모리를 낭비하게 돼요.

📦 클로저가 뭘 기억하나요? 클로저는 마치 사진첩처럼 함수가 만들어질 때 주변 환경을 찍어서 보관해요. 그런데 배경에 우연히 찍힌 큰 건물까지 모두 저장하는 거예요. 우리는 사람만 찍고 싶었는데 배경까지 다 저장되는 셈이죠.

🎯 왜 문제가 되나요? 작은 메모 하나만 기억하면 되는데, 그 메모가 있던 큰 책상 전체를 기억한다고 상상해보세요. 책상 위에 무거운 백과사전이 있다면 그것까지 계속 들고 다니는 거예요. 메모리도 마찬가지로 필요 없는 큰 데이터를 계속 기억하면 낭비가 돼요.

🚨 언제 이런 일이 생기나요? 예를 들어 친구 이름만 기억하면 되는데, 친구가 살던 아파트 전체 주민 명단을 기억하는 것과 같아요. 꼭 필요한 것만 골라서 기억해야 하는데, 한 덩어리로 모두 기억해버리면 메모리가 부족해질 수 있어요.

💡 어떻게 해결하나요? 사진을 찍을 때 배경을 흐리게 처리하듯이, 꼭 필요한 정보만 따로 복사해서 기억하면 돼요. 큰 데이터는 일을 마친 후 ‘더 이상 필요 없어요’라고 알려주면 컴퓨터가 치워줘요.

중급

클로저는 외부 스코프의 변수를 참조하지만, 실제로는 전체 스코프 체인을 유지합니다. 이로 인해 의도하지 않은 대용량 객체나 DOM 요소까지 메모리에 남아있을 수 있습니다.

참조 유지 메커니즘 클로저가 생성될 때 JavaScript 엔진은 해당 함수가 사용하는 변수뿐만 아니라 전체 렉시컬 환경(Lexical Environment)을 참조합니다. 이는 필요 이상의 데이터를 메모리에 유지시키는 원인이 됩니다.

function createHandler() {
  const largeData = new Array(1000000).fill('data'); // 큰 배열
  const someValue = 42;

  return function() {
    console.log(someValue); // someValue만 사용
  };
  // 하지만 largeData도 메모리에 유지됨
}

const handler = createHandler();
// handler는 someValue만 필요하지만 largeData도 참조 유지

문제점 분석 위 코드에서 반환된 함수는 someValue만 사용하지만, largeData도 같은 스코프에 있어서 가비지 컬렉션되지 않습니다. 이는 불필요한 메모리 낭비를 초래합니다.

function createHandler() {
  const largeData = new Array(1000000).fill('data');
  const someValue = 42;

  // 필요한 값만 추출
  const valueToKeep = someValue;

  // largeData는 이제 참조되지 않음
  return function() {
    console.log(valueToKeep);
  };
  // largeData는 가비지 컬렉션 대상이 됨
}

심화

클로저의 메모리 유지 메커니즘은 ECMAScript 명세의 환경 레코드(Environment Record) 구조와 가비지 컬렉션 알고리즘의 도달 가능성(reachability) 분석을 통해 이해할 수 있습니다.

ECMAScript 렉시컬 환경과 참조 유지 ECMAScript 2023, Section 9.1 (Environment Records)에 따르면, 함수가 생성될 때 [[Environment]] 내부 슬롯에 현재 렉시컬 환경에 대한 참조가 저장됩니다. 이 참조는 전체 환경 레코드를 가리키므로, 클로저가 단 하나의 변수만 사용하더라도 전체 스코프의 바인딩이 유지됩니다.

가비지 컬렉션과 도달 가능성 분석 현대 JavaScript 엔진은 Mark-and-Sweep 알고리즘을 사용합니다. 클로저가 존재하면 해당 클로저의 [[Environment]] 체인을 따라가며 모든 객체를 “도달 가능”으로 표시합니다. 이는 실제로 사용되지 않는 변수도 메모리에 유지시킵니다.

V8 엔진의 컨텍스트 최적화 V8 엔진(9.0+)은 컨텍스트 스냅샷(Context Snapshot) 최적화를 통해 실제로 사용되는 변수만 유지하려 시도합니다. Hidden Class 분석과 Type Feedback을 활용하여 사용되지 않는 변수를 식별하고 제거합니다.

// V8 내부 최적화 예시
Context:
  - used_variable: 42 (kept)
  - unused_large_array: [...] (potentially optimized away)

하지만 이 최적화는 다음 조건에서 실패할 수 있습니다:

  • eval() 사용 시 (동적 스코프 접근 가능성)
  • with 문 사용 시
  • 디버거 활성화 시 (모든 변수 접근 가능해야 함)

메모리 프로파일링과 탐지 Chrome DevTools의 Memory Profiler는 Retainer Tree를 통해 클로저로 인한 메모리 유지를 시각화합니다. Shallow Size(객체 자체 크기)와 Retained Size(객체가 유지시키는 총 크기)의 차이가 크면 클로저 관련 메모리 누수를 의심할 수 있습니다.

Event Listener Memory Leaks

입문

웹 페이지에서 버튼 클릭 같은 이벤트를 감지하는 장치를 달아두는데, 더 이상 필요 없어도 떼어내지 않으면 메모리가 낭비돼요.

🎧 이벤트 리스너가 뭔가요? 이벤트 리스너는 초인종처럼 특정 신호(버튼 클릭, 마우스 움직임 등)를 기다리는 감지 장치예요. 누군가 버튼을 누르면 “띵동!” 하고 알려주는 거죠. 그런데 이 초인종이 배터리로 작동한다고 생각해보세요.

🔌 왜 메모리 누수가 생기나요? 집을 이사 갈 때 옛날 집 초인종을 그대로 두고 가면 계속 전기를 먹잖아요? 웹 페이지도 마찬가지예요. 페이지를 바꿔도 옛날 페이지의 이벤트 리스너가 계속 작동 중이면 메모리를 계속 쓰게 돼요.

📱 실생활 예시 채팅 앱에서 친구 목록을 보다가 다른 화면으로 넘어갔어요. 그런데 친구 목록의 ‘새 메시지 알림 감지 장치’가 계속 켜져 있다면? 보이지도 않는 화면인데 계속 배터리(메모리)를 쓰는 거예요.

🧹 어떻게 정리하나요? 이사 갈 때 초인종 배터리를 빼듯이, 페이지를 떠날 때 이벤트 리스너를 제거해야 해요. “이제 이 버튼 안 쓸 거야”라고 알려주면 컴퓨터가 메모리를 회수해 가요.

중급

이벤트 리스너에 클로저가 사용되면 리스너 함수뿐만 아니라 클로저가 참조하는 모든 변수가 메모리에 유지됩니다. 특히 DOM 요소를 참조하는 경우 DOM과 클로저 간 순환 참조가 발생할 수 있습니다.

이벤트 리스너 메모리 누수 패턴 SPA에서 동적으로 DOM을 추가/제거할 때 이벤트 리스너를 제거하지 않으면 제거된 DOM 요소가 여전히 메모리에 남아있게 됩니다. 이는 리스너 함수가 DOM 요소를 참조하고 있기 때문입니다.

function attachListeners() {
  const button = document.getElementById('myButton');
  const largeData = new Array(100000).fill('data');

  // 클로저가 largeData를 캡처
  button.addEventListener('click', function() {
    console.log('Data size:', largeData.length);
  });

  // button을 DOM에서 제거해도 리스너가 유지됨
  // largeData도 메모리에 계속 남음
}
function attachListeners() {
  const button = document.getElementById('myButton');
  const largeData = new Array(100000).fill('data');

  // 리스너 함수를 별도로 저장
  const handleClick = function() {
    console.log('Data size:', largeData.length);
  };

  button.addEventListener('click', handleClick);

  // 정리 함수 제공
  return function cleanup() {
    button.removeEventListener('click', handleClick);
    // 이제 handleClick과 largeData가 가비지 컬렉션됨
  };
}

const cleanup = attachListeners();
// 컴포넌트 언마운트 시
cleanup();

React 환경에서의 적용 React의 useEffect 훅에서 반환하는 cleanup 함수가 바로 이 패턴을 구현한 것입니다. 컴포넌트가 언마운트될 때 자동으로 이벤트 리스너를 제거합니다.

useEffect(() => {
  const handleResize = () => {
    console.log('Window resized');
  };

  window.addEventListener('resize', handleResize);

  // cleanup 함수
  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []);

심화

이벤트 리스너 메모리 누수는 DOM 명세의 이벤트 타겟(EventTarget) 구조와 JavaScript 가비지 컬렉션의 순환 참조 탐지 메커니즘이 상호작용하면서 발생합니다.

DOM Event Model과 메모리 관리 WHATWG DOM Living Standard, Section 2.8 (EventTarget)에 따르면, EventTarget.addEventListener()는 이벤트 리스너 목록(event listener list)에 리스너를 추가합니다. 이 목록은 Strong Reference를 유지하므로, DOM 요소가 문서에서 제거되어도 리스너가 존재하면 가비지 컬렉션되지 않습니다.

순환 참조 패턴 분석 다음과 같은 순환 참조가 발생합니다:

DOM Element → Event Listener (Strong Reference)
     ↑              ↓
     └── Closure [[Environment]] ──┘

DOM 요소가 클로저를 참조하고, 클로저가 다시 DOM 요소를 참조하는 순환 구조가 형성됩니다. 현대 엔진(V8 9.0+, SpiderMonkey 91+)의 Mark-and-Sweep GC는 순환 참조를 탐지할 수 있지만, 도달 가능한 객체는 수집하지 않습니다.

브라우저별 이벤트 리스너 최적화

  1. Chrome/V8 (Oilpan GC)

    • Blink 렌더링 엔진은 Oilpan이라는 별도 GC를 사용하여 DOM 객체를 관리합니다.
    • Oilpan은 JavaScript Heap과 독립적으로 동작하지만, Cross-Heap Reference Tracking을 통해 JavaScript 클로저와 DOM 간 참조를 추적합니다.
    • 하지만 명시적으로 removeEventListener()를 호출하지 않으면 참조가 유지됩니다.
  2. Firefox/SpiderMonkey

    • Cycle Collector를 사용하여 순환 참조를 탐지합니다.
    • Purple Buffer에 순환 참조 후보를 저장하고 주기적으로 스캔합니다.
    • 그러나 이벤트 리스너는 Strong Root로 간주되어 수집되지 않습니다.

WeakRef와 FinalizationRegistry 활용 ECMAScript 2021에서 도입된 WeakRef를 사용하면 약한 참조를 생성할 수 있습니다:

class WeakEventListener {
  constructor(target, eventType, handler) {
    this.targetRef = new WeakRef(target);
    this.eventType = eventType;
    this.handler = handler;

    target.addEventListener(eventType, handler);

    // FinalizationRegistry로 자동 정리
    this.registry = new FinalizationRegistry((heldValue) => {
      const target = heldValue.targetRef.deref();
      if (target) {
        target.removeEventListener(heldValue.eventType, heldValue.handler);
      }
    });

    this.registry.register(target, this, this);
  }
}

그러나 WeakRef는 GC 타이밍이 비결정적이므로 프로덕션 환경에서는 명시적 cleanup이 여전히 권장됩니다.

메모리 프로파일링 지표 Chrome DevTools에서 확인할 수 있는 지표:

  • Detached DOM Tree Count: 분리되었지만 메모리에 남은 DOM 수
  • Event Listeners Count: 등록된 리스너 총 개수
  • Retained Size by Listeners: 리스너가 유지하는 메모리 크기

Circular Reference Patterns

입문

두 개의 물건이 서로를 가리키고 있으면 둘 다 버릴 수 없는 상황이 생겨요. 마치 두 친구가 서로 손을 놓지 않으면 집에 못 가는 것과 같아요.

🔄 순환 참조가 뭔가요? A가 B를 가리키고, B가 다시 A를 가리키는 상황이에요. 마치 거울 두 개를 마주 보게 하면 끝없이 반사되는 것처럼, 서로를 참조하는 고리가 생겨요.

🎯 왜 문제가 되나요? 쓰레기를 버릴 때 “이거 누가 쓰나?”를 확인하잖아요. A는 “B가 쓰고 있어!”라고 하고, B는 “A가 쓰고 있어!”라고 해요. 그럼 컴퓨터는 둘 다 필요한 줄 알고 버리지 않아요. 실제로는 둘 다 필요 없는데도요!

💡 실생활 비유 두 사람이 서로에게만 택배를 보내는 상황을 생각해보세요. 우체국에서 보면 “아, 이 주소들은 계속 사용 중이구나!” 하고 판단해요. 실제로는 아무도 그 택배를 안 쓰는데도 시스템은 계속 보관하게 돼요.

🔧 어떻게 해결하나요? 한쪽이 손을 놓으면 돼요. A와 B 중 하나가 “나는 더 이상 상대를 참조하지 않을게”라고 선언하면 컴퓨터가 둘 다 치울 수 있게 돼요.

중급

순환 참조는 두 개 이상의 객체가 서로를 참조하는 패턴입니다. 클로저가 이러한 구조에 포함되면 가비지 컬렉션이 복잡해지며, 구형 브라우저에서는 메모리 누수를 유발할 수 있습니다.

순환 참조의 기본 형태 가장 단순한 형태는 객체 A가 객체 B를 참조하고, 객체 B가 다시 객체 A를 참조하는 경우입니다. 클로저가 이러한 구조의 일부가 되면 문제가 더 복잡해집니다.

function createCircularRef() {
  const obj1 = {};
  const obj2 = {};

  obj1.ref = obj2;
  obj2.ref = obj1; // 순환 참조 형성

  return obj1;
}

// 현대 브라우저는 이를 올바르게 처리하지만
// 클로저가 포함되면 복잡해짐
function createLeakyStructure() {
  const largeData = new Array(100000).fill('data');

  const obj = {
    data: largeData,
    getInfo: function() {
      // 클로저가 obj를 참조
      return this.data.length;
    }
  };

  // obj → getInfo (클로저) → obj (this 참조)
  // 순환 참조 형성
  return obj;
}

DOM과 클로저의 순환 참조 특히 위험한 패턴은 DOM 요소와 클로저 간의 순환 참조입니다. DOM 요소가 클로저를 참조하고, 클로저가 다시 DOM 요소를 참조하는 경우입니다.

function attachHandler(element) {
  element.onClick = function() {
    // 클로저가 element를 참조
    element.style.color = 'red';
  };
  // element → onClick → element (순환 참조)
}

// 해결 방법: 참조 해제
function attachHandlerSafely(element) {
  const elementId = element.id;

  element.onClick = function() {
    // element를 직접 참조하지 않음
    document.getElementById(elementId).style.color = 'red';
  };

  // 또는 cleanup 시 명시적 해제
  return function cleanup() {
    element.onClick = null; // 순환 참조 끊기
  };
}

심화

순환 참조 탐지는 가비지 컬렉션 알고리즘의 핵심 과제이며, Reference Counting과 Tracing GC의 근본적인 차이를 드러냅니다.

가비지 컬렉션 알고리즘 비교

  1. Reference Counting (구형 방식)

    • 각 객체의 참조 횟수를 추적합니다.
    • 참조 횟수가 0이 되면 즉시 회수합니다.
    • 치명적 약점: 순환 참조를 탐지하지 못합니다.
    obj1.ref = obj2; // obj2 refCount: 1
    obj2.ref = obj1; // obj1 refCount: 1
    obj1 = null; obj2 = null;
    // 하지만 obj1.ref와 obj2.ref로 인해 refCount가 여전히 1
    // 메모리 누수 발생!
  2. Mark-and-Sweep (현대 엔진)

    • Root 객체(전역 변수, 스택 변수)부터 도달 가능한 모든 객체를 마킹합니다.
    • 마킹되지 않은 객체를 회수합니다.
    • 장점: 순환 참조를 올바르게 처리합니다.
    • 원리: 도달 가능성(reachability)을 기준으로 판단하므로, 순환 참조가 있어도 Root에서 도달 불가능하면 회수됩니다.

V8의 Oilpan GC와 Cross-Heap References V8 엔진은 두 개의 독립적인 힙을 관리합니다:

  • JavaScript Heap: JavaScript 객체 저장
  • C++ Heap (Oilpan): DOM 노드, 브라우저 객체 저장

클로저와 DOM 간 순환 참조는 Cross-Heap Reference를 생성합니다:

JavaScript Heap:
  Closure → [[Environment]] → DOM Reference (Wrapper)

C++ Heap (Oilpan):
  DOM Node → Event Listener → Closure (through V8 Handle)

V8은 Unified Heap GC (V8 9.0+)를 통해 두 힙을 동시에 스캔하여 순환 참조를 탐지합니다. Incremental Marking과 Concurrent Marking을 활용하여 성능 영향을 최소화합니다.

WeakMap을 활용한 순환 참조 방지 ECMAScript 2015의 WeakMap은 약한 참조(weak reference)를 제공하여 순환 참조 문제를 해결할 수 있습니다:

const metadata = new WeakMap();

function attachMetadata(element, data) {
  // element를 키로 사용, weak reference 생성
  metadata.set(element, data);

  element.onClick = function() {
    const data = metadata.get(element);
    console.log(data);
  };

  // element가 GC되면 metadata 엔트리도 자동 제거됨
}

WeakMap의 키는 강한 참조를 생성하지 않으므로, 키 객체가 다른 곳에서 참조되지 않으면 자동으로 GC됩니다.

Internet Explorer의 레거시 문제 IE8 이하 버전은 DOM 객체에 Reference Counting을, JavaScript 객체에 Mark-and-Sweep을 사용했습니다. 이로 인해 DOM-JS 순환 참조가 메모리 누수를 유발했습니다:

// IE8 이하에서 메모리 누수
var element = document.getElementById('myDiv');
element.expandoProperty = {};
element.expandoProperty.element = element; // 순환 참조 → 메모리 누수

// 해결: 명시적 해제
element.expandoProperty.element = null;

현대 브라우저(IE9+)는 모두 Tracing GC를 사용하여 이 문제를 해결했습니다.

Memory Leak Prevention Strategies

입문

메모리 누수를 막는 방법은 크게 세 가지예요. 안 쓰는 건 바로 정리하고, 너무 큰 건 나눠서 보관하고, 자주 확인하는 거예요.

🧹 정리 습관이 중요해요 방을 쓴 후 정리하지 않으면 점점 지저분해지죠? 코드도 마찬가지예요. 이벤트 리스너나 타이머를 사용했으면 끝날 때 꼭 정리해야 해요. “다 썼어요!” 신호를 보내는 거예요.

📦 큰 짐은 나눠서 보관하기 큰 배낭 하나를 계속 메고 다니는 것보다, 필요한 것만 작은 주머니에 넣어 다니는 게 편하잖아요? 데이터도 마찬가지로 큰 덩어리 전체를 기억하지 말고, 꼭 필요한 부분만 복사해서 쓰세요.

🔍 정기적으로 확인하기 자동차도 정기 점검을 받듯이, 웹 애플리케이션도 메모리를 주기적으로 확인해야 해요. 브라우저에 있는 ‘메모리 측정 도구’로 “어? 메모리가 계속 늘어나네?” 하는 문제를 미리 발견할 수 있어요.

💡 예방이 최선이에요 치료보다 예방이 낫듯이, 메모리 누수도 발생하기 전에 막는 게 좋아요. 코드를 짤 때부터 “이거 나중에 정리해야겠다”라고 생각하면서 만들면 문제가 훨씬 줄어들어요.

중급

메모리 누수를 예방하려면 체계적인 접근이 필요합니다. 클로저 사용 시 참조 관리, 이벤트 리스너 정리, 메모리 프로파일링 도구 활용이 핵심입니다.

1. 명시적 cleanup 패턴 리소스를 사용하는 코드에는 항상 정리 로직을 함께 작성해야 합니다. 특히 이벤트 리스너, 타이머, WebSocket 연결 등은 반드시 정리가 필요합니다.

class ResourceManager {
  constructor() {
    this.cleanupCallbacks = [];
  }

  addCleanup(callback) {
    this.cleanupCallbacks.push(callback);
  }

  attach(element, eventType, handler) {
    element.addEventListener(eventType, handler);
    this.addCleanup(() => {
      element.removeEventListener(eventType, handler);
    });
  }

  setTimer(callback, delay) {
    const timerId = setInterval(callback, delay);
    this.addCleanup(() => {
      clearInterval(timerId);
    });
  }

  cleanup() {
    this.cleanupCallbacks.forEach(fn => fn());
    this.cleanupCallbacks = [];
  }
}

// 사용 예시
const manager = new ResourceManager();
manager.attach(button, 'click', handleClick);
manager.setTimer(updateData, 1000);

// 컴포넌트 언마운트 시
manager.cleanup();

2. 불필요한 참조 최소화 클로저가 꼭 필요한 변수만 캡처하도록 스코프를 최소화합니다. 필요한 값만 추출하여 새로운 변수로 만드는 것이 효과적입니다.

// 나쁜 예: 전체 객체 캡처
function createHandler(user) {
  return function() {
    console.log(user.name); // user 전체를 캡처
  };
}

// 좋은 예: 필요한 값만 추출
function createHandlerOptimized(user) {
  const userName = user.name; // 이름만 복사
  return function() {
    console.log(userName); // userName만 캡처
  };
  // user 객체는 가비지 컬렉션 가능
}

3. 메모리 프로파일링 Chrome DevTools의 Memory Profiler를 활용하여 메모리 누수를 탐지하고 분석합니다.

// 1. 기준점 생성 (Heap Snapshot)
// DevTools > Memory > Take heap snapshot

// 2. 작업 수행
function performAction() {
  for (let i = 0; i < 100; i++) {
    createComponent();
  }
}

// 3. 컴포넌트 제거
function removeComponents() {
  // 제거 로직
}

// 4. 두 번째 Heap Snapshot
// 5. Comparison 뷰에서 메모리 증가 확인
// 6. Retained Size가 큰 객체 분석

4. WeakMap/WeakSet 활용 메타데이터나 캐시를 저장할 때 WeakMap을 사용하면 자동으로 메모리 관리가 됩니다.

// 일반 Map: 메모리 누수 가능
const cache = new Map();
function getData(element) {
  if (!cache.has(element)) {
    cache.set(element, expensiveComputation(element));
  }
  return cache.get(element);
}
// element가 제거되어도 cache에 남음

// WeakMap: 자동 정리
const weakCache = new WeakMap();
function getDataSafe(element) {
  if (!weakCache.has(element)) {
    weakCache.set(element, expensiveComputation(element));
  }
  return weakCache.get(element);
}
// element가 GC되면 자동으로 cache에서도 제거

심화

메모리 누수 예방은 소프트웨어 공학의 리소스 관리(Resource Management) 원칙과 JavaScript 엔진의 GC 동작을 종합적으로 이해해야 합니다.

RAII 패턴의 JavaScript 적용 C++의 RAII(Resource Acquisition Is Initialization) 패턴을 JavaScript에 적용할 수 있습니다. ES2015의 Symbol과 Iterator를 활용한 자동 정리 메커니즘:

class ScopedResource {
  constructor(acquireFn, releaseFn) {
    this.resource = acquireFn();
    this.releaseFn = releaseFn;
  }

  [Symbol.dispose]() {
    this.releaseFn(this.resource);
  }
}

// TC39 Proposal: Explicit Resource Management (Stage 3)
{
  using resource = new ScopedResource(
    () => addEventListener(...),
    (r) => removeEventListener(...)
  );
  // 블록 종료 시 자동으로 Symbol.dispose 호출
}

메모리 프로파일링 심화 분석

  1. Heap Snapshot 분석

    • Shallow Size: 객체 자체의 메모리 크기
    • Retained Size: 객체가 유지시키는 전체 메모리 (더 중요)
    • Retainers: 해당 객체를 참조하는 체인
  2. Allocation Timeline

    • 시간에 따른 메모리 할당 추적
    • Blue bars: 정상적으로 회수된 메모리
    • Gray bars: 회수되지 않은 메모리 (누수 의심)
  3. Allocation Sampling

    • 낮은 오버헤드로 장시간 프로파일링
    • Call stack과 메모리 할당을 연결하여 누수 원인 코드 식별

V8의 메모리 관리 최적화 기법

  1. Generational Hypothesis 활용

    • Young Generation (Scavenger): 단명 객체, 빈번한 GC
    • Old Generation (Mark-Sweep-Compact): 장수 객체, 느린 GC
    • 클로저는 주로 Old Generation에 배치되므로 장기간 메모리 점유
  2. Inline Caching과 Hidden Class

    • 클로저 내부의 객체 접근 패턴이 일정하면 Inline Cache 최적화
    • Hidden Class 변경을 피하여 메모리 효율성 향상
  3. Escape Analysis

    • 함수 외부로 탈출하지 않는 객체는 스택 할당 가능
    • 클로저가 없으면 Escape Analysis 성공률 증가

프로덕션 환경 모니터링

  1. Performance API 활용
// 메모리 사용량 모니터링
if (performance.memory) {
  const {
    usedJSHeapSize,
    totalJSHeapSize,
    jsHeapSizeLimit
  } = performance.memory;

  const memoryUsageRatio = usedJSHeapSize / jsHeapSizeLimit;

  if (memoryUsageRatio > 0.9) {
    console.warn('High memory usage detected');
    // 정리 로직 트리거
  }
}
  1. PerformanceObserver로 장기 추적
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.entryType === 'measure') {
      console.log(`${entry.name}: ${entry.duration}ms`);
    }
  }
});

observer.observe({ entryTypes: ['measure', 'navigation'] });

정적 분석 도구

  • ESLint 플러그인: eslint-plugin-memory-leak (클로저 패턴 분석)
  • TypeScript의 strictNullChecks: null 참조 방지
  • Closure Compiler의 Advanced Optimizations: 불필요한 클로저 제거

메모리 누수 체크리스트

  1. ✅ 모든 addEventListener에 대응하는 removeEventListener 존재
  2. ✅ setInterval/setTimeout의 clearInterval/clearTimeout 호출
  3. ✅ 큰 객체를 참조하는 클로저 최소화
  4. ✅ DOM 참조를 클로저에 저장하지 않기
  5. ✅ 컴포넌트 언마운트 시 cleanup 함수 실행
  6. ✅ WeakMap/WeakSet을 캐시나 메타데이터에 사용
  7. ✅ 정기적인 메모리 프로파일링 (CI/CD 통합)