본문으로 건너뛰기
기술스택 · stack/javascript/메모리와 가비지 컬렉션: 누수는 어디서 생길까

JavaScript · 4장 객체

메모리와 가비지 컬렉션: 누수는 어디서 생길까

자바스크립트는 다 쓴 값을 알아서 치워 줘서, 메모리를 직접 비울 일이 없어요. 그런데도 오래 켜 둔 페이지가 점점 느려지는 일은 흔해요. 엔진이 무엇을 치우고 무엇을 남기는지, 실무에서 메모리가 새는 곳은 어디인지 정리했어요.

#4.3

01. 닿을 수 있는 값만 남아요

엔진은 주기적으로 "지금 코드에서 닿을 수 있는 값"을 찾아요. 출발점(루트)은 전역 변수와 지금 실행 중인 함수의 변수들이에요. 거기서 참조를 따라 갈 수 있는 값은 남기고, 어떤 길로도 닿을 수 없는 값은 치워요. 이 일을 가비지 컬렉션(GC)이라고 해요.

let user = { name: '하늘', profile: { photo: 'sky.png' } };
const cart = { items: ['연필'] };

user = null;  // 이제 { name: '하늘' } 객체로 가는 길이 없어요
// → 그 객체와, 그 객체만 가리키던 profile 객체도 다음 GC 때 치워져요
// → cart는 아직 닿을 수 있으니 남아요
①루트에서 user와 cart를 따라가면 모든 객체에 닿아요. user = null로 길을 끊으면 user 객체와 그 안의 profile은 닿을 수 없게 되어 치워지고, cart 쪽은 남아요.

GC가 언제 돌지는 엔진이 정하고, 일반적인 웹 페이지 코드에서는 직접 부를 수 없어요. 우리가 할 수 있는 건 더 이상 쓰지 않는 값으로 가는 길을 남겨 두지 않는 것뿐이에요. 길이 남아 있으면 다시 쓰지 않을 값도 영원히 남아요. 이게 메모리 누수예요.

02. 실무에서 누수가 생기는 곳

누수는 대부분 "어딘가에 등록해 두고 해제를 잊은 것"이에요. 자주 만나는 네 가지예요.

원인무엇이 남나요
지우지 않은 이벤트 리스너리스너 함수와, 그 함수가 쓰는 모든 값. 같은 리스너가 계속 더 붙기도 해요
멈추지 않은 타이머 (setInterval)타이머 함수와 그 함수가 쓰는 값. 화면이 사라져도 계속 돌아요
끝없이 커지는 캐시 · 목록넣기만 하고 빼지 않은 모든 항목
오래 사는 곳에 넣어 둔 참조전역 변수 · 모듈 변수에 넣어 둔, 이미 사라진 화면 요소나 큰 데이터

캐시를 예로 들어 볼게요. 한 번 불러온 사용자를 다시 부르지 않으려고 저장해 두는 건 좋은 생각이지만, 지우는 규칙이 없으면 앱이 켜져 있는 동안 계속 커져요.

const cache = new Map();
function getUser(id) {
  if (!cache.has(id)) cache.set(id, { id, name: '사용자' + id });
  return cache.get(id);
}
for (let id = 1; id <= 10000; id++) getUser(id);
console.log(cache.size);   // 10000 — 넣기만 하고 지우지 않아요

// 크기 제한을 둔 캐시: 넘치면 가장 먼저 넣은 것부터 지워요
const MAX = 100;
const limited = new Map();
function getUserLimited(id) {
  if (limited.has(id)) return limited.get(id);
  const user = { id, name: '사용자' + id };
  limited.set(id, user);
  if (limited.size > MAX) limited.delete(limited.keys().next().value);
  return user;
}
for (let id = 1; id <= 10000; id++) getUserLimited(id);
console.log(limited.size); // 100

Map은 5장에서 자세히 다뤄요. 객체가 살아 있는 동안만 값을 붙여 두고 싶을 때 쓰는 WeakMap도 그때 함께 봐요.

03. 쓰고 나면 정리해요

등록하는 코드를 쓸 때 해제하는 코드를 바로 함께 쓰는 게 가장 좋은 습관이에요. 타이머는 setInterval이 돌려주는 번호로 clearInterval을 불러 멈춰요.

let ticks = 0;
const timerId = setInterval(() => { ticks++; }, 20);

setTimeout(() => {
  clearInterval(timerId);              // 멈춰요
  const stoppedAt = ticks;
  setTimeout(() => {
    console.log('멈춘 뒤에도 늘었나요?', ticks !== stoppedAt); // false
  }, 100);
}, 100);

