1. JVM 메모리 구조와 GC 완전 정복

Java 개발자라면 누구나 OutOfMemoryError를 한 번쯤 만납니다. 트래픽이 몰리는 순간 갑자기 응답이 수백 ms 느려지는 현상도 겪어봤을 겁니다. 이 모든 현상의 뒤에는 JVM이 메모리를 어떻게 관리하는가라는 원리가 있습니다. 이번 장에서는 GC를 전혀 몰라도 괜찮습니다. 처음부터 차근차근 쌓아갑니다.

1. Java가 메모리를 관리하는 방식: GC란 무엇인가

C/C++에서는 개발자가 직접 메모리를 할당(malloc)하고 해제(free)해야 합니다. 깜빡 잊으면 메모리 누수(Memory Leak)가 생기고, 잘못 해제하면 프로그램이 죽습니다.

Java는 이 귀찮고 위험한 일을 JVM(Java Virtual Machine)이 대신 해줍니다. 개발자는 객체를 new로 만들기만 하면 되고, 더 이상 쓰이지 않는 객체는 JVM이 자동으로 메모리를 회수합니다. 이 자동 메모리 회수 프로세스가 바로 GC(Garbage Collection)입니다.

비유: 식당의 홀 서빙

손님(객체)이 테이블(메모리)에 앉습니다. 손님이 식사를 마치고 떠나면(참조가 없어지면), 웨이터(GC)가 테이블을 치우고 다음 손님을 위해 자리를 비워둡니다. 웨이터가 테이블을 치우는 동안에는 새 손님을 받지 못하는 순간이 생깁니다. 이것이 STW(Stop-The-World)입니다.

2. JVM 메모리 구조: 어디에 무엇이 저장되는가

JVM은 메모리를 크게 여러 영역으로 나눠서 관리합니다. 각 영역이 무엇을 저장하는지부터 이해해야 합니다.

JVM 메모리 전체 구조

🗂 Heap (힙)

GC가 관리하는 영역. new로 생성된 모든 객체가 여기에 저장됩니다.

Young Generation
Eden Survivor 0 Survivor 1
새로 만들어진 객체
대부분의 객체는 여기서 죽음
Old Generation
오래 살아남은 객체
(Survivor를 N번 통과)
📦 Metaspace

클래스 정보, 메서드 코드. 힙 외부(Native Memory)에 존재. 상한 없으면 무한 증가 가능.

📍 Stack

메서드 호출 프레임, 지역 변수. 스레드마다 하나. GC 대상 아님.

⚡ Code Cache

JIT 컴파일된 네이티브 코드. 자주 실행되는 메서드를 기계어로 캐싱.

GC는 Heap만 관리합니다

Stack은 메서드가 반환되면 자동으로 정리됩니다. Metaspace는 클래스 로더가 관리합니다. GC가 열심히 일하는 곳은 오직 Heap입니다. 따라서 GC 문제 = 힙 문제로 봐도 됩니다.

3. 객체의 일생: Eden에서 Old Gen까지

힙에서 객체가 어떻게 이동하는지 단계별로 따라가봅니다.

객체 생명주기 흐름

① Eden 탄생
new Object()
호출 시 Eden에 할당
② Minor GC
Eden이 꽉 차면 발생
살아있는 객체만 Survivor로
③ Survivor 왕복
Minor GC마다
S0 ↔ S1 이동
age 카운트 +1
④ Old Gen 승격
age가 임계값(기본 15) 초과
또는 Survivor 공간 부족 시
⑤ Major/Full GC
Old Gen이 꽉 차면
긴 STW 발생

대부분의 객체는 ②에서 제거됩니다 (단명 객체). ④까지 가는 객체는 소수입니다.

Minor GC에서 살아남는다는 것의 의미

JVM은 모든 객체에 age(나이)를 부여합니다. Minor GC를 한 번 살아남을 때마다 age가 1씩 늘어납니다. age가 일정 값(기본 15, -XX:MaxTenuringThreshold)을 넘으면 Old Gen으로 이동합니다. 이것을 Promotion(승격)이라고 합니다.

