본문 바로가기

메모리 관리와 가비지 컬렉션

값의 할당부터 도달 가능성 기반 마크-스윕 회수까지 추적하고 이벤트·타이머·참조가 만드는 누수를 예방합니다.

지금까지 배운 문법, 비동기 처리, 모듈 시스템은 모두 코드를 어떻게 작성할지에 관한 것이었습니다. 하지만 코드는 실행되는 순간 컴퓨터의 자원, 특히 메모리(memory)를 씁니다. 코드가 메모리를 어떻게 쓰고, 더 필요 없어진 메모리를 어떻게 돌려주는지 알아야 느려지거나 멈추지 않는 애플리케이션을 만들 수 있습니다.

C나 C++과 달리 자바스크립트에서는 메모리를 직접 할당하거나 해제하지 않습니다. 자바스크립트 엔진이 가비지 컬렉션(garbage collection, GC)으로 알아서 관리하기 때문입니다. 그렇다고 메모리를 전혀 신경 쓰지 않아도 되는 것은 아닙니다. 가비지 컬렉션이 무엇을 기준으로 메모리를 회수하는지 알아야, 의도하지 않게 메모리가 계속 늘어나는 메모리 누수(memory leak)를 막을 수 있습니다. 이 글에서는 메모리가 할당되고 회수되는 과정, 누수가 생기는 경우와 막는 방법을 차례로 살펴봅니다.


메모리의 생명주기

어떤 언어든 메모리는 비슷한 생명주기를 거칩니다. 먼저 프로그램이 변수나 객체를 만들 때 메모리를 할당(allocation)하고, 할당한 메모리에서 값을 읽고 쓰며 사용(usage)합니다. 더 필요 없어지면 메모리를 해제(release)해 다른 용도로 쓸 수 있게 합니다. 자바스크립트에서는 할당과 해제를 엔진이 자동으로 처리합니다.

원시값과 객체

값이 메모리에 어떻게 놓이는지 설명할 때 흔히 스택(stack)과 힙(heap)이라는 모델을 씁니다. 다만 ECMAScript 명세가 값의 실제 저장 위치나 크기를 정하지는 않으며, 실제 배치는 엔진의 구현과 최적화에 따라 다릅니다. 이 모델은 값과 참조가 어떻게 다르게 동작하는지 이해하기 위한 도구입니다.

숫자, 문자열, 불리언, null, undefined, 심벌(Symbol), BigInt는 원시값(primitive value)입니다. 원시값을 다른 변수에 대입하면 값 자체가 전달됩니다. 스택은 마지막에 쌓은 데이터를 먼저 꺼내는 LIFO(last-in, first-out) 구조라 관리가 빠르고 간단하지만, 길이가 정해지지 않은 문자열이나 BigInt까지 모두 크기가 정해진 스택 칸에 들어간다고 생각할 필요는 없습니다.

let a = 10;    // 숫자 원시 값
let b = "hello"; // 문자열 원시 값; 실제 저장 방식은 엔진에 따름
let c = true;  // 불리언 원시 값

객체와 함수는 크기가 미리 정해져 있지 않고 생성과 소멸 시점도 제각각이라, 할당 순서와 관계없이 자유롭게 할당하고 해제하는 힙에 놓인다고 설명합니다. 변수는 객체 자체를 담는 것이 아니라 그 객체를 가리키는 참조를 담습니다.

let obj = { name: "Alice", age: 30 }; // obj는 객체를 참조
let arr = [1, 2, 3];               // arr는 배열 객체를 참조
let func = function() { console.log("hi"); }; // func는 함수 객체를 참조

let obj2 = obj; // obj2는 obj와 같은 객체를 참조
obj2.age = 31;
console.log(obj.age); // 31 (obj와 obj2는 같은 객체를 참조하므로)
console.log(obj === obj2); // true

객체를 다른 변수에 대입하면 객체가 복사되지 않고, 두 변수가 같은 객체를 가리키게 됩니다. 그래서 obj2로 바꾼 age가 obj에서도 보입니다. 자바스크립트에서는 객체의 실제 메모리 주소를 읽을 수 없고 엔진이 객체를 옮기기도 하므로, 주소를 따지는 대신 obj === obj2로 같은 객체인지, 어느 변경이 함께 보이는지를 확인하면 됩니다.


가비지 컬렉션

