가비지 컬렉션(Garbage Collection)
1. 개요
가. 정의
가비지 컬렉션(GC)은 프로그램이 더 이상 참조하지 않아 다시 사용될 수 없는(unreachable) 힙 메모리 객체를 런타임이 자동으로 식별하고 회수하여, 개발자가 명시적으로 메모리를 해제하지 않아도 되도록 하는 자동 메모리 관리 기법이다.
가비지 컬렉션이 등장한 근본 이유는 수동 메모리 관리의 위험성에 있다. C/C++처럼 개발자가 malloc/free, new/delete로 직접 할당·해제를 책임지는 환경에서는, 해제를 잊으면 메모리 누수(memory leak)가 쌓여 장기 구동 서버가 서서히 죽고, 이미 해제한 영역을 다시 쓰면 댕글링 포인터(dangling pointer)·이중 해제(double free)로 정의되지 않은 동작과 보안 취약점(Use-After-Free)이 발생한다. 이런 오류는 재현이 어렵고 디버깅 비용이 막대하다. GC는 "객체의 수명(lifetime)을 추적하는 책임"을 사람에게서 런타임으로 옮겨, 생산성과 안정성을 동시에 끌어올리기 위해 고안되었다.
나. 등장 배경과 필요성
GC의 역사는 1959년 존 매카시가 LISP를 설계하며 리스트 구조의 순환 참조를 다루기 위해 최초로 도입한 데서 시작한다. 이후 Smalltalk·Java·C#·Go·JavaScript·Python 등 현대 주류 언어가 대부분 GC를 내장하면서, 오늘날 서버·모바일·웹 애플리케이션 대다수가 GC 위에서 돌아간다. 배경에는 애플리케이션 규모와 수명의 폭발적 증가가 있다. 수백 개의 라이브러리와 수백만 객체가 얽힌 대형 시스템에서 모든 객체의 소유권(ownership)을 사람이 추적하는 것은 현실적으로 불가능에 가깝고, 수개월간 무중단으로 도는 서버에서는 작은 누수조차 치명적이기 때문이다.
다만 GC가 "공짜"는 아니다. 자동화의 대가로 실행 시점에 회수 작업이 끼어들며, 이때 애플리케이션 스레드를 멈추는 STW(Stop-The-World) 구간이 발생해 지연(latency)과 처리량(throughput)에 영향을 준다. 따라서 현대 GC 기술의 발전사는 곧 "회수의 정확성은 유지하면서 멈춤 시간을 어떻게 줄이고 숨길 것인가"라는 질문에 대한 끊임없는 응답이라 할 수 있다. 응답성이 중요한 금융 거래 시스템이나 대화형 서비스에서는 수십 ms의 멈춤도 SLA 위반이 될 수 있어, GC 선택과 튜닝은 기술사 관점에서 중요한 설계 결정이 된다.
또한 GC는 단순히 "메모리를 비워 주는 기능"에 그치지 않고, 할당자(allocator)·런타임·언어 설계와 긴밀히 맞물린 하나의 서브시스템이다. 어떤 할당 전략을 쓰느냐(범프 포인터 vs 분리 가용 리스트), 객체 이동을 허용하느냐, 포인터에 메타데이터를 심느냐에 따라 가능한 GC 알고리즘이 달라진다. 그래서 GC를 제대로 이해한다는 것은 메모리 할당·객체 배치·런타임 장벽까지 아우르는 메모리 관리 전체의 사고틀을 갖추는 일이다.
다. 가비지 컬렉션의 특징
GC는 몇 가지 본질적 특징을 지닌다. 첫째, 도달가능성(reachability) 기반이다. "쓰레기"의 정의는 "앞으로 안 쓸 객체"가 아니라 "루트에서 참조 경로로 도달할 수 없는 객체"이며, GC는 미래를 예측하지 않고 현재의 참조 그래프만으로 안전하게 회수 대상을 판별한다. 둘째, 근사적 보수성으로, 도달 가능한 객체는 절대 회수하지 않지만(안전성), 도달 불가능한 객체를 항상 즉시 회수하지는 않는다(지연 허용). 셋째, 런타임 개입성이다. 할당 압력이 임계치를 넘으면 애플리케이션 흐름 중간에 비동기적으로 개입하므로 성능 프로파일이 불연속적이다. 넷째, 세대·지역성 활용으로, 대부분의 객체가 금방 죽는다는 경험적 가설(약한 세대 가설)을 적극 이용한다.
이러한 특징은 GC가 "있으면 메모리를 신경 쓰지 않아도 되는 마법"이 아니라, 명확한 비용과 동작 모델을 가진 자원 관리 서브시스템임을 의미한다. 기술사 관점에서 중요한 것은 GC의 존재 여부가 아니라, 그것이 언제·얼마나·어떤 방식으로 개입하여 애플리케이션의 지연과 처리량에 영향을 주는지를 정량적으로 이해하고 통제하는 능력이다. 아래에서는 GC의 작동 원리부터 현대적 초저지연 수집기까지, 그리고 이를 실무에서 운영·튜닝하는 관점을 차례로 살펴본다.
2. 가비지 컬렉션의 기본 원리와 전체 구조
GC의 출발점은 루트 집합(root set)이다. 루트란 스레드 스택의 지역 변수, CPU 레지스터, 전역·정적 변수처럼 "프로그램이 직접 손에 쥐고 있는 참조"를 말한다. GC는 이 루트에서 시작해 참조를 따라가며 도달 가능한 객체를 모두 표시하고, 표시되지 않은 나머지를 쓰레기로 간주한다. 아래 구조도는 힙을 참조 그래프로 바라본 전체 그림이다.
flowchart LR
subgraph Roots["루트 집합(Root Set)"]
S["스택 지역변수"]
G["전역/정적 변수"]
R["레지스터"]
end
S --> A["객체 A(도달 가능)"]
G --> B["객체 B(도달 가능)"]
A --> C["객체 C(도달 가능)"]
B --> C
D["객체 D(쓰레기)"] --> E["객체 E(쓰레기)"]
E -.->|"순환 참조"| D
style D fill:#f8d7da
style E fill:#f8d7da
루트 집합을 정확히 파악하는 일은 생각보다 까다롭다. 컴파일러가 각 실행 지점에서 어떤 스택 슬롯·레지스터가 실제 객체 참조를 담고 있는지 알려주는 스택 맵(stack map)을 제공해야 GC가 "정확한(precise)" 수집을 할 수 있다. 이런 정보가 없는 환경(예: 네이티브 스택을 보수적으로 스캔하는 경우)에서는 보수적(conservative) GC를 쓰는데, 포인터처럼 생긴 정수 값을 참조로 오인해 죽은 객체를 살려 두는 부정확성이 생길 수 있다. 자바·C# 같은 관리 런타임은 정확한 GC를, 일부 C/C++용 GC(Boehm GC)는 보수적 방식을 채택한다.
위 그림에서 D와 E는 서로를 참조하지만 루트에서 도달할 수 없으므로 모두 쓰레기다. 이 예시는 순환 참조(cyclic reference)가 왜 중요한지를 보여준다. 단순히 "나를 가리키는 참조 수"만 세는 방식(참조 계수)으로는 D와 E가 서로를 가리켜 계수가 0이 되지 않아 영원히 회수되지 않지만, 도달가능성 기반 추적 방식은 루트에서의 연결 여부만 보므로 이 순환 쓰레기를 정확히 걸러낸다.
GC 알고리즘은 크게 참조 계수 방식과 추적 기반(tracing) 방식으로 나뉜다. 두 접근은 "쓰레기를 언제·어떻게 판정하는가"에서 철학이 갈린다.
- 참조 계수: 참조가 끊기는 즉시 판정. 지연이 평탄하나 순환 참조를 못 잡고 상시 갱신 비용이 있다.
- 추적 기반: 주기적으로 루트부터 그래프를 훑어 판정. 순환을 정확히 회수하나 수집 시 멈춤·부하가 집중된다.
실무 런타임은 둘을 섞어 쓰는 경우가 많다. 예컨대 CPython은 기본은 참조 계수이되 순환 쓰레기를 위해 추적 기반 보조 수집기를 함께 돌린다. 다음 절차도는 가장 대표적인 추적 기반인 마크-스윕(Mark-Sweep)의 동작을 단계별로 나타낸다.
flowchart TD
Start["할당 실패/임계치 도달"] --> STW["STW: 애플리케이션 스레드 정지"]
STW --> Mark["Mark 단계: 루트부터 그래프 순회하며 생존 객체 표시"]
Mark --> Sweep["Sweep 단계: 미표시 객체를 가용 리스트로 회수"]
Sweep --> Compact{"단편화 심각?"}
Compact -->|"예"| Comp["Compact: 생존 객체를 한쪽으로 압축"]
Compact -->|"아니오"| Resume["애플리케이션 재개"]
Comp --> Resume
Mark(표시) 단계에서는 루트에서 깊이·너비 우선으로 그래프를 순회하며 도달한 객체마다 마크 비트를 켠다. 이 순회 비용은 생존 객체 수와 참조 밀도에 비례하므로, 살아 있는 객체가 많은 힙일수록 마크가 비싸진다. Sweep(청소) 단계에서는 힙 전체를 훑어 마크되지 않은 객체를 가용 메모리로 되돌리고, 다음 수집을 위해 마크 비트를 초기화한다. Sweep 비용은 힙 전체 크기에 비례한다. 이 방식은 순환 참조를 정확히 회수하는 장점이 있으나, 회수 후 빈 공간이 조각조각 흩어지는 외부 단편화(fragmentation)가 누적된다. 그래서 Mark-Sweep-Compact는 Sweep 뒤에 생존 객체를 메모리 한쪽으로 몰아 연속된 가용 공간을 확보한다. 압축은 단편화를 없애고 이후 할당을 포인터 증가만으로 끝내는 범프 포인터(bump pointer) 할당을 가능케 하지만, 객체 이동에 따른 참조 갱신 비용이 든다.
여기서 기억할 점은 GC의 멈춤 길이가 반드시 힙 크기에 비례하지는 않는다는 사실이다. 초기 수집기에서는 힙이 커질수록 전면 STW 시간이 길어져, 수백 GB 힙에서는 한 번의 Full GC가 수 초에 달해 서비스가 사실상 정지하는 문제가 있었다. 현대의 동시·영역 기반 수집기는 이 비례 관계를 끊는 것을 핵심 목표로 삼으며, 이것이 바로 다음 절들에서 살펴볼 알고리즘 진화의 근본 동기다.
3. 핵심 알고리즘 — 참조 계수·복사·세대별 수집
가. 참조 계수(Reference Counting)
참조 계수는 각 객체가 "나를 가리키는 참조의 수"를 헤더에 들고 있다가, 참조가 생기면 +1, 끊기면 −1 하여 0이 되는 순간 즉시 회수하는 방식이다. 별도의 수집기 스레드가 전체 힙을 순회하지 않고, 참조 대입이라는 일상적 연산에 회수 로직을 분산시킨다는 점에서 추적 기반과 철학이 근본적으로 다르다. CPython과 Swift(ARC, Automatic Reference Counting)가 대표적이다.
가장 큰 장점은 즉시성과 예측가능성이다. 쓰레기가 발생한 바로 그 지점에서 회수가 일어나므로 메모리가 신속히 반환되고, 전면적인 STW 멈춤이 원리적으로 필요 없어 지연 분포가 평탄하다. 또한 회수 비용이 프로그램 전반에 잘게 흩어져 있어, 수집기가 어느 순간 몰아서 일하는 추적 기반 특유의 "성능 절벽"이 완화된다. 이 때문에 응답성이 중요한 모바일(iOS/Swift)에서 ARC가 선호된다.
그러나 참조 계수에는 구조적 약점이 뚜렷하다. 첫째, 앞서 본 순환 참조를 스스로 회수하지 못한다. 서로를 가리키는 두 객체는 계수가 1 밑으로 내려가지 않아 영원히 남으므로, CPython은 주기적으로 도는 순환 수집기(cyclic collector)를, Swift는 개발자가 명시하는 weak/unowned 참조를 보완책으로 둔다. 둘째, 모든 참조 대입마다 계수를 갱신해야 해 상시 오버헤드가 깔리며, 멀티스레드에서는 이 갱신을 원자적(atomic)으로 처리해야 해 캐시 라인 경합 비용이 커진다. 실제로 CPython이 GIL(전역 인터프리터 락)을 오래 유지해 온 배경의 하나가 바로 이 참조 계수의 스레드 안전성 문제이며, 최근의 free-threaded CPython 실험이 어려운 이유도 여기에 있다. 셋째, 큰 객체 그래프의 마지막 참조가 끊기면 연쇄적으로 수많은 소멸이 한꺼번에 일어나 순간적 지연 스파이크가 생길 수 있다.
나. 복사 수집(Copying / Scavenging)
복사 수집은 힙을 From·To 두 반공간(semi-space)으로 나누고, 수집 시 From의 생존 객체만 To로 복사·압축한 뒤 From 전체를 한 번에 비우는 방식이다. 복사 과정에서 각 생존 객체의 새 위치를 원래 자리에 전달 포인터(forwarding pointer)로 남겨, 같은 객체를 가리키는 여러 참조가 모두 일관되게 새 주소를 가리키도록 갱신한다.
이 방식의 결정적 강점은 비용이 생존 객체 수에만 비례한다는 점이다. 죽은 객체는 아예 건드리지 않고 From 공간을 통째로 버리므로, 쓰레기가 아무리 많아도 수집 시간이 늘지 않는다. 즉 "쓰레기가 많을수록 빠른" 특성을 가져 생존율이 낮은 영역에서 압도적으로 유리하다. 또한 복사와 동시에 압축이 끝나 외부 단편화가 원천적으로 없고, 빈 공간이 항상 연속되어 있어 할당을 포인터 증가만으로 끝내는 범프 포인터 방식이 가능하다. 객체들이 할당 순서대로 모여 참조 지역성(locality)이 좋아지는 부수 효과도 있다.
반면 가용 메모리의 절반밖에 쓰지 못한다는 공간 낭비가 본질적 약점이다. 항상 한쪽 반공간을 비워 두어야 하기 때문이다. 이 때문에 복사 수집을 힙 전체에 적용하는 경우는 드물고, 생존율이 낮아 이점이 큰 "젊은 객체 영역"에만 국소적으로 적용하는 것이 일반적이다. 실제 JVM의 Young 세대가 Eden과 두 개의 Survivor 공간으로 나뉘어 Eden+한쪽 Survivor의 생존자를 다른 Survivor로 복사하는 구조가 바로 이 복사 수집의 변형으로, 반공간 낭비를 Survivor 비율 조정으로 완화한 설계다.
다. 세대별 수집(Generational GC)
세대별 수집은 "대부분의 객체는 생성 직후 금방 죽고, 오래 살아남은 객체는 앞으로도 오래 산다"는 약한 세대 가설(weak generational hypothesis)에 기반한다. 힙을 Young(Eden+Survivor)과 Old(Tenured) 세대로 나누고, 할당은 모두 Eden에서 일어난다. 아래 표는 세대별 전략의 핵심을 정리한 것이다.
| 구분 | Young 세대(Minor GC) | Old 세대(Major/Full GC) |
|---|---|---|
| 대상 객체 | 갓 생성된 단명 객체 | 여러 번 생존해 승격된 장수 객체 |
| 수집 빈도 | 매우 잦음 | 드묾 |
| 주 알고리즘 | 복사 수집 | 마크-스윕-컴팩트 |
| 수집 비용 | 짧음(생존 객체 적음) | 긺(영역 큼, STW 가능성) |
| 효과 | 짧은 멈춤으로 대량 회수 | 장수 객체 누수·단편화 정리 |
Young 세대는 생존율이 낮아 복사 수집으로 빠르게 쓸어내고, 일정 횟수 이상 살아남은 객체는 Old로 승격(promotion)시킨다. 핵심 난제는 세대 간 참조다. Old 객체가 Young 객체를 가리키면, Young만 수집할 때 그 Old 객체도 루트처럼 취급해야 한다. 이를 위해 쓰기 장벽(write barrier)으로 세대 간 참조를 기억 집합(Remembered Set) 또는 카드 테이블(card table)에 기록해 두고, Minor GC 때 Old 전체를 스캔하지 않고도 참조를 빠짐없이 따라간다. 이 구조 덕분에 세대별 GC는 전체 힙을 뒤지지 않고도 짧은 멈춤으로 대부분의 쓰레기를 회수한다.
실제 사례로, 웹 요청 처리 서버는 요청마다 수많은 임시 DTO·문자열 객체를 만들었다 버리는데, 이들 대부분은 응답 직후 죽는 전형적 단명 객체다. 세대별 GC는 이런 워크로드에서 Eden을 수 ms 내의 Minor GC로 비워내며, 측정상 전체 할당 객체의 90% 이상이 첫 Minor GC에서 회수되는 경우가 흔하다. 반대로 세션·캐시처럼 오래 사는 객체가 Old로 모이므로, 드물게 일어나는 Major GC가 이들을 한꺼번에 정리한다. 이렇게 수명에 따라 객체를 분리해 "자주·싸게"와 "가끔·비싸게"를 조합하는 것이 세대별 GC의 설계 사상이다.
라. 삼색 표기와 동시 마크의 정합성
동시(concurrent) 마크를 이해하려면 삼색 표기(tri-color marking) 추상이 필수다. 모든 객체를 흰색(아직 미발견, 회수 후보), 회색(발견했으나 자식 참조 미탐색), 검은색(자식까지 모두 탐색 완료)으로 분류하고, 회색이 소진될 때까지 "회색을 검게 칠하며 그 자식을 흰색에서 회색으로" 전진시킨다. 마크가 끝나면 흰색으로 남은 객체가 쓰레기다.
문제는 마크와 애플리케이션이 동시에 돌 때 발생한다. 이미 검게 칠한 객체가 새로 흰색 객체를 참조하도록 바뀌고, 그 흰색을 가리키던 회색 경로가 사라지면, 살아 있는 객체를 쓰레기로 오인해 회수하는 치명적 오류가 생긴다. 이를 막기 위해 GC는 쓰기 장벽(write barrier)으로 참조 변경을 가로채 불변식을 지킨다. 대표 전략이 변경되는 새 참조를 회색화하는 증분 갱신(incremental update)과, 끊기는 옛 참조를 보존하는 SATB(Snapshot-At-The-Beginning)이며, G1은 SATB를 채택한다. 이 메커니즘 덕에 ZGC·Shenandoah 같은 동시 수집기가 애플리케이션을 거의 멈추지 않고도 안전하게 마크를 완성할 수 있다.
4. 멈춤 시간을 줄이는 현대 GC — 동시·병렬·영역 기반
초기 GC가 전면 STW였다면, 현대 GC는 애플리케이션과 동시에(concurrent) 수집하거나 작업을 잘게 쪼개 멈춤을 분산한다. 자바 생태계를 중심으로 주요 수집기를 비교하면 다음과 같다.
| 수집기 | 전략 | 목표 멈춤 | 특징 |
|---|---|---|---|
| Serial GC | 단일 스레드 STW | 상대적으로 김 | 소형 힙·단일 CPU 환경 |
| Parallel GC | 다중 스레드 STW | 김(처리량 우선) | 배치·처리량 최적화 |
| CMS(deprecated) | 동시 마크-스윕 | 중간 | 단편화·동시 모드 실패 이슈 |
| G1 GC | 영역(Region) 기반 | 예측형(수백 ms→수십 ms) | 힙을 리전으로 분할, 가비지 많은 리전 우선 |
| ZGC / Shenandoah | 동시 압축 | 1ms 이하 지향 | 로드/컬러드 포인터, 대형 힙에서 초저지연 |
G1(Garbage First)은 힙을 동일 크기의 리전(region)으로 잘게 나누고, 각 리전의 쓰레기 비율을 추적해 회수 효율이 높은 리전부터(garbage-first) 수집함으로써 사용자가 지정한 목표 멈춤 시간을 예측적으로 지키려 한다. ZGC·Shenandoah는 한 걸음 더 나아가, 객체를 이동시키는 압축까지 애플리케이션과 동시에 수행해 힙 크기와 무관하게 멈춤을 1ms 수준으로 묶는다. 이를 가능케 하는 핵심 기술이 로드 장벽(load barrier)과 포인터에 상태 비트를 심는 컬러드 포인터(colored pointer)로, 애플리케이션이 이동 중인 객체를 읽을 때 투명하게 최신 주소로 재지정(remapping)한다.
동시·영역 기반 GC라 해도 실패 모드가 존재한다는 점을 운영자는 알아야 한다. 동시 수집이 진행되는 동안에도 애플리케이션은 계속 할당을 하므로, 수집 속도가 할당 속도를 따라잡지 못하면 Old 공간이 먼저 차버리는 동시 모드 실패(concurrent mode failure)나 승격 실패(promotion failure)가 발생한다. 이때 런타임은 최후의 수단으로 전면 STW Full GC로 되돌아가며, 바로 이 순간이 초저지연 서비스에서 가장 긴 멈춤으로 관측된다. 따라서 동시 GC 튜닝의 핵심은 "멈춤을 0으로 만드는 것"이 아니라 수집기가 할당 압력에 뒤처지지 않도록 힙 여유와 수집 시작 임계치를 조정하는 것이다. 실무에서는 GC 로그에서 이 실패 이벤트의 빈도를 핵심 지표로 삼아 힙 크기·수집기 파라미터를 조정한다.
여기서 반드시 짚어야 할 개념이 STW와 안전점(safepoint)이다. GC가 참조 그래프를 일관되게 보려면 모든 스레드가 "힙 상태가 정합적인 지점"에서 잠시 멈춰야 하는데, 이 지점이 안전점이다. 동시 GC도 마크 시작·종료 등 짧은 순간에는 전 스레드를 안전점에 세우므로, "STW가 전혀 없다"가 아니라 "STW가 매우 짧고 힙 크기에 비례하지 않는다"가 정확한 표현이다. 또한 장수 객체 참조를 끊지 않아 발생하는 논리적 누수(예: 캐시·리스너에 객체가 계속 등록되어 있어 GC가 회수하지 못함)는 GC가 있어도 생기는 문제로, GC는 "도달 불가능"만 회수할 뿐 "쓸모없음"은 판단하지 못한다는 한계를 분명히 보여준다.
5. 심화 — 최신 동향과 실무 적용 사례
GC 기술은 초저지연·대용량·관찰가능성 축으로 빠르게 진화하고 있다. 자바 진영에서는 ZGC가 세대별(Generational ZGC, JDK 21 정식)로 확장되어 저지연과 처리량을 함께 잡으려 하고, Shenandoah도 세대별 모드를 도입했다. 이는 "동시 GC는 처리량 손해"라는 오랜 통념을 약한 세대 가설과 결합해 완화하려는 흐름이다. 한편 Go 언어는 낮은 지연을 최우선으로 설계된 동시 마크-스윕 GC를 쓰되, 압축을 포기하는 대신 크기별 분리 할당(size-classed allocator)으로 단편화를 억제한다. Go가 압축을 포기한 것은 "모든 선택에는 대가가 있다"는 점을 잘 보여준다. 압축이 없으니 멈춤은 짧지만 단편화·할당 경로 비용을 감수해야 하고, 이를 메모리 할당자 설계로 메운다. .NET의 경우 서버 GC와 워크스테이션 GC, 그리고 백그라운드 GC를 상황에 맞게 전환할 수 있게 해, 동일 런타임 안에서도 처리량·지연 목표에 따라 GC 거동을 바꿀 수 있도록 설계되어 있다. 최근에는 할당률을 토대로 수집 시점을 조절하는 GC 조정자(pacer)와, 실제 하드웨어 메모리 압력에 반응하는 소프트 메모리 리미트(soft memory limit)가 도입되어, 클라우드 컨테이너의 메모리 한도와 GC를 연동하는 실무 요구에 대응한다.
실무 적용 사례로 쿠버네티스 환경의 JVM 컨테이너를 들 수 있다. 과거 JVM은 컨테이너의 cgroup 메모리 한도를 인식하지 못해 호스트 전체 메모리를 기준으로 힙을 잡아 OOM Kill을 유발했는데, 최신 JVM은 컨테이너 인식(UseContainerSupport)으로 cgroup 한도를 읽어 힙을 비율(MaxRAMPercentage)로 설정한다. 대화형 API 게이트웨이나 결제 서버처럼 꼬리 지연(tail latency)이 중요한 서비스는 G1에서 ZGC로 전환해 P99 멈춤을 수백 ms에서 한 자릿수 ms로 낮춘 사례가 다수 보고된다. 반대로 야간 배치·데이터 파이프라인처럼 총 처리량이 핵심이면 Parallel GC가 여전히 유리하다. 이처럼 "정답 GC"는 없으며, 워크로드의 지연·처리량·메모리 특성에 맞춘 선택과 측정 기반 튜닝이 핵심이다.
GC 운영의 관찰가능성도 중요해졌다. GC 로그를 수집해 할당률, Minor/Major 빈도, 승격률, 멈춤 분포(P99/P99.9)를 모니터링하고, 승격 실패·동시 모드 실패·빈번한 Full GC를 조기에 탐지하는 것이 SRE 실무의 기본이 되었다. 예컨대 어떤 서비스에서 응답 지연의 꼬리가 갑자기 길어졌다면, 애플리케이션 코드 변경이 없더라도 할당률 급증으로 Minor GC가 과도하게 잦아졌거나, 메모리 누수로 Old가 차 Full GC가 반복되는 것이 원인인 경우가 많다. 이때 GC 로그와 힙 덤프 분석(예: 지배 트리 dominator tree로 큰 객체 보유자 추적)을 결합하면 원인을 정확히 짚을 수 있어, GC 가시성은 성능 사고 대응의 1차 진단 도구로 쓰인다.
6. 고려사항 및 시사점
기술사 관점에서 GC는 단순한 메모리 자동화를 넘어 시스템 성능·비용·안정성을 좌우하는 아키텍처 결정으로 다뤄야 한다.
- 지연 vs 처리량 트레이드오프: 동시·영역 기반 GC는 멈춤을 줄이지만 쓰기/로드 장벽과 추가 스레드로 CPU·처리량을 소모한다. SLA의 성격(평균 처리량이냐, 꼬리 지연이냐)을 먼저 정의하고 수집기를 선택해야 하며, 선택은 반드시 부하 시험과 GC 로그 분석으로 검증한다.
- 힙 크기와 메모리 비용의 균형: GC는 여유 공간이 많을수록 빈도가 줄어 효율적이지만, 메모리는 클라우드 비용과 직결된다. 힙을 무작정 키우기보다 할당률을 낮추는(객체 재사용·풀링·스트리밍) 설계가 근본 처방이며, 특히 컨테이너에서는 cgroup 한도와 GC를 반드시 연동한다.
- 논리적 누수와 설계 책임: GC가 있어도 전역 캐시·이벤트 리스너·ThreadLocal에 객체가 묶여 회수되지 않는 누수는 발생한다. 명시적 수명 관리(약한 참조 weak reference, 명시적 해제, 캐시 만료 정책)를 설계에 포함해야 하며, GC 의존이 메모리 설계의 면죄부가 될 수 없다.
- 언어·런타임 선택과의 연계: 실시간성이 극도로 중요한 제어·고빈도 트레이딩 영역에서는 GC 멈춤 자체가 허용되지 않아 Rust의 소유권(ownership) 기반 무GC 모델이나 off-heap·객체 재사용 기법이 선택된다. 반대로 생산성과 안정성이 우선인 엔터프라이즈 백엔드는 GC 언어가 합리적이다. GC는 문제 영역과 비기능 요구에 맞춘 언어·런타임 전략의 일부로 함께 설계되어야 한다.
- 보안 관점의 양면성: GC는 Use-After-Free·이중 해제 같은 메모리 안전성 취약점을 구조적으로 제거해 공격 표면을 크게 줄인다. 반면 GC 멈춤의 발생 패턴이 사이드 채널로 관측될 수 있고, 민감 정보(암호키 등)가 회수 전까지 힙에 잔존해 메모리 덤프로 노출될 위험이 있다. 따라서 보안 민감 데이터는 사용 직후 명시적으로 0으로 덮어쓰거나 off-heap에 관리하는 보완이 필요하다.
- 전망: 세대별 동시 GC의 보편화, 컨테이너·NUMA·대용량 힙 인식 강화, 머신러닝 기반 GC 파라미터 자동 튜닝, 그리고 소유권 모델(Rust)과 자동 GC의 공존이 당분간 핵심 흐름이 될 것이다. 기술사는 GC를 "알아서 되는 것"이 아니라 관측·측정·튜닝 대상으로 바라보는 운영 철학을 조직에 정착시킬 필요가 있다.
참고자료
- Oracle, "HotSpot Virtual Machine Garbage Collection Tuning Guide" — https://docs.oracle.com/en/java/javase/21/gctuning/
- OpenJDK, "ZGC (The Z Garbage Collector)" Wiki — https://wiki.openjdk.org/display/zgc/Main
- The Go Programming Language, "A Guide to the Go Garbage Collector" — https://tip.golang.org/doc/gc-guide
- Microsoft, ".NET Fundamentals of Garbage Collection" — https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals
- R. Jones, A. Hosking, E. Moss, "The Garbage Collection Handbook" (개념 참고)
한 줄 요약: 가비지 컬렉션은 도달가능성을 기준으로 쓰레기 객체를 자동 회수하는 메모리 관리 기법으로, 참조 계수·추적·복사·세대별 전략에서 G1·ZGC 같은 동시·영역 기반 초저지연 수집기로 발전해 왔으며, 지연·처리량·메모리 비용의 트레이드오프를 측정 기반으로 튜닝하는 것이 기술사적 핵심이다.