
가비지 컬렉션(GC)이란?

자바를 배우다 보면 한 번쯤 들어봤을 용어, 바로 가비지 컬렉션(Garbage Collection, GC)입니다.
간단히 말해, GC는 더 이상 접근할 수 없는 객체(=가비지)를 찾아내서 자동으로 메모리를 정리해주는 기능이에요.
개발자가 직접 free()나 delete 같은 명령으로 메모리를 해제하지 않아도 된다는 점이죠.
이 덕분에 자바는 “메모리 안전성과 개발 생산성” 두 가지를 모두 잡을 수 있었습니다.
GC가 없었다면 매번 메모리를 신경 써야 했을 테니, 개발 속도는 훨씬 느려졌겠죠.
조금 더 깊이 들어가 보면, GC는 JVM의 힙(Heap) 구조에서 작동하며
수집기(Collector)의 종류나 동작 방식에 따라 성능이 달라집니다.
이 부분을 이해하면 실무에서 메모리 누수나 성능 이슈를 다루는 게 훨씬 쉬워집니다.
정리하자면, 가비지 컬렉션은 더 이상 사용되지 않는 객체를 자동으로 회수해주는 자바의 메모리 관리자입니다.
수동 관리 vs 자동 관리 — GC가 존재하는 이유
C나 C++처럼 메모리를 직접 다루는 언어에서는malloc()으로 메모리를 할당하고, free()로 해제해야 합니다.
문제는, 이걸 사람이 직접 관리하다 보면 실수할 여지가 너무 많다는 거예요.
예를 들어, 이미 해제한 메모리를 또 해제하면 이중 해제(double free) 에러가 나고,
해제를 깜빡하면 메모리 누수(leak)가 생기죠.
또 해제된 메모리에 접근하면 Use-After-Free(UAF) 같은 위험한 버그로 이어집니다.
자바는 이런 문제를 원천적으로 차단하기 위해 가비지 컬렉션(GC)을 도입했습니다.
개발자가 직접 메모리를 해제하지 않아도,
“더 이상 도달할 수 없는 객체(=가비지)”를 자동으로 찾아 제거합니다.
쉽게 말해, 살아 있는 객체만 남기고 나머지는 정리해주는 자동 청소 시스템인 셈이죠.
예를 들어 아래 코드를 볼까요?
Member m = new Member();
m.setName("Park");
m = null; // 이전 Member 인스턴스는 더 이상 참조되지 않음 → 가비지 후보
m = new Member();
m.setName("Park Seokhee");
여기서 첫 번째 Member 객체는 더 이상 어떤 변수도 가리키지 않기 때문에
GC의 수거 대상이 됩니다.
즉, 자바는 m = null 이후 자동으로 메모리 정리를 예약해 두는 거죠.
JVM 메모리 모델 - 핵심만 딱 잡기
가비지 컬렉션(GC)을 이해하려면 JVM이 메모리를 어떻게 구성하는지부터 알아야 합니다.

Heap
대부분의 객체가 실제로 생성되는 공간입니다.
개발자가 new 키워드로 만든 객체들은 모두 여기 들어갑니다.
GC는 바로 이 힙 영역(Heap)을 무대로 동작해,
더 이상 필요 없는 객체(가비지)를 찾아내고 메모리를 회수합니다.
Stack
각 스레드(thread)마다 하나씩 존재하며,
메서드 호출 정보, 로컬 변수, 임시 계산값 등이 저장됩니다.
메서드가 끝나면 해당 스택 프레임은 자동으로 사라지기 때문에
GC의 대상은 아닙니다.
Method Area / Metaspace
클래스의 메타데이터(클래스 이름, 필드, 메서드 정보 등)가 저장되는 영역입니다.
- 예전에는 Method Area (PermGen) 이라고 불렸지만,
- JDK 8 이후부터는 Metaspace라는 영역으로 바뀌었어요.
쉽게 말해, “클래스의 설계도 정보가 올라가는 공간”입니다.
GC Roots
GC는 모든 객체를 무작정 지우지 않습니다.
기준이 되는 루트(Root)가 있고,
이 루트에서 도달할 수 있는 객체만 ‘살아있는 객체’로 간주합니다.
대표적인 GC Root는 다음과 같습니다.
- 스택에 있는 로컬 변수
- 클래스의 정적(static) 필드
- JNI 참조(네이티브 코드에서 참조 중인 객체)
이 Root들에서 연결되지 않은 객체는 가비지(수거 대상)로 처리됩니다.
GC의 기본 동작 — Mark → Sweep → (Compact)
가비지 컬렉션(GC)은 단순히 “필요 없는 걸 지운다” 수준이 아닙니다.
사실 내부에서는 여러 단계를 거쳐 객체를 식별하고 정리하는 정교한 과정이 일어나요.

