노트

브라우저 JS 메모리 누수 원인

Memory Leaks in JavaScript

프론트엔드#js#performance · 연결된 개념 5개

쉽게 말하면

자바스크립트는 방에서 아무도 붙잡고 있지 않은 물건만 알아서 치워요. 메모리 누수는 다 쓴 물건을 전역 변수나 타이머 같은 누군가가 계속 붙잡고 있어서 방이 점점 차는 거예요.

비유의 한계 청소기는 '다시 쓸 물건인지'가 아니라 '전역에서 참조를 따라가 닿는지'만 봐요. 그래서 다시 안 쓸 객체도 닿으면 남고, 서로만 붙잡은 순환 참조는 닿지 않으면 치워져요.

메모리 누수(Memory Leak)는 더 이상 쓰지 않는 메모리가 해제되지 않고 계속 남는 현상이다. 자바스크립트는 가비지 컬렉션(Garbage Collection, GC)으로 메모리를 자동으로 회수하므로, 누수는 대개 필요 없어진 객체를 가리키는 참조가 어딘가에 남아 있을 때 생긴다.

GC는 도달 가능성으로 판단한다

현대 엔진의 GC는 마크 앤 스윕(mark-and-sweep) 방식이다. 루트(자바스크립트에서는 전역 객체)에서 출발해 참조를 따라 닿는 객체를 모두 찾고, 닿지 않는 객체를 회수한다. 즉 "필요 없다"를 "도달할 수 없다(unreachable)"로 바꿔 판단한다.

  • GC는 앞으로 그 객체를 다시 쓸지 알 수 없다. 다시 쓰지 않을 객체도 루트에서 닿으면 남는다
  • 서로만 참조하는 순환 참조는 루트에서 닿지 않으면 회수된다. 순환 참조가 누수 원인이던 것은 옛 참조 카운팅(reference counting) 방식이다
  • 자바스크립트에는 GC를 직접 실행하는 표준 API가 없다. 메모리를 놓아 주려면 참조를 끊어 닿지 않게 만들어야 한다

흔한 원인

  1. 의도하지 않은 전역 변수: 엄격 모드가 아닌 코드에서 선언하지 않은 변수에 대입하면 전역 객체에 속성이 생긴다. 전역 객체는 루트이므로 거기 매달린 값은 페이지가 살아 있는 동안 회수되지 않는다. 엄격 모드('use strict')에서는 이런 대입이 ReferenceError가 되고, ES 모듈은 처음부터 엄격 모드다
  2. 분리된 DOM 노드(detached DOM node): DOM 트리에서 떼어 냈지만 JS가 아직 참조하는 노드다. DOM 노드는 DOM 트리와 JS 코드 어느 쪽에서도 참조하지 않아야 회수된다. 떼어 낸 ul을 변수에 담아 두면 그 아래 li들까지 함께 남는다
  3. 클로저가 붙잡은 큰 참조: 클로저(Closure)는 바깥 스코프의 변수를 참조한다. 이벤트 핸들러나 캐시에 등록된 클로저가 살아 있으면, 그 클로저가 참조하는 큰 배열이나 DOM 노드도 루트에서 닿으므로 남는다
  4. 해제하지 않은 타이머와 이벤트 리스너: setInterval 콜백은 clearInterval로 취소할 때까지 계속 실행된다. 리스너는 removeEventListener로 떼거나 signal로 취소하기 전까지 대상에 등록된 채 남는다. window처럼 페이지 내내 살아 있는 대상이면 리스너도 계속 남는다. 콜백이 참조하는 값도 같이 남으므로, 쓰임이 끝나면 clearInterval이나 removeEventListener로 해제한다
function openPanel(panel) {
  const rows = loadBigData()                       // 큰 배열
  const id = setInterval(() => render(panel, rows), 1000)
  const controller = new AbortController()
  window.addEventListener('resize', () => render(panel, rows), { signal: controller.signal })
 
  return function close() {                        // 패널을 닫을 때 부른다
    clearInterval(id)                              // 타이머 해제
    controller.abort()                             // signal로 등록한 리스너 일괄 해제
  }
}
  • removeEventListener는 등록할 때와 같은 함수 참조가 있어야 뗄 수 있다. 익명 함수로 등록했다면 위처럼 signal 옵션을 주고 abort()로 떼는 편이 간단하다
  • React의 Effect 정리 함수나 옵저버의 구독 해제도 같은 문제를 다룬다(Effect 정리 함수, 옵저버 패턴)
  • 객체에 데이터를 덧붙이되 그 객체가 사라질 때 같이 사라져야 한다면 WeakMap을 쓴다. WeakMap의 키는 약하게 참조되어, 다른 곳에서 키를 참조하지 않으면 키와 값 모두 회수될 수 있다

찾는 법

크롬 개발자 도구 Memory 패널에서 힙 스냅샷을 찍고 클래스 필터에 Detached를 입력하면 분리된 DOM 트리를 찾을 수 있다(크롬 개발자 도구 팁). 노드를 고르면 어떤 변수가 그 노드를 참조하는지 보인다. 스냅샷 두 장을 비교해 쌓이는 객체와 붙잡는 참조(retainer)를 찾는 절차는 운영 Node 프로세스의 메모리 누수 찾기에 정리했다.

출처: MDN - Memory management: Mark-and-sweep algorithm · WeakMaps and WeakSets · MDN - Strict mode: Assigning to undeclared variables · MDN - Closures · MDN - addEventListener: Memory issues · signal · MDN - setInterval · Chrome DevTools - Discover detached DOM tree memory leaks

연결된 개념

이 노트를 가리키는 문서

뜻이 가까운 노트

  • localStorage와 sessionStorage

    브라우저에 문자열 키-값을 저장하는 Web Storage API의 두 가지. 쿠키와 달리 요청에 자동으로 실리지 않고, 출처 단위로 나뉘며(same-origin-policy), 보통 출처당 5MB 안팎을 쓸 수 있다. 동기 API라 큰 데이터를 자주 읽고 쓰면 메인 스레드를 막는다.

  • 이벤트 루프

    자바스크립트를 실행하는 하나의 스레드가 할 일을 큐에서 하나씩 꺼내 처리하는 반복 구조. JS 엔진 자체에는 타이머도 네트워크도 없고, 브라우저(또는 Node.js)가 이 루프를 돌리면서 콜백을 엔진에 넘겨준다.

  • bfcache(뒤로·앞으로 가기 캐시)

    페이지를 떠날 때 DOM과 JS 힙까지 통째로 메모리에 얼려 두었다가, 뒤로·앞으로 가기에서 그대로 되살리는 브라우저 캐시.

  • Activity와 숨겨진 라우트 보존

    Activity는 React 19.2의 컴포넌트로, UI를 언마운트하지 않고 display: none으로 숨기면서 state와 DOM(Document Object Model)을 보존한다. 숨기는 동안 Effect는 정리(cleanup)된다. 예전 이름은 Offscreen이다.

  • 브라우저 캐시

    한 번 받은 리소스(HTML·CSS·JS·이미지)를 브라우저가 저장해 두었다가 다시 쓰는 기능. 서버가 응답 헤더로 얼마나, 어떻게 캐시할지 알려 주고 브라우저가 그대로 따른다.

보기 옵션