본문 바로가기

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

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

우리는 지금까지 자바스크립트의 문법, 동작 원리, 비동기 처리, 모듈 시스템 등 다양한 주제를 학습했습니다.

이 지식은 코드를 어떻게 작성할 것인가에 집중되어 있습니다.

하지만 코드를 실행하는 순간 컴퓨터 자원, 특히 메모리(Memory)를 사용하게 됩니다.

코드가 메모리를 어떻게 쓰고, 불필요해진 메모리를 어떻게 해제하는지 이해해야 성능 최적화와 애플리케이션 안정성을 확보할 수 있습니다.

자바스크립트는 C/C++ 같은 저수준 언어와 달리, 개발자가 직접 메모리를 할당하거나 해제할 필요가 없습니다.

자바스크립트 엔진이 가비지 컬렉션(Garbage Collection) 메커니즘으로 자동 관리하기 때문입니다.

다만 이 자동 관리는 메모리 관리를 전혀 신경 쓰지 않아도 된다는 뜻이 아닙니다.

가비지 컬렉션의 동작 원리를 이해해야 의도치 않은 메모리 누수(Memory Leak)를 막고 애플리케이션 성능을 최적화할 수 있습니다.

이번 장에서는 자바스크립트 메모리의 할당, 회수, 누수 시나리오, 방지 방법을 순서대로 살펴보겠습니다.

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 실행 시점은 다르다

자바스크립트 메모리 생명주기

프로그래밍 언어에서 메모리 관리는 일반적으로 다음과 같은 생명주기를 가집니다.

메모리 할당 (Allocation): 프로그램이 변수나 함수 등을 필요로 할 때 메모리를 할당합니다.

메모리 사용 (Usage): 할당된 메모리에서 값을 읽고 그곳에 쓰는 등의 작업을 수행합니다.

메모리 해제 (Release): 더 이상 필요 없는 메모리를 해제하여 다른 용도로 사용할 수 있도록 합니다.

자바스크립트에서는 이 모든 과정이 엔진에 의해 자동으로 처리됩니다.

값의 종류와 메모리 할당 방식

값과 참조를 구분할 때 스택과 힙 모델을 사용할 수 있습니다. 다만 ECMAScript가 모든 값의 실제 저장 위치나 크기를 지정하는 것은 아니며, 엔진의 표현과 최적화에 따라 달라집니다.

원시 값 (Primitive Values): Number, String, Boolean, null, undefined, Symbol, BigInt는 원시 값입니다. 이를 대입하면 값이 전달되며, 문자열이나 BigInt까지 모두 고정 크기의 스택 칸에 저장된다고 일반화할 수는 없습니다.

스택은 데이터가 순서대로 쌓이고 가장 마지막에 쌓인 데이터가 먼저 제거되는 (LIFO: Last-In, First-Out) 구조로, 접근 속도가 빠르고 관리가 용이합니다.

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

객체 (Objects)와 함수 (Functions): 객체의 저장 공간을 설명할 때는 힙(Heap) 모델을 사용합니다. 실제 표현은 엔진에 따르며, 아래 실습에서는 저장 주소 대신 같은 객체인지와 참조 관계를 추적합니다.

힙은 스택과 달리 크기가 가변적이며, 데이터가 할당되는 순서에 관계없이 자유롭게 할당되고 해제됩니다.

변수는 객체를 참조하며, 그 참조의 실제 저장 위치도 실행 환경과 최적화에 따라 달라집니다.

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는 같은 객체를 참조하므로)

위 원래 주석의 “동일한 힙 메모리 주소”는 두 변수가 같은 객체를 참조한다는 비유입니다. JavaScript에서 그 실제 주소를 읽거나, 엔진이 이동·최적화하지 않는다고 보장하는 표현은 아닙니다.

원시 값의 대입과 객체 참조의 대입을 구분하세요. 객체를 다른 변수에 대입하면 객체를 복제하지 않고 같은 객체를 참조하게 됩니다. 주소를 계산하는 대신 obj === obj2와 어느 변경이 공유되는지를 확인하면 됩니다.


가비지 컬렉션 (Garbage Collection)

