전체 목록
JavaScriptHard#08

메모리 누수(Memory Leak)가 발생하는 일반적인 원인과 방지 방법을 설명해주세요.

#JS#메모리#성능#고급
힌트

전역 변수, 이벤트 리스너, 클로저, setInterval을 생각해보세요.

정답 및 해설

메모리 누수(Memory Leak)가 발생하는 일반적인 원인과 방지 방법을 설명해주세요.

메모리 누수(Memory Leak)란 더 이상 필요하지 않은 메모리가 가비지 컬렉터(GC)에 의해 회수되지 못하고 계속 점유되는 현상입니다. JavaScript는 가비지 컬렉션을 자동으로 수행하지만, 코드에서 의도치 않게 참조를 유지하면 GC가 메모리를 회수하지 못합니다. 누수가 누적되면 성능 저하, 앱 크래시로 이어집니다.

가비지 컬렉션 기본 이해

JavaScript 엔진은 도달 가능성(Reachability) 을 기준으로 메모리를 관리합니다. 루트(전역 변수, 콜 스택 등)에서 참조를 따라갈 수 있으면 살아있는 객체, 그렇지 않으면 수거 대상입니다.

let user = { name: 'Alice' }; // 메모리에 객체 생성, user가 참조
user = null; // 참조를 끊음 → GC 수거 대상

원인 1: 실수로 생성된 전역 변수

strict mode를 사용하지 않으면, 선언 없이 변수에 값을 할당할 때 전역 변수가 생성됩니다. 전역 변수는 애플리케이션이 종료될 때까지 메모리에 남습니다.

// 나쁜 예 — 전역 변수 생성
function processData() {
  result = []; // var/let/const 없이 할당 → 전역 변수
  for (let i = 0; i < 100000; i++) {
    result.push({ id: i, data: new Array(1000).fill('x') });
  }
}

// 좋은 예 — strict mode로 방지
'use strict';
function processData() {
  // result = []; // ReferenceError 발생
  const result = []; // 함수 스코프 내 변수
  // ...
  return result;
}

원인 2: 해제되지 않은 이벤트 리스너

이벤트 리스너는 콜백 함수와 그 클로저를 통해 외부 객체에 대한 참조를 유지합니다. 리스너를 제거하지 않으면 해당 객체도 GC 대상이 되지 않습니다.

// 나쁜 예 — 리스너를 제거하지 않음
class DataManager {
  constructor() {
    this.data = new Array(100000).fill('데이터');
    window.addEventListener('resize', this.handleResize.bind(this));
    // DataManager 인스턴스가 사라져도 window가 참조를 유지
  }

  handleResize() {
    console.log('리사이즈 처리');
  }
}

// 좋은 예 — cleanup 메서드 구현
class DataManager {
  constructor() {
    this.data = new Array(100000).fill('데이터');
    this.handleResize = this.handleResize.bind(this);
    window.addEventListener('resize', this.handleResize);
  }

  handleResize() {
    console.log('리사이즈 처리');
  }

  destroy() {
    window.removeEventListener('resize', this.handleResize);
    this.data = null;
  }
}

React에서의 useEffect cleanup