가비지 컬렉션은 더 이상 쓸 수 없는 객체를 자동으로 찾아 메모리를 회수하는 기능입니다. 개발자가 메모리를 직접 해제하지 않아도 쓰지 않는 메모리가 쌓이지 않도록 하는 것이 목표입니다.

도달 가능성

가비지 컬렉터는 도달 가능성(reachability)을 기준으로 회수할 객체를 정합니다. 먼저 루트(root)는 실행 환경이 지금 쓰고 있는 접근 경로의 출발점입니다. 실행 중인 함수의 변수, 전역 환경과 모듈 환경, 현재 문서, 등록된 타이머와 이벤트 리스너 같은 것을 루트로 생각하면 되며, 실제로 무엇을 루트로 다루는지는 엔진마다 다릅니다.

루트에서 참조를 따라가 닿을 수 있는 객체는 도달 가능한 객체이고, 어떤 경로로도 닿을 수 없는 객체는 가비지(garbage)가 되어 회수 대상이 됩니다. 전역의 let과 const나 모듈의 변수는 window의 속성이 아니어도 루트에서 닿을 수 있습니다. 반대로 문서에서 떼어 낸 DOM 노드가 저절로 루트로 남는 것은 아니며, 다른 루트에서 그 노드로 이어지는 경로가 있는지를 봐야 합니다.

let user = {
    name: "John"
};
// user 객체는 'user' 변수에 의해 참조되므로 도달 가능합니다.

user = null; // 이제 user 변수는 null을 가리킵니다.
             // { name: "John" } 객체는 더 이상 어떤 변수도 참조하지 않으므로 가비지가 되어 GC 대상이 됩니다.

{ name: "John" } 객체는 처음에 user 변수에서 닿을 수 있습니다. user에 null을 넣으면 이 객체로 가는 경로가 없어지므로 회수 대상이 됩니다.

let user1 = { name: "Alice" };
let user2 = user1; // user2도 { name: "Alice" }를 참조합니다.

user1 = null; // { name: "Alice" }는 여전히 user2에 의해 참조되므로 가비지가 아닙니다.

user2 = null; // 이제 { name: "Alice" }는 더 이상 참조되지 않으므로 가비지가 됩니다.

user1에 null을 넣어도 user2가 같은 객체를 가리키므로 아직 회수 대상이 아닙니다. user2까지 null이 되어 모든 경로가 사라져야 회수할 수 있습니다. 이처럼 null을 한 번 대입했다고 객체가 회수된다고 단정할 수는 없고, 루트에서 그 객체로 가는 경로가 하나라도 남아 있는지를 따져야 합니다.

root에서 객체까지의 도달 가능성 — 상태 추적

closure/timer/A→B 참조를 제거하면 B·C 순환은 남아도 root 경로가 끊긴다. 고정 객체에서 링크 해제와 도달 여부 변화를 보여 주되 실제 즉시 GC 완료는 주장하지 않는다.

root에서 객체까지의 도달 가능성

DEPENDENCY · WEB PLATFORM CONTRACT

root에서 객체까지의 도달 가능성

루트에서 A·B·C에 닿는 경로가 있으면 객체가 유지된다. 이 예제는 A를 남긴 채 B·C로 가는 모든 경로를 제거하여 순환 객체를 회수 가능하게 만든다.

루트 경로를 제거한 전후의 객체 관계정리 전에는 전역에서 A를 거쳐 B, 유지된 클로저에서 B, 등록된 타이머에서 C로 향하며 B와 C는 순환 참조한다. 정리 후에는 전역에서 A로 가는 경로만 남고 클로저, 타이머, A에서 B로 가는 경로를 제거해 B와 C의 순환만 고립된다. A가 유지되는 것과 B, C가 회수 대상이 될 수 있는 것을 구분한다. 모바일도 같은 정리 전 여섯 연결과 정리 후 세 연결을 보여 준다. 즉시 회수가 끝났다는 증명은 아니다.정리 전: 루트 경로 있음정리 후: B·C 경로 제거전역 환경객체 A유지된 클로저객체 B등록된 타이머객체 C전역 환경객체 A유지됨객체 B객체 C클로저·타이머 경로 제거A → B도 제거B·C 순환: 회수 대상 가능실제 회수 시점은 보장하지 않음
루트 경로를 제거한 전후의 객체 관계정리 전에는 전역에서 A를 거쳐 B, 유지된 클로저에서 B, 등록된 타이머에서 C로 향하며 B와 C는 순환 참조한다. 정리 후에는 전역에서 A로 가는 경로만 남고 클로저, 타이머, A에서 B로 가는 경로를 제거해 B와 C의 순환만 고립된다. A가 유지되는 것과 B, C가 회수 대상이 될 수 있는 것을 구분한다. 모바일도 같은 정리 전 여섯 연결과 정리 후 세 연결을 보여 준다. 즉시 회수가 끝났다는 증명은 아니다.정리 전: 루트 경로 있음전역환경객체A클로저유지됨객체B타이머등록됨객체C클로저·타이머 해제A→B 참조도 제거정리 후전역환경객체AB·C로 가는 루트 없음객체B객체C순환만: 회수 대상 가능회수 시점은 보장 아님

