JVM 메모리와 GC - 힙 구조 G1 ZGC 선택과 OutOfMemoryError 읽는 법 (자바 고급 19단원)
이 단원에서 배우는 것
17~18단원에서 재고·주문 서비스를 여러 스레드로 돌렸다. 스레드를 늘리면 처리량이 오르지만, 스레드마다 스택이 잡히고 작업마다 객체가 생긴다. 그 객체들이 어디에 놓이고 언제 사라지는지를 모르면 "부하 테스트는 통과했는데 운영 3일째 새벽에 죽는" 서비스가 된다. 이번 단원은 JVM이 메모리를 어떻게 나눠 쓰는지, GC가 무엇을 근거로 객체를 지우는지, 그리고 이미 터진 OutOfMemoryError 로그를 어떻게 읽는지를 다룬다. 튜닝 옵션 나열이 아니라 진단 순서가 목적이다.
- 힙의 세대 구조와 약한 세대 가설이 왜 성능으로 이어지는지 설명한다
- G1 · ZGC · Parallel 의 트레이드오프를 이해하고 서비스 성격에 맞게 고른다
- OOM 메시지 다섯 종류를 구분하고, 힙 덤프와 GC 로그로 원인까지 좁힌다
왜 필요한가
실제로 겪는 순서는 대개 이렇다. 새벽 3시에 알림이 온다. 프로세스가 죽었다. 로그 끝에는 한 줄이 있다.
java.lang.OutOfMemoryError: Java heap space
여기서 대부분 -Xmx 를 두 배로 올리고 재시작한다. 그러면 죽는 주기가 3일에서 6일로 늘 뿐이다. 메모리 누수는 힙을 늘려서 고쳐지지 않는다. 반대로 OutOfMemoryError: unable to create native thread 처럼 힙과 무관한 OOM에 -Xmx 를 올리면 상황이 더 나빠진다. 힙이 커지면 프로세스가 쓸 수 있는 나머지 메모리가 줄어 스레드를 더 못 만들기 때문이다.
즉 메시지를 읽을 줄 아는 것이 튜닝보다 먼저다. 그리고 컨테이너 시대에는 하나가 더 붙는다. 쿠버네티스에서 파드가 Exit Code 137 로 사라졌다면 그건 자바의 OOM이 아니라 커널이 프로세스를 죽인 것이라, 자바 로그에는 아무것도 안 남는다. 원인 계층이 다르면 처방도 다르다.
문법과 예제
1. 프로세스 메모리는 힙만이 아니다
자바 프로세스가 OS에서 잡는 메모리는 대략 이렇게 나뉜다.
| 영역 | 무엇이 들어가나 | 크기를 정하는 것 |
|---|---|---|
| 힙(Heap) | new 로 만든 모든 객체 | -Xms / -Xmx |
| 메타스페이스 | 클래스 메타데이터. 힙이 아니라 네이티브 메모리 | -XX:MaxMetaspaceSize (기본 무제한) |
| 스레드 스택 | 지역 변수, 호출 프레임. 스레드 수 × 스택 크기 | -Xss (기본 512KB~1MB) |
| 코드 캐시 | JIT가 컴파일한 기계어 | -XX:ReservedCodeCacheSize |
| 다이렉트 버퍼 | NIO ByteBuffer.allocateDirect, 네티 버퍼 | -XX:MaxDirectMemorySize |
그래서 컨테이너 메모리 한도 = -Xmx 로 잡으면 반드시 터진다. 힙 밖에서 최소 수백 MB를 더 쓰기 때문이다. 실무 기준으로는 컨테이너 한도의 50~75%를 힙으로 준다. JDK 10 이후로는 컨테이너 인식이 기본이라 숫자를 직접 쓰는 대신 비율로 주는 편이 안전하다.
java -XX:MaxRAMPercentage=70.0 -jar order-service.jar
참고로 옵션을 아무것도 안 주면 JVM이 알아서 정한다. 현재 머신에서 확인해 보면 이렇다.
$ java -XX:+PrintFlagsFinal -version | grep -E "MaxHeapSize|UseG1GC|MaxRAMPercentage"
size_t MaxHeapSize = 4294967296 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
bool UseG1GC = true {product} {ergonomic}
기본 최대 힙은 물리 메모리의 25% 이고, JDK 17의 기본 GC는 G1이다. 16GB 머신이면 4GB가 잡힌다. 개발 PC에서 잘 돌던 배치가 2GB 컨테이너에서 OOM 나는 이유가 대개 이 기본값 차이다.
2. 힙 안의 세대 구조와 GC 로그
GC 설계의 출발점은 관찰 하나다. 대부분의 객체는 만들어지자마자 죽는다(약한 세대 가설). 요청 하나를 처리하며 만든 DTO, 문자열, 스트림 중간 객체는 응답을 내보내는 순간 전부 쓰레기가 된다. 그래서 힙을 Young(Eden + Survivor 두 개)과 Old로 나누고, Young만 자주 훑는다. Young GC는 살아남은 것만 복사하므로 죽은 객체가 많을수록 빨라진다. 살아남기를 여러 번 반복한 객체만 Old로 승격된다.
말로 읽는 것보다 로그가 빠르다. 아래를 직접 돌려 보자.
import java.util.ArrayList;
import java.util.List;
public class GcDemo {
public static void main(String[] args) {
List<byte[]> live = new ArrayList<>(); // 오래 사는 객체
for (int i = 0; i < 200_000; i++) {
byte[] tmp = new byte[1024]; // 곧 버려질 객체
tmp[0] = 1;
if (i % 1000 == 0) live.add(new byte[4096]);
}
System.out.println("살아남은 객체 = " + live.size());
}
}
$ java -Xmx64m -Xlog:gc GcDemo.java
[0.004s][info][gc] Using G1
[0.022s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 23M->1M(64M) 0.338ms
[0.027s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 38M->1M(64M) 0.218ms
[0.030s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 38M->1M(64M) 0.183ms
살아남은 객체 = 200
한 줄을 읽는 법은 다음과 같다. 38M->1M(64M) 은 GC 전 사용량 → GC 후 사용량 (전체 힙) 이다. 38MB 중 37MB가 쓰레기였다는 뜻이고, 이것이 건강한 모습이다. 정지 시간은 0.2ms대다.
반대로 위험한 로그는 이렇게 생겼다.
Pause Full (G1 Compaction Pause) 1902M->1898M(2048M) 3402ms
Full GC를 3.4초나 돌았는데 회수된 것이 4MB뿐이다. 살아 있는 객체가 힙을 가득 채웠다는 뜻이고, 이 상태가 반복되면 곧 OOM이다. 진단할 때 보는 순서는 (1) Full GC 빈도, (2) GC 후 사용량이 계속 우상향하는가, (3) 한 번의 정지 시간 이다. 특히 (2)가 핵심이다. GC 후 값이 톱니처럼 오르내리면 정상, 계단처럼 올라가기만 하면 누수다.
JDK 9 이후 GC 로그 옵션은 -Xlog 로 통일됐다. 운영에서는 파일로 회전시켜 남긴다.
java -Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level:filecount=5,filesize=20M \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/app/ \
-jar order-service.jar
GC 로그는 켜 두는 비용이 사실상 0이다. 운영 서버에 GC 로그와 힙 덤프 옵션이 없으면, 장애가 나도 원인을 알 방법이 없다. 이 두 줄은 배포 표준에 넣어야 한다.
3. GC 선택
| GC | 특징 | 맞는 곳 |
|---|---|---|
| Serial | 스레드 하나. 단순하고 오버헤드 최소 | 힙 수백 MB, 코어 1~2개인 사이드카·CLI |
| Parallel | 정지하되 여러 스레드로 빨리 끝냄. 처리량 최대 | 응답 지연이 상관없는 배치 |
| G1 (기본) | 힙을 리전으로 쪼개 정지 목표를 맞춤 | 일반 웹 서비스. 힙 2~32GB |
| ZGC | 정지 시간 1ms 미만, 힙 크기와 무관. 처리량은 5~15% 손해 | 지연에 민감하고 힙이 큰 서비스(수십 GB~TB) |
JDK 17에서 ZGC는 정식이지만 세대 구분이 없었고, JDK 21부터 세대별 ZGC(-XX:+UseZGC -XX:+ZGenerational)가 들어와 실사용 가치가 크게 올랐다. 다만 처음부터 GC를 바꾸는 것은 거의 항상 틀린 선택이다. 순서는 (1) 객체를 덜 만든다 → (2) 힙 크기를 맞춘다 → (3) 그래도 정지가 문제면 GC를 바꾼다 이다. GC 교체로 얻는 이득보다 코드에서 불필요한 객체 생성을 없애서 얻는 이득이 대개 훨씬 크다.
4. OOM 메시지 읽기
| 메시지 | 실제 원인 | 첫 조치 |
|---|---|---|
Java heap space | 힙 부족. 누수이거나 한 번에 너무 큰 적재 | 힙 덤프 분석. -Xmx 증설은 그다음 |
GC overhead limit exceeded | GC에 98% 이상을 쓰는데 2% 미만만 회수 | 사실상 누수. 위와 같음 |
Metaspace | 클래스가 계속 로딩됨. 동적 프록시·핫 리로드·클래스로더 누수 | 로드된 클래스 수 추이 확인(20단원과 연결) |
unable to create native thread | 스레드 수 상한 또는 네이티브 메모리 부족 | 스레드 덤프로 풀 개수 확인. -Xmx 를 낮춘다 |
Direct buffer memory | NIO 다이렉트 버퍼 누수(네티, HTTP 클라이언트) | 버퍼 릴리스 누락 확인 |
가장 흔한 첫 번째 케이스를 재현해 보자. 상한 없는 캐시는 실무에서 가장 흔한 누수 형태다.
import java.util.*;
public class LeakDemo {
static final Map<String, byte[]> CACHE = new HashMap<>(); // 상한이 없다
public static void main(String[] args) {
for (long i = 0; ; i++) {
CACHE.put("order-" + i, new byte[10 * 1024]); // 10KB 씩
if (i % 1000 == 0) System.out.println("적재 " + i + "건");
}
}
}
$ java -Xmx64m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/x.hprof LeakDemo.java
적재 0건
...
적재 6000건
java.lang.OutOfMemoryError: Java heap space
Dumping heap to /tmp/x.hprof ...
Heap dump file created [67944101 bytes in 0.022 secs]
덤프 파일은 Eclipse MAT나 IntelliJ Ultimate의 프로파일러로 연다. 볼 것은 딱 하나, 지배자 트리(dominator tree)에서 가장 큰 객체가 무엇에 붙들려 있는가 이다. 이 예제라면 LeakDemo.CACHE 가 힙의 95%를 붙잡고 있다고 바로 나온다. GC는 참조가 남아 있는 객체를 절대 지우지 않으므로, 메모리 누수는 "버그"라기보다 "안 놓아준 참조" 다.
5. 죽기 전에 들여다보기
덤프가 나오기 전에 진행 중인 프로세스를 보는 도구도 알아 둔다. 모두 JDK에 기본 포함이다.
jcmd -l # PID 확인
jcmd <pid> GC.heap_info # 세대별 사용량
jcmd <pid> Thread.print # 스레드 덤프 (데드락 자동 표시)
jcmd <pid> GC.class_histogram # 클래스별 인스턴스 수 상위
jcmd <pid> GC.heap_dump /tmp/now.hprof # 살아 있는 상태로 덤프
jstat -gcutil <pid> 1s # 1초마다 세대별 사용률과 GC 횟수
GC.class_histogram 을 5분 간격으로 두 번 떠서 증가한 클래스를 비교하는 것이 누수 추적의 가장 빠른 방법이다. 덤프 전체를 받는 것보다 훨씬 가볍고, 운영 중에도 부담이 적다.
실무에서 자주 틀리는 것
1. 컨테이너 한도와 -Xmx 를 같게 준다
메모리 한도 1GB 파드에 -Xmx1g 를 주면, 힙이 1GB에 가까워지기 전에 메타스페이스·스택·다이렉트 버퍼가 더해져 커널의 OOM Killer가 먼저 프로세스를 죽인다. 이때 자바 로그에는 아무것도 안 남는다. kubectl describe pod 에 Reason: OOMKilled, Exit Code: 137 만 보인다. 자바 OutOfMemoryError 와 OOMKilled 는 완전히 다른 사건이고, 후자는 힙 덤프도 안 남는다. 진단 첫 단계는 항상 "자바가 죽었나, 커널이 죽였나"를 가르는 것이다.
2. System.gc() 를 호출한다
"메모리가 걱정돼서" 넣는 System.gc() 는 대부분 Full GC를 강제로 일으켜 수백 ms에서 수 초의 정지를 만든다. 게다가 진짜 문제(놓아주지 않은 참조)는 전혀 해결하지 못한다. GC가 필요한 시점은 JVM이 훨씬 잘 안다. 운영에서는 아예 -XX:+DisableExplicitGC 로 막아 두는 곳도 많다. 다만 이 옵션은 다이렉트 버퍼 회수를 System.gc() 에 의존하는 일부 라이브러리와 충돌할 수 있어, 켤 때는 다이렉트 메모리 사용량을 함께 본다.
3. static 컬렉션과 리스너를 누수 지점으로 의심하지 않는다
실무 누수의 대부분은 세 가지에서 나온다. (a) 상한 없는 static 캐시, (b) 등록만 하고 해제하지 않은 리스너·콜백, (c) 스레드 풀 스레드에 남은 ThreadLocal. 특히 (c)는 18단원에서 본 것처럼 풀 스레드가 재사용되므로 값이 영원히 살아남는다. 캐시는 직접 만들지 말고 상한과 만료가 있는 구현(Caffeine 등)을 쓰거나, 최소한 LinkedHashMap 의 removeEldestEntry 로 크기를 막는다. WeakHashMap 은 키만 약참조라, 값이 키를 참조하면 아무것도 회수되지 않는다는 점도 자주 놓친다.
4. 힙 덤프를 안 남기고 재시작한다
장애 상황에서 가장 급한 것은 복구지만, 재시작하면 증거가 사라진다. -XX:+HeapDumpOnOutOfMemoryError 를 미리 켜 두면 죽는 순간 자동으로 덤프가 남는다. 주의할 점은 덤프 크기가 힙 크기만큼 이라는 것이다. 8GB 힙이면 8GB 파일이 생기므로, 디스크 여유와 저장 경로(컨테이너라면 종료와 함께 사라지지 않는 볼륨)를 미리 확보해야 한다. 이걸 안 해 두면 덤프를 쓰다가 디스크가 차서 다른 장애를 부른다.
5. GC 옵션부터 만진다
블로그에서 본 -XX:MaxGCPauseMillis=50 같은 옵션을 근거 없이 붙이는 경우가 많다. G1은 이 목표를 맞추려고 Young 영역을 줄이는데, 그러면 Young GC가 더 자주 돌고 승격이 빨라져 오히려 Full GC가 늘 수 있다. 22단원에서 다시 말하겠지만 측정 없이 바꾼 옵션은 튜닝이 아니라 도박이다. 옵션 하나를 바꿀 때마다 같은 부하로 GC 로그를 다시 받아 비교해야 한다.
스스로 확인하기
- GC 로그에 다음 두 줄이 반복된다. 각각 어떤 상태인가?
(A) Pause Young (Normal) 512M->48M(1024M) 12ms (B) Pause Full (G1 Compaction Pause) 990M->985M(1024M) 2800ms - 메모리 한도 2GB인 쿠버네티스 파드에서 자바 앱이
Exit Code 137로 반복 재시작된다. 애플리케이션 로그에는OutOfMemoryError가 없다. 무엇이 일어났으며 어떤 순서로 확인하는가? - 스레드 풀을 요청마다 새로 만드는 버그가 있는 코드에서 결국
OutOfMemoryError: unable to create native thread가 났다.-Xmx를 8g에서 12g로 올리면 상황은 어떻게 되는가?
정답
- (A)는 정상이다. 512MB 중 464MB가 회수됐고 정지는 12ms다. 대부분의 객체가 금방 죽는, GC 입장에서 가장 좋은 형태다. (B)는 임계 상태다. 2.8초 정지에 회수량이 5MB뿐이라는 것은 힙의 거의 전부가 살아 있는 객체라는 뜻이다. 곧
OutOfMemoryError: Java heap space또는GC overhead limit exceeded가 난다. 대응은 힙 증설이 아니라 힙 덤프를 떠서 무엇이 그 990MB를 붙잡고 있는지 찾는 것이다. 137 = 128 + 9, 즉 SIGKILL이다. 커널의 OOM Killer가 프로세스를 죽였으므로 JVM은 로그를 남길 틈이 없었다. 확인 순서는 (1)kubectl describe pod에서Reason: OOMKilled확인, (2) 실행 커맨드의-Xmx또는MaxRAMPercentage값 확인, (3) 힙 외 사용량 점검 — 스레드 수 × 스택 크기, 메타스페이스, 다이렉트 버퍼. 흔한 원인은 힙을 한도에 너무 가깝게 잡은 것이고,-XX:MaxRAMPercentage=70수준으로 낮추면서-XX:NativeMemoryTracking=summary로 힙 외 사용을 함께 본다.- 더 빨리 죽는다. 이 OOM은 힙이 아니라 네이티브 메모리·스레드 수 상한 때문에 난다. 힙을 4GB 더 잡으면 프로세스 주소 공간에서 스레드 스택에 쓸 수 있는 몫이 그만큼 줄어, 더 적은 수의 스레드에서 같은 오류가 난다. 올바른 조치는 스레드 덤프(
jcmd <pid> Thread.print)로 같은 이름의 스레드가 수천 개 쌓였는지 확인하고, 풀을 싱글턴으로 만들어 재사용하도록 코드를 고치는 것이다. 18단원의 "풀을 종료하지 않는다"와 같은 뿌리의 문제다.