왜 Young/Old로 나눌까?
대부분의 객체는 생성 직후 빠르게 쓰이고 버려집니다 (예: HTTP 요청 처리 중 만들어지는 DTO, 임시 버퍼). 이런 "단명 객체"를 빠르게 정리하는 것이 Young GC입니다. 전체 힙을 매번 스캔하면 너무 느리기 때문에, 빠르게 죽을 객체만 모아서 자주 청소합니다.

4. STW(Stop-The-World): 응답 지연의 진짜 원인

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 ~ 수 초 힙 전체 부족, 메모리 누수 서비스 타임아웃 발생

5. GC 알고리즘의 진화: 왜 G1GC를 쓰는가

Java 버전이 올라가면서 GC 알고리즘도 진화해왔습니다. 흐름을 이해하면 G1GC가 왜 선택받는지 명확해집니다.

GC 알고리즘 진화

Serial / Parallel GC
Old Gen 전체를 한 번에 청소 → Full GC 때 수 초 STW 발생. 단순하지만 대용량 서비스에는 부적합.
CMS
Old Gen 청소를 앱 실행 중에 병행(Concurrent). STW 단축. 하지만 메모리 단편화 문제 → Java 9에서 deprecated.
G1GC ⭐
힙을 Region으로 분할, 가비지 많은 Region만 선택 수집. Java 9부터 기본 GC. 예측 가능한 STW 시간 보장.
ZGC / Shenandoah
거의 모든 작업을 앱과 병행. STW 1ms 이하 목표. Java 15+ 에서 사용 가능. 초저지연 서비스용.

6. G1GC 동작 원리 상세

현재 가장 많이 쓰이는 G1GC가 어떻게 힙을 관리하는지 파악합니다.

핵심 아이디어: Region으로 쪼개기

G1GC는 힙 전체를 수백 개의 동일 크기 Region으로 나눕니다. 각 Region은 역할(Eden, Survivor, Old, Humongous)이 동적으로 바뀝니다. GC 사이클마다 "가비지가 가장 많은 Region(Garbage First)"을 우선 수집하기 때문에 G1이라는 이름이 붙었습니다.

G1GC Region 구조 (개념도)

E
O
E
F
O
S
F
E
F
O
S
O
E
F
H
F
E Eden S Survivor O Old H Humongous(대형 객체) F Free(빈 공간)

G1GC의 GC 사이클

단계별 동작

  1. Young GC (Minor GC) — 가장 자주 발생
    Eden Region이 꽉 차면 수행합니다. Eden + Survivor의 살아있는 객체를 새 Survivor Region으로 복사하고, 기존 Eden은 통째로 Free 처리합니다. STW 있음, 짧음(수~50ms)
  2. Concurrent Marking — STW 없이 백그라운드에서
    Old Gen에 살아있는 객체를 파악합니다. 앱과 동시에 실행되므로 사용자는 느끼지 못합니다. Old Gen이 전체 힙의 45%(InitiatingHeapOccupancyPercent) 이상 차면 시작합니다.
  3. Mixed GC — Concurrent Marking 결과를 기반으로
    Young Region + "가비지 밀도 높은" Old Region들을 함께 수집합니다. Full GC 없이 Old Gen을 점진적으로 비웁니다. STW 있음, 중간(50~200ms)
  4. Full GC — 비정상 상황에서만 발생
    Mixed GC가 Old Gen을 충분히 비우지 못하거나, 너무 빠르게 객체가 Old Gen에 쌓이면 발생합니다. 전체 힙을 단일 스레드로 처리. STW 수백ms ~ 수 초, 서비스 장애 수준

7. OOM(OutOfMemoryError)의 종류

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 사용 시 주의)

8. 실전 GC 튜닝 옵션

이론을 알았으니 실제로 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 전에 조치할 수 있도록 합니다."