function WindowSizeTracker() {
  const [size, setSize] = useState({ width: 0, height: 0 });

  useEffect(() => {
    function handleResize() {
      setSize({ width: window.innerWidth, height: window.innerHeight });
    }

    window.addEventListener('resize', handleResize);

    // cleanup 함수 — 컴포넌트 언마운트 또는 재실행 시 호출됨
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);

  return <div>{size.width} x {size.height}</div>;
}

원인 3: 클로저로 인한 참조 유지

클로저는 외부 함수의 변수를 참조하므로, 클로저가 살아있는 한 해당 변수도 GC 대상이 되지 않습니다.

// 나쁜 예 — 큰 데이터를 클로저가 참조
function createHandler() {
  const largeData = new Array(1000000).fill('대용량 데이터');

  // largeData를 실제로 사용하지 않지만 클로저가 참조를 유지
  return function handler() {
    console.log('handler called');
    // largeData를 사용하지 않지만 GC 불가
  };
}

const handler = createHandler();
// handler가 살아있는 한 largeData도 메모리에 남음

// 좋은 예 — 필요한 값만 추출
function createHandlerGood() {
  const largeData = new Array(1000000).fill('대용량 데이터');
  const needed = largeData.length; // 필요한 값만 추출

  // largeData는 더 이상 참조되지 않으므로 GC 가능
  return function handler() {
    console.log('길이:', needed);
  };
}

원인 4: 해제되지 않은 타이머

setInterval은 명시적으로 clearInterval을 호출하지 않으면 영원히 실행되며, 콜백 내에서 참조하는 모든 것을 메모리에 유지합니다.

// 나쁜 예
function startPolling() {
  const cache = new Map();

  setInterval(() => {
    // cache를 계속 참조 → GC 불가
    cache.set(Date.now(), fetchData());
  }, 1000);
  // intervalId를 저장하지 않아 clearInterval 불가
}

// 좋은 예
function startPolling() {
  const cache = new Map();

  const intervalId = setInterval(() => {
    cache.set(Date.now(), fetchData());
  }, 1000);

  // cleanup 함수 반환
  return function stopPolling() {
    clearInterval(intervalId);
    cache.clear();
  };
}

const stopPolling = startPolling();
// 나중에 정리할 때
stopPolling();

React에서의 타이머 cleanup

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      setCount((prev) => prev + 1);
    }, 1000);

    return () => clearInterval(intervalId); // cleanup
  }, []);

  return <div>카운트: {count}</div>;
}

원인 5: DOM 참조를 변수에 보관 후 DOM에서 제거

DOM 요소를 JavaScript 변수에 저장해 두고, 해당 요소를 DOM에서 제거해도 변수가 참조를 유지하면 메모리에서 해제되지 않습니다.

// 나쁜 예
const elements = [];

function addAndTrack() {
  const div = document.createElement('div');
  div.textContent = '아이템';
  document.body.appendChild(div);
  elements.push(div); // 참조를 배열에 저장
}

function removeAll() {
  document.body.innerHTML = ''; // DOM에서는 제거됨
  // 하지만 elements 배열이 여전히 참조를 유지 → GC 불가
}

// 좋은 예
function removeAll() {
  document.body.innerHTML = '';
  elements.length = 0; // 배열의 참조도 제거
}

방지 방법 요약

WeakMap / WeakRef 활용

WeakMapWeakSet은 키 객체에 대한 **약한 참조(Weak Reference)**를 사용합니다. 해당 객체에 다른 참조가 없으면 GC가 수거할 수 있습니다.

// Map — 강한 참조, GC 불가
const cache = new Map();
let obj = { data: '...' };
cache.set(obj, '캐시 값');
obj = null; // obj를 null로 해도 Map이 참조를 유지

// WeakMap — 약한 참조, GC 가능
const weakCache = new WeakMap();
let obj2 = { data: '...' };
weakCache.set(obj2, '캐시 값');
obj2 = null; // obj2에 대한 참조가 없으므로 GC 수거 가능

방지 체크리스트

원인해결책
전역 변수 실수 생성'use strict' 사용, ESLint no-undef 규칙
이벤트 리스너 미제거removeEventListener, useEffect cleanup
클로저 참조필요한 값만 추출, 클로저 라이프사이클 관리
타이머 미제거clearInterval / clearTimeout, useEffect cleanup
DOM 참조 보관WeakMap 사용, 제거 시 참조도 함께 정리
대용량 데이터 캐시LRU 캐시, WeakRef + FinalizationRegistry

메모리 누수 디버깅

Chrome DevTools의 Memory 탭을 활용합니다.

  1. Heap Snapshot: 특정 시점의 메모리 상태 스냅샷
  2. Allocation Timeline: 시간별 메모리 할당 추적
  3. Allocation Sampling: 함수별 메모리 사용량 샘플링
// 개발 환경에서 메모리 사용량 확인
if (performance.memory) {
  console.log('사용 중인 JS 힙:', performance.memory.usedJSHeapSize / 1024 / 1024, 'MB');
  console.log('총 JS 힙:', performance.memory.totalJSHeapSize / 1024 / 1024, 'MB');
}

메모리 누수는 한 번에 크게 발생하기보다 서서히 누적되는 경우가 많습니다. 컴포넌트/클래스의 생명주기를 명확히 설계하고, 생성한 리소스는 반드시 소멸 시 정리하는 습관이 중요합니다.