Before — reachable

global에서 A→B, 유지된 closure에서 B, timer registry에서 C로 이어진다. B↔C 순환까지 모두 도달 가능하다.

B·C로 가는 경로 제거

closure 참조·timer·listener를 정리하고 A→B 참조도 제거한다. global→A는 그대로 유지한다.

After — unreachable

B와 C가 서로 참조해도 root path가 없으므로 GC가 수집할 수 있다. cycle 자체는 root가 아니다.

순환 참조만 남아도 회수 가능

B↔C가 서로 참조해도 루트에서 닿는 경로가 없으면 GC 대상이다. A는 global에서 도달 가능하여 살아 있다. 참조 제거가 즉시 수거 완료를 보장하지는 않는다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
도달 가능한 객체roots: A·closure·timer / B/C: 도달 가능global A와 closure B, timer C가 참조되고 B↔C도 연결된다순환 자체가 생존 이유가 아니라 root 도달성이 기준이다
외부 참조 해제A: root에서 유지 / B/C: 외부 경로 없음closure/timer와 A→B를 제거한다timer 해제와 참조 제거는 별도 동작이다
고립된 순환B/C: 수거 가능 / 수거 시점: 즉시 보장 아님B↔C는 서로를 가리켜도 root에서 도달하지 않는다수거 가능성과 실제 GC 실행 시점은 다르다

root에서 객체까지의 도달 가능성 — 상태 추적

closure/timer/A→B 참조를 제거하면 B·C 순환은 남아도 root 경로가 끊긴다. 고정 객체에서 링크 해제와 도달 여부 변화를 보여 주되 실제 즉시 GC 완료는 주장하지 않는다.

root에서 객체까지의 도달 가능성

DEPENDENCY · WEB PLATFORM CONTRACT

root에서 객체까지의 도달 가능성

루트에서 A·B·C에 닿는 경로가 있으면 객체가 유지된다. 이 예제는 A를 남긴 채 B·C로 가는 모든 경로를 제거하여 순환 객체를 회수 가능하게 만든다.

루트 경로를 제거한 전후의 객체 관계정리 전에는 전역에서 A를 거쳐 B, 유지된 클로저에서 B, 등록된 타이머에서 C로 향하며 B와 C는 순환 참조한다. 정리 후에는 전역에서 A로 가는 경로만 남고 클로저, 타이머, A에서 B로 가는 경로를 제거해 B와 C의 순환만 고립된다. A가 유지되는 것과 B, C가 회수 대상이 될 수 있는 것을 구분한다. 모바일도 같은 정리 전 여섯 연결과 정리 후 세 연결을 보여 준다. 즉시 회수가 끝났다는 증명은 아니다.정리 전: 루트 경로 있음정리 후: B·C 경로 제거전역 환경객체 A유지된 클로저객체 B등록된 타이머객체 C전역 환경객체 A유지됨객체 B객체 C클로저·타이머 경로 제거A → B도 제거B·C 순환: 회수 대상 가능실제 회수 시점은 보장하지 않음
루트 경로를 제거한 전후의 객체 관계정리 전에는 전역에서 A를 거쳐 B, 유지된 클로저에서 B, 등록된 타이머에서 C로 향하며 B와 C는 순환 참조한다. 정리 후에는 전역에서 A로 가는 경로만 남고 클로저, 타이머, A에서 B로 가는 경로를 제거해 B와 C의 순환만 고립된다. A가 유지되는 것과 B, C가 회수 대상이 될 수 있는 것을 구분한다. 모바일도 같은 정리 전 여섯 연결과 정리 후 세 연결을 보여 준다. 즉시 회수가 끝났다는 증명은 아니다.정리 전: 루트 경로 있음전역환경객체A클로저유지됨객체B타이머등록됨객체C클로저·타이머 해제A→B 참조도 제거정리 후전역환경객체AB·C로 가는 루트 없음객체B객체C순환만: 회수 대상 가능회수 시점은 보장 아님

