자바는 GC가 메모리를 관리해주는데, 그래도 메모리 누수가 생길 수 있다고? C/C++처럼 직접 free()를 호출하는 것도 아닌데 대체 왜? 실무에서 갑자기 OOM(OutOfMemoryError)이 터지면 뭘 봐야 하는지, 어디서부터 시작해야 하는지 막막할 때가 많다. 이 글에서는 자바 메모리 누수의 원인부터 힙 덤프 분석, 실전 트러블슈팅까지 정리해본다.
▸ TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.
1. 자바에서도 메모리 누수가 생기는 이유
자바의 GC는 더 이상 도달할 수 없는(unreachable) 객체를 회수한다. 핵심은 "도달할 수 없는"이라는 조건이다.
PLAINTEXT
GC Root → A → B → C ← 전부 reachable, 회수 안 됨GC Root → A → B ← C는 unreachable, 회수됨
메모리 누수의 정의: 더 이상 사용하지 않지만, GC Root에서 참조 체인이 끊어지지 않아 GC가 회수하지 못하는 상태.
프로그래머가 "이 객체는 더 이상 안 써"라고 생각하지만, 코드 어딘가에서 참조를 놓지 않고 있으면 GC 입장에서는 여전히 살아있는 객체다. GC Root가 될 수 있는 것들은 다음과 같다.
활성 스레드의 스택 프레임에 있는 지역 변수
static 변수
JNI 참조
활성 모니터(synchronized 락을 잡고 있는 객체)
2. 대표적인 누수 패턴
패턴 1: static 컬렉션에 쌓이는 데이터
JAVA
publicclassCacheManager {// static이니까 클래스가 언로드되지 않는 한 영원히 살아있음privatestaticfinal Map<String, byte[]> cache = newHashMap<>();publicstaticvoidput(String key, byte[] data) { cache.put(key, data); }// remove()를 호출하지 않으면? → 계속 쌓인다}
해결: 크기 제한이 있는 캐시를 사용하거나, WeakHashMap, LRU 캐시(Caffeine, Guava Cache) 등을 쓴다.
"http-nio-8080-exec-1" #25 daemon prio=5 java.lang.Thread.State: WAITING (parking) at sun.misc.Unsafe.park(Native Method) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175) at com.myapp.service.SlowService.process(SlowService.java:42)
주요 확인 사항:
BLOCKED 스레드가 많은지 → 락 경합, 데드락 의심
같은 스택 트레이스를 가진 스레드가 대량으로 있는지 → 특정 코드에서 병목
jstack은 데드락을 자동으로 감지해서 Found one Java-level deadlock 메시지를 출력한다.
8. VisualVM과 JFR로 실시간 모니터링
VisualVM
힙 메모리 그래프에서 정상과 누수의 차이를 한눈에 알 수 있다.
PLAINTEXT
[정상] 톱니 모양 — GC가 주기적으로 회수 ╱╲ ╱╲ ╱╲ ╱╲ ╱ ╲╱ ╲╱ ╲╱ ╲[누수] 우상향 — GC 후에도 기저 메모리가 계속 증가 ╱╲ ╱╲ ╱╲ ╱╲╱ ╲╱ ╲ ╱╲╱ ╱
JFR 기록은 **JDK Mission Control(JMC)**에서 열어서 메모리 할당 핫스팟, GC 이벤트 상세, 스레드 활동, 락 경합 등을 분석한다.
9. 실전 디버깅 시나리오 — 단계별 접근법
"OOM이 터졌어요"라는 알람이 오면, 다음 순서대로 접근한다.
Step 1: 증상 확인
BASH
jstat -gcutil <PID> 1000 10 # 1초 간격으로 10회 GC 통계 출력
PLAINTEXT
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 99.12 87.45 98.34 95.67 92.34 523 12.45 47 38.92 51.37
O(Old Gen)가 98% → 힙이 거의 꽉 참
FGC 47회, FGCT 38.92초 → Full GC가 너무 자주, 너무 오래 발생
Step 2: 힙 덤프 확보 & MAT 분석
BASH
jcmd <PID> GC.heap_dump /tmp/heapdump.hprof
MAT에서 Leak Suspects → Dominator Tree → Path to GC Roots 순서로 추적한다.
Step 3: 원인 코드 수정
JAVA
// Before: 누수 — 세션을 넣기만 하고 제거하지 않음privatestaticfinal Map<String, UserSession> sessions = newConcurrentHashMap<>();// After: 만료 정책이 있는 캐시 사용privatestaticfinal Cache<String, UserSession> sessions = Caffeine.newBuilder() .expireAfterAccess(30, TimeUnit.MINUTES) // 30분 미접근 시 자동 제거 .maximumSize(10_000) // 최대 1만 개 .build();
Step 4: 검증
BASH
# JFR로 30분간 기록하며 Old Gen 사용률이 안정적으로 유지되는지 확인jcmd <PID> JFR.start duration=1800s filename=/tmp/after-fix.jfrjstat -gcutil <PID> 5000
트러블슈팅은 증상 확인 → 힙 덤프 → MAT 분석 → 코드 수정 → 검증 순서로 진행한다.
-XX:+HeapDumpOnOutOfMemoryError는 운영 환경 필수 옵션이다.
공부하다 보니, GC가 있어도 메모리를 신경 써야 하는 이유가 "참조"에 있다는 게 핵심이었다. 결국 GC는 도달 불가능한 객체만 회수하니까, 우리가 실수로 참조를 유지하면 GC도 어쩔 수 없다. 힙 덤프 분석이 처음에는 어렵게 느껴지지만, MAT의 Dominator Tree → Path to GC Roots 흐름만 익히면 대부분의 누수는 찾을 수 있다.
다음 글에서는 성능 최적화와 프로파일링 — 자바가 느리다는 편견 깨기를 다룬다. JIT 컴파일러의 최적화, 벤치마킹 방법론(JMH), 그리고 실무에서 자주 쓰는 성능 개선 패턴까지 정리해보자.