이번에 GC를 공부하면서 단순히 “자동으로 메모리를 정리해주는 기능” 이라는 개념에서 벗어나, V8 엔진이 얼마나 정교하게 메모리를 관리하는지 알게 되었습니다.
특히 Incremental GC나 Concurrent GC처럼 사용자 경험을 방해하지 않으려는 최적화 노력이 흥미로웠습니다.
프로젝트에서 실제로 이미지 처리 로직 부분에서 GC를 너무 믿어 브라우저 메모리가 터진 경험이 있었는데, 이번 기회에 동작 원리에 대해 자세히 이해할 수 있어 좋았습니다.
“GC가 있다고 방심하지 말고, 불필요한 참조는 직접 정리하는 습관” 이 필요하다는 교훈을 얻었고,
'Chrome DevTools Memory 탭 지표만으로도 GC 동작을 제대로 파악할 수 있는지?', 'V8 엔진이 실제로 클로저 변수를 어떻게 관리하고, GC가 어떤 기준으로 수거하는지' 더 깊이 분석해보고 싶습니다.
정의
JavaScript V8 엔진의 Garbage Collection은 더 이상 사용되지 않는 메모리를 자동으로 해제하여 메모리 누수를 방지하는 메모리 관리 기법입니다.
자바스크립트의 GC는 단순히 “언젠가 자동으로 메모리를 비워준다”가 아니라 V8 엔진은 객체가 도달 가능한지를 기준으로, 더 이상 필요 없는 메모리를 찾아내고 해제합니다. 여기서 ‘도달 가능’이란 전역 객체, 현재 실행 중인 함수의 지역 변수, 아직 닫히지 않은 클로저 등 루트(Root)에서 참조 체인을 따라가면 닿을 수 있는 상태를 말합니다. 반대로 루트에서 어느 경로로도 도달할 수 없는 객체는 쓰레기로 간주되어 수거 대상이 됩니다. 이 기준 덕분에 V8은 옛날 브라우저가 쓰던 참조 카운팅(ref-counting) 방식이 해결 못 하던 순환 참조도 잘 정리합니다. (두 객체가 서로 가리키더라도 루트에서 갈 수 없다면 수거되기 때문입니다)
V8은 기본적으로 Mark-and-Sweep 알고리즘을 기반으로 하며, Generational GC(세대별 가설) 전략을 적용해 메모리를 New space(Young Generation)과 Old space로 나누어 각각 다른 GC 방식을 사용합니다.
1. 메모리 공간 분할 전략

V8은 이 판단을 Mark-and-Sweep(표시-쓸기) 알고리즘으로 수행합니다. 먼저 루트에서 시작해 살아 있는 객체를 마킹(mark) 하고, 마킹되지 않은 영역을 쓸어내(sweep) 메모리를 회수합니다. 그런데 모든 객체를 매번 전부 훑으면 비싸기 때문에, V8은 메모리를 세대(Generations) 로 나눠 비용을 줄입니다. “대부분의 객체는 금방 죽는다(Generational Hypothesis)”는 가정에 기대어, 새로 생성된 객체는 New space(Young generation) 에, 여러 번의 검사에서 살아남은 객체는 Old space 로 보냅니다. Young 쪽은 작고 짧게 살 가능성이 커서 Minor GC 라는 가벼운 절차로 자주 청소합니다. 이때 복사하는(copying) 방식(= Cheney) 을 사용해서 살아 있는 애들만 From-space → To-space 로 복사하고, 두 번 이상 살아남은 객체는 Old space로 승격(promotion) 시킵니다. 반면 Old space는 데이터가 크고 오래 살아서, Major GC 가 상대적으로 덜 자주 돌지만 한 번 돌 때 Mark → Sweep → Compact까지 거치기 때문에 비용이 큰 작업입니다.
2. 이중 GC 시스템
1) Minor GC (Scavenge)

- New space에서 발생하는 작은 단위의 GC
- Copying GC (Cheney 알고리즘) 기반
- From-space ↔ To-space를 번갈아 사용하며 살아남은 객체만 복사
- 두 번 살아남으면 Old space로 승격
2) Major GC (Mark-and-Sweep & Compact)

