적용 환경: V8 엔진 기반 (Chrome, Edge, Node.js). 원리는 SpiderMonkey(Firefox), JavaScriptCore(Safari)에도 대부분 적용됩니다.
들어가며
SPA를 운영하다 보면 "사용자가 오래 사용할수록 페이지가 느려진다"는 보고를 받는 경우가 있습니다. DevTools의 Performance Monitor를 열어보면 JS Heap Size 그래프가 특정 동작을 반복할 때마다 계단식으로 올라가며, 내려오지 않는 모습을 확인할 수 있습니다. 메모리가 해제되어야 할 시점에 해제되지 않는 것, 이것이 메모리 누수(memory leak)의 전형적인 징후입니다.
브라우저 렌더링 파이프라인에서 DOM과 CSSOM이 트리 구조로 구축된다는 것을 살펴보았는데, 이 트리의 모든 노드는 힙 메모리에 할당된 객체입니다. 컴포넌트가 마운트될 때 생성되고, 언마운트될 때 해제되어야 하지만, 어딘가에서 참조가 남아 있으면 가비지 컬렉터가 이를 수거할 수 없습니다. 메모리 누수를 진단하고 해결하려면, JavaScript가 메모리를 어떻게 관리하고 가비지 컬렉터가 어떤 기준으로 객체를 수거하는지를 이해해야 합니다.
메모리의 두 공간: 스택과 힙
JavaScript 엔진은 메모리를 크게 두 영역으로 나누어 관리합니다. 스택(Stack)과 힙(Heap)이 그것이며, 각각의 역할과 생명주기가 다릅니다.
스택은 함수 호출 시 생성되는 실행 컨텍스트(execution context)를 저장합니다. 작은 정수(V8에서는 SMI, Small Integer)와 같은 원시값과 힙 객체를 가리키는 참조(포인터)가 여기에 위치하며, 함수가 반환되면 해당 프레임이 자동으로 제거됩니다. 별도의 메모리 관리가 필요 없는, LIFO(Last-In-First-Out) 구조의 예측 가능한 영역입니다. 다만 언어 명세상 원시 타입인 string도 V8 내부에서는 힙에 할당되므로, "원시값 = 스택"이라는 등식은 개념적 모델에 가깝고 실제 엔진 구현과는 차이가 있습니다.
힙은 객체, 배열, 함수, 클로저, DOM 노드 등 크기가 동적으로 변하는 데이터가 저장되는 공간입니다. 스택과 달리 함수가 반환되더라도 자동으로 정리되지 않으며, 이 영역의 메모리 회수를 담당하는 것이 바로 가비지 컬렉터(Garbage Collector)입니다.
function createUser(name) {
const id = 1; // Stack: 원시값 (number)
const user = { id, name }; // Heap: 객체 할당, Stack: 객체를 가리키는 참조
return user; // 참조가 반환되므로 객체는 힙에서 유지
}
const admin = createUser("Alice"); // admin 변수가 힙 객체를 참조
// createUser의 스택 프레임은 사라지지만, 반환된 객체는 admin이 참조하므로 힙에 남음이 코드에서 id는 원시값이므로 스택에 바로 저장되고, { id, name } 객체는 힙에 할당된 뒤 스택의 user 변수가 그 주소를 참조합니다. createUser 함수가 반환되면 스택 프레임은 제거되지만, 반환된 객체는 외부의 admin 변수가 참조하고 있으므로 힙에서 살아남습니다. 만약 admin = null로 참조를 끊으면, 그 객체는 더 이상 도달할 수 없게 되어 가비지 컬렉터의 수거 대상이 됩니다.
가비지 컬렉션의 기본 원리
참조 카운팅의 한계
가비지 컬렉션의 가장 초기 접근 방식은 참조 카운팅(Reference Counting)입니다. 각 객체가 자신을 참조하는 횟수를 추적하고, 참조 카운트가 0이 되면 즉시 메모리를 해제하는 단순한 알고리즘입니다. 직관적이고 구현이 간단하지만, 치명적인 결함이 하나 있습니다.
// ❌ 순환 참조: 참조 카운팅으로는 해제 불가
function createCycle() {
const objA = {};
const objB = {};
objA.ref = objB; // objB의 참조 카운트: 2 (objB 변수 + objA.ref)
objB.ref = objA; // objA의 참조 카운트: 2 (objA 변수 + objB.ref)
}
createCycle();
// 함수 반환 후: objA와 objB 변수의 참조는 사라지지만
// objA.ref → objB, objB.ref → objA 상호 참조가 남아 카운트가 1
// 프로그램에서 접근할 방법이 없는데도 메모리가 해제되지 않음두 객체가 서로를 참조하면 함수가 종료되어도 각각의 참조 카운트가 0에 도달하지 않습니다. 프로그램 어디에서도 이 객체들에 접근할 수 없는데도 메모리에 남아 있는 것입니다. 이 문제는 이론적인 것만이 아니었습니다. IE6/7은 DOM 객체에 COM 기반 참조 카운팅을 사용했고, JavaScript 객체와 DOM 객체 사이의 순환 참조가 실제 메모리 누수로 이어져 많은 웹 개발자를 괴롭혔습니다.
Mark-and-Sweep 알고리즘
현대의 모든 주요 JavaScript 엔진은 참조 카운팅 대신 Mark-and-Sweep 알고리즘을 사용합니다. 판단 기준이 "참조 횟수"에서 "도달 가능성(Reachability)"으로 바뀐 것이 핵심적인 차이입니다.
Reference Counting
참조 횟수 기반, 순환 참조 문제
Mark-and-Sweep
도달 가능성 기반, 순환 참조 해결
Generational GC
세대 분리 + 병렬/증분 처리
Reference Counting
참조 횟수 기반, 순환 참조 문제
Mark-and-Sweep
도달 가능성 기반, 순환 참조 해결
Generational GC
세대 분리 + 병렬/증분 처리
Mark-and-Sweep은 두 단계로 동작합니다. 먼저 루트(roots)에서 출발하여 참조 그래프를 순회하며 도달 가능한 모든 객체에 "살아 있음" 표시(mark)를 남깁니다. 루트란 전역 객체(window, global), 현재 콜 스택에 있는 지역 변수, 활성 클로저 등 프로그램이 직접 접근할 수 있는 진입점을 말합니다. 마킹이 완료되면, 표시가 없는 객체를 모두 해제(sweep)합니다.
이 방식에서는 순환 참조가 문제가 되지 않습니다. 앞선 createCycle 예제에서 objA와 objB가 서로를 참조하더라도, 함수 반환 후 루트에서 이 객체들에 도달할 수 있는 경로가 없으므로 마킹되지 않고 수거됩니다.
도달 가능성(Reachability)이 핵심
현대 가비지 컬렉터의 판단 기준은 "참조 횟수"가 아니라 "루트에서 도달 가능한가"입니다. 어떤 객체든 루트에서 해당 객체까지의 경로가 하나라도 존재하면 살아남고, 모든 경로가 끊기면 수거됩니다. 순환 참조 여부는 판단에 영향을 미치지 않습니다.
V8의 세대별 가비지 컬렉션
Mark-and-Sweep이 개념적으로는 완전하지만, 실제 엔진에서 전체 힙을 매번 순회하는 것은 비용이 큽니다. V8은 세대별 가비지 컬렉션(Generational Garbage Collection)을 채택하여 이 문제를 해결합니다.
세대 가설 (Generational Hypothesis)
세대별 GC의 이론적 토대는 세대 가설입니다. 경험적 관찰에 따르면, 대부분의 객체는 생성 직후 매우 짧은 시간 안에 불필요해집니다. 함수 내에서 생성된 임시 객체, 문자열 연결의 중간 결과물, 이벤트 핸들러에서 만든 일회성 데이터 등이 전형적인 예입니다. 반면 한 번 생존한 객체는 상당히 오래 살아남는 경향이 있습니다. 전역 상태, 캐시, 장기 구독 등이 이에 해당합니다.
이 관찰에 기반하여 V8은 힙을 Young Generation(신세대)과 Old Generation(구세대)으로 나눕니다.
Young Generation은 크기가 작고(일반적으로 1~16MB) 자주 수거되며, Old Generation은 크기가 크고 상대적으로 드물게 수거됩니다. "단명 객체가 많은 곳을 자주, 장수 객체가 모인 곳을 가끔" 수거하는 전략이므로, 전체 힙을 매번 스캔하는 것보다 훨씬 효율적입니다.
Young Generation은 동일한 크기의 두 반공간(semi-space)으로 나뉩니다. From-space(활성 영역)와 To-space(비활성 영역)입니다. 새 객체는 항상 From-space에 할당됩니다.
From-space가 가득 차면 Scavenger(Minor GC)가 실행됩니다. Scavenger는 Cheney의 알고리즘을 기반으로, 루트에서 도달 가능한 살아 있는 객체만 To-space로 복사합니다. 복사가 완료되면 From-space 전체를 한꺼번에 비우고, From-space와 To-space의 역할을 교체합니다.
이 방식이 빠른 이유는 Young Generation의 크기가 작아 순회 범위가 제한적이고, 세대 가설에 따라 대부분의 객체가 이미 죽어 있어 실제로 복사해야 할 객체의 수가 적기 때문입니다. 살아남은 소수만 복사하고 나머지는 통째로 버리는 구조이므로, 살아 있는 객체의 비율이 낮을수록 효율이 높아집니다.
한 번의 Minor GC에서 살아남은 객체는 다시 From-space로 돌아가고, 두 번째 Minor GC에서도 살아남으면 Old Generation으로 승격(promotion)됩니다. 두 번 연속 살아남았다는 것은 단명 객체가 아닐 가능성이 높다는 판단에 기반합니다.
모던 GC 최적화 기법
초기의 Mark-and-Sweep 구현은 Stop-the-World 방식이었습니다. GC가 실행되는 동안 JavaScript 실행이 완전히 멈추는 것입니다. 힙이 작을 때는 문제가 없었지만, 대규모 애플리케이션에서 수백 MB의 힙을 한 번에 마킹하면 수십~수백 ms의 일시 정지가 발생할 수 있습니다. 브라우저 렌더링 파이프라인에서 살펴본 것처럼 하나의 프레임 예산은 약 16.6ms(60fps 기준)이므로, 이 정도의 정지는 여러 프레임을 건너뛰는 jank(끊김)로 이어지고, Core Web Vitals의 INP 지표를 직접적으로 악화시킵니다.
V8 팀은 Orinoco 프로젝트를 통해 이 문제에 체계적으로 대응했습니다. 핵심 전략은 GC 작업을 잘게 쪼개고, 가능한 한 메인 스레드 바깥에서 처리하는 것입니다.
전체 힙을 한 번에 마킹하는 대신, 마킹 작업을 작은 단위로 나누어 JavaScript 실행과 인터리빙(interleaving)합니다. 조금 마킹하고, JavaScript를 실행하고, 다시 조금 마킹하는 식입니다.
이를 가능하게 하는 것이 삼색 마킹(tri-color marking) 기법입니다. 모든 객체는 세 가지 색 중 하나를 가집니다.
| 색상 | 의미 |
|---|---|
| White | 아직 방문하지 않음 (GC 시작 시 모든 객체의 초기 상태) |
| Grey | 방문했지만, 이 객체가 참조하는 다른 객체는 아직 처리 안 됨 |
| Black | 방문 완료. 이 객체와 이 객체가 참조하는 모든 객체가 처리됨 |
마킹은 Grey 객체가 남아 있는 한 계속됩니다. Grey 객체를 하나 꺼내서 참조하는 객체들을 Grey로 만들고, 자신은 Black으로 전환합니다. 증분 마킹에서는 이 과정을 일정 시간만큼만 수행한 뒤 중단했다가 나중에 재개하는 방식으로, 개별 정지 시간을 수 ms 이하로 유지합니다.
다만 마킹과 JavaScript 실행이 번갈아 일어나므로, JavaScript가 중간에 참조 관계를 변경할 수 있습니다. 이를 추적하기 위해 쓰기 장벽(write barrier)이 사용됩니다. 삼색 마킹의 핵심 불변식은 "Black 객체가 White 객체를 직접 참조해서는 안 된다"는 것입니다. Black 객체가 새로운 White 객체를 참조하게 되면, V8의 Dijkstra 스타일 write barrier가 그 White 대상 객체를 Grey로 전환하여 마킹 대상에 추가합니다.
V8의 GC 일시 정지 시간
V8 팀은 Orinoco 프로젝트를 통해 Major GC의 메인 스레드 일시 정지 시간을 수십 ms에서 수 ms 수준으로 줄였습니다. 대부분의 마킹과 스위핑이 백그라운드 스레드에서 처리되므로, 일반적인 웹 애플리케이션에서 GC로 인한 jank는 크게 줄어들었습니다. 그러나 힙 크기가 수 GB에 달하거나, 객체 그래프가 극도로 복잡한 경우에는 여전히 눈에 띄는 정지가 발생할 수 있습니다.
JavaScript 메모리 누수 패턴
가비지 컬렉터가 아무리 효율적이어도, 프로그램이 불필요한 참조를 유지하면 메모리를 회수할 수 없습니다.
정상적인 애플리케이션에서는 메모리 사용량이 톱니(sawtooth) 패턴을 보입니다. 객체 할당으로 메모리가 올라갔다가, GC가 실행되면 다시 내려오는 반복입니다. 그러나 메모리 누수가 있으면 GC 이후에도 기준선이 점차 상승하며, 궁극적으로 메모리 한계에 도달합니다.
GC 사이클에 따른 메모리 사용량: 정상 vs 누수
다음은 실무에서 자주 발생하는 메모리 누수 패턴과 그 해결 방법입니다. 아래 차트는 각 패턴이 실무 코드베이스에서 발견되는 상대적 빈도를 보여줍니다.
메모리 누수 패턴별 발생 빈도 (실무 기준)
해제되지 않는 이벤트 리스너
이벤트 리스너의 콜백 함수는 클로저를 통해 외부 변수를 참조할 수 있고, 이 참조 체인이 큰 데이터 구조를 메모리에 묶어둘 수 있습니다. 특히 SPA에서 컴포넌트가 언마운트될 때 리스너를 제거하지 않으면, 콜백과 그것이 캡처한 모든 변수가 영구적으로 메모리에 남습니다.
// ❌ 이벤트 리스너 미해제: 컴포넌트가 사라져도 콜백과 closure가 메모리에 남음
function setupScrollHandler(data) {
window.addEventListener('scroll', () => {
processScroll(data); // data(대용량 배열일 수 있음)가 closure에 의해 유지됨
});
}// ✅ 이벤트 리스너 정리: cleanup 함수를 반환하여 명시적으로 해제
function setupScrollHandler(data) {
const handler = () => processScroll(data);
window.addEventListener('scroll', handler);
return () => window.removeEventListener('scroll', handler);
}
// React에서의 패턴
useEffect(() => {
const handler = () => processScroll(data);
window.addEventListener('scroll', handler);
return () => window.removeEventListener('scroll', handler);
}, [data]);정리되지 않는 타이머
setInterval은 명시적으로 clearInterval을 호출하지 않는 한 콜백을 계속 실행합니다. 콜백이 외부 변수를 참조한다면, 그 변수와 변수가 가리키는 객체 전체가 GC 대상에서 제외됩니다.
// ❌ 타이머 미정리: 컴포넌트가 사라져도 1초마다 콜백이 실행되며 cache를 유지
function startPolling(cache) {
setInterval(() => {
const result = fetchData();
cache.set(Date.now(), result); // cache가 무한히 커짐
}, 1000);
}
// ✅ 타이머 정리: 더 이상 필요 없을 때 명시적으로 해제
function startPolling(cache) {
const timerId = setInterval(() => {
const result = fetchData();
cache.set(Date.now(), result);
}, 1000);
return () => clearInterval(timerId);
}분리된 DOM 노드 (Detached DOM)
DOM 트리에서 제거된 노드를 JavaScript 변수가 여전히 참조하고 있으면, 해당 노드와 그 자식 노드 전체가 메모리에 남습니다. 이를 분리된 DOM(Detached DOM)이라 부르며, DevTools의 Heap Snapshot에서 "Detached" 키워드로 검색하면 확인할 수 있습니다.
// ❌ 제거된 DOM 노드에 대한 참조가 남아 있음
let cachedElement = null;
function showNotification(message) {
const el = document.createElement('div');
el.textContent = message;
document.body.appendChild(el);
cachedElement = el; // 전역 변수에 참조 저장
}
function hideNotification() {
if (cachedElement) {
cachedElement.remove(); // DOM에서는 제거되지만
// cachedElement 변수가 여전히 참조 → GC 불가
}
}부주의한 전역 변수
JavaScript의 비엄격 모드(sloppy mode)에서는 const나 let 선언 없이 변수에 값을 할당하면 에러가 발생하지 않습니다. 엔진이 현재 스코프부터 상위 스코프를 차례로 탐색하고, 어디에서도 해당 이름의 선언을 찾지 못하면 전역 객체(window)의 프로퍼티로 자동 생성합니다. 이것이 암묵적 전역(implicit global)이며, 이렇게 생성된 전역 변수는 페이지가 살아 있는 한 루트에서 항상 도달 가능하므로 GC 대상이 되지 않습니다.
// ❌ 암묵적 전역 변수 생성 (strict mode가 아닌 경우)
function processData(items) {
result = items.map(transform);
// 1. 현재 스코프에 'result' 선언이 없음
// 2. 외부 스코프, 전역 스코프에도 없음
// 3. 비엄격 모드이므로 → window.result = items.map(transform) 으로 처리
// 이후 processData가 반환되어도 window.result가 데이터를 계속 참조
}ES 모듈(import/export를 사용하는 파일)은 자동으로 strict mode가 적용되므로 이 문제가 발생하지 않지만, 레거시 <script> 태그에서 직접 실행되는 코드에서는 여전히 주의가 필요합니다. "use strict" 선언을 추가하면 암묵적 전역 생성 시 ReferenceError가 발생하여 실수를 조기에 잡아낼 수 있습니다.
전역 변수와 관련된 또 다른 흔한 누수 원인은 상한 없이 커지는 전역 캐시입니다. Map이나 일반 객체를 캐시로 사용하면서 항목을 추가만 하고 제거하지 않으면, 캐시 자체가 메모리 누수의 원인이 됩니다.
// ❌ 상한 없는 전역 캐시
const cache = new Map();
function getData(key) {
if (!cache.has(key)) {
cache.set(key, computeExpensiveResult(key)); // 항목이 계속 추가만 됨
}
return cache.get(key);
}클로저에 의한 의도치 않은 참조 유지
클로저는 자신이 생성된 스코프의 변수에 접근할 수 있습니다. V8은 클로저가 실제로 참조하는 변수만 캡처하도록 최적화하지만, 동일 스코프에 여러 클로저가 존재하면 상황이 달라집니다. 같은 스코프의 클로저들은 하나의 Context 객체를 공유하므로, 한 클로저가 특정 변수를 참조하면 다른 클로저도 그 변수를 간접적으로 유지하게 됩니다.
// ❌ 동일 스코프의 여러 클로저가 Context를 공유하여 대용량 데이터가 유지됨
function createHandler() {
const largeData = new Array(1000000).fill('*');
const id = generateId();
return {
getId: () => id, // id만 사용
process: () => transform(largeData), // largeData를 참조
// getId는 largeData가 필요 없지만, process와 동일한 Context를 공유
// → getId만 살아 있어도 largeData가 GC되지 않음
};
}
// ✅ 필요한 데이터를 별도 스코프로 분리하여 Context 공유 방지
function createHandler() {
const id = generateId();
const processor = createProcessor(new Array(1000000).fill('*'));
// largeData는 createProcessor 내부 스코프에 격리됨
return {
getId: () => id, // id만 캡처한 가벼운 Context
process: () => processor(), // processor 참조만 유지, largeData는 별도 스코프
};
}React/SPA에서의 메모리 누수
SPA에서는 페이지 전환 시 실제 페이지 언로드가 발생하지 않으므로, 컴포넌트 언마운트 시 이벤트 리스너, 타이머, WebSocket 연결, AbortController 등을 명시적으로 정리하지 않으면 누수가 누적됩니다. React의 useEffect cleanup 함수, Vue의 onUnmounted 훅이 이 역할을 담당하며, 이 정리 로직을 빠뜨리는 것이 SPA 메모리 누수의 가장 흔한 원인입니다.
DevTools로 메모리 문제 진단하기
메모리 누수의 존재를 감지하는 것과, 원인을 정확히 찾아내는 것은 다른 문제입니다. Chrome DevTools의 Memory 패널은 힙의 상태를 스냅샷으로 촬영하고 비교할 수 있는 도구를 제공합니다.
Heap Snapshot으로 메모리 구조 분석
Heap Snapshot은 촬영 시점의 힙에 있는 모든 객체와 그 참조 관계를 기록합니다. DevTools에서 Memory 탭 → Heap Snapshot을 선택하고 "Take Snapshot" 버튼을 클릭하면 촬영됩니다.
스냅샷에서 주목해야 할 두 가지 크기 개념이 있습니다.
| 크기 | 의미 |
|---|---|
| Shallow Size | 객체 자체가 직접 점유하는 메모리 |
| Retained Size | 해당 객체가 GC되면 함께 해제될 수 있는 총 메모리 |
Retained Size가 큰 객체가 누수 분석에서 더 중요합니다. 객체 자체는 작더라도, 그것이 거대한 객체 그래프의 유일한 진입점이라면 Retained Size가 매우 클 수 있습니다. 이런 객체의 참조를 끊는 것만으로 대량의 메모리가 해제됩니다.
Allocation Timeline으로 할당 추적
Heap Snapshot이 "특정 시점의 상태"를 보여준다면, Allocation Timeline은 "시간 경과에 따른 할당 패턴"을 보여줍니다. Memory 탭에서 "Allocation instrumentation on timeline"을 선택하고 기록을 시작하면, 시간축을 따라 할당된 객체들이 파란색(아직 살아 있음)과 회색(수거됨) 바로 표시됩니다.
타임라인의 특정 구간을 선택하면 해당 시간대에 할당되어 아직 살아 있는 객체 목록을 볼 수 있습니다. 특정 동작을 반복할 때 파란색 바가 계속 쌓인다면, 그 동작에서 생성된 객체가 정상적으로 수거되지 않는 것입니다.
실전 디버깅 워크플로
기준점 스냅샷 촬영
페이지 로드 직후, 안정적인 상태에서 첫 번째 Heap Snapshot을 촬영합니다. 이것이 비교의 기준점이 됩니다. 촬영 전에 DevTools의 "Collect garbage" 버튼(쓰레기통 아이콘)을 클릭하여 수거 가능한 객체를 먼저 정리하면 더 깨끗한 기준점을 얻을 수 있습니다.
의심되는 동작 반복
메모리 누수가 의심되는 동작을 5~10회 반복합니다. 모달 열기/닫기, 페이지 간 이동, 리스트 스크롤 등 메모리를 할당하고 해제하는 동작을 반복한 뒤, 다시 "Collect garbage"를 실행합니다.
두 번째 스냅샷 촬영 후 비교
두 번째 Heap Snapshot을 촬영합니다. 스냅샷 뷰 상단의 드롭다운에서 "Comparison"을 선택하고 첫 번째 스냅샷과 비교합니다. # Delta 컬럼에서 양수인 Constructor를 찾으면, 해당 타입의 객체가 첫 번째 스냅샷 대비 증가한 것이므로 누수 후보입니다.
Retainers 추적
누수가 의심되는 객체를 선택하고 하단의 Retainers 패널을 확인합니다. Retainers는 해당 객체를 살려두고 있는 참조 체인을 보여주며, 이 체인을 역추적하면 어떤 코드가 불필요한 참조를 유지하고 있는지 파악할 수 있습니다. "Detached" 키워드가 보이면 분리된 DOM 노드 누수를 의심할 수 있습니다.
Performance Monitor 활용
빠른 확인이 필요할 때는 DevTools의 Performance Monitor 패널(More tools → Performance Monitor)에서 JS Heap Size를 실시간으로 모니터링할 수 있습니다. 특정 동작 후 그래프가 계단식으로 올라가며 GC 이후에도 이전 수준으로 내려오지 않는다면 메모리 누수를 의심할 수 있습니다.
프로그래밍적 메모리 모니터링
DevTools는 개발 중 수동으로 메모리 문제를 진단하는 데 탁월하지만, 한계가 분명합니다. 개발자가 직접 스냅샷을 촬영하고 비교해야 하므로, 사용자 환경에서 발생하는 간헐적인 누수를 포착하기 어렵고, 지속적인 모니터링도 불가능합니다. 브라우저는 JavaScript에서 직접 힙 메모리 정보에 접근할 수 있는 API를 제공하며, 이를 활용하면 애플리케이션 내부에서 실시간 메모리 모니터링과 자동화된 누수 탐지를 구현할 수 있습니다.
performance.memory API
Chrome과 Edge(V8 기반 브라우저)에서는 performance.memory API를 통해 JavaScript 힙의 현재 상태를 조회할 수 있습니다.
// Chrome/Edge에서만 사용 가능 (비표준 API)
const memory = performance.memory;
console.log({
usedJSHeapSize: memory.usedJSHeapSize, // 현재 사용 중인 힙 크기
totalJSHeapSize: memory.totalJSHeapSize, // 힙에 할당된 총 크기
jsHeapSizeLimit: memory.jsHeapSizeLimit, // 힙 최대 크기
});| 속성 | 의미 |
|---|---|
usedJSHeapSize | GC로 수거되지 않은, 실제 사용 중인 힙 메모리 |
totalJSHeapSize | V8이 시스템에서 할당받은 힙 전체 크기 |
jsHeapSizeLimit | 힙이 커질 수 있는 최대 한계 |
이 API에는 두 가지 중요한 제약이 있습니다.
- Chrome과 Edge에서만 사용 가능합니다. Firefox와 Safari는
performance.memory를 지원하지 않으므로, 크로스 브라우저 모니터링에서는 DOM 노드 수(document.getElementsByTagName('*').length)나 이벤트 리스너 카운트 같은 대체 지표를 함께 활용해야 합니다. - 보안상의 이유로 값이 양자화(quantized)되어 반환됩니다. 정확한 바이트 수가 아닌 근사값이므로, 미세한 변화보다는 전체적인 추세를 분석하는 데 적합합니다.
measureUserAgentSpecificMemory()
performance.memory의 후속 API로 performance.measureUserAgentSpecificMemory()가 제안되어 있습니다. 이 API는 크로스 오리진 iframe을 포함한 더 정밀한 메모리 측정을 제공하며, crossOriginIsolated 환경에서 사용할 수 있습니다. 아직 실험적 단계이지만, 장기적으로 performance.memory를 대체할 표준 후보로 주목받고 있습니다.
선형 회귀를 활용한 누수 탐지
performance.memory로 힙 크기를 주기적으로 수집하면, 이 시계열 데이터를 분석하여 메모리 누수를 프로그래밍적으로 감지할 수 있습니다. 핵심 아이디어는 선형 회귀(Linear Regression)로 메모리 증가 추세를 수치화하는 것입니다.
수집된 힙 크기 데이터에 최소제곱법을 적용하면 기울기(slope)와 결정계수(R²)를 얻을 수 있습니다. 기울기는 샘플당 메모리 증가량을, R²는 증가 패턴의 일관성을 나타냅니다. 기울기가 양수이고 R²가 높다면, 메모리가 일정한 비율로 꾸준히 증가하고 있다는 뜻이므로 누수를 의심할 수 있습니다.
// 단순 선형 회귀로 메모리 추세 분석
function analyzeMemoryTrend(samples) {
const n = samples.length;
if (n < 10) return null; // 최소 10개 샘플 필요
let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
for (let i = 0; i < n; i++) {
sumX += i;
sumY += samples[i].heapUsed;
sumXY += i * samples[i].heapUsed;
sumXX += i * i;
}
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
const intercept = (sumY - slope * sumX) / n;
// R² (결정계수) 계산
const meanY = sumY / n;
let ssRes = 0, ssTot = 0;
for (let i = 0; i < n; i++) {
const predicted = slope * i + intercept;
ssRes += (samples[i].heapUsed - predicted) ** 2;
ssTot += (samples[i].heapUsed - meanY) ** 2;
}
const rSquared = ssTot === 0 ? 0 : 1 - ssRes / ssTot;
return { slope, rSquared };
// slope > 0 && rSquared > 0.7 → 누수 가능성 높음
}그러나 선형 회귀만으로는 거짓 양성(false positive)이 빈번합니다. 애플리케이션이 정상적으로 데이터를 로딩하는 중에도 메모리가 일시적으로 증가할 수 있고, GC가 아직 실행되지 않아 축적된 것처럼 보일 수도 있기 때문입니다. 신뢰할 수 있는 누수 탐지를 위해서는 GC 사이클을 인식하는 다중 요인 분석이 필요합니다.
GC 인식 다중 요인 분석
정교한 누수 탐지는 단순한 추세 분석을 넘어, GC의 동작까지 고려해야 합니다. 앞서 살펴본 것처럼 진짜 누수란 루트에서 도달 가능한 참조가 남아 있어 GC가 수거하지 못하는 메모리의 축적입니다. 따라서 GC가 실행된 후에도 회복되지 않는 메모리 증가만이 진짜 누수 신호이며, GC 사이클을 감지하고 GC 후의 베이스라인 변화를 추적하는 것이 알고리즘의 핵심입니다.
GC 이벤트 감지
연속된 샘플에서 힙 크기가 10% 이상 급격히 감소한 지점을 GC 이벤트로 판별합니다. performance.memory는 GC 실행 여부를 직접 알려주지 않으므로, 메모리 감소 패턴을 간접 지표로 활용하는 것입니다.
베이스라인 추적
각 GC 이벤트 직후의 힙 크기를 기록하여 베이스라인(baseline)을 산출합니다. 정확한 베이스라인을 수립하려면 최소 2회 이상의 GC 사이클이 관찰되어야 하며, GC 이벤트가 부족한 초기 단계에서는 수집된 샘플 중 최소 힙 값을 근사 베이스라인으로 사용합니다. 정상적인 애플리케이션에서는 GC 후 메모리가 비슷한 수준으로 돌아오지만, 누수가 있으면 GC 후의 베이스라인이 점진적으로 상승합니다. 이 차이가 핵심적인 판별 기준입니다.
다중 요인 가중 평가
단일 지표가 아닌 여러 요인을 가중 합산하여 누수 확률을 산출합니다. 각 요인의 점수를 합산하여 전체 확률이 70%를 넘으면 누수로 판정합니다.
거짓 양성 보정
가중 합산만으로는 정상적인 메모리 증가를 누수로 오판할 수 있으므로, 최종 확률에 추세 기반 캡핑(capping)을 적용합니다. 메모리 추세가 감소 중이면 확률을 최대 10%로, 안정적이면 최대 30%로 제한하고, 기울기가 0 이하이거나 임계치의 30% 미만이면 최대 20%로 제한합니다. 또한 GC가 효과적으로 메모리를 회수하고 있고 베이스라인 추세가 상승하지 않는 경우에는 확률에서 20%p를 차감합니다. 이러한 다중 보정을 통해 "메모리가 실제로 줄고 있는데 누수로 판정되는" 상황을 원천 차단합니다.
| 요인 | 가중치 | 의미 |
|---|---|---|
| 기울기(Slope) | 30% | 메모리 증가 속도. 샘플당 증가량이 클수록 점수가 높아짐 |
| R²(적합도) | 20% | 증가 패턴의 일관성. 1에 가까울수록 선형적으로 꾸준히 증가 |
| GC 비효율성 | 25% | GC 실행 후에도 메모리가 회복되지 않는 정도 |
| 관찰 시간 | 15% | 충분한 시간(최소 30초) 관찰이 이루어졌는지 |
| 베이스라인 상승 | 10% | GC 후 최저점(바닥)이 점차 올라가고 있는지 |
GC 비효율성에 25%라는 높은 가중치를 부여한 이유는, 앞서 Mark-and-Sweep 알고리즘에서 살펴본 것처럼 GC의 판단 기준이 "도달 가능성"이기 때문입니다. GC가 실행되었는데도 메모리가 회복되지 않는다는 것은, 루트에서 해당 객체까지의 참조 경로가 여전히 존재한다는 직접적인 증거입니다.
알고리즘은 누수 확률(probability)과 별도로 신뢰도(confidence) 점수도 산출합니다. 신뢰도는 수집된 샘플 수, GC 관찰 횟수, 관찰 시간, 회귀 적합도(R²)를 종합하여 현재 분석 데이터의 품질을 평가한 값입니다. 누수 확률이 80%이더라도 신뢰도가 40%라면 아직 충분한 데이터가 축적되지 않았다는 의미이므로, 판정 결과를 섣불리 신뢰하기보다 관찰을 계속하는 것이 적절합니다.
React에서의 실시간 메모리 모니터링
위의 원리를 React 훅으로 구현하면, 개발 중에 메모리 상태를 실시간으로 추적하고 누수를 조기에 감지할 수 있습니다. @usefy/use-memory-monitor는 앞서 설명한 performance.memory 폴링, 선형 회귀 기반 추세 분석, GC 인식 다중 요인 누수 탐지를 하나의 훅으로 제공합니다.
import { useMemoryMonitor } from '@usefy/use-memory-monitor';
function MemoryDebugPanel() {
const {
formatted, // 사람이 읽을 수 있는 형식 (예: "45.2 MB")
severity, // 'low' | 'medium' | 'high' | 'critical'
trend, // 'increasing' | 'stable' | 'decreasing'
isLeakDetected, // 다중 요인 분석 결과
history, // 순환 버퍼에 저장된 이력 데이터
takeSnapshot, // 특정 시점의 힙 상태 캡처
compareSnapshots, // 두 스냅샷 간 차이 비교
getLeakAnalysis, // 누수 분석 상세 정보
} = useMemoryMonitor({
interval: 1000,
leakDetection: {
enabled: true,
sensitivity: 'medium', // 'low' | 'medium' | 'high'
sampleSize: 10,
minDuration: 30000, // 최소 30초 관찰 후 판정
},
onLeakDetected: (analysis) => {
console.warn('메모리 누수 감지:', {
probability: analysis.probability,
slope: analysis.averageGrowth,
gcEffective: analysis.gcAnalysis?.isGCEffective,
});
},
});
return (
<div>
<p>힙 사용량: {formatted.heapUsed} / {formatted.heapLimit}</p>
<p>심각도: {severity} | 추세: {trend}</p>
{isLeakDetected && <p>⚠️ 메모리 누수가 감지되었습니다</p>}
</div>
);
}이 훅은 탭이 비활성화되면 자동으로 폴링을 중단하여 불필요한 리소스 소모를 방지하고, useSyncExternalStore를 사용하여 React 18의 동시성 모드에서도 안정적으로 동작합니다. performance.memory를 지원하지 않는 브라우저에서는 DOM 노드 수 기반의 폴백 전략으로 자동 전환되므로, Firefox나 Safari 환경에서도 제한적인 모니터링이 가능합니다.
UI가 포함된 완성형 컴포넌트가 필요하다면 @usefy/memory-monitor를 사용할 수 있습니다. 실시간 게이지, 메모리 이력 차트, 스냅샷 비교, HTML 리포트 생성까지 포함된 슬라이드인 패널을 제공하며, Ctrl + Shift + M 단축키로 토글할 수 있습니다.
스냅샷을 활용한 구간별 메모리 분석
DevTools의 Heap Snapshot과 유사하게, takeSnapshot으로 특정 시점의 메모리 상태를 캡처하고 compareSnapshots로 두 시점 사이의 차이를 비교할 수 있습니다. 예를 들어 모달 열기 전과 닫은 후의 스냅샷을 비교하면, 해당 컴포넌트의 메모리 정리가 정상적으로 이루어졌는지 프로그래밍적으로 검증할 수 있습니다. 이 방식은 앞서 소개한 DevTools 워크플로의 기준점 스냅샷 → 동작 반복 → 비교 과정을 코드로 자동화한 것입니다.
WeakRef와 약한 참조 도구들
JavaScript는 가비지 컬렉터와 협력할 수 있는 약한 참조(weak reference) API를 제공합니다. 약한 참조란 객체를 참조하되, 그 참조가 GC를 방해하지 않는 것을 말합니다.
WeakMap과 WeakSet
WeakMap은 키가 약한 참조인 Map입니다. 키로 사용된 객체에 대한 다른 참조가 모두 사라지면, 해당 키-값 쌍은 자동으로 GC의 수거 대상이 됩니다. 이 특성은 객체에 메타데이터를 연결하면서도 메모리 누수를 방지해야 할 때 유용합니다.
// ✅ WeakMap: DOM 요소가 제거되면 캐시도 자동 해제
const computedCache = new WeakMap();
function getComputedLayout(element) {
if (computedCache.has(element)) {
return computedCache.get(element);
}
const result = expensiveLayoutCalculation(element);
computedCache.set(element, result);
return result;
}
// element가 DOM에서 제거되고 다른 참조도 없어지면
// computedCache의 해당 엔트리도 GC에 의해 자동 정리됨WeakSet은 동일한 원리로 동작하는 Set입니다. 방문한 노드를 추적하거나, 특정 객체에 플래그를 부여할 때 사용할 수 있으며, 대상 객체가 GC되면 자동으로 제거됩니다.
주의할 점은 WeakMap과 WeakSet은 열거(iteration)가 불가능하다는 것입니다. 키 목록을 순회하거나 크기를 확인할 수 없습니다. 이는 GC의 비결정적 타이밍과 관련된 설계 결정으로, 열거를 허용하면 GC 실행 시점에 따라 결과가 달라져 프로그램의 동작이 비결정적이 되기 때문입니다.
WeakRef와 FinalizationRegistry
WeakRef(ES2021)는 개별 객체에 대한 약한 참조를 직접 생성할 수 있게 합니다. deref() 메서드로 원본 객체에 접근하되, 객체가 이미 GC되었다면 undefined를 반환합니다.
// ❌ 강한 참조 캐시: 원본 객체가 필요 없어져도 캐시가 유지시킴
const cache = new Map();
function cacheResult(key, value) {
cache.set(key, value); // value에 대한 강한 참조
}
// ✅ WeakRef 캐시: 원본 객체가 GC되면 캐시 엔트리도 무효화
const cache = new Map();
function cacheResult(key, value) {
cache.set(key, new WeakRef(value));
}
function getCached(key) {
const ref = cache.get(key);
if (!ref) return undefined;
const value = ref.deref();
if (value === undefined) {
cache.delete(key); // 이미 GC된 엔트리 정리
return undefined;
}
return value;
}위 패턴에는 한 가지 한계가 있습니다. Map의 키와 WeakRef 래퍼 객체 자체는 getCached를 통해 접근할 때만 정리됩니다. 다시 접근되지 않는 항목은 키와 빈 WeakRef 래퍼가 영구적으로 남게 됩니다. 이 문제를 해결하기 위해 FinalizationRegistry를 함께 사용할 수 있습니다.
FinalizationRegistry는 객체가 GC에 의해 수거될 때 콜백을 실행할 수 있는 메커니즘입니다. 외부 리소스(파일 핸들, 네트워크 연결 등)의 정리는 물론, 위와 같은 WeakRef 캐시에서 stale 엔트리를 자동으로 제거하는 데에도 활용할 수 있습니다.
const registry = new FinalizationRegistry((heldValue) => {
console.log(`객체가 수거됨. 연관 데이터: ${heldValue}`);
// 외부 리소스 정리 로직
});
function createTrackedObject(id) {
const obj = { data: new ArrayBuffer(1024 * 1024) }; // 1MB
registry.register(obj, id); // obj가 GC되면 콜백에 id가 전달됨
return obj;
}WeakRef와 FinalizationRegistry 사용 시 주의
GC의 실행 시점은 엔진에 따라 다르고 예측할 수 없습니다. FinalizationRegistry 콜백이 호출되는 시점도, 호출 여부조차도 보장되지 않습니다(페이지가 닫히면 콜백 없이 메모리가 해제될 수 있습니다). 따라서 핵심 로직이 이들의 동작 시점에 의존해서는 안 되며, 캐시 최적화나 리소스 정리의 보조 수단으로만 사용하는 것이 바람직합니다. TC39 제안서에서도 "가능하면 사용하지 않는 것이 좋다"고 명시하고 있습니다.
성능 관점에서의 메모리 최적화
GC가 아무리 최적화되어 있어도, 메모리 할당 자체가 공짜는 아닙니다. 할당 빈도가 높으면 Minor GC가 자주 실행되고, 승격되는 객체가 많아지면 Major GC의 빈도도 올라갑니다. 특히 애니메이션이나 이벤트 핸들러처럼 반복적으로 실행되는 코드에서의 불필요한 할당은 GC 압력을 높여 프레임 드롭으로 이어질 수 있습니다.
// ❌ 매 프레임 객체 할당: Minor GC 빈도 증가
function animate() {
const position = { x: element.offsetLeft, y: element.offsetTop }; // 매번 새 객체
const velocity = { dx: position.x - prevX, dy: position.y - prevY }; // 또 새 객체
updatePhysics(position, velocity);
requestAnimationFrame(animate);
}
// ✅ 객체 재사용: 할당 최소화
const position = { x: 0, y: 0 };
const velocity = { dx: 0, dy: 0 };
function animate() {
position.x = element.offsetLeft;
position.y = element.offsetTop;
velocity.dx = position.x - prevX;
velocity.dy = position.y - prevY;
updatePhysics(position, velocity);
requestAnimationFrame(animate);
}첫 번째 코드는 60fps 기준으로 초당 120개의 임시 객체를 생성합니다. 이 객체들은 곧 불필요해지지만, Young Generation을 빠르게 채워 Minor GC를 빈번하게 트리거합니다. 두 번째 코드는 객체를 미리 생성해두고 속성만 갱신하므로 추가 할당이 발생하지 않습니다.
메모리 사용량이 극단적으로 높아지면 브라우저 자체의 방어 메커니즘이 동작합니다. 탭의 메모리가 임계치를 넘으면 브라우저는 백그라운드 탭을 자동으로 폐기(discard)하거나, 전경 탭이라도 강제 크래시시킬 수 있습니다. Chrome의 경우 chrome://discards/ 페이지에서 탭별 메모리 상태를 확인할 수 있습니다.
이러한 메모리 최적화는 Core Web Vitals의 INP에 직접적으로 영향을 미칩니다. GC 일시 정지가 사용자 입력 처리를 지연시키면 INP가 악화되기 때문입니다. 메모리를 효율적으로 관리하는 것은 단순히 크래시를 방지하는 것을 넘어, 사용자 경험의 응답성을 개선하는 일이기도 합니다.
정리
핵심 정리
-
스택과 힙: 원시값은 스택에, 객체는 힙에 할당됩니다. 가비지 컬렉터는 힙 메모리를 관리하며, 스택은 함수 반환 시 자동으로 정리됩니다.
-
Mark-and-Sweep: 현대 GC의 핵심은 "루트에서 도달 가능한가"입니다. 도달 불가능한 객체는 순환 참조 여부와 관계없이 수거되며, 이는 참조 카운팅의 근본적 한계를 해결합니다.
-
V8의 세대별 GC: Young Generation은 Scavenger로 자주, Old Generation은 Mark-Sweep-Compact로 드물게 수집합니다. "대부분의 객체는 일찍 죽는다"는 세대 가설이 이 설계의 근거입니다.
-
메모리 누수 방지: 이벤트 리스너, 타이머, 분리된 DOM 노드, 무한 성장 캐시가 주요 원인입니다.
useEffectcleanup,clearInterval, 참조 null 처리, WeakMap 활용으로 대응합니다. -
DevTools 활용: Heap Snapshot 비교와 Retainers 추적으로 누수 원인을 정확히 찾을 수 있습니다. Performance Monitor의 JS Heap Size 그래프로 빠르게 누수 여부를 감지할 수 있습니다.
-
프로그래밍적 모니터링:
performance.memoryAPI와 선형 회귀 기반 추세 분석을 결합하면, GC 사이클을 인식하는 다중 요인 분석으로 메모리 누수를 자동 탐지할 수 있습니다.
참고 자료
- V8 Blog - Trash talk: the Orinoco garbage collector
- V8 Blog - Concurrent marking in V8
- MDN - Memory Management
- Chrome DevTools - Fix memory problems
- Chrome DevTools - Record heap snapshots
- web.dev - Monitor your web page's total memory usage
- MDN - WeakRef
- MDN - FinalizationRegistry
- TC39 - WeakRefs Proposal
- MDN - performance.memory
- web.dev - measureUserAgentSpecificMemory()
- @usefy/use-memory-monitor - React 메모리 모니터링 훅
- @usefy/memory-monitor - 메모리 모니터링 UI 컴포넌트