Java 가비지 컬렉션의 원리와 GC 선택 기준
Java GC의 도달 가능성, 세대별 힙 구조, 주요 수집기와 로그 분석·힙 튜닝 기준을 실무 관점에서 정리합니다.
2026-08-14 · 최초 발행 2026-01-12
자동 메모리 관리는 무엇을 해결하는가
수동 메모리 관리에서는 해제 누락으로 인한 메모리 누수, 이미 해제된 메모리에 접근하는 댕글링 포인터, 같은 메모리를 두 번 해제하는 이중 해제가 문제가 된다. Java의 GC(Garbage Collection)는 더 이상 참조되지 않는 객체, 즉 도달할 수 없는 객체를 찾아 회수한다.
개발자는 메모리 할당과 해제를 직접 관리하지 않아도 된다. 그 결과 메모리 관련 버그를 줄이고 세부 관리 방식을 런타임에 맡길 수 있다. GC는 미사용 객체 감지와 메모리 회수를 맡으며, 구현 방식에 따라 단편화 방지와 메모리 최적화도 수행한다.
GC는 1959년 Lisp에서 처음 도입됐고, 1960년대에 여러 알고리즘이 개발됐다. 1990년대에는 Java와 C# 같은 주류 언어가 이를 채택했으며, 현재는 많은 언어의 표준 기능으로 자리 잡았다.
GC가 추구하는 목표는 하나가 아니다. 낮은 오버헤드와 빠른 할당, 효율적인 회수, 최소 정지 시간을 원하면서도 큰 힙과 멀티코어, 동시성 및 병렬성을 지원해야 한다. 처리량과 지연시간, 메모리 사용량과 성능, 단순성과 최적화 사이의 선택이 수집기와 설정에 반영된다.
객체의 생존 여부는 참조 경로로 결정된다
GC는 GC Roots에서 출발해 참조 체인을 따라갈 수 있는 객체를 살아있는 객체로 본다. 반대로 이 경로로 닿지 못하는 객체는 가비지다. 객체끼리 순환 참조하더라도 GC Roots에서 이어지는 체인이 없으면 회수 대상이 된다.
class Node {
Object data;
Node next;
}
Node root = new Node(); // root는 GC Root
root.next = new Node(); // 도달 가능
root.next.next = new Node(); // 도달 가능
root.next = null; // 마지막 두 노드는 가비지
GC Roots는 참조 탐색의 시작점이다. 메소드 실행 중인 스택 지역 변수, 클래스의 static 필드, 네이티브 코드에서 참조하는 JNI 레퍼런스, 실행 중인 스레드, 동기화 블록의 모니터가 여기에 속한다.
public class Example {
static Object staticObject; // GC Root (정적 변수)
public void method() {
Object localObject = new Object(); // GC Root (스택 지역 변수)
// localObject는 메소드 실행 중 GC Root
}
// 메소드 종료 후 localObject는 더 이상 GC Root 아님
}
참조의 강도도 회수 시점에 영향을 준다. 강한 참조를 가진 객체는 참조가 null이 될 때까지 GC 대상이 아니다.
Object obj = new Object(); // 강한 참조
// obj가 null이 될 때까지 GC 대상 아님
소프트 참조는 메모리가 부족할 때 회수되므로 캐시 구현에 활용할 수 있다.
SoftReference<Object> softRef = new SoftReference<>(new Object());
// 메모리 부족 시에만 GC
// 캐시 구현에 유용
약한 참조는 다음 GC 때 회수되며 WeakHashMap 구현과 연결된다.
WeakReference<Object> weakRef = new WeakReference<>(new Object());
// 다음 GC 때 회수
// WeakHashMap 구현
팬텀 참조는 객체가 finalize된 뒤 리소스 정리를 추적하는 데 사용한다.
PhantomReference<Object> phantomRef =
new PhantomReference<>(new Object(), referenceQueue);
// 객체가 finalize된 후
// 리소스 정리 추적
회수 방식은 단편화와 정지 시간에 영향을 준다
Mark-Sweep은 GC Roots에서 시작해 살아있는 객체를 표시한 뒤, 힙 전체를 스캔하며 표시되지 않은 객체를 회수하고 프리 리스트에 추가한다. 구조가 단순하고 순환 참조를 처리할 수 있지만, 전체 힙 스캔과 Stop-the-World가 필요하며 단편화가 남을 수 있다.
Mark-Sweep-Compact는 표시와 회수 후 살아있는 객체를 한쪽으로 이동해 연속된 메모리 공간을 만든다. 단편화를 해소하고 빠른 할당을 돕지만, 객체 이동과 참조 업데이트 비용 때문에 정지 시간이 길어질 수 있다.
Copying은 메모리를 From과 To 영역으로 나눈다. 평소에는 From에서 할당하고, GC가 발생하면 살아있는 객체를 To로 복사한 뒤 From 전체를 회수하고 두 영역의 역할을 바꾼다. 포인터 이동만으로 빠르게 할당할 수 있고 단편화가 없지만, 메모리 절반만 사용하며 복사 비용이 발생한다. Young Generation의 Eden 및 Survivor Space에 활용된다.
Reference Counting은 객체마다 참조 카운터를 유지한다. 참조가 생기면 증가하고 해제되면 감소하며, 카운터가 0이 되면 즉시 회수한다. Stop-the-World 없이 예측 가능하게 회수할 수 있으나 순환 참조를 해결하지 못하고, 카운터 유지 및 스레드 동기화 비용이 있다. Java는 이 방식을 사용하지 않는다.
세대별 힙은 객체 수명 분포를 전제로 한다
세대별 GC는 대부분의 객체가 짧게 살고, 오래된 객체에서 젊은 객체를 참조하는 경우는 드물다는 Weak Generational Hypothesis를 기반으로 한다. 지역 변수와 임시 버퍼처럼 일시적인 객체는 많고, 싱글톤과 캐시처럼 영구 객체는 적다는 관찰에 맞춰 새 객체는 자주, 오래된 객체는 드물게 스캔한다.
Young Generation은 Eden Space와 Survivor Space 0(S0), Survivor Space 1(S1)으로 구성된다. Minor GC에서는 Eden과 S0의 살아있는 객체를 S1으로 복사하고, 살아남은 횟수인 Age를 증가시킨다. Eden과 S0를 회수한 뒤 S0와 S1의 역할을 바꾸며, Age 임계값을 넘은 객체는 Old Generation으로 승격된다. Copying 알고리즘을 사용하므로 빈번하게 실행되지만 Stop-the-World 시간은 짧다.
Old Generation은 Young Generation에서 승격된 객체가 머무는 큰 메모리 공간이다. 이 영역이 가득 차면 Major GC(Full GC)가 발생하며, Mark-Sweep-Compact 알고리즘으로 전체 힙을 스캔한다. 실행 빈도는 낮지만 Stop-the-World 시간이 길고 영향도 크다.
Metaspace는 Java 8부터 Permanent Generation을 대체했다. 클래스 메타데이터를 저장하며 네이티브 메모리를 사용한다. 크기를 동적으로 조정하고, 부족하면 OutOfMemoryError: Metaspace가 발생할 수 있다. 최대 크기는 -XX:MaxMetaspaceSize로 설정한다.
수집기는 워크로드의 우선순위에 따라 고른다
Serial GC는 단일 스레드로 동작하며 작은 힙(수십 MB)의 클라이언트 애플리케이션에 맞는다. Young 영역에는 Mark-Copy, Old 영역에는 Mark-Sweep-Compact를 사용한다.
java -XX:+UseSerialGC
Parallel GC는 다중 스레드를 사용해 처리량을 우선하는 수집기다. Java 8 기본 GC이며, Young 영역에는 Parallel Mark-Copy, Old 영역에는 Parallel Mark-Sweep-Compact를 적용한다. 배치 처리, 멀티코어 서버, 높은 처리량이 필요한 환경에 적합하다.
java -XX:+UseParallelGC
java -XX:ParallelGCThreads=4 # GC 스레드 수
CMS GC는 Concurrent Mark Sweep의 약자로 저지연을 우선한다. Initial Mark 단계에서 GC Roots를 마킹하고, Concurrent Mark 단계에서는 애플리케이션과 동시에 참조 체인을 마킹한다. Remark 단계에서 변경사항을 다시 마킹한 뒤 Concurrent Sweep 단계에서 애플리케이션과 동시에 가비지를 회수한다. Java 9부터 Deprecated, Java 14에서 제거됐다.
java -XX:+UseConcMarkSweepGC
CMS는 정지 시간이 짧고 동시 실행이 가능하지만 단편화, CPU 오버헤드, Floating Garbage 문제가 있다.
G1 GC는 Garbage First 방식의 리전 기반 수집기다. Java 9+ 기본 GC이며 예측 가능한 정지 시간을 목표로 한다. 힙을 여러 리전으로 나누며 보통 2048개 리전을 사용한다. 각 리전은 Eden, Survivor, Old, Humongous 용도로 할당되고, 가비지가 많은 리전을 우선 회수한다. Young-only Phase에서 Minor GC를 수행하고, Space Reclamation Phase에서 Young과 Old 리전 일부를 대상으로 Mixed GC를 수행한다. 수십 GB 힙을 지원하고 단편화를 최소화한다.
java -XX:+UseG1GC
java -XX:MaxGCPauseMillis=200 # 목표 정지 시간 (ms)
ZGC는 Java 11+에서 실험적 기능으로 제공됐고 Java 15+에서 프로덕션으로 제공됐다. 초저지연(< 10ms)과 테라바이트급 힙 확장을 목표로 하며, Colored Pointers, Load Barriers, Concurrent Compaction을 사용한다. 매우 큰 힙과 낮은 지연시간이 필수인 트레이딩 시스템에 적합하다.
java -XX:+UseZGC
Shenandoah GC는 Red Hat이 OpenJDK에서 개발한 수집기다. < 10ms의 저지연, 동시 압축, 힙 크기 독립성을 특징으로 하며 Brooks Pointers와 Concurrent Evacuation을 사용한다. ZGC는 Colored Pointers와 Oracle 개발, Shenandoah는 Brooks Pointers와 Red Hat 개발이라는 차이가 있다.
java -XX:+UseShenandoahGC
로그와 힙 상태를 함께 보며 조정한다
GC 튜닝은 로그 분석, 힙 크기 조정, 수집기 선택, 성능 지표 측정을 분리하지 않고 함께 다루는 작업이다. Java 8과 Java 9+의 GC 로그 활성화 방식은 다음과 같다.
# Java 8
java -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:gc.log
# Java 9+
java -Xlog:gc*:file=gc.log:time,level,tags
로그는 GCViewer, GCEasy, Eclipse MAT(Memory Analyzer Tool), VisualVM으로 분석할 수 있다. GC가 지나치게 자주 발생하는지, 정지 시간이 허용 범위에 드는지, 힙 크기가 적절한지, Old Generation 승격 빈도인 Promotion Rate가 어떤지를 확인한다.
초기 및 최대 힙 크기는 다음과 같이 지정한다.
java -Xms512m -Xmx2g MyApp
# -Xms: 초기 힙 크기
# -Xmx: 최대 힙 크기
Young과 Old 영역의 비율도 조절할 수 있다.
java -XX:NewRatio=2 # Old:Young = 2:1
java -XX:NewSize=128m -XX:MaxNewSize=256m
Survivor 영역 비율은 다음 옵션으로 설정한다.
java -XX:SurvivorRatio=8 # Eden:Survivor = 8:1
Xms와 Xmx를 같은 크기로 설정하면 크기 변경 오버헤드를 제거할 수 있다. Young Generation을 충분히 크게 잡으면 Minor GC 최적화에 도움이 되며, 전체 메모리의 70-80% 정도를 힙에 할당하는 방안도 고려할 수 있다.
수집기 선택은 애플리케이션 유형과 자원 조건을 기준으로 한다. 배치에는 Parallel GC, 웹에는 G1 GC, 초저지연 환경에는 ZGC 또는 Shenandoah GC를 고려한다. 힙이 < 100MB로 작으면 Serial GC, 수 GB 수준이면 G1 GC, 수십 GB+라면 ZGC가 후보가 된다. CPU 코어가 단일이면 Serial GC, 다중이면 Parallel GC 또는 G1 GC가 맞는다.
처리량은 애플리케이션 실행 시간을 전체 시간으로 나눈 비율이며, 처리량 = 1 - (GC 시간 / 전체 시간)으로 표현할 수 있다. 목표는 99% 이상이며 Parallel GC가 유리하다. 지연시간은 개별 GC 정지 시간으로 응답 시간에 직접 영향을 주며, 목표는 수십 ~ 수백 ms다. G1 GC와 ZGC가 유리하다. 메모리 효율은 힙 사용량, 단편화 정도, 오버헤드로 판단한다.
애플리케이션이 체감하는 GC 비용
Stop-the-World 동안에는 모든 애플리케이션 스레드가 멈춘다. 객체 이동과 참조 업데이트를 안전하게 수행하기 위해서지만, 응답 시간이 늘고 사용자 경험과 처리량이 저하될 수 있다. 동시 GC를 사용하거나 G1·ZGC처럼 정지 시간이 짧은 수집기를 고르고, 힙 크기와 Young Generation 크기를 조정해 영향을 줄일 수 있다.
처리량을 높이려면 Parallel GC 사용, GC 빈도 감소, Young Generation 크기 증가, 효율적인 객체 할당 패턴을 검토한다. 지연시간을 줄이려면 CMS·G1·ZGC·Shenandoah 같은 동시 GC를 사용하고, Full GC가 지나치게 느려지지 않도록 힙 크기를 조절하며, G1 GC의 목표 정지 시간을 설정한다.
GC는 메타데이터, 카드 테이블(Card Table), G1 GC의 Remembered Sets, 단편화 방지를 위한 여유 메모리를 사용한다. 객체 풀링과 불필요한 객체 생성 최소화, 큰 객체 회피, 적절한 데이터 구조 선택은 이런 메모리 사용량을 관리하는 방법이다.