Before — reachable

global에서 A→B, 유지된 closure에서 B, timer registry에서 C로 이어진다. B↔C 순환까지 모두 도달 가능하다.

B·C로 가는 경로 제거

closure 참조·timer·listener를 정리하고 A→B 참조도 제거한다. global→A는 그대로 유지한다.

After — unreachable

B와 C가 서로 참조해도 root path가 없으므로 GC가 수집할 수 있다. cycle 자체는 root가 아니다.

순환 참조만 남아도 회수 가능

B↔C가 서로 참조해도 루트에서 닿는 경로가 없으면 GC 대상이다. A는 global에서 도달 가능하여 살아 있다. 참조 제거가 즉시 수거 완료를 보장하지는 않는다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
도달 가능한 객체roots: A·closure·timer / B/C: 도달 가능global A와 closure B, timer C가 참조되고 B↔C도 연결된다순환 자체가 생존 이유가 아니라 root 도달성이 기준이다
외부 참조 해제A: root에서 유지 / B/C: 외부 경로 없음closure/timer와 A→B를 제거한다timer 해제와 참조 제거는 별도 동작이다
고립된 순환B/C: 수거 가능 / 수거 시점: 즉시 보장 아님B↔C는 서로를 가리켜도 root에서 도달하지 않는다수거 가능성과 실제 GC 실행 시점은 다르다

위 그림은 경로를 정리하기 전과 후의 객체 관계를 보여 줍니다. 정리 전에는 전역 환경에서 객체 A를 거쳐 B로, 유지된 클로저에서 B로, 등록된 타이머에서 C로 가는 경로가 있고, B와 C는 서로를 참조합니다. 이 상태에서는 A, B, C 모두 루트에서 닿을 수 있어 유지됩니다.

정리 후에는 클로저와 타이머를 해제하고 A에서 B로 가는 참조도 지웁니다. 전역에서 A로 가는 경로는 그대로라 A는 계속 유지되지만, B와 C는 서로를 가리킬 뿐 루트에서 닿는 경로가 없습니다. 순환 참조는 루트가 아니므로, 서로 참조하는 B와 C도 회수 대상이 될 수 있습니다. 다만 회수 대상이 되었다는 것과 그 즉시 회수가 끝났다는 것은 다르며, 실제로 언제 회수할지는 엔진이 정합니다.

마크 앤 스윕

마크 앤 스윕(mark-and-sweep)은 가장 널리 쓰이는 가비지 컬렉션 알고리즘입니다. 마크 단계에서는 루트부터 시작해 참조를 모두 따라가며 닿는 객체마다 표시합니다. 스윕 단계에서는 표시되지 않은 객체, 즉 도달할 수 없는 객체의 메모리를 회수합니다. 이 방식은 루트에서 닿는지만 보기 때문에, 그림의 B와 C처럼 서로 참조하는 객체도 루트에서 떨어져 있으면 회수할 수 있습니다.

엔진은 회수 비용을 줄이기 위한 기법도 함께 씁니다. 세대별 수집(generational collection)은 새로 만든 객체는 금방 가비지가 되고 오래 살아남은 객체는 계속 살아남는 경향이 있다는 가정에 따라, 새 객체가 모인 영역을 더 자주 빠르게 수집합니다. 점진적 수집(incremental GC)은 수집 작업을 작은 조각으로 나눠 조금씩 진행해, 가비지 컬렉션 때문에 애플리케이션이 잠시 멈추는 현상을 줄입니다. 이런 설명만으로 특정 객체가 언제 회수될지 예측할 수는 없습니다.

더 깊이: V8은 실제로 어떻게 수집할까

Chrome과 Node.js의 V8 엔진이 쓰는 가비지 컬렉터는 Orinoco라는 이름으로 소개되었습니다. V8은 힙을 젊은 세대(young generation)와 늙은 세대(old generation)로 나누고, 새 객체는 젊은 세대에 할당합니다. 젊은 세대는 다시 갓 만든 객체가 모이는 영역과 한 번 살아남은 객체가 옮겨 가는 중간 영역으로 나뉘며, 거기서도 살아남은 객체가 늙은 세대로 올라갑니다. 대부분의 객체가 일찍 죽는다는 세대 가설(generational hypothesis)을 그대로 구조로 옮긴 것입니다.