이벤트 리스너는 removeEventListener로 떼어 낼 수 있지만, 붙인 함수를 똑같이 다시 넘겨야 해서 번거로워요. 요즘은 AbortController를 많이 써요. 리스너를 붙일 때 signal을 함께 넘겨 두면, abort() 한 번으로 그 신호로 붙인 리스너를 한꺼번에 떼어 내요.

function openModal() {
  const controller = new AbortController();
  const { signal } = controller;

  saveButton.addEventListener('click', save, { signal });
  window.addEventListener('keydown', onEscape, { signal });

  return function closeModal() {
    controller.abort();   // 위에서 붙인 리스너 두 개가 한꺼번에 떨어져요
  };
}

아래에서 직접 확인해 보세요. "열기"를 할 때마다 저장 버튼에 리스너를 붙이는 화면이에요. 정리를 끈 채로 열기 · 닫기를 몇 번 한 뒤 저장을 눌러 보세요.

정리하지 않으면 화면을 닫아도 리스너가 남아서, 다시 열 때마다 하나씩 더 붙어요. 그러면 저장 버튼을 한 번 눌러도 저장이 여러 번 실행돼요. 아래 숫자는 실제로 함수가 불린 횟수예요.

React의 useEffect가 정리 함수를 돌려받는 것, Vue의 onUnmounted도 같은 이유로 있어요. 화면 조각이 사라질 때 그 조각이 등록한 것을 해제하라는 자리예요.

04. 누수가 의심될 때

오래 켜 둘수록 느려지거나, 같은 동작을 반복할수록 메모리 사용량이 계속 오른다면 누수를 의심해요. Chrome 개발자 도구의 Memory 패널에서 힙 스냅샷(그 순간 메모리에 남아 있는 객체 목록)을 찍을 수 있어요. 의심 가는 동작을 하기 전과 여러 번 반복한 뒤에 스냅샷을 찍어 비교하면, 늘어나기만 하는 객체가 무엇인지 보여요.

찾고 나면 대부분 원인은 위의 표에 있어요. "이걸 등록한 코드는 어디고, 해제하는 코드는 어디지?"를 따라가 보면 돼요.

05. 실전 포인트

  • 언제 신경 쓰나요? 페이지를 오래 켜 두는 앱(관리 화면, 메신저, 대시보드), 화면 조각을 자주 열고 닫는 앱(모달 · 탭 · 페이지 이동이 많은 SPA)에서 특히 중요해요.
  • 대신 쓰는 것: 리스너는 AbortController의 signal로 붙여 한 번에 떼고, 타이머 번호는 꼭 보관해 두고 멈춰요. 캐시에는 크기나 시간 제한을 둬요. 프레임워크를 쓴다면 정리 함수(useEffect의 반환값 등)에 해제를 넣어요.
  • 흔한 실수: 화면을 다시 그릴 때마다 리스너를 또 붙이는 것, setInterval을 시작만 하고 멈추는 곳이 없는 것, 디버깅용으로 전역 변수에 큰 객체를 넣어 둔 채 잊는 것이에요.

정리

  • 엔진은 루트에서 참조를 따라 닿을 수 있는 값만 남기고, 나머지는 알아서 치워요.
  • 누수는 쓰지 않는 값으로 가는 길이 남아 있을 때 생겨요. 리스너 · 타이머 · 캐시 · 오래 사는 변수가 단골이에요.
  • 등록하는 코드와 해제하는 코드를 함께 써요. 리스너는 AbortController, 타이머는 clearInterval이에요.
  • 의심되면 Memory 패널의 힙 스냅샷을 동작 전후로 비교해요.
let data = { … }를 다 쓴 뒤 data = null을 하면 그 객체는 바로 사라질까요?

바로는 아니에요. 다른 곳에서 그 객체를 가리키지 않는다면, 엔진이 정한 다음 GC 때 치워져요. 그리고 다른 변수 · 배열 · 리스너가 여전히 가리키고 있다면 계속 남아요.

모달을 열 때마다 저장 버튼에 리스너를 붙이고 해제하지 않으면 무슨 일이 생길까요?

열 때마다 리스너가 하나씩 더 붙어서, 저장을 한 번 눌러도 저장이 여러 번 실행돼요. 리스너가 붙잡고 있는 값도 메모리에 계속 남아요.

리스너 여러 개를 한 번에 떼어 내는 가장 간단한 방법은?

붙일 때 같은 AbortController의 signal을 넘겨 두고, 정리할 때 controller.abort()를 한 번 불러요.

SIMPLE · 기술스택 / JavaScript — 4장 객체