쉬운코드 라이브 강의 + 추가 심화 내용 통합 정리 이전 편(JVM 구조)에서 ClassLoader, JIT, JVM 메모리 구조를 다뤘다면 이번 편은 그 위에서 동작하는 GC 자체를 깊게 파고들고, GC가 왜 탄생했는지부터, 어떤 테크닉을 쓰는지, 어떻게 튜닝하는지까지 한 번에 정리 해보려고 합니다.
최대한 정리를 하였지만, 명강의 선생님을 따라 갈 수 없기에 유튜브 멤버십을 통하여 강의를 꼭 들어보시기를 추천드립니다!!
https://www.youtube.com/@ezcd
목차
- GC가 탄생한 이유: C++ vs Java 메모리 관리
- GC의 장단점
- GC의 역할: OS ↔ JVM Heap ↔ Application
- GC의 주요 테크닉
- 언제 GC 선택과 튜닝이 중요한가
- 왜 GC 선택과 튜닝이 중요한가 (Amdahl's Law)
- Serial GC는 언제 적절한가
- Ergonomics: JVM의 자동 최적화
- JVM 주요 default 설정
- Maximum Pause-Time Goal
- Throughput Goal
- Minimum Footprint
- GC 튜닝 가이드 (단계별 순서)
1. GC가 탄생한 이유: C++ vs Java 메모리 관리
C++의 수동 메모리 관리
C++처럼 메모리를 직접 관리하는 언어에서는 개발자가 new로 할당하고 delete로 반환하는 모든 과정을 직접 챙겨야 한다.
// C++ 방식
Object* obj = new Object();
// ...logic...
delete obj; // 개발자가 직접 반환
이 방식의 문제점은 명확하다. 언제 delete를 불러야 하는지, 이미 반환된 메모리를 다시 참조하진 않는지, 반환을 빠뜨리진 않는지 — 이런 것들이 모두 개발자의 책임이다. 복잡한 시스템에서는 이 실수들이 버그와 메모리 누수로 이어진다.
Java의 해결책: GC
이런 불편함을 해소하기 위해 나온 아이디어가 바로 언어 레벨에서 메모리를 알아서 관리해 주는 것, 즉 GC(Garbage Collector)다. GC는 자바에만 있는 개념이 아니다. Python, Go, C# 등 수많은 언어가 GC를 채택하고 있다. 그만큼 보편적인 패러다임이다.
public class Main {
public static void main(String[] args) {
allocate();
boolean isRun = true;
while (isRun) {
// ...logic
}
}
public static void allocate() {
Object obj = new Object(); // JVM Heap에 생성
// ...logic
// delete 없이 그냥 메서드 종료
}
}
allocate() 메서드가 끝나면 스택 프레임이 사라지면서 obj 참조도 함께 사라진다. 이제 Heap의 Object 인스턴스를 아무도 참조하지 않는 상태가 된다. Java에서는 이 시점에 GC가 알아서 그 공간을 수거해 간다. 개발자가 따로 delete를 호출할 필요가 없다.
스택 JVM Heap
┌──────────┐ ┌─────────────┐
│ allocate │ │ Object() │ ← GC가 나중에 수거
│ frame │──참조──▶ │ │
└──────────┘ └─────────────┘
↓ 메서드 종료
┌──────────┐
│ (frame │ ┌─────────────┐
│ 사라짐) │ │ Object() │ ← 참조 없음 → GC 대상
└──────────┘ └─────────────┘
2. GC의 장단점
GC가 생김으로써 얻는 것과 잃는 것이 분명히 있다.
장점
개발자 메모리 관리 비용 거의 제거. C++처럼 new/delete를 한 땀 한 땀 챙겨야 했던 관리 비용이 사실상 사라진다. 물론 완전한 제로는 아니다. 아주 가끔 GC가 수거할 수 있도록 참조를 끊어줘야 하는 경우(메모리 누수 방지)가 있긴 하지만, C++에 비하면 비교도 안 될 수준이다.
메모리 직접 관리 시 발생하는 버그 대폭 감소. double free, use-after-free, memory leak 등 메모리를 직접 관리할 때 나오는 다양한 종류의 버그들이 대부분 사라진다. 개발자는 비즈니스 로직 자체에 더 집중할 수 있게 된다.
단점
GC 오버헤드 발생. GC 자체가 추가적인 비용이다. C++ 처럼 그냥 delete 한 줄로 끝날 걸, GC는 Heap 전체(혹은 일부)를 스캔하면서 "이 객체는 아직 쓰고 있나? 이건 이미 안 쓰나?" 를 확인해야 한다. 이 확인 비용이 오버헤드다. 그리고 이것이 우리가 GC를 공부하는 핵심 이유이기도 하다 — 이 오버헤드를 어떻게 줄일 것인가.
Stop-The-World(STW): GC가 동작하는 동안 애플리케이션 스레드가 완전히 멈추는 현상. GC 스레드만 CPU를 점유하고 나머지 애플리케이션 코드는 일시 중단된다. GC를 배우는 핵심 목적 중 하나는 이 STW 시간을 줄이는 것이다.
3. GC의 역할: OS ↔ JVM Heap ↔ Application
GC는 단순히 "죽은 객체를 지우는 것" 이상의 역할을 한다.
3-1. OS로부터 메모리 확보 및 반납
GC는 OS로부터 메모리 영역을 확보하고, 안 쓰는 영역은 다시 OS에 반납하는 역할을 한다.
[OS Physical Memory]
↕ (요청/반납)
[GC가 확보한 JVM Heap 영역]
↕ (객체 할당/회수)
[Java Application]
애플리케이션 실행 중 트래픽이 몰려 메모리가 부족해지면 GC가 OS에 추가로 요청해서 Heap을 늘린다. 반대로 사용량이 줄어들면 일부를 OS에 반납한다. 이렇게 반납하는 이유는 JVM이 점유만 하고 안 쓰면 그만큼 다른 프로세스들이 메모리를 할당받지 못하기 때문이다.
3-2. 사용 중/미사용 영역 파악 및 회수
GC는 자신이 확보한 JVM Heap 영역에서 실제로 사용 중인 객체와 사용하지 않는 객체를 파악한다. 아무도 참조하지 않는 객체는 회수해서 다시 할당 가능한 공간으로 만든다.
[JVM Heap]
┌────┬────┬────┬────┬────┬────┬────────────┐
│Obj1│ ✗ │Obj3│ ✗ │JVM │Obj5│ (free) │
│(사용중) │(사용중) │heap│(사용중) │
└────┴────┴────┴────┴────┴────┴────────────┘
↓ GC 수거 후 Compaction
┌────┬────┬────┬──────────────────────────┐
│Obj1│Obj3│JVM │Obj5│ (free) │
│ │ │heap│ │ │
└────┴────┴────┴────┴──────────────────────┘
단, 회수 후 남겨진 빈 공간들이 흩어져 있으면 큰 객체를 할당하기 어렵다. 그래서 GC는 살아있는 객체들을 한 곳에 모아 연속된 free 영역을 확보한다. 이 과정을 Compaction이라 한다.
주의: 실제 GC(특히 G1GC 이후)는 이런 단순한 방식으로 동작하지 않는다. Young Gen의 Eden/Survivor 구조, Old Gen 등 더 복잡한 구조를 사용한다. 이건 다음 편에서 상세히 다룬다.
4. GC의 주요 테크닉
GC는 그냥 무작정 전체를 스캔하지 않는다. 성능을 높이기 위한 세 가지 핵심 테크닉을 사용한다.
4-1. 세대별 청소 (Generational Scavenging) + Aging
JVM Heap을 나이(age) 기준으로 구분한다. Young Generation에는 막 생성된 객체들이 쌓이고, 오래 살아남은 객체는 Old Generation으로 옮겨진다(Promotion).
┌─────────────────────────────────────────────────────────┐
│ JVM Heap │
│ ┌──────────────────────┐ ┌─────────────────────────┐ │
│ │ Young Generation │ │ Old Generation │ │
│ │ ┌──────┐ ┌───┐ ┌───┐│ │ │ │
│ │ │ Eden │ │ S0│ │ S1││ │ 오래 살아남은 객체들 │ │
│ │ │(신생)│ │ │ │ ││ │ │ │
│ │ └──────┘ └───┘ └───┘│ │ │ │
│ └──────────────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
이렇게 나누는 이유는 객체의 대부분은 금방 죽는다는 통계적 사실 때문이다. 매번 전체를 스캔하는 대신 Young Gen에 집중해서 GC를 돌리는 것이 훨씬 효율적이다.
Aging: 객체가 Young Gen의 Minor GC를 살아남을 때마다 age가 증가한다. age가 특정 임계값을 넘으면 Old Gen으로 Promotion된다.
4-2. Compaction (살아있는 객체 모으기)
회수 후 흩어진 빈 공간들을 그냥 두면 단편화(fragmentation)가 발생한다. 살아있는 객체들을 한 곳에 모아서 최대한 연속된 free 영역을 확보한다. 이래야 큰 객체도 할당할 수 있다.
4-3. 병렬 GC 스레드
GC가 동작할 때 여러 스레드를 사용해서 최대한 병렬로 처리한다. 멀티코어 환경에서 GC 스레드들이 동시에 동작해 처리 시간을 줄인다. 오래 걸리는 GC 작업은 백그라운드에서 애플리케이션 코드와 동시에 실행될 수 있도록 하는 전략도 사용된다.
CPU Core 1: [App Thread] ──────────────────────────▶
CPU Core 2: [App Thread] ──────────────────────────▶
CPU Core 3: [GC Thread] ────────────────────────▶ (백그라운드 GC)
CPU Core 4: [GC Thread] ────────────────────────▶
5. 언제 GC 선택과 튜닝이 중요한가
기본적으로 자바 실행 환경(Java SE)이 애플리케이션이 실행되는 머신의 시스템 특성을 보고 어떤 GC로 동작할지 자동으로 선택한다. 이 디폴트 설정이 대부분의 경우엔 충분하다.
상황 GC 튜닝 필요 여부
| 토이 프로젝트 | ❌ 불필요 |
| 사내 시스템 (트래픽 적음) | ❌ 불필요 |
| 하루 수십~수백 건 처리 | ❌ 불필요 |
| MAU 수백만, TPS 수천 규모 서비스 | ✅ 필요할 수 있음 |
| 데이터/객체를 매우 많이 생성하는 서비스 | ✅ 필요할 수 있음 |
| 스레드를 헤비하게 쓰고 높은 처리량 요구 | ✅ 필요할 수 있음 |
GC 튜닝은 실버 블렛이 아니다. 안 해도 되는 상황에 억지로 하면 시간 낭비다. 하지만 진짜 필요한 상황에선 GC 튜닝만으로도 처리량을 크게 끌어올릴 수 있다.
실무 관점: 네카라쿠배 급의 대용량 트래픽 서비스, 또는 평소엔 괜찮지만 이벤트 때 트래픽이 급격히 몰리는 서비스라면 GC 선택/튜닝을 한번쯤 검토할 가치가 있다.
6. 왜 GC 선택과 튜닝이 중요한가 (Amdahl's Law)
Amdahl's Law란
어떤 문제에서 병렬을 통한 성능 향상은 해당 문제의 순차적인 부분에 의해 제한된다.
아무리 병렬로 잘 처리해도, 반드시 순차적으로 실행해야 하는 영역이 있다면 그 영역이 전체 성능의 병목이 된다.
GC와 Amdahl's Law의 관계
Oracle JDK 문서에서는 이 법칙을 GC에 적용해 설명한다. 전제 조건은 이렇다.
- 시스템 자체는 아이디얼하게 프로세서 수가 늘어날수록 완벽하게 스케일업된다.
- 단 하나의 예외: Garbage Collection은 그렇게 스케일업되지 않는다.
이 상황에서 프로세서 수를 늘려가며 처리량(Throughput)을 측정하면 아래 그래프처럼 된다.

GC가 전체 실행 시간의 1%만 차지해도, 프로세서 수가 30개 정도 되면 Throughput이 약 0.75 수준으로 떨어진다. GC 비율이 30%라면 아예 스케일업이 거의 안 된다.
결론: 소규모 시스템에서는 GC 오버헤드가 큰 문제가 아니다. 하지만 대규모 시스템(코어를 많이 쓰는, 즉 멀티스레딩이 헤비한 환경)에서는 GC가 드라마틱하게 병목이 될 수 있다. 역으로, GC 오버헤드를 조금만 줄여도 대규모 시스템에서는 처리량이 크게 올라갈 수 있다.
Java가 제공하는 4가지 GC (Oracle JDK 25 기준)
┌─────────────────────────────────────────────────────────┐
│ Java GC 종류 │
│ │
│ 1. Serial GC ─── 단일 스레드 (병렬 X) │
│ 2. Parallel GC ─── 병렬 ✅ │
│ 3. G1GC ─── 병렬 ✅ (서버 디폴트) │
│ 4. ZGC ─── 병렬 ✅ (초저지연) │
│ │
│ → Serial GC를 제외한 나머지 3개는 모두 병렬 동작 │
└─────────────────────────────────────────────────────────┘
7. Serial GC는 언제 적절한가
Serial GC는 GC 작업을 하나의 스레드만으로 처리한다. 그 특성상 적합한 케이스가 따로 있다.
적합한 경우
소규모 애플리케이션, 대부분 사이즈가 작은 애플리케이션. 특히 Heap size가 약 100MB 이하인 애플리케이션에 적합하다. Java는 백엔드 서버에만 쓰이는 게 아니다. 라즈베리파이 같은 소형 디바이스, 임베디드 환경, 데스크탑 설치형 앱 등 작은 환경에서도 쓰인다. 이런 환경에서는 Serial GC가 더 나을 수 있다.
왜 소규모에서는 Serial이 더 나을까? 병렬 GC는 여러 스레드가 동시에 동작하는 만큼, 스레드 간 동기화 비용이 발생한다. 스캔해야 할 객체가 많지 않은 소규모 상황에서는 이 동기화 비용이 오히려 오버헤드가 된다.
적합하지 않은 경우
사이즈가 크고 멀티코어를 갖춘 시스템에서 스레드를 헤비하게 쓰는 서버 애플리케이션이라면 Serial GC는 선택지가 될 수 없다.
서버 클래스로 분류될 수 있는 머신에서 애플리케이션이 실행된다면 G1GC가 디폴트로 사용된다.
실무 관점: 백엔드 개발자 입장에서 Serial GC를 직접 선택할 일은 거의 없다. 그냥 G1GC가 디폴트라고 이해하면 충분하다. 단, Serial GC가 왜 존재하는지, 어떤 맥락에서 유리한지 이해하는 것 자체가 GC 선택의 판단력을 높인다.
8. Ergonomics: JVM의 자동 최적화
Ergonomics란
Ergonomics(에르고노믹스): JVM이 주어진 환경에서 GC와 메모리 관련 설정을 스스로 최적화하는 것. 쉽게 말해 자동 최적화다.
개발자가 따로 설정하지 않아도 JVM이 알아서 최적화를 해준다. Ergonomics는 크게 두 가지 일을 한다.
Ergonomics가 하는 일
├── 1. Default Selection
│ ├── GC 종류 선택 (어떤 garbage collector?)
│ ├── Heap size 결정 (얼마나 잡을지?)
│ ├── GC 스레드 수 결정
│ └── JIT compiler 설정
│
└── 2. 실행 중 동적 조정 (Behavior-based Tuning)
├── Maximum Pause-Time Goal 모니터링
├── Throughput Goal 모니터링
└── 두 목표 달성 시 → Minimum Footprint 추구
중요한 건 이렇다. JVM이 스스로 조정하는 것이지, 개발자가 직접 관여하는 게 아니다. 개발자가 개입할 수 있는 부분은 VM 옵션으로 목표값을 지정하는 것이고, 그 목표를 달성하기 위한 실제 조정(Heap 사이즈 변경 등)은 JVM이 알아서 한다.
9. JVM 주요 default 설정
9-1. Garbage Collector 디폴트 선택
JVM은 실행 환경을 보고 GC를 자동으로 선택한다.
머신 분류 디폴트 GC
| 서버 클래스 머신 | G1GC (Garbage-First Garbage Collector) |
| 그 외 | Serial Collector |
서버 클래스 머신 판단 기준: 두 개 이상의 프로세서(코어)를 가지며 RAM(physical memory) ≥ 1792MB인 경우.
현대의 노트북, 데스크탑, 서버는 거의 모두 이 기준을 만족한다. 즉 개발자가 별도로 지정하지 않으면 G1GC가 사용된다고 봐도 무방하다.
# 직접 확인하는 방법
jps -l # 현재 실행 중인 Java 프로세스 PID 확인
jcmd <PID> VM.flags # 해당 프로세스의 VM 옵션 확인
# 결과 예시
-XX:InitialHeapSize=268435456
-XX:MaxHeapSize=4294967296
-XX:MinHeapSize=8388608
-XX:+UseG1GC # G1GC 사용 중
9-2. Heap Size 디폴트 설정
설정 디폴트 값 설명
| Initial Heap Size | RAM의 1/64 | JVM 시작 시 바로 확보하는 Heap 크기 |
| Maximum Heap Size | RAM의 1/4 | JVM Heap이 최대로 늘어날 수 있는 한계 |
| Minimum Heap Size | 복잡하게 결정 | JVM Heap이 줄어들 수 있는 최솟값 |
예시: RAM이 16GB인 경우
- Initial Heap = 16GB / 64 = 250MB
- Maximum Heap = 16GB / 4 = 4GB
Virtual Address Space
┌──────────────────────────────────────────────────────────────┐
│ │
│ [min heap] [max heap] │
│ ↑ ↑ │
│ ┌───────────┤ │ │
│ │ JVM heap │ ←→ 유동적으로 조정│ │
│ └───────────┴──────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
9-3. JIT Compiler 디폴트
C1(Client Compiler)과 C2(Server Compiler)를 모두 사용하는 Tiered Compilation이 디폴트다. (이전 편 JVM 구조에서 다룬 내용)
9-4. 서버 애플리케이션의 Heap 설정 관례
서버 백엔드 애플리케이션에서는 Initial, Minimum, Maximum Heap Size 세 값을 모두 동일하게 설정하는 것이 일반적이다.
# 서버 애플리케이션 실행 예시 (16GB RAM 환경 기준)
java -Xms8g -Xmx8g -jar app.jar
# └── Initial=Min Heap └── Max Heap
# (동일하게 맞춤) (동일하게 맞춤)
왜 동일하게 맞추는가?
서버 애플리케이션은 들어오는 트래픽을 처리하기 위해 해당 머신의 자원을 거의 전부 독점(dedicated)한다. 굳이 Heap을 늘렸다 줄였다 할 필요 없이, 처음부터 원하는 크기로 고정해두면 된다. 늘렸다 줄였다 하는 과정 자체도 성능에 영향을 주기 때문이다.
이력서/면접 팁: 백엔드 서버 구성 경험을 이야기할 때 "JVM 힙 사이즈를 -Xms와 -Xmx로 동일하게 설정했다"는 포인트는 꽤 센스 있는 내용으로 받아들여질 수 있다.
10. Maximum Pause-Time Goal
Pause Time이란
Pause Time: GC로 인해 애플리케이션이 완전히 멈추는 시간. 즉 Stop-The-World 시간이다.
GC가 동작하는 패턴은 대략 이렇다.
시간 →
[App 실행] → [GC 발생 → STW] → [App 실행] → [GC 발생 → STW] → [App 실행] ...
각 GC 발생 시마다 STW가 일어나고, 그 동안 애플리케이션은 완전히 멈춘다.
Maximum Pause-Time Goal 설정
Pause Time이 길어질수록 사용자 경험이 나빠진다. 개발자는 VM 옵션으로 최대 허용 Pause Time을 지정할 수 있다.
# -XX:MaxGCPauseMillis=nnn (단위: ms)
java -XX:MaxGCPauseMillis=1000 -jar app.jar # GC 한 번에 최대 1초까지만 허용
java -XX:MaxGCPauseMillis=500 -jar app.jar # GC 한 번에 최대 500ms까지만 허용
내부 동작 원리
JVM은 GC가 발생할 때마다 Pause Time을 기록하고, 가중 평균(Weighted Average)과 분산을 계산해서 목표값과 비교한다.
가중 평균을 쓰는 이유: 과거보다 최근 데이터에 더 가중치를 부여하기 위해서다. 최근 GC 동향이 더 중요하기 때문이다.
가중 평균 예시:
이전 Pause: 4ms, 최근 Pause: 6ms, 비율 1:3
가중 평균 = (1×4 + 3×6) / (1+3) = (4+18) / 4 = 22/4 = 5.5ms
계산된 (가중 평균 + 분산)이 목표값을 초과하면 JVM은 Heap 크기 및 GC 관련 파라미터를 조정해서 다음 GC 때는 Pause Time이 목표치 이하가 되도록 시도한다.
Pause Time 목표 초과 시 Heap 크기는 줄어든다.
이유: Heap이 작을수록 스캔해야 할 객체 수가 적어
→ GC 처리 시간이 빠름
→ Pause Time 감소 기대
Pause Time의 디폴트 목표값은 GC 컬렉터마다 다르다.
11. Throughput Goal
Throughput Goal이란
Throughput Goal: GC time과 Application time을 비교해서 특정 비율을 맞추도록 하는 것.
개념부터 정리하면:
전체 실행 시간 = Application Time + GC Time
┌──────────────────────────────────────────────────────┐
│ Application Time │ GC Time │ App │ GC │ App │
└──────────────────────────────────────────────────────┘
↑
GC Time = STW가 발생한 시간의 총합
- GC Time: 지금까지 GC로 인해 STW가 된 시간의 총합
- Application Time: GC Time 외의 시간의 총합. 일부 GC 스레드가 앱과 병렬로 실행됐다면, 그 시간도 Application Time으로 분류된다.
Throughput Goal 설정
# -XX:GCTimeRatio=nnn
java -XX:GCTimeRatio=19 -jar app.jar
# GC Time Ratio = 1 / (1 + nnn)
# nnn = 19 → GC Time Ratio = 1/20 = 5%
# 즉, 전체 실행 시간의 5%만 GC에 사용하겠다는 목표
nnn 값 GC Time 비율
| 9 | 10% (1/10) |
| 19 | 5% (1/20) |
| 99 | 1% (1/100) |
Throughput Goal 미충족 시 동작
Throughput Goal이 충족되지 않으면 GC는 여러 방법을 통해 달성하려 하는데, 그 중 하나가 Heap 사이즈를 늘리는 것이다.
Heap 크기 증가
→ 객체가 가득 차는 시간이 오래 걸림
→ GC 발생 빈도 감소
→ 전체 GC Time 비율 감소
→ Throughput 개선
단, Heap을 늘리면 한 번 GC 할 때 스캔해야 할 객체가 많아져서 Pause Time은 늘어날 수 있다. 여기서 Throughput Goal과 Maximum Pause-Time Goal의 Trade-off 관계가 발생한다.
┌─────────────────────────────────────────────────────────┐
│ Trade-off 관계 │
│ │
│ Throughput Goal 달성 → Heap 증가 → Pause Time 증가 │
│ Pause-Time Goal 달성 → Heap 감소 → Throughput 감소 │
│ │
│ → 두 목표는 서로 반대 방향으로 Heap을 조정한다 │
└─────────────────────────────────────────────────────────┘
12. Minimum Footprint
Footprint란
Footprint: 프로세스가 현재 사용 중인 메모리의 크기. Heap이 전체 메모리 크기에 가장 큰 영향을 미친다.
Minimum Footprint 동작 원리
Throughput Goal과 Maximum Pause-Time Goal 둘 다 충족되는 상황이 되면, GC는 더 이상 Heap을 늘리거나 줄일 필요가 없다. 이 상태에서 GC는 Heap 크기를 조금씩 줄이기 시작한다.
왜 줄이는가? 불필요하게 메모리를 점유하고 있으면 다른 프로세스들이 그만큼 RAM을 사용하지 못한다. 백엔드 서버 어플리케이션처럼 머신 자원을 독점하는 경우가 아니라면, 남는 메모리는 반납하는 것이 합리적이다.
두 Goal 중 하나가 깨질 때까지 Heap을 줄여나간다.
어느 Goal이 먼저 깨지는가? 예외 없이 Throughput Goal이 먼저 깨진다.
이유: Heap이 줄면 GC 발생 빈도가 증가한다. GC 빈도가 늘면 GC Time 비율이 증가해서 Throughput Goal이 먼저 위반된다. 반면 Heap이 줄면 오히려 한 번 GC할 때 스캔 대상이 적어 Pause Time은 줄어드는 경향이 있다.
Heap Size 옵션
# -Xms=<size> : Initial Heap Size & Minimum Heap Size (동시에 적용)
# -Xmx=<size> : Maximum Heap Size
java -Xms1g -Xmx2g -jar app.jar
# ↑ ↑
# 1GB로 시작 최대 2GB까지 확장 가능
# 서버 애플리케이션: 동일하게 설정
java -Xms8g -Xmx8g -jar app.jar
Virtual Address Space
┌──────────────────────────────────────────────────────────────┐
│ │
│ -Xms=1g -Xmx=2g │
│ ↑ ↑ │
│ ┌───────────┤ │ │
│ │ JVM heap │ ←── 1g~2g 사이에서 조정 ──▶│ │
│ └───────────┴──────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
initial heap size, max heap size
min heap size
세 Goal의 Trade-off 정리
[Heap 증가 방향] →
┌───────────────────────────────────────────────────────────┐
│ │
│ Minimum Maximum Throughput │
│ Footprint ← Pause-Time vs Goal │
│ (줄이기) Goal (늘리기) │
│ (줄이기) │
│ │
│ 두 Goal가 달성되면 Heap ↓ | Throughput 못 달성시 Heap ↑│
│ │
└───────────────────────────────────────────────────────────┘
13. GC 튜닝 가이드 (단계별 순서)
Oracle 공식 문서에서 제안하는 GC 튜닝 가이드다. 단계별로 순서가 있다.
Step 1. Throughput Goal부터 잡는다
-XX:GCTimeRatio=nnn 옵션으로 원하는 GC Time 비율을 설정한다.
Step 2. Max Heap Size로도 Throughput Goal 미달성 시
RAM 크기에 근접하게 Max Heap Size를 잡아본다. 단, 스왑(swap)이 발생하지 않을 정도로.
# 예: RAM 16GB, swap 없이 운용하려면 약 12~14GB 정도까지
java -Xmx14g -XX:GCTimeRatio=19 -jar app.jar
Step 3. 그래도 Throughput Goal 미달성이면
현재 하드웨어(RAM) 대비 목표치 자체가 너무 높은 것이다. 스케일업(RAM 증설) 또는 목표값을 현실적으로 조정해야 한다.
Step 4. Throughput Goal은 달성했는데 Pause Time이 너무 길면
이 때 Maximum Pause-Time Goal을 설정한다.
java -Xms8g -Xmx8g -XX:GCTimeRatio=19 -XX:MaxGCPauseMillis=500 -jar app.jar
Step 5. Trade-off를 인식하고 균형점을 찾는다
Pause-Time Goal을 달성하려면 Throughput Goal이 또 안 맞을 수 있다. 두 목표는 Trade-off 관계임을 인지하고, 서비스 특성에 맞는 균형점을 찾아야 한다.
e.g. 실시간 게임 서버 → Pause-Time 우선 (STW 최소화)
e.g. 배치 처리 시스템 → Throughput 우선 (전체 처리량 최대화)
e.g. 일반 API 서버 → 균형 잡기
서버 애플리케이션에서의 튜닝 가이드 실효성
서버 애플리케이션처럼 -Xms = -Xmx로 고정한 경우, 이 튜닝 가이드의 핵심인 "Heap을 늘렸다 줄였다 하면서 최적화"하는 부분의 효용성이 떨어진다. Heap 크기가 고정되어 있어 Ergonomics가 자동으로 조정할 여지가 없기 때문이다.
Heap 고정 (Xms = Xmx)
→ Ergonomics가 Heap 크기 조정 불가
→ 위 튜닝 가이드의 Heap 조정 메커니즘 동작 안 함
→ 다른 GC 파라미터 튜닝에 집중해야
정리: 큰 그림
GC 동작 전체 흐름 요약
[Java 객체 생성] → [JVM Heap (Young Gen) 에 할당]
↓ Eden 가득 참
[Minor GC 발생] → STW → 살아남은 객체 → Survivor 이동, age 증가
↓ age 임계값 초과
[Old Gen으로 Promotion]
↓ Old Gen 가득 참
[Major GC (Full GC)] → STW 더 오래 발생
↓ Full GC 시
[Metaspace도 정리 (클래스 언로딩 시도)]
GC 선택 기준 요약
[서버 애플리케이션]
├── 머신: 2코어 이상 + RAM 1792MB 이상
├── 디폴트 GC: G1GC
├── Heap 설정: -Xms = -Xmx (동일하게 고정)
└── 튜닝 필요 시: GCTimeRatio, MaxGCPauseMillis 검토
[소규모 / 임베디드 애플리케이션]
├── Heap 100MB 이하
├── 디폴트 GC: Serial GC (단일 코어 환경)
└── 튜닝 불필요한 경우가 대부분
GC 3대 Trade-off
Throughput Goal ←→ Maximum Pause-Time Goal ←→ Minimum Footprint
(Heap ↑) (Heap ↓) (Heap ↓)
서로 반대 방향으로 Heap에 영향을 미친다
주요 JVM GC 튜닝 플래그 요약
# === GC 선택 ===
-XX:+UseG1GC # G1GC (Java 9+ 서버 디폴트)
-XX:+UseSerialGC # Serial GC
-XX:+UseParallelGC # Parallel GC
-XX:+UseZGC # ZGC (Java 15+ GA, 초저지연)
# === Heap 크기 ===
-Xms<size> # Initial & Min Heap Size
-Xmx<size> # Max Heap Size
# 서버 설정 예시
-Xms8g -Xmx8g # 8GB 고정
# === Ergonomics 목표 설정 ===
-XX:MaxGCPauseMillis=<nnn> # Maximum Pause-Time Goal (ms)
-XX:GCTimeRatio=<nnn> # Throughput Goal (GC 비율 = 1/(1+nnn))
# === GC 로깅 (Java 9+) ===
-Xlog:gc*:gc.log:time,uptime,level,tags
# === 진단 ===
-XX:+PrintFlagsFinal # 모든 VM 플래그 출력
-XX:+HeapDumpOnOutOfMemoryError # OOM 시 Heap Dump
-XX:HeapDumpPath=/tmp/heapdump.hprof
# === 현재 VM 설정 확인 ===
# jps -l → 실행 중인 Java 프로세스 PID 확인
# jcmd <PID> VM.flags → 현재 적용된 VM 플래그 확인
다음 편에서는 G1GC의 내부 구조(Region, Eden/Survivor/Old/Humongous)와 Minor GC, Mixed GC, Full GC의 동작 방식을 상세히 다룰 예정이다.
'자바 > 자바' 카테고리의 다른 글
| (JAVA) GC JVM의 기초 정리 (JVM구조) *유튜브 쉬운코드 (0) | 2026.03.30 |
|---|---|
| [Java] 제네릭 와일드카드 완벽 정리 (feat. 공변과 반공변) (1) | 2026.01.22 |
| [Java] 제네릭은 왜 불변(Invariant)일까? (feat. 공변과 서브타입) (1) | 2026.01.21 |
| (java) 패턴 매칭 instanceof 캐스팅 제거 (Flow Scoping 완벽 이해) (0) | 2026.01.20 |
| (자바) Apache Kafka 핵심 개념 (RabbitMQ <->Redis (Pub/Sub) <->Apache Kafka 차이 ) (0) | 2025.12.15 |