두 세대는 방법이 다릅니다. 젊은 세대는 공간을 둘로 나눈 세미스페이스(semi-space) 방식으로, 살아 있는 객체만 다른 쪽 공간으로 복사하고 원래 공간은 통째로 비웁니다. 복사 비용이 살아남은 객체의 양에만 비례하므로, 금방 버려지는 객체를 많이 만드는 코드도 젊은 세대의 수집은 빠르게 끝납니다. 늙은 세대는 이 글의 마크 앤 스윕으로 회수하고, 빈 곳이 흩어져 있으면 객체를 모아 붙이는 압축(compaction)까지 합니다.

V8은 이 작업이 메인 스레드를 오래 멈추지 않게 하는 데 집중합니다. 멈춘 동안 여러 스레드가 함께 일하는 병렬(parallel) 수집, 작업을 잘게 나눠 자바스크립트 실행 사이사이에 하는 점진적 수집, 자바스크립트가 실행되는 동안 다른 스레드에서 표시 작업을 하는 동시(concurrent) 수집을 함께 쓰고, Chrome은 화면을 그리고 남는 시간에 수집 작업을 하기도 합니다. 구조는 V8 블로그의 Orinoco 설명에 있습니다.


메모리 누수

가비지 컬렉션이 자동으로 메모리를 회수해도, 실수로 더 필요 없는 객체가 루트에서 닿는 상태로 남으면 회수되지 않습니다. 이런 객체가 쌓여 메모리 사용량이 계속 늘어나는 것이 메모리 누수입니다. 누수가 심해지면 애플리케이션이 점점 느려지고, 결국 탭이 멈추거나 강제로 종료될 수 있습니다. 흔한 누수 상황과 막는 방법을 살펴봅니다.

의도하지 않은 전역 변수

엄격 모드가 아닌 코드에서 선언하지 않은 이름에 값을 대입하면, 전역 객체의 속성이 새로 만들어집니다. let이나 const로 선언한 변수가 이런 전역을 만드는 것은 아닙니다.

function createLeakyGlobal() {
    // 'use strict'가 없으면 window.leakyVar = '...' 가 됨
    leakyVar = "나는 의도치 않은 전역 변수!";
}
createLeakyGlobal();
// leakyVar는 계속 메모리에 남아있습니다.

leakyVar는 함수가 끝나도 window의 속성으로 남아, 다른 값을 넣거나 속성을 지우기 전까지 계속 유지됩니다. 변수는 항상 const나 let으로 선언하고 엄격 모드를 쓰면 이 실수를 막을 수 있습니다. 엄격 모드에서는 선언하지 않은 이름에 대입할 때 ReferenceError가 납니다.

해제하지 않은 타이머

setInterval()에 등록한 콜백은 해제하기 전까지 계속 실행되며, 콜백이 참조하는 바깥 변수도 함께 유지됩니다. 브라우저의 타이머 목록이 콜백을 붙잡고, 콜백이 다시 data를 붙잡는 구조이므로, 코드의 다른 곳에서 data를 더 쓰지 않아도 회수되지 않습니다.

// 누수 예시: 타이머 ID를 저장하지 않아 멈출 방법이 없음
let data = { value: "important data" };
setInterval(() => {
    console.log(data.value); // 콜백이 data를 참조하므로 data는 계속 유지됨
}, 1000);

이 타이머는 페이지가 열려 있는 동안 1초마다 계속 실행되고, 돌려받은 ID를 저장하지 않아 clearInterval()로 멈출 방법도 없습니다. 실행해 봤다면 페이지를 새로고침해서 멈추세요. 타이머는 할 일이 끝나면 직접 해제해야 합니다.

// 방지법: 할 일이 끝나면 타이머를 해제합니다.
function startTimer() {
    let count = 0;
    const timerId = setInterval(() => {
        console.log(count++);
        if (count > 5) {
            clearInterval(timerId); // 타이머 명시적 해제
        }
    }, 500);
}
startTimer();

0부터 5까지 출력한 뒤 타이머가 해제됩니다. 타이머 ID를 함수 안의 const에 두었으므로, startTimer()를 여러 번 호출해도 각 호출이 자기 타이머를 해제합니다.

타이머를 언제 멈출지 바깥에서 정해야 한다면, 시작하는 함수가 정리하는 함수를 돌려주게 만들 수 있습니다. 다음 코드는 시작하자마자 정리하므로 반복 출력은 일어나지 않습니다.