앞 장의 객체 참조·클로저를 바탕으로 루트에서 어떤 경로가 남는가를 추적합니다. null 대입 한 번이나 순환 참조의 존재만으로 회수 여부를 결론 내리지 않습니다.

자바스크립트는 더 이상 필요 없는(도달 불가능한) 객체를 자동으로 찾아내 메모리에서 해제하는 가비지 컬렉션(Garbage Collection, GC) 메커니즘을 사용합니다.

GC의 목표는 개발자가 수동으로 메모리를 관리할 필요 없이 메모리 누수를 방지하고 효율적인 자원 사용을 가능하게 하는 것입니다.

도달 가능성 (Reachability) 개념

가비지 컬렉션은 기본적으로 도달 가능성(Reachability)이라는 개념을 사용하여 메모리를 해제할지 말지를 결정합니다.

  • 루트(Roots): 런타임이 현재 유지하는 접근 경로의 출발점입니다. 여기서는 필요한 실행 환경의 바인딩, 전역·모듈 환경, 활성 문서와 등록된 작업 등을 모델로 봅니다. 실제 루트와 최적화는 엔진에 따릅니다.
    • 전역 let·const나 모듈 바인딩이 모두 window·global의 속성인 것은 아닙니다.
    • 문서에서 떼어낸 DOM 노드가 자동으로 영구 루트가 되는 것도 아닙니다. 다른 루트에서 그 노드로 이어지는 경로가 남는지 확인합니다.
  • 도달 가능한 객체: 루트에서부터 참조 체인을 따라갈 때 접근할 수 있는 모든 객체를 도달 가능한 객체라고 합니다.
  • 가비지 (Garbage): 도달 가능한 객체가 아닌 모든 객체는 가비지로 간주되어 가비지 컬렉션의 대상이 됩니다. 즉, 어떤 변수도 참조하지 않거나, 참조하더라도 루트로부터 도달할 수 없는 객체는 가비지가 됩니다.
let user = {
    name: "John"
};
// user 객체는 'user' 변수에 의해 참조되므로 도달 가능합니다.

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

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

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

마크-스윕 (Mark-and-Sweep) 알고리즘

가장 널리 사용되는 가비지 컬렉션 알고리즘 중 하나입니다.

마크 (Mark) 단계: 가비지 컬렉터는 루트에서부터 시작하여 모든 도달 가능한 객체들을 표시(mark)합니다.

루트에서 직접 참조하는 객체들과 그 객체들이 참조하는 객체들을 거쳐 모든 참조를 따라가며 표시합니다.

스윕 (Sweep) 단계: 마크되지 않은(즉, 도달 불가능한) 모든 객체들을 메모리에서 제거(sweep)합니다.

아래는 회수 비용을 줄이기 위한 기법의 개요입니다. 특정 예제의 객체가 어느 세대에 있고 언제 회수되는지까지 이 설명으로 예측할 수는 없습니다.

  • Generational Collection: 객체를 새로운 세대와 오래된 세대로 나누어 관리합니다. 새로 생성된 객체는 빠르게 가비지가 될 확률이 높고, 오래된 객체는 오랫동안 살아남을 확률이 높다고 가정합니다. 따라서 새로운 세대에 대한 GC를 더 자주, 더 빠르게 수행하여 효율성을 높입니다.
  • Incremental GC: GC 작업을 한 번에 모두 수행하지 않고, 작은 조각으로 나누어 점진적으로 수행합니다. 이는 GC로 인해 애플리케이션이 잠시 멈추는 현상(Stop-The-World)을 줄여 사용자 경험을 향상시킵니다.

메모리 누수 (Memory Leak)

가비지 컬렉션이 자동으로 메모리를 해제해주지만, 개발자의 실수로 인해 더 이상 필요 없는 객체가 도달 가능한 상태로 남아 있게 되는 경우가 발생합니다.

이렇게 되면 가비지 컬렉터가 해당 객체를 가비지로 인식하지 못해 메모리에서 해제하지 못하게 되고, 결국 메모리 사용량이 계속 증가하는 현상이 발생하는데, 이를 메모리 누수(Memory Leak)라고 합니다.

메모리 누수가 심해지면 애플리케이션의 성능이 저하되고, 결국 크래시(Crash)로 이어질 수 있습니다.