- Old space에서 발생하는 본격적인 GC
- Mark → Sweep → Compact 단계 진행
- 실행 빈도는 낮지만 실행 시간이 길어 성능에 영향이 큼
3. 성능 최적화 기법 (Orinoco 프로젝트)
문제는 GC가 돌아가는 동안 메인 스레드를 멈춰야(Stop-the-world) 정확한 세계를 볼 수 있다는 점입니다. 사용자는 앱이 순간적으로 돌아가지 않는 것처럼 느껴질 수 있기 때문에, V8은 Orinoco 라는 프로젝트에서 여러 최적화를 넣었습니다. Incremental GC 는 마킹을 잘게 쪼개 렌더링과 이벤트 처리 사이사이에 끼워 넣고, Parallel GC는 보조 스레드로 병렬 처리, Concurrent GC는 동시에 수행하며 메인 스레드가 덜 멈추게 합니다. 덕분에 긴 멈춤이 짧고 미세한 멈춤으로 바뀌며 사용자가 체감하는 UX가 개선됩니다.
- Incremental GC: GC 작업을 잘게 나누어 Main Thread 작업 중간에 끼워넣는 방식 → 앱이 멈추는 현상(Stop-the-world) 방지

- Parallel GC: Helper thread를 이용해 병렬 처리 → Minor GC 속도 개선

- Concurrent GC: Main thread 실행과 동시에 일부 GC 작업 수행 → 체감 성능 향상