function startTimerForPractice() {
    let payloadForTimer = { label: "학습 데이터" };
    const idForTimer = setInterval(() => {
        console.log(payloadForTimer.label);
    }, 1000);
    return function stopTimerForPractice() {
        clearInterval(idForTimer);
        payloadForTimer = null;
    };
}
const stopForTimer = startTimerForPractice();
stopForTimer();
console.log("타이머 정리 요청 완료");

stopTimerForPractice()는 타이머를 해제하고, 클로저가 붙잡고 있던 payloadForTimer도 null로 비웁니다. 다른 곳에서 그 객체를 참조하지 않는다면 회수 대상이 될 수 있습니다. 다만 “타이머 정리 요청 완료”가 출력되었다고 가비지 컬렉션이 실행되었거나 메모리가 바로 줄었다는 뜻은 아닙니다.

해제하지 않은 이벤트 리스너

이벤트 리스너도 등록된 동안에는 리스너 함수와 그 함수가 참조하는 값이 유지됩니다. 리스너를 제거하려면 등록할 때 쓴 것과 같은 함수 참조를 removeEventListener()에 넘겨야 하므로, 리스너 함수를 변수에 담아 둡니다. 다음 코드는 HTML에 <button id="myButton">버튼</button>이 있고, 그 요소보다 뒤에 있는 <script>에서 실행한다고 가정합니다.

const button = document.getElementById('myButton');
const largeData = new Array(100000).fill("data"); // 리스너가 참조하는 큰 데이터

function handleClick() {
    console.log("버튼 클릭!", largeData.length);
}

button.addEventListener('click', handleClick);

// 버튼이 더 필요 없어지면 같은 함수 참조로 리스너를 제거하고 노드를 문서에서 뗍니다.
button.removeEventListener('click', handleClick);
button.remove();

노드와 리스너가 서로를 참조하더라도, 그 묶음 전체가 루트에서 닿지 않으면 순환만으로 누수가 되지는 않습니다. 문제는 오래 유지되는 쪽에 등록한 리스너입니다. 예를 들어 window에 등록한 resize 리스너가 큰 데이터를 참조한다면, 화면의 작은 노드를 지워도 window에서 그 데이터로 가는 경로는 남습니다. React에서도 useEffect 안에서 직접 addEventListener()로 등록한 리스너는 그 Effect의 정리 함수에서 직접 제거해야 하며, React가 대신 정리해 주지는 않습니다. 정리 함수가 언제 실행되는지는 useEffect 심화에서 다룹니다.

오래 유지되는 클로저

클로저는 바깥 스코프의 변수를 기억하므로, 클로저가 오래 유지되면 그 변수도 회수되지 않습니다. 다음 코드의 createClosure()는 실행이 끝나면 지역 변수 largeData도 함께 사라질 것처럼 보이지만, 안에서 만든 함수를 전역 변수에 넣어 둔다는 점이 다릅니다.

let longLivedRef = null;

function createClosure() {
    let largeData = new Array(1000000).fill('some string'); // 큰 배열
    longLivedRef = function() { // 이 클로저가 largeData를 참조
        console.log(largeData.length);
    };
}

createClosure();
// longLivedRef 함수가 null로 설정되거나, longLivedRef를 참조하는 다른 변수가 없어지기 전까지 largeData는 해제되지 않습니다.
// longLivedRef = null; // 이렇게 해야 largeData가 GC 대상이 됩니다.

createClosure()가 끝나도 전역의 longLivedRef가 클로저를 가리키고, 클로저가 largeData를 참조하므로 백만 개짜리 배열이 계속 남습니다. 클로저가 참조하는 값이 언제까지 필요한지 따져 보고, 필요 없어지면 longLivedRef = null처럼 클로저로 가는 참조를 끊어야 배열도 회수 대상이 됩니다.

누수를 찾는 방법

크롬 개발자 도구의 Memory 탭에서 힙 스냅샷(heap snapshot)을 찍으면 그 순간 메모리에 남아 있는 객체를 볼 수 있고, Performance 탭으로는 시간에 따른 메모리 사용량을 볼 수 있습니다. 도구의 버전에 따라 화면의 이름은 조금 다를 수 있습니다.

메모리 누수: 참조 유지 경로 진단

DIAGNOSTIC FLOW

메모리 누수: 참조 유지 경로 진단

메모리 증가만으로 누수를 확정하지 않는다. 반복 동작 뒤 불필요한 객체가 어디에서 유지되는지 찾아 정리하고, 같은 조건으로 다시 확인한다.