흔히 발생하는 메모리 누수 시나리오와 방지법

의도치 않은 전역 변수: 비엄격 모드에서 선언하지 않은 이름에 대입하여 전역 객체의 속성을 만드는 경우입니다. let·const 선언이 의도치 않은 전역을 만든다는 뜻은 아닙니다.

전역에서 불필요한 객체를 계속 참조하면 그 객체가 유지될 수 있습니다. 참조를 다른 값으로 바꾸거나 해당 속성을 제거하고 나머지 접근 경로도 없애면 회수 대상이 될 수 있으므로, 모든 전역 객체가 페이지 종료까지 반드시 남는다고 일반화하지 않습니다.

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

방지법: 항상 const, let을 사용하여 변수를 선언하고, 'use strict' 모드를 사용합니다.

타이머 (setTimeout, setInterval)의 잘못된 사용: 타이머의 콜백 함수 내부에서 외부 스코프의 변수를 참조하고, 타이머가 명시적으로 해제되지 않으면, 해당 변수들이 메모리에서 해제되지 않을 수 있습니다.

let data = { value: "important data" };
setInterval(() => {
    // data 객체를 참조하는 콜백이 계속 실행되므로, data는 GC 대상이 되지 않음
    console.log(data.value);
}, 1000);
// data = null; // 다른 참조가 없으면 기존 객체는 GC 대상이 됨. 이후 콜백의 data.value 접근은 TypeError

// 방지법: 필요 없으면 반드시 타이머를 해제합니다.
let timerId;
function startTimerLeak() {
    let count = 0;
    timerId = setInterval(() => {
        console.log(count++);
        if (count > 5) {
            clearInterval(timerId); // 타이머 명시적 해제
        }
    }, 500);
}
startTimerLeak();

위 블록의 첫 setInterval은 ID를 저장하지 않는 누수 원인 시연입니다. 뒤의 clearInterval(timerId)가 앞의 첫 타이머까지 해제하지는 않습니다. 이 블록 전체를 평소 페이지에 붙여 두지 마세요. startTimerLeak()도 한 번 호출하는 조건이며, 여러 번 호출하면 공용 timerId가 덮어써져 먼저 만든 타이머를 제때 해제하지 못할 수 있습니다.

정리 책임을 분리하려면 각 시작 호출이 자기 타이머를 정리하는 함수를 반환하게 만들 수 있습니다. 새 콘솔에서 아래 코드를 실행하면 즉시 정리하므로 반복 출력은 발생하지 않습니다.

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("타이머 정리 요청 완료");

타이머 등록을 해제하고 클로저 안의 객체 참조도 비웁니다. 다른 곳에서 해당 객체를 참조하지 않는다는 조건에서 회수 대상이 될 수 있지만, 이 로그가 실제 GC 실행이나 즉시 메모리 감소를 증명하지는 않습니다.

이벤트 리스너의 미해제: 제거한 DOM 노드나 그 리스너가 다른 오래 유지되는 루트에서 계속 참조되는지 확인합니다. 노드와 리스너가 서로 참조해도 그 전체가 도달 불가능하면 순환 자체만으로 누수가 확정되지는 않습니다. 반대로 window에 등록한 리스너가 큰 데이터를 잡고 있으면 문서의 작은 노드만 제거해도 그 경로는 남을 수 있습니다.

const button = document.getElementById('myButton');
button.addEventListener('click', function onClick() {
    // 이 함수가 외부 스코프의 큰 객체를 참조한다면 메모리 누수 발생 가능성
    console.log("버튼 클릭!");
});
// DOM에서 제거해도 외부 참조가 button/리스너를 계속 유지하면 누수 가능
// 해제하려면 등록에 쓴 함수 참조를 바깥 변수에 보관해 removeEventListener에 전달해야 함
// 위 기명 함수 표현식의 onClick 이름은 함수 바깥에서 사용할 수 없음

방지법: removeEventListener를 사용하여 더 이상 필요 없는 리스너는 명시적으로 제거합니다.

React에서 직접 등록한 외부 이벤트 리스너는 Effect의 cleanup에서 직접 해제해야 합니다. React가 임의의 addEventListener 호출까지 자동으로 정리하지는 않습니다.