Mark (표시 단계)
먼저 GC Roots에서 시작해, 객체 그래프를 따라가며
“도달 가능한 객체”에 표시(mark)를 남깁니다.
쉽게 말해, "이 객체는 아직 쓰고 있어요!" 라고 표시를 붙여두는 과정이에요.
Sweep (제거 단계)
이제 마킹되지 않은 객체, 즉 더 이상 접근할 수 없는 객체(가비지)를 찾아내서
메모리에서 회수(해제)합니다.
이 단계가 바로 GC가 실제로 메모리를 비우는 순간입니다.
Compact (정리 단계)

마지막으로 살아남은 객체들을 한쪽으로 모아 붙여(compact) 줍니다.
이 과정을 거치면 메모리 단편화(fragmentation)를 줄일 수 있죠.
다만, 이 Compact 과정은 모든 수집기(Collector)가 수행하는 건 아닙니다.
GC의 종류나 세대(Young / Old)에 따라 다르게 동작합니다.
이 과정에서 Mark나 Compact 단계 중에는
모든 애플리케이션 스레드가 멈추는 순간이 발생합니다.
이걸 Stop-The-World(STW)라고 부릅니다.
STW 시간이 길어질수록 애플리케이션이 잠깐 멈춘 것처럼 느껴지기 때문에,
최신 GC들은 Concurrent(동시) 단계를 늘려
이 정지 시간을 최대한 줄이는 방향으로 발전해왔습니다.
왜 “세대별(Generational) GC”인가?

자바의 가비지 컬렉션(GC)은 모든 객체를 한 번에 수집하지 않습니다.
대신, 객체의 “생명 주기”에 따라 다르게 다룹니다.
이 아이디어의 기반이 바로 경험 법칙(Weak Generational Hypothesis)이에요.
경험 법칙 두 가지
- 대부분의 객체는 금방 죽는다.
→ 예를 들어, 메서드 안에서 잠깐 쓰이는 임시 객체들. - 오래 사는 객체가 젊은 객체를 참조하는 경우는 드물다.
→ 이미 안정된 객체가 새로 생긴 임시 객체에 의존할 일은 거의 없다는 뜻입니다.
이 두 가지 특성 덕분에, JVM은 힙(Heap)을 두 영역으로 나눠 관리합니다.
| 영역 | 설명 |
| Young Generation | 새로 생성된 객체가 저장되는 공간 (Eden + Survivor 0, 1) |
| Old Generation | 오래 살아남은 객체가 저장되는 공간 |
- 짧은 생명 주기의 객체는 Young 영역에서 빠르게, 자주 수집됩니다. → Minor GC
- 오래 사는 객체는 Old 영역으로 옮겨지고, 더 드물게 수집됩니다. → Major GC 또는 Mixed GC
Young 영역의 동작 흐름
Young generation 영역은 Eden 영역과 Survivor 영역으로 나누어지는데, 에덴 영역은 새로 생성된 객체가 저장되는 영역이고 서바이버 영역은 에덴 영역에서 살아남은 객체가 저장되는 곳입니다.

새 객체가 생성되면 → Eden에 저장됩니다.
Eden이 가득 차면 Minor GC가 발생합니다.