유지 경로 진단과 반복 측정같은 동작 재현, Snapshot 비교, 루트까지 참조 경로 추적, 책임자 찾기, 정리와 재측정 뒤 불필요한 객체가 계속 증가하는지 판정한다. 예는 3번 참조 경로로 돌아가고 아니오는 증가 억제와 원래 기능 검증으로 간다. 모바일도 같은 반복과 종료 분기를 유지한다. 한 차례 메모리 수치나 GC 시점만으로 누수를 확정하지 않는다.1 증상 재현같은 동작·입력 반복2 Snapshot 비교유지 객체 수·종류 비교3 참조 경로루트까지 연결 추적4 책임자 찾기등록·캐시·참조 소유자5 정리·재측정동일 동작을 다시 반복계속 증가하는가?불필요한 객체인지 확인예 → 3번 다시 추적아니오증가 억제 확인원래 기능도 회귀 검증
유지 경로 진단과 반복 측정같은 동작 재현, Snapshot 비교, 루트까지 참조 경로 추적, 책임자 찾기, 정리와 재측정 뒤 불필요한 객체가 계속 증가하는지 판정한다. 예는 3번 참조 경로로 돌아가고 아니오는 증가 억제와 원래 기능 검증으로 간다. 모바일도 같은 반복과 종료 분기를 유지한다. 한 차례 메모리 수치나 GC 시점만으로 누수를 확정하지 않는다.1 증상 재현같은 동작·입력 반복2 Snapshot 비교유지 객체 수·종류 비교3 참조 경로루트까지 연결 추적4 책임자 찾기등록·캐시·참조 소유자5 정리·재측정동일 동작을 다시 반복계속 증가하는가?불필요한 객체인지 확인예: 3번 다시 추적아니오증가 억제 확인원래 기능도 검증한 번의 수치로 확정하지 않음
  1. 증상 재현

    같은 동작·입력을 여러 번 반복한다.

  2. Snapshot 비교

    동작 전·후의 객체 종류·유지 수와 크기를 비교한다.

  3. Retainer path

    불필요하게 남은 객체가 루트에서 어떻게 유지되는지 추적한다.

  4. Owner 식별

    참조를 관리하는 리스너·timer·cache 등의 책임자를 찾는다.

  5. 정리·재측정

    등록 해제·참조 제거 후 같은 조건을 다시 반복한다.

  6. 판정

    계속 증가하면 유지 경로를 다시 추적한다. 증가 억제와 기능 회귀를 확인하면 종료한다.

재측정으로 누수 가설 확인

한 번의 메모리 크기나 GC 실행 시점만으로 결론 내리지 않는다. 같은 동작을 반복하고 유지 객체·참조 경로·정리 이후 변화와 기능을 함께 비교한다.

위 그림은 메모리가 늘어날 때 누수를 진단하는 순서를 보여 줍니다. 먼저 같은 화면을 열고 닫는 것처럼 같은 동작과 입력을 여러 번 반복해 증상을 다시 만듭니다. 동작 전후로 힙 스냅샷을 찍어 남아 있는 객체의 종류와 개수를 비교하고, 불필요하게 남은 객체가 루트에서 어떤 참조 경로(retainer path)로 유지되는지 따라갑니다.

경로를 찾으면 그 참조를 쥐고 있는 쪽, 즉 리스너나 타이머, 캐시 같은 소유자(owner)를 찾아 등록을 해제하거나 참조를 지웁니다. 그다음 같은 조건으로 다시 반복해 측정합니다. 불필요한 객체가 여전히 늘어나면 참조 경로를 다시 추적하고, 늘어나지 않으면 원래 기능이 그대로 동작하는지까지 확인한 뒤 마칩니다.

그림 아래의 설명처럼 메모리 수치가 한 번 늘었다는 것만으로 누수라고 확정하지는 않습니다. 캐시나 아직 실행되지 않은 가비지 컬렉션도 수치에 영향을 주고, 개발자 도구의 콘솔에 출력해 둔 객체도 참조로 남아 결과에 영향을 줄 수 있습니다.