4. Write Barrier
세대가 나뉘면 또 한 가지 문제가 생깁니다. Old 객체가 New 객체를 참조하는 경우에 Minor GC가 New만 훑을 때 Old를 전부 스캔하지 않으면 참조 누락이 생길 수 있습니다. 이를 막는 장치가 Write Barrier인데요, Old에서 New로 참조하는 경우가 생길 때마다 카드 테이블/ Set 같은 구조에 표시를 해둬서, Minor GC가 그 표지만 빠르게 확인하도록 합니다. 덕분에 New만 빠르게 돌면서도 정확성을 잃지 않습니다. 실무 성능 최적화의 핵심 기법 중 하나입니다.
- Old 객체가 New 객체를 참조할 경우 별도 리스트로 관리
- Minor GC 시 Old space 전체를 뒤지지 않아도 참조 관계를 빠르게 파악 가능
- 성능 최적화 핵심 기술 중 하나
실제 사용 예시
1. 일반적인 메모리 해제
function createUser() {
let user = { name: "daisy", age: 24 };
return null;
}
createUser();
// user는 참조가 사라져 Minor GC의 대상이 됩니다.
이 예시는 함수 내부에서 객체를 생성했다가 함수가 끝나면 참조가 사라지는 상황입니다. user 변수는 Stack(스택)에 있고, 실제 { name: "daisy", age: 24 } 객체는 Heap(힙)에 저장됩니다. 함수가 종료되면 user 변수 자체는 스택에서 사라지지만, 힙에 있는 객체는 여전히 존재합니다.
이 시점에서 객체를 가리키는 참조(reference) 가 더 이상 없으므로, GC는 이를 “unreachable”로 판단하고 Mark-and-Sweep 단계에서 제거합니다. 즉, 루트(전역 객체) 로부터 더 이상 접근할 수 없으니, 다음 GC 주기 때 메모리에서 해제됩니다. 이 과정은 V8의 Minor GC (Young Generation)에서 빠르게 수행됩니다.
2. 순환 참조 해결
function circularReference() {
var x = {};
var y = {};
x.a = y;
y.a = x;
}
circularReference();
// Reference Counting 방식이면 해제 불가하지만
// Mark-and-Sweep은 Root에서 도달 불가 → 정상 해제
해당 예제는 x와 y가 서로를 참조하고 있습니다. 옛날 브라우저의 참조 카운팅(reference counting) 방식에서는 이런 구조가 문제였습니다. 서로 참조 중이라 GC가 해제하지 못해 메모리 누수 발생하는 경우가 생겼습니다.
하지만 V8의 Mark-and-Sweep은 참조 개수가 아니라 도달 가능성(reachability)을 기준으로 판단하기 때문에 함수가 끝나면 x와 y 모두 더 이상 전역 스코프에서 접근할 수 없으므로 루트에서의 경로가 끊깁니다. 따라서 이 둘은 서로 연결돼 있어도 루트로부터 도달 불가하므로 정상적으로 수거됩니다. 덕분에 순환 참조 문제를 자동으로 해결할 수 있습니다.
3. 메모리 누수 케이스
(1) 전역 변수 누수
// (1) 전역 변수 누수
var globalArray = [];
function addData() {
globalArray.push(new Array(1000000).fill('data'));
}
globalArray는 전역 객체(window나 globalThis)에 속하기 때문에, 이 배열에 계속 데이터를 푸시하면 참조가 끊기지 않은 채 누적됩니다. GC는 도달 불가능한 객체만 제거하므로, globalArray가 살아 있는 한 내부 배열들도 GC 대상이 되지 않습니다. 이런 누적은 브라우저 탭을 오래 열어둘 때 메모리 점유가 계속 증가하는 원인이 됩니다.
해결하는 방법: 필요 없는 데이터는 globalArray = [] 나 length = 0 으로 직접 비워주기
(2) DOM 참조 누수
// (2) DOM 참조 누수
function setupEvent() {
var element = document.getElementById('button');
var data = new Array(1000000).fill('heavy data');
element.onclick = function() {
console.log(data[0]); // 클로저 때문에 data 해제 안 됨
};
}
여기서는 클로저와 DOM의 결합이 문제인 상황입니다. setupEvent()가 끝나도 내부 onclick 함수는 여전히 data를 참조하고 있습니다.
이 이벤트 핸들러가 element에 연결되어 있는 동안 data 배열은 GC 대상이 되지 않기 때문에, 화면에서 버튼이 사라졌더라도 만약 이벤트 리스너를 제거하지 않았다면 해당 element와 data는 여전히 서로 참조 관계를 가지고 있어 메모리 누수가 발생합니다. 이 경우 브라우저가 화면에는 안 보이는데 메모리는 계속 먹는 현상이 생깁니다.
해결하는 방법: 이벤트 리스너를 명시적으로 제거(element.onclick = null)하거나, WeakRef/WeakMap 구조를 사용해 GC가 참조를 자동 정리하도록 하는 것이 좋습니다.
보조 개념 정리
- Reachability: 전역 객체나 실행 중인 함수에서 도달 가능한 객체만 살아남음
- Generation Hypothesis: 대부분 객체는 금방 죽는다 → 세대별 GC 전략
- Stop-the-World: GC 동안 모든 실행 중단 → V8은 Incremental/Concurrent로 해결
- Memory Compaction: 단편화 해결 위해 객체들을 연속적으로 재배치
- Stack (스택)
- GC 대상 아님: 함수 호출이 끝나면 자동으로 메모리 해제
- 저장 데이터: 원시값 (number, string, boolean 등), 함수 매개변수, 지역변수
- 메모리 관리: LIFO(Last In First Out) 구조로 컴파일러가 자동 관리
function example() { let num = 42; // Stack에 저장 let str = "hello"; // Stack에 저장 } // 함수 끝나면 Stack에서 자동 제거 (GC 불필요)
- Heap (힙)
- GC 대상 맞음: 참조가 없어져도 GC가 명시적으로 제거해야 함
- 저장 데이터: 객체, 배열, 함수 등 참조 타입
- 메모리 관리: 동적 할당, V8 GC가 Mark-and-Sweep으로 관리
function example() {
let obj = { name: "test" }; // Stack: obj 변수, Heap: { name: "test" } 객체*
let arr = [1, 2, 3]; // Stack: arr 변수, Heap: [1, 2, 3] 배열
} // 함수 끝나도 Heap의 객체들은 GC가 나중에 정리
또 자주 헷갈리는 개념이 스택과 힙입니다. 스택은 함수 호출과 함께 자동으로 잡히고 자동으로 사라지는 영역이라 GC 대상이 아닙니다(원시값, 지역 변수, 매개변수 등) 반면 힙은 동적으로 할당되는 객체/배열/함수 들이 살고, 이건 참조가 끊겨야만 GC로 회수됩니다. 함수가 끝났다고 힙 객체가 곧장 사라지지 않는 이유가 여기에 있고, 클로저가 캡처한 변수처럼 여전히 참조가 남아 있으면 GC 대상이 아닙니다. 이벤트 핸들러 안에서 큰 배열을 캡처하고 DOM 노드를 계속 붙잡아두면, 화면에서 사라져도 메모리가 회수되지 않는 이유가 이것입니다. 장수 객체 가 Old space로 승격돼 Major GC 비용과 단편화(compaction) 부담을 키웁니다.
Reference
'📚 CS > JavaScript' 카테고리의 다른 글
| [JavaScript] 프로토타입(Prototype)과 클래스(Class) (0) | 2025.09.03 |
|---|---|
| [JavaScript] 스코프 체인 (Scope Chain) (1) | 2025.08.31 |
| [JavaScript] this 바인딩 (0) | 2025.08.30 |
| [JavaScript] 실행 컨텍스트와 호이스팅 (1) | 2025.08.27 |
| [JavaScript] 클로저(Closure)란? (1) | 2025.08.24 |