Java 개발자라면 누구나 OutOfMemoryError를 한 번쯤 만납니다.
트래픽이 몰리는 순간 갑자기 응답이 수백 ms 느려지는 현상도 겪어봤을 겁니다.
이 모든 현상의 뒤에는 JVM이 메모리를 어떻게 관리하는가라는 원리가 있습니다.
이번 장에서는 GC를 전혀 몰라도 괜찮습니다. 처음부터 차근차근 쌓아갑니다.
C/C++에서는 개발자가 직접 메모리를 할당(malloc)하고 해제(free)해야 합니다.
깜빡 잊으면 메모리 누수(Memory Leak)가 생기고, 잘못 해제하면 프로그램이 죽습니다.
Java는 이 귀찮고 위험한 일을 JVM(Java Virtual Machine)이 대신 해줍니다.
개발자는 객체를 new로 만들기만 하면 되고, 더 이상 쓰이지 않는 객체는 JVM이 자동으로 메모리를 회수합니다.
이 자동 메모리 회수 프로세스가 바로 GC(Garbage Collection)입니다.
손님(객체)이 테이블(메모리)에 앉습니다. 손님이 식사를 마치고 떠나면(참조가 없어지면), 웨이터(GC)가 테이블을 치우고 다음 손님을 위해 자리를 비워둡니다. 웨이터가 테이블을 치우는 동안에는 새 손님을 받지 못하는 순간이 생깁니다. 이것이 STW(Stop-The-World)입니다.
JVM은 메모리를 크게 여러 영역으로 나눠서 관리합니다. 각 영역이 무엇을 저장하는지부터 이해해야 합니다.
GC가 관리하는 영역. new로 생성된 모든 객체가 여기에 저장됩니다.
클래스 정보, 메서드 코드. 힙 외부(Native Memory)에 존재. 상한 없으면 무한 증가 가능.
메서드 호출 프레임, 지역 변수. 스레드마다 하나. GC 대상 아님.
JIT 컴파일된 네이티브 코드. 자주 실행되는 메서드를 기계어로 캐싱.
Stack은 메서드가 반환되면 자동으로 정리됩니다. Metaspace는 클래스 로더가 관리합니다. GC가 열심히 일하는 곳은 오직 Heap입니다. 따라서 GC 문제 = 힙 문제로 봐도 됩니다.
힙에서 객체가 어떻게 이동하는지 단계별로 따라가봅니다.
new Object()대부분의 객체는 ②에서 제거됩니다 (단명 객체). ④까지 가는 객체는 소수입니다.
JVM은 모든 객체에 age(나이)를 부여합니다. Minor GC를 한 번 살아남을 때마다 age가 1씩 늘어납니다.
age가 일정 값(기본 15, -XX:MaxTenuringThreshold)을 넘으면 Old Gen으로 이동합니다.
이것을 Promotion(승격)이라고 합니다.
왜 Young/Old로 나눌까?
대부분의 객체는 생성 직후 빠르게 쓰이고 버려집니다 (예: HTTP 요청 처리 중 만들어지는 DTO, 임시 버퍼). 이런 "단명 객체"를 빠르게 정리하는 것이 Young GC입니다. 전체 힙을 매번 스캔하면 너무 느리기 때문에, 빠르게 죽을 객체만 모아서 자주 청소합니다.
GC가 동작하는 동안, JVM은 애플리케이션 스레드를 전부 멈춥니다. 이 일시 정지를 STW(Stop-The-World)라고 합니다.
GC가 객체 참조 관계를 분석하는 도중에 애플리케이션이 계속 실행되면, 분석 중인 참조 관계가 바뀌어버립니다. 마치 물 위에 떠있는 퍼즐 조각의 위치를 기록하려는데, 물결이 계속 움직이는 것과 같습니다. 정확한 GC를 위해서는 잠깐이라도 세상을 멈춰야 합니다.
STW 시간이 길면 그 시간만큼 모든 요청의 응답이 지연됩니다. P99 레이턴시 스파이크의 주요 원인이 바로 이겁니다.
| GC 종류 | STW 시간 | 발생 조건 | 서비스 영향 |
|---|---|---|---|
| Minor GC | 수 ms ~ 50ms | Eden이 꽉 찰 때 | 거의 없음 |
| Mixed GC (G1GC) | 50ms ~ 200ms | Old Gen이 일정 % 초과 시 | P99 증가 가능 |
| Full GC | 수백 ms ~ 수 초 | 힙 전체 부족, 메모리 누수 | 서비스 타임아웃 발생 |
Java 버전이 올라가면서 GC 알고리즘도 진화해왔습니다. 흐름을 이해하면 G1GC가 왜 선택받는지 명확해집니다.
현재 가장 많이 쓰이는 G1GC가 어떻게 힙을 관리하는지 파악합니다.
G1GC는 힙 전체를 수백 개의 동일 크기 Region으로 나눕니다. 각 Region은 역할(Eden, Survivor, Old, Humongous)이 동적으로 바뀝니다. GC 사이클마다 "가비지가 가장 많은 Region(Garbage First)"을 우선 수집하기 때문에 G1이라는 이름이 붙었습니다.
InitiatingHeapOccupancyPercent) 이상 차면 시작합니다.
OOM은 종류에 따라 원인이 다릅니다. 에러 메시지만 봐도 어디서 문제가 생겼는지 알 수 있습니다.
| 에러 메시지 | 발생 위치 | 주요 원인 |
|---|---|---|
Java heap space |
Heap | 힙 부족 또는 메모리 누수 (가장 흔함) |
GC overhead limit exceeded |
Heap | GC에 98% 이상 시간 소비, 2% 미만만 회수됨 → 사실상 힙 고갈 |
Metaspace |
Metaspace | 클래스 로딩 과다 (동적 Proxy, Reflection, 플러그인 시스템) |
unable to create native thread |
OS | 스레드 수 한도 초과 (스레드 풀 설정 오류) |
Direct buffer memory |
Off-heap | NIO Direct Buffer 누수 (Netty 사용 시 주의) |
이론을 알았으니 실제로 JVM 옵션을 어떻게 설정하는지 알아봅니다.
# 기본 힙 크기 설정 (최소/최대를 같게 설정하면 동적 조정 오버헤드 제거)
-Xms4g -Xmx4g
# G1GC 명시적 지정 (Java 9+는 기본값이지만 명시 권장)
-XX:+UseG1GC
# G1GC 목표 STW 시간 (ms) - 이 값 이하로 STW를 유지하려고 노력
# 너무 낮으면 GC가 충분히 청소 못해 역효과
-XX:MaxGCPauseMillis=200
# Mixed GC 시작 임계값 (Old Gen이 힙의 몇 %를 차지하면 시작할지)
# 낮출수록 더 일찍 Mixed GC 시작 → Full GC 예방, 하지만 GC 빈도 증가
-XX:InitiatingHeapOccupancyPercent=35
# OOM 발생 시 자동 힙 덤프 생성 (운영 필수)
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heapdump.hprof
# GC 로그 남기기 (Java 9+)
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime:filecount=5,filesize=20m
Q: "Java에서 GC가 발생하는 원리와 STW가 서비스에 미치는 영향을 설명해주세요."
A: "JVM은 힙을 Young Generation과 Old Generation으로 나눠 관리합니다. 새로 생성된 객체는 Eden에 배치되고, 참조가 없어지면 Minor GC에서 회수됩니다. 여러 번 살아남은 객체는 Old Gen으로 승격되고, Old Gen이 일정 비율 이상 차면 G1GC의 Mixed GC가 동작합니다. GC 수행 중에는 모든 애플리케이션 스레드를 정지하는 STW가 발생하는데, Minor GC는 수십 ms로 짧지만 Full GC는 수백 ms에서 수 초까지 걸릴 수 있어 P99 레이턴시에 직접 영향을 줍니다. G1GC를 사용하면 힙을 Region 단위로 나눠 가비지 밀도 높은 Region만 선택 수집하기 때문에 Full GC 없이 힙을 관리할 수 있습니다."
Q: "OutOfMemoryError가 발생했을 때 어떻게 대응하시겠어요?"
A: "먼저 에러 메시지 종류로 위치를 특정합니다. 'Java heap space'라면 힙 부족이고, 'Metaspace'라면 클래스 로딩 문제입니다. 힙 OOM이라면 -XX:+HeapDumpOnOutOfMemoryError로 자동 수집된 힙 덤프 파일을 Eclipse MAT으로 분석해 Dominator Tree에서 가장 많은 메모리를 점유하는 객체 계층을 확인합니다. 해제되지 않는 캐시, 무한히 쌓이는 컬렉션, 닫히지 않는 DB 커넥션 같은 누수 패턴을 찾습니다. 운영 중 이런 상황에 대비하기 위해 Prometheus로 Heap 사용률 85% 초과 시 사전 경보를 설정해 OOM 전에 조치할 수 있도록 합니다."