클로저의 과도한 사용: 클로저는 외부 스코프의 변수를 기억하는 강력한 기능이지만, 클로저가 불필요하게 오래 유지되면 클로저가 기억하는 외부 변수들도 GC 대상에서 제외되어 메모리 누수로 이어질 수 있습니다.

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 대상이 됩니다.

방지법: 클로저를 사용할 때, 클로저가 참조하는 외부 변수가 언제까지 필요한지 신중하게 고려하고, 필요 없어진 시점에는 참조를 해제(예: 변수에 null 할당)하거나 클로저 자체를 null로 만들어 GC 대상이 되도록 합니다.

메모리 누수 진단 도구: 크롬 개발자 도구의 Performance 탭이나 Memory 탭을 활용하면 실시간으로 메모리 사용량을 모니터링하고, 특정 시점의 힙 스냅샷(Heap Snapshot)을 찍어 어떤 객체들이 메모리에 남아 있는지 분석하여 메모리 누수를 진단할 수 있습니다.

같은 화면을 열고 닫는 동작을 예를 들어 세 번 반복한 뒤, 힙 스냅샷에서 계속 남는 객체의 개수와 retainer path(그 객체를 유지하는 참조 경로)를 비교하세요. 메모리 숫자가 한 번 증가했다는 사실만으로 누수를 확정하지 않습니다. 캐시나 아직 실행되지 않은 회수도 영향을 주며, 개발자 도구에서 잡아 둔 객체 참조도 관찰에 영향을 줄 수 있습니다. 도구 버전에 따라 화면 이름은 달라질 수 있습니다.

아래 다이어그램은 메모리 증가가 관찰될 때 어떤 참조부터 의심하고 해제해야 하는지 정리한 진단 흐름입니다.

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

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 실행 시점만으로 결론 내리지 않는다. 같은 동작을 반복하고 유지 객체·참조 경로·정리 이후 변화와 기능을 함께 비교한다.


독립 연습: 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가 비어도 registryForMemory → 배열의 첫 원소 → 객체 경로가 남습니다. B에서는 배열 원소를 제거했습니다. 이 코드 바깥의 참조가 없다는 가정에서 객체는 도달 불가능해져 회수 대상이 될 수 있습니다. B 뒤에도 const 바인딩과 빈 배열은 유지됩니다. 객체가 정확히 그 줄에서 회수됐다는 결론은 내릴 수 없습니다.

추가로 ownerForMemory = null; 직전에 const savedForMemory = ownerForMemory;를 넣으면, B 뒤에도 그 상수에서 객체로 가는 경로가 남습니다. 배열 길이 0만으로 전체 참조가 없어졌다고 판단할 수 없습니다.

근거: MDN 도달 가능성과 메모리 관리의 한계.

메모리 관리 정리

이 절에서는 누수 시나리오를 증상 기준으로 분류하고, 해제 책임이 있는 객체를 선별하는 관점을 강조합니다.

이번 장에서는 자바스크립트의 메모리 관리 방식과 가비지 컬렉션(Garbage Collection) 원리를 다뤘습니다.

값과 객체 참조의 차이, 가비지 컬렉터가 도달 가능성을 기준으로 회수 대상을 찾는 Mark-and-Sweep 알고리즘을 확인했습니다.

또한, 자동 메모리 관리에도 불구하고 개발자의 실수로 인해 발생할 수 있는 메모리 누수(Memory Leak)의 주요 시나리오(의도치 않은 전역 변수, 해제되지 않은 타이머/이벤트 리스너, 클로저의 잘못된 사용 등)를 파악하고, 이를 방지하기 위한 실제적인 방법들을 살펴보았습니다.

메모리 관리 원리를 이해하면 누수가 생기는 참조 경로를 더 빨리 찾을 수 있습니다.

개발자 도구로 메모리 사용량을 주기적으로 확인하고, 타이머·이벤트 리스너·클로저가 불필요한 참조를 남기지 않는지 점검해야 합니다.

메모리 누수는 대부분 “루트에서 아직 도달 가능한 참조”가 남아 있을 때 발생합니다.

메모리 누수는 전역 변수, 타이머, 이벤트 리스너, 클로저가 만든 retainer path를 루트부터 순서대로 확인합니다.