정리

  • 자바스크립트 엔진은 메모리를 자동으로 할당하고 회수하지만, 무엇이 회수되는지는 참조가 남아 있는지에 달려 있습니다.
  • 원시값은 대입할 때 값이 전달되고, 객체는 참조가 전달되어 두 변수가 같은 객체를 가리킵니다.
  • 가비지 컬렉터는 루트에서 참조를 따라 닿을 수 없는 객체를 회수하며, 서로 참조하는 객체도 루트에서 떨어져 있으면 회수 대상입니다.
  • null을 대입해도 다른 경로가 남아 있으면 회수되지 않고, 회수 대상이 되어도 언제 회수할지는 엔진이 정합니다.
  • 의도하지 않은 전역 변수, 해제하지 않은 타이머와 이벤트 리스너, 오래 유지되는 클로저가 흔한 누수 원인입니다.
  • 누수는 같은 동작을 반복하며 힙 스냅샷을 비교하고, 남은 객체의 참조 경로를 따라가 소유자를 정리한 뒤 다시 측정해 확인합니다.

연습 문제

1. null 뒤에도 남는 경로

다음 코드는 메모리가 실제로 언제 회수되는지 측정하는 실험이 아닙니다. A와 B 시점에 { label: "기록" } 객체로 가는 경로를 각각 그려 보고, 출력되는 배열 길이와 객체가 회수 대상이 될 수 있는 시점을 구분해 보세요.

let ownerForMemory = { label: "기록" };
const registryForMemory = [];
registryForMemory.push(ownerForMemory);
ownerForMemory = null;
console.log("A", registryForMemory.length);
registryForMemory.length = 0;
console.log("B", registryForMemory.length);
해설 보기

출력은 A 1, B 0입니다. A 시점에는 ownerForMemory가 null이 되었어도 registryForMemory에서 배열의 첫 원소를 거쳐 객체로 가는 경로가 남아 있습니다. B 시점에는 배열의 원소를 지웠으므로, 이 코드 바깥에 다른 참조가 없다면 객체는 도달할 수 없게 되어 회수 대상이 될 수 있습니다. 그렇다고 객체가 정확히 그 줄에서 회수되었다고 말할 수는 없습니다.

만약 ownerForMemory = null; 바로 앞에 const savedForMemory = ownerForMemory;를 넣었다면, B 뒤에도 그 상수에서 객체로 가는 경로가 남습니다. 배열 길이가 0이라는 것만으로 모든 참조가 사라졌다고 판단할 수는 없습니다.

2. 덮어쓴 타이머 ID

다음 코드는 타이머 ID를 바깥의 변수 하나에 저장합니다. 두 타이머는 각각 어떻게 될까요? 그리고 어떻게 고치면 될까요?

let sharedTimerId;
function startSharedTimer(label, interval) {
    let count = 0;
    sharedTimerId = setInterval(() => {
        console.log(label, count++);
        if (count > 2) {
            clearInterval(sharedTimerId);
        }
    }, interval);
}
startSharedTimer("첫째", 500);
startSharedTimer("둘째", 1000);
해설 보기

두 번째 호출이 sharedTimerId를 둘째 타이머의 ID로 덮어씁니다. 첫째 타이머는 약 1.5초에 세 번째 값 첫째 2를 출력한 뒤 clearInterval(sharedTimerId)를 호출하는데, 이 ID는 둘째 타이머의 것입니다. 그래서 둘째 0만 한 번 출력된 둘째 타이머가 멈추고, 첫째 타이머는 해제되지 않아 첫째 3, 첫째 4, …를 계속 출력합니다.

앞의 startTimer()처럼 타이머 ID를 함수 안의 const timerId에 두면, 각 호출의 콜백이 자기 타이머의 ID를 기억해 정확히 자기 타이머만 해제합니다.

3. 제거되지 않는 리스너

다음 코드에서 removeEventListener()는 리스너를 제거할까요? 그 이유는 무엇일까요?

button.addEventListener("click", () => console.log("클릭"));
button.removeEventListener("click", () => console.log("클릭"));
해설 보기

제거하지 못합니다. 두 화살표 함수는 내용이 같아도 각각 따로 만들어진 서로 다른 함수 객체입니다. removeEventListener()는 등록할 때 넘긴 것과 같은 함수 객체를 찾아 제거하므로, 새로 만든 함수로는 처음 등록한 리스너를 찾지 못합니다.

앞의 handleClick처럼 리스너 함수를 변수에 담아 두고, 등록할 때와 제거할 때 같은 변수를 넘겨야 합니다.

도달 가능성과 자동 메모리 관리의 한계는 MDN의 메모리 관리 문서에서 확인할 수 있습니다.

다음 글 디자인 패턴 in 자바스크립트에서는 자주 마주치는 설계 문제를 푸는 검증된 코드 구조인 디자인 패턴을 자바스크립트로 구현해 봅니다.