- 살아남은 객체들은 Survivor 영역(S0 또는 S1)으로 복사됩니다.
- 이때 객체의 나이(age)가 증가합니다. (이를 테너링(tenuring)이라 부릅니다.)
객체가 일정 나이(기본적으로 15세, MaxTenuringThreshold=15) 이상이 되거나
Survivor 공간이 꽉 차면 → Old 영역으로 승격(promote)됩니다.
Eden과 Survivor 영역이면 충분할 것 같은데 Survivor 영역은 왜 S0 / S1 두개로 나뉠까요?
그 이유는 바로 복사(복제, Copying) 기반 수집 방식 때문이에요.
Minor GC가 일어나면 GC는 살아남은 객체만 복사하고,
나머지는 한꺼번에 버립니다.
- 이때 Eden + 한쪽 Survivor(S0) → 살아남은 객체를 다른 Survivor(S1)로 복사
- 다음 GC 때는 역할을 바꿔치기(Swap) 합니다.
즉, 한쪽은 From(출발지), 다른 한쪽은 To(도착지) 로 번갈아 사용하는 구조예요.
다음 GC가 오면 S0 ↔ S1이 서로 역할을 바꿉니다.
이렇게 하면 살아남은 객체를 “압축(Compact)”하는 효과도 자연스럽게 생기죠.
이 구조 덕분에 Minor GC는 빠르고 깔끔하게 동작할 수 있어요.
자바의 주요 GC 수집기 한눈에 비교
자바에는 여러 종류의 가비지 컬렉터(GC Collector)가 존재합니다.
각 수집기는 성능 목표(처리량 vs 지연 시간)에 따라 동작 방식이 다르죠.
JDK 9 이후 기본 수집기는 G1 GC이며,
과거의 CMS는 JDK 9에서 Deprecated, JDK 14에서 완전히 제거되었습니다.
그리고 최근에는 ZGC와 Shenandoah처럼
초저지연(ultra low-latency)을 목표로 하는 수집기가 각광받고 있습니다.
| 수집기 | 특징 요약 | 장점 | 주의 / 적합한 환경 |
Serial GC-XX:+UseSerialGC |
단일 스레드로 Mark-Sweep-Compact 수행 | 구현이 단순, 작은 힙 / 단일 코어 환경에 적합 | STW(Stop-The-World)가 길어 서버용으로는 부적합 |
Parallel GC-XX:+UseParallelGC |
Young/Old 영역을 병렬로 수집 | 처리량(Throughput)에 최적화 | STW 시간이 다소 길 수 있음 |
G1 GC (기본값)-XX:+UseG1GC |
힙을 균일한 Region 단위로 관리, Young/Old 혼합 수집(Mixed GC) | 예측 가능한 짧은 STW, 대형 힙에 강함 | 튜닝 파라미터가 많고 초기 학습 필요 |
ZGC-XX:+UseZGC |
수 GB~TB 단위 힙 지원, 대부분의 단계가 동시(Concurrent) | 수 밀리초(ms) 수준의 초저지연 | 매우 큰 힙, 낮은 지연이 중요한 서비스에 적합 |
Shenandoah-XX:+UseShenandoahGC |
Red Hat 주도 초저지연 GC, ZGC와 유사한 목표 | 낮은 지연 시간 | 특정 배포 환경(JDK 빌드)에 의존적 |
CMS (Deprecated)-XX:+UseConcMarkSweepGC |
과거 대표 저지연 수집기 | 당시 기준으로는 빠른 응답 | JDK 14 이후 제거 -> G1 / ZGC / Shenandoah 사용 권장 |
실무에서의 GC 튜닝 원칙
튜닝은 GC 옵션부터가 아니라, 코드부터 시작된다.
가비지 컬렉션(GC) 튜닝은 자바 서버 성능 최적화의 마지막 단계입니다.
많은 분들이 먼저 JVM 옵션을 만지지만, 실제로는 그 전에 손볼 게 훨씬 많아요.
1. 코드·쿼리·아키텍처 최적화
불필요한 객체 생성, 과도한 DB 호출, 비효율적인 로직을 먼저 개선하세요.
이는 GC 옵션을 바꾸는 것보다 훨씬 큰 효과를 줍니다.
2. JDK / GC 선택
최신 LTS 버전(JDK 17 또는 21)으로 업그레이드를 고려하세요.
기본 수집기인 G1 GC가 대부분의 워크로드에 효과적입니다.
초저지연이 중요하다면 ZGC도 고려해볼 수 있습니다.
3. 힙 사이즈와 목표 지연 설정
너무 작은 힙은 GC 빈도를 높이고, 너무 큰 힙은 STW 시간을 늘립니다.
이는 적절한 균형점을 찾는 게 핵심이에요.
4. GC 로그 분석으로 병목 확인
실행 후 GC로그(gc.log)를 확인하고 어떤 시점에 멈추는지를 분석해 미세 조정을 진행합니다.
이제 실제 JVM 설정 예시를 볼까요?
bootRun {
jvmArgs = [
'-Xms512m', // 초기 힙 크기
'-Xmx1024m', // 최대 힙 크기
'-XX:+UseG1GC', // G1 GC 사용
'-XX:MaxGCPauseMillis=100', // 목표 GC Pause (절대 보장은 아님)
'-Xlog:gc*:file=logs/gc.log:time,uptime,level,tags:filecount=10,filesize=10M'
]
}
-XX:MaxGCPauseMillis는 목표치일 뿐, 절대 보장값은 아닙니다.
G1은 상황에 따라 휴리스틱하게 동작합니다.
GC 로그 해석 가이드
GC 튜닝의 핵심은 로그를 읽을 줄 아는가입니다.
하지만 처음 보면 숫자와 용어가 너무 많아서 막막하죠 😅
아래에서 간단한 가이드를 제시해볼게요.
1. GC 이벤트 종류 파악
먼저 어떤 GC 이벤트가 발생했는지 구분합니다.
| 구분 | 설명 |
| Young / Minor GC | Young 영역(에덴 + 서바이버) 수집. 자주 일어남 |
| Mixed GC (G1 전용) | Young + 일부 Old 영역을 함께 수집 |
| Full GC | 전체 힙을 멈추고 정리. 가장 무겁고 피해야 할 이벤트 |
Full GC가 자주 뜬다면 이미 성능 병목이 발생하고 있는 상태입니다.
2. Pause Time(정지 시간) / Frequency (빈도)
각 GC 이벤트는 Pause Time(STW, Stop-The-World) 동안
애플리케이션 스레드를 멈춥니다.
- 지연(latency)이 문제라면 → Pause Time과 빈도를 확인하세요.
- STW가 1초씩만 걸려도, 1분에 10번 일어나면 사용자 입장에선 버벅임으로 느껴집니다.
3. Promotion Failure / Humongous Allocation (G1)
- Promotion Failure → Survivor / Old 공간이 부족해서 승격에 실패한 경우
- Humongous Allocation → G1에서 50% 이상의 Region을 차지하는 초대형 객체
이 두 가지는 Old 영역이 비정상적으로 빠르게 커지고 있다는 신호입니다.
빈번하게 보인다면 힙 크기나 객체 생명주기를 점검해야 합니다.
4. 객체 생존 패턴 확인
GC 로그에는 Survivor, Old 영역의 점유율 변화가 함께 표시됩니다.
이를 보면 객체의 생존 주기 패턴을 추정할 수 있어요.
- Survivor가 꾸준히 가득 차 있다면 → 단기 객체가 너무 많다는 뜻
- Old 영역이 점점 커진다면 → 장기 객체가 쌓이고 있다는 신호
다음번에 기회가 된다면 함께 실제 GC 로그로 튜닝해보는 실습기도 남겨보도록 할게요!
'Language > Java ☕️' 카테고리의 다른 글
| Java static 키워드 완전 가이드 (0) | 2024.05.22 |
|---|---|
| 자바 메모리 구조 이해하기 (0) | 2024.05.22 |
| String vs StringBuffer vs StringBuilder — 자바 문자열 클래스의 차이 완전 정리 (0) | 2024.02.02 |
| Double과 Float를 사용하면 안되는 이유 (0) | 2024.02.01 |
| JDK, JRE, JVM의 차이와 자바 실행 구조 이해하기 (0) | 2024.02.01 |
안녕하세요, 저는 주니어 개발자 박석희 입니다. 언제든 하단 연락처로 연락주세요 😆