frontend/javascript

🟨 2-26. 메모리 누수와 성능 저하를 막는 실전 관리 전략 (JS & 브라우저 관점)

mirabo01 2025. 11. 7. 08:56

1. 메모리 누수란 무엇인가

메모리 누수(Memory Leak)는
더 이상 필요하지 않은 객체가 여전히 참조되고 있어
GC(가비지 컬렉션)에 의해 회수되지 못하는 현상을 의미한다.

즉, “쓰레기가 쌓이는데 버리지 못하는 상황”이다.

🧠 예시

  • 페이지를 열어둔 채 시간이 지날수록 브라우저가 느려짐
  • SPA 앱에서 라우팅 이동 후에도 이전 페이지 데이터가 남아 있음
  • setInterval, Event Listener가 해제되지 않음

2. 브라우저의 메모리 구조 다시보기

브라우저의 JS 엔진(V8 등)은 크게 두 가지 영역으로 메모리를 관리한다 👇

영역 설명

Stack 함수 호출, 기본형 데이터 저장 (Number, Boolean 등)
Heap 객체, 배열, 함수 등 동적 데이터 저장
function demo() {
  const x = 10; // Stack
  const obj = { y: 20 }; // Heap
}

✅ Stack은 짧은 생명 주기를 가지며 자동으로 해제
❌ Heap은 객체 참조가 남아 있으면 GC가 제거하지 못함


3. 가비지 컬렉션(Garbage Collection)의 원리

자바스크립트는 자동으로 메모리를 관리하지만,
“참조가 남아 있는지” 를 기준으로 객체를 제거한다.

🔹 Mark-and-Sweep 알고리즘
1️⃣ Root(전역 객체)에서 접근 가능한 객체를 Mark(표시)
2️⃣ 표시되지 않은 객체를 Sweep(제거)

let obj = { name: "test" };
obj = null; // 참조 해제 → GC 가능

✅ 참조가 끊긴 순간 다음 GC 사이클에서 정리됨


4. 메모리 누수가 생기는 대표적인 패턴 5가지


(1) 전역 변수 남용

전역 변수는 앱이 종료될 때까지 메모리에서 해제되지 않는다.

var data = []; // ❌ 전역 변수
setInterval(() => data.push(new Array(1000).fill('*')), 1000);

➡ 시간이 갈수록 data가 계속 쌓임 → 브라우저 메모리 폭발

✅ 해결: 전역 대신 함수 스코프 또는 클로저 내부에서 관리


(2) 클로저(Closure) 오용

클로저는 내부 함수가 외부 변수에 접근할 수 있도록 하지만,
필요 없는 참조까지 유지하면 메모리 누수가 발생한다.

function outer() {
  const largeArray = new Array(100000).fill('*');
  return function inner() {
    console.log(largeArray[0]); // 참조 유지됨
  };
}
const leak = outer(); // largeArray는 해제되지 않음

✅ 해결: 필요 없는 클로저는 null로 끊기

leak = null;

(3) 이벤트 리스너 해제 누락

DOM 요소를 제거했는데 이벤트 리스너가 남아 있으면,
해당 함수 참조가 유지되어 GC가 회수하지 못한다.

const button = document.getElementById("btn");
button.addEventListener("click", () => console.log("clicked"));
document.body.removeChild(button); // ❌ 리스너는 여전히 참조됨

✅ 해결: 이벤트 제거 필수

button.removeEventListener("click", handleClick);

(4) setInterval, setTimeout 미정리

setInterval(() => {
  console.log("running...");
}, 1000);

이 타이머가 중단되지 않으면,
콜백이 계속 메모리에 남고 클로저를 통해 객체를 계속 참조한다.

✅ 해결: 페이지 이동 전 반드시 clear

clearInterval(timer);

(5) DOM 참조를 변수로 계속 유지

DOM을 삭제해도 JS 변수에 참조가 남아 있으면
GC는 해당 노드를 지우지 않는다.

let el = document.getElementById("app");
document.body.removeChild(el); // ❌ 여전히 el 참조 존재

✅ 해결

el = null;

5. 실무에서 자주 터지는 메모리 누수 패턴

상황 원인 해결 방법

SPA 페이지 전환 후 느려짐 이전 라우트 컴포넌트가 해제되지 않음 useEffect cleanup, beforeUnmount 사용
무한 Scroll 구현 이벤트 핸들러 누적 removeEventListener 또는 AbortController
setTimeout 반복 호출 클로저 참조 유지 clearTimeout() 호출
React 상태관리 불필요한 state 유지 unmount 시 state reset

6. React 기반 메모리 누수 예시

useEffect(() => {
  const interval = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);

  // ✅ cleanup 함수 필수
  return () => clearInterval(interval);
}, []);

✅ useEffect의 cleanup 함수는 GC보다 먼저 실행되어
참조를 끊고 메모리를 확보하게 돕는다.


7. 메모리 누수 탐지 방법

브라우저 개발자 도구(DevTools)에서 쉽게 추적 가능하다.

(1) Performance 탭

  • Memory Profile을 측정
  • 점점 증가하는 메모리 그래프 확인

(2) Memory 탭

  • “Heap Snapshot” 촬영
  • 특정 객체가 GC 후에도 남아 있는지 추적

✅ 오래된 리스너, 클로저, 캐시 객체를 쉽게 식별 가능


8. 실무 방어 전략

전략 설명

🔹 이벤트 해제 습관화 addEventListener 후 반드시 removeEventListener
🔹 setInterval/Timeout 정리 컴포넌트 unmount 시 반드시 clear
🔹 클로저 최소화 불필요한 외부 변수 참조 지양
🔹 WeakMap / WeakSet 사용 참조가 사라지면 자동으로 GC 가능
🔹 React cleanup 활용 useEffect 리턴 함수 적극 활용

예시 👇

const cache = new WeakMap();
function remember(el, data) {
  cache.set(el, data);
}

✅ WeakMap은 key가 GC될 때 자동으로 해제된다.


9. 메모리 누수 방지 코드 패턴

class Resource {
  constructor() {
    this.timer = setInterval(() => console.log('running'), 1000);
    window.addEventListener('resize', this.handleResize);
  }

  destroy() {
    clearInterval(this.timer);
    window.removeEventListener('resize', this.handleResize);
  }

  handleResize() {
    console.log('resized');
  }
}

const r = new Resource();
// ✅ 종료 시 반드시 정리
r.destroy();

10. 마무리

메모리 누수는 단순한 버그가 아니라,
시간이 지날수록 시스템을 죽이는 병이다.
GC가 있다고 해서 모든 걸 자동으로 해결해주지 않는다.

“가비지 컬렉션은 청소부일 뿐, 청소 시점을 아는 건 개발자 자신이다.”

클로저, 이벤트, 타이머, DOM 참조 —
이 네 가지를 관리하는 습관만 들여도,
SPA나 대형 프론트엔드 앱의 성능 저하는 대부분 사라진다.