garbage collection과 memory leak을 설명해 주세요
- 예상 시간
- 7분
Garbage collection은 프로그램이 더 이상 접근할 수 없는 객체를 찾아 메모리를 회수하는 방식입니다. 보통 전역 객체, 현재 실행 중인 stack, register 같은 root에서 참조를 따라가며 도달 가능한 객체는 살아 있다고 보고, 도달 불가능한 객체를 정리 대상으로 봅니다.
Memory leak은 더 이상 필요 없는 데이터가 계속 도달 가능한 상태로 남아 메모리가 회수되지 않는 문제입니다. 무제한으로 커지는 cache, 정리되지 않은 listener나 timer, 오래 남는 closure가 큰 객체를 붙잡는 경우가 대표적입니다. GC가 있어도 참조가 남아 있으면 collector는 그 객체를 안전하게 지울 수 없습니다.
GC가 등장하기 전에는 개발자가 malloc으로 할당하고 free로 직접 해제했습니다. 해제를 빠뜨리면 메모리가 누수되고, 해제된 메모리를 다시 접근하면 dangling pointer 문제가, 같은 메모리를 두 번 해제하면 double free 문제가 생겼습니다. 이 버그들은 재현하기 어렵고 찾기도 힘들었습니다.
GC는 1960년 Lisp에서 처음 도입된 개념입니다. 개발자 대신 런타임이 "이 객체가 아직 필요한가"를 판단해 메모리 관리를 대신 맡습니다. 이후 Java, Python, JavaScript, Go 같은 언어들이 GC를 기본으로 채택하면서 메모리 관련 버그를 크게 줄였습니다.
꼬리질문
조금 더 깊게 물어본다면
답변 뒤에 이어질 수 있는 질문들을 하나씩 열어볼 수 있어요.
도달 가능성(reachability)이란?
전역 객체, 실행 stack, register 같은 root에서 참조를 따라가 도달할 수 있는지를 뜻합니다. 도달 가능하면 GC는 보통 살아 있다고 봅니다.
그래서 참조가 남아 있는 객체는 실제로 더 이상 쓰이지 않아도 회수되지 않을 수 있습니다.
root set은 구체적으로 현재 실행 중인 함수의 local variable, 전역 변수, CPU 레지스터에 담긴 참조들입니다. GC는 이 root들에서 시작해 참조 그래프를 따라 모든 도달 가능한 객체를 찾습니다.
mark-and-sweep 구현에서는 tri-color marking을 씁니다. 처음에 모든 객체는 white(미방문)입니다. GC가 처음 발견한 객체는 gray(방문했지만 자식을 아직 확인 안 함)로 바꾸고 큐에 넣습니다. 큐에서 꺼내 자식을 모두 확인하면 black(완전히 처리됨)으로 바꿉니다. 모든 처리가 끝났을 때 여전히 white인 객체는 root에서 도달 불가능하므로 회수 대상입니다.
mark-and-sweep은 무엇인가요?
root에서 시작해 도달 가능한 객체를 mark하고, mark되지 않은 객체를 sweep 단계에서 회수하는 방식입니다.
세부 구현은 런타임마다 다르지만, 도달 가능한 객체와 도달 불가능한 객체를 나눈다는 기준은 GC를 이해하는 기본 모델이 됩니다.
고전적인 mark-and-sweep은 전체 객체 그래프를 순회하는 동안 프로그램을 멈춰야 합니다(stop-the-world). 프로그램이 실행되는 도중에 객체 참조가 바뀌면 GC가 올바른 결과를 보장할 수 없기 때문입니다.
세대별 가설(generational hypothesis)이 이 비용을 줄이는 핵심 아이디어입니다. "대부분의 객체는 금방 죽는다"는 관찰에 기반해, young generation을 훨씬 자주 작은 범위에서만 스캔합니다. long-lived 객체만 old generation으로 승격해 드물게 전체 GC를 실행합니다. V8의 Scavenger(minor GC)와 Mark-Compact(major GC)가 이 구조로 동작합니다.
reference counting의 한계는?
객체가 참조될 때마다 카운트를 늘리고, 참조가 끊어지면 줄여 0이 되면 회수하는 방식입니다. 구현이 단순하고 회수가 즉시 일어나는 장점이 있습니다.
다만 순환 참조가 생기면 두 객체가 서로를 가리켜 카운트가 0이 되지 않아 회수되지 않습니다. CPython은 reference counting을 기본으로 쓰면서 이를 보완하기 위해 cyclic garbage collector를 별도로 실행합니다.
reference counting의 장점은 회수 시점이 결정론적(deterministic)이라는 점입니다. 참조가 끊기는 즉시 메모리가 해제되므로 GC pause가 없습니다. Swift와 Objective-C의 ARC(Automatic Reference Counting)가 이 방식입니다. 컴파일러가 적절한 위치에 retain/release 호출을 자동으로 삽입합니다.
Rust는 다른 방법으로 접근합니다. 소유권(ownership) 시스템과 borrow checker가 컴파일 타임에 객체 수명을 검증해, GC도 reference counting도 없이 use-after-free와 double free를 원천 차단합니다.
순환 참조는 GC에서 어떻게 처리되나요?
mark-and-sweep 방식의 GC는 root에서 도달 가능한지를 기준으로 삼기 때문에, 서로를 참조하지만 root에서 도달할 수 없는 순환 참조 그룹은 정상적으로 회수됩니다.
반면 reference counting 방식은 순환 참조가 생기면 카운트가 0이 되지 않아 회수하지 못합니다. CPython은 이를 보완하기 위해 별도의 cyclic garbage collector를 실행합니다. C++의 shared_ptr도 같은 이유로 weak_ptr을 통해 순환을 끊어야 합니다.
구형 IE의 JavaScript 엔진은 reference counting을 써서, DOM 객체와 JavaScript 객체 사이의 순환 참조가 생기면 누수가 발생했습니다. JavaScript 객체가 DOM 노드를 참조하고, DOM 노드의 이벤트 핸들러가 JavaScript 객체를 클로저로 잡는 구조가 대표적인 예입니다. 현대 엔진은 mark-and-sweep을 써서 이 문제가 없습니다.
JavaScript의 WeakMap과 WeakSet은 key가 다른 강한 참조 없이 GC 대상이 되면 entry가 자동으로 사라지는 자료구조입니다. DOM 노드에 보조 데이터를 붙일 때, 노드가 제거되면 데이터도 같이 정리되길 원하는 경우에 씁니다.
GC pause는 왜 생기나요?
객체 그래프를 추적하고 이동하거나 회수하는 과정에서 프로그램 실행을 잠시 멈춰야 하는 구간이 생길 수 있습니다. 현대 GC는 pause를 줄이기 위해 여러 기법을 씁니다.
객체를 이동(compact)할 때 그 객체를 가리키는 모든 포인터를 새 주소로 업데이트해야 합니다. 프로그램이 실행되는 도중에 포인터를 업데이트하다 일부만 바뀐 상태를 프로그램이 읽으면 위험합니다. 그래서 고전적 GC는 이 과정에서 프로그램을 멈춥니다.
현대 GC는 pause를 줄이기 위해 여러 접근을 씁니다. incremental GC는 작업을 작게 나눠 조금씩 처리합니다. concurrent GC는 GC 스레드를 프로그램과 병렬로 실행합니다. write barrier는 프로그램이 포인터를 수정할 때 GC에게 알려줘 concurrent 실행 중 발생하는 변경을 추적합니다.
Java의 ZGC는 이 기법들을 조합해 힙 크기에 관계없이 1ms 이하의 pause를 목표로 합니다. colored pointer(64비트 포인터의 사용하지 않는 비트에 GC 메타데이터를 저장)를 활용해 load barrier로 동시 이동을 구현합니다.
manual memory management와 GC의 trade-off는?
수동 관리는 제어권이 크지만 dangling pointer나 double free 위험이 있습니다. GC는 안전성과 생산성이 좋지만 pause와 예측 어려움이 생길 수 있습니다.
실시간 시스템(게임 엔진, 임베디드, 오디오 처리)에서는 GC pause가 치명적입니다. 16ms(60fps) 안에 프레임을 완성해야 하는 게임에서 GC가 수십ms 동안 멈추면 프레임이 드롭됩니다. 이런 환경에서는 GC 언어를 피하거나, GC를 쓰더라도 hot path에서 할당을 최소화해 GC를 유도하지 않는 방식으로 작성합니다.
Rust는 두 단점을 모두 피하는 방향을 택했습니다. 소유권과 borrow checker가 컴파일 타임에 메모리 안전성을 검증해, GC 없이도 dangling pointer와 double free를 막습니다. 대신 그 규칙을 학습하고 코드를 맞추는 비용이 있습니다.
JavaScript에서 GC는 어떻게 동작하나요?
JavaScript 엔진은 객체의 도달 가능성을 기준으로 heap을 관리합니다. 전역 객체, 현재 call stack, closure, DOM과 연결된 참조 등이 root나 root에서 이어지는 참조가 될 수 있습니다.
브라우저에서는 제거된 DOM node를 계속 참조하거나, event listener를 정리하지 않거나, React effect cleanup에서 timer와 subscription을 해제하지 않으면 누수가 생길 수 있습니다. WeakMap이나 WeakSet은 key 객체의 수명에 맞춰 보조 데이터를 관리할 때 도움이 됩니다.
V8은 세대별 GC를 씁니다. 새로 할당된 객체는 young generation(nursery)에 들어갑니다. Scavenger(minor GC)가 young generation에서 살아남은 객체를 intermediate 영역으로 이동시키고, 두 번 살아남으면 old generation으로 승격합니다. old generation은 Mark-Compact(major GC)가 처리합니다.
Scavenger는 Cheney's algorithm을 씁니다. 힙을 두 반쪽으로 나눠 살아 있는 객체를 한쪽에서 다른 쪽으로 복사하고, 원래 반쪽 전체를 버립니다. 이렇게 하면 메모리 단편화 없이 compact한 상태가 유지됩니다.
React 환경에서 흔한 누수 패턴은 useEffect cleanup 누락입니다. setInterval, addEventListener, WebSocket subscription 등을 cleanup 함수에서 해제하지 않으면 컴포넌트가 언마운트돼도 콜백이 계속 살아 있습니다.
useEffect(() => {
const id = setInterval(() => { /* ... */ }, 1000);
return () => clearInterval(id); // 이게 없으면 누수
}, []);Java에서 GC는 어떻게 동작하나요?
Java는 JVM heap에 생성된 객체를 GC가 관리합니다. stack frame의 local variable, static field, JNI 참조 같은 GC root에서 도달 가능한 객체는 살아 있다고 보고, 도달 불가능한 객체는 회수 대상이 됩니다.
현대 JVM은 보통 세대별 가설을 활용합니다. 새 객체는 young generation에 만들고, 오래 살아남은 객체는 old generation으로 승격합니다. G1, ZGC, Shenandoah처럼 pause time과 throughput의 균형을 다르게 잡는 collector들이 있습니다.
G1GC(Garbage First)는 힙을 고정 크기 region으로 나눕니다. young/old 영역이 연속된 메모리 공간이 아니라 region들의 집합입니다. GC할 때 garbage 비율이 높은(Garbage First) region을 우선 처리해 효율을 높입니다.
ZGC는 더 나아가 힙 크기(수백GB)에 관계없이 pause를 1ms 이하로 목표합니다. 64비트 포인터의 상위 비트에 GC 상태를 저장(colored pointer)하고, load barrier를 통해 프로그램이 포인터를 읽을 때마다 GC 상태를 확인합니다. 이렇게 concurrent하게 객체를 이동할 수 있습니다.
C++에서는 GC가 어떻게 다른가요?
C++은 일반적으로 언어 런타임 GC에 의존하지 않고 개발자가 객체 수명을 관리합니다. new/delete를 직접 쓰면 해제 누락, double free, dangling pointer 같은 문제가 생길 수 있습니다.
현대 C++에서는 RAII와 smart pointer로 수명을 표현하는 경우가 많습니다. unique_ptr은 단일 소유권, shared_ptr은 참조 카운팅 기반 공유 소유권을 표현합니다. 다만 shared_ptr끼리 순환 참조를 만들면 참조 카운트가 0이 되지 않아 누수가 생길 수 있고, 이때 weak_ptr로 순환을 끊습니다.
RAII(Resource Acquisition Is Initialization)는 리소스 획득을 객체 생성에, 해제를 소멸자에 묶는 패턴입니다. 객체가 스코프를 벗어나면 소멸자가 결정론적으로 호출되어 파일 핸들, 뮤텍스, 네트워크 연결 같은 리소스가 자동으로 해제됩니다.
{
std::unique_ptr<int> p = std::make_unique<int>(42);
// 스코프를 벗어나면 자동으로 delete
}이 방식은 GC pause 없이 결정론적 해제를 보장합니다. 단, 개발자가 소유권 구조를 명시적으로 설계해야 합니다.
memory leak은 어떻게 확인하나요?
heap snapshot, allocation timeline, RSS/heap 사용량 추이, 객체 retain path를 봅니다. 재현 가능한 사용자 시나리오를 반복 실행했을 때 메모리가 계속 증가하고 내려오지 않는지 확인합니다.
단순히 메모리가 한 번 증가했다고 바로 leak이라고 단정할 수는 없습니다. GC 타이밍, cache warm-up, runtime allocator 동작까지 함께 봐야 합니다.
Chrome DevTools의 Memory 탭에서 heap snapshot을 두 시점에 찍어 비교하면 어떤 객체가 늘었는지 확인할 수 있습니다. "Retained size"는 해당 객체가 살아 있기 때문에 함께 살아 있는 객체들의 총 크기입니다. 이 값이 큰 객체가 예상치 않게 남아 있으면 누수 후보입니다.
"Retainers" 패널에서 그 객체를 붙잡고 있는 참조 경로를 거슬러 올라가면 어디에서 참조를 유지하는지 찾을 수 있습니다.
Node.js에서는 --inspect 플래그로 Chrome DevTools에 연결하거나, v8.writeHeapSnapshot()으로 스냅샷 파일을 떨어뜨린 뒤 분석합니다.
메모리 누수의 흔한 패턴은?
무제한으로 커지는 cache, 제거하지 않은 event listener, 끝난 요청의 데이터를 계속 잡는 closure, clear하지 않은 timer 등이 있습니다.
GC가 있어도 이런 패턴은 참조를 유지하므로 회수되지 않습니다. 컴포넌트 언마운트 시 timer와 subscription을 정리하고, 장기간 유지되는 캐시에는 크기 제한을 두는 것이 좋습니다.
프런트엔드에서 자주 보이는 누수 패턴을 구체적으로 보면:
- 무한 스크롤 상태 누적: 스크롤할 때마다 데이터를 state에 append하면 수천 개가 메모리에 쌓입니다. 가상화(virtualization) 없이 DOM도 함께 쌓이면 렌더링도 함께 느려집니다.
- 전역 이벤트 리스너:
window.addEventListener로 등록한 리스너는 컴포넌트가 사라져도 남습니다. 같은 컴포넌트가 여러 번 마운트/언마운트되면 리스너가 중복 등록됩니다. - closure가 이전 렌더의 참조를 잡는 경우:
setInterval콜백 안에서 컴포넌트 state를 참조하면, 인터벌이 살아 있는 한 이전 렌더의 컴포넌트 인스턴스가 GC되지 않습니다.
서버 사이드(Node.js)에서는 요청마다 전역 Map에 데이터를 저장하고 지우지 않는 패턴, EventEmitter에 리스너를 누적 등록하는 패턴이 흔합니다. Node.js는 기본 리스너 수가 10개를 넘으면 경고를 출력합니다.
부가 설명
GC의 기준은 "의미상 필요한가"가 아니라 "root에서 도달 가능한가"입니다. 개발자가 보기에는 더 이상 쓰지 않는 데이터라도 어딘가에서 참조가 남아 있으면 살아 있는 객체로 취급됩니다.
const cache = [];
function remember(value) {
cache.push(value);
}이런 cache가 계속 커지고 비워지지 않으면 내부 값들은 계속 도달 가능한 상태로 남습니다. 언어가 메모리 회수를 자동으로 해도, 오래 사는 참조 구조를 만들면 누수는 생길 수 있습니다.
GC는 수동 메모리 관리보다 dangling pointer나 double free 위험을 줄입니다. 대신 객체 그래프를 추적하고 회수하는 비용이 있고, 런타임에 따라 pause나 예측하기 어려운 지연이 생길 수 있습니다.
한 줄 정리
GC는 도달 불가능한 객체를 회수하지만, 불필요한 참조가 남아 있으면 자동 메모리 관리 환경에서도 누수는 생깁니다.