Theme:

자바는 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
public class CacheManager {
    // static이니까 클래스가 언로드되지 않는 한 영원히 살아있음
    private static final Map<String, byte[]> cache = new HashMap<>();

    public static void put(String key, byte[] data) {
        cache.put(key, data);
    }
    // remove()를 호출하지 않으면? → 계속 쌓인다
}

해결: 크기 제한이 있는 캐시를 사용하거나, WeakHashMap, LRU 캐시(Caffeine, Guava Cache) 등을 쓴다.

패턴 2: 이벤트 리스너 / 콜백 미해제

JAVA
public class EventBus {
    private final List<EventListener> listeners = new ArrayList<>();

    public void register(EventListener listener) {
        listeners.add(listener);
    }
    // unregister()를 깜빡하면?
    // → listener가 참조하는 모든 객체가 GC되지 않음
}

화면이 닫혀도 리스너가 등록된 채로 남아있으면, 그 리스너가 참조하는 컴포넌트 전체가 메모리에 남는다.

패턴 3: ThreadLocal 미정리

JAVA
public class UserContext {
    private static final ThreadLocal<UserSession> currentSession = new ThreadLocal<>();

    public static void set(UserSession session) { currentSession.set(session); }
    public static UserSession get() { return currentSession.get(); }
    // 요청이 끝나면 반드시 remove()!
    public static void clear() { currentSession.remove(); }
}

톰캣 같은 서블릿 컨테이너는 스레드 풀을 사용한다. 요청이 끝나도 스레드는 죽지 않고 풀에 반환된다. remove()를 빠뜨리면 이전 요청의 데이터가 스레드에 계속 붙어 있어서, 메모리 누수뿐 아니라 다른 사용자의 세션이 보이는 보안 이슈까지 발생할 수 있다.

패턴 4: 비정적 내부 클래스의 외부 참조

JAVA
public class Outer {
    private byte[] bigData = new byte[10 * 1024 * 1024]; // 10MB

    // 비정적 내부 클래스 → Outer에 대한 암묵적 참조를 가짐
    public class Inner {
        public void doSomething() {
            // bigData를 직접 안 써도, Outer 참조는 유지됨
        }
    }
}
// Inner 객체가 살아있는 한 Outer(10MB)도 GC되지 않음
Outer.Inner inner = new Outer().createInner();

해결: 외부 클래스 참조가 필요 없으면 static 내부 클래스를 사용한다.


3. OOM 에러 유형

OOM이 터졌다고 다 같은 OOM이 아니다. 에러 메시지를 보면 어디가 문제인지 힌트를 얻을 수 있다.

에러 메시지문제 영역주요 원인확인 옵션
Java heap space객체 과다 생성 / 누수-Xmx
Metaspace메타스페이스클래스 대량 동적 생성 (CGLIB, 리플렉션 프록시)-XX:MaxMetaspaceSize
GC overhead limit exceededGC가 98% 이상 시간을 쓰면서 2% 미만 회수-Xmx
Unable to create native threadOS스레드 무한 생성, ulimit -u 제한ulimit -u
Direct buffer memory네이티브ByteBuffer.allocateDirect() 누수-XX:MaxDirectMemorySize

GC overhead limit exceeded는 사실상 heap space OOM의 전조 증상이다. 메모리가 거의 꽉 차서 GC만 계속 도는 상황이다.


4. 참조 유형과 GC의 관계

자바에는 Strong Reference 외에 세 가지 특수 참조 유형이 있다. 캐시 설계나 누수 방지에 활용할 수 있다.

WeakReference

JAVA
Object obj = new Object();
WeakReference<Object> weakRef = new WeakReference<>(obj);
obj = null; // Strong Reference 제거
// 다음 GC에서 weakRef.get()은 null을 반환

Strong Reference가 없으면 다음 GC에서 바로 수거된다. WeakHashMap이 이 원리를 이용한다.

SoftReference

JAVA
byte[] bigData = new byte[10 * 1024 * 1024]; // 10MB
SoftReference<byte[]> softRef = new SoftReference<>(bigData);
bigData = null;
// 메모리가 충분하면 유지, 부족하면 GC가 수거

메모리가 부족할 때만 수거된다. "있으면 좋지만, 없어도 다시 로드할 수 있는" 캐시 데이터에 적합하다.

PhantomReference

JAVA
ReferenceQueue<Object> queue = new ReferenceQueue<>();
Object obj = new Object();
PhantomReference<Object> phantomRef = new PhantomReference<>(obj, queue);
obj = null;
// phantomRef.get()은 항상 null
// GC가 객체를 수거하면 phantomRef가 큐에 등록됨 → 정리(cleanup) 용도

Cleaner API(Java 9+)가 내부적으로 PhantomReference를 사용한다.

참조 유형 비교

유형GC 수거 조건대표 용도
StrongGC Root에서 도달 불가능할 때일반 객체 참조
WeakStrong Reference가 없으면 즉시WeakHashMap, 캐시 키
Soft메모리 부족 시메모리 민감 캐시
Phantom객체 finalize 이후리소스 정리 알림

5. 힙 덤프 뜨기

OOM이 발생하면 가장 먼저 할 일은 **힙 덤프(Heap Dump)**를 확보하는 것이다.

JVM 옵션으로 자동 생성

BASH
# OOM 발생 시 자동으로 힙 덤프 생성 — 운영 환경 필수!
java -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/var/log/heapdump/ \
     -Xmx512m -jar myapp.jar

jcmd로 수동 생성 (권장)

BASH
# 실행 중인 JVM의 PID 확인 후 덤프
jps -l
jcmd <PID> GC.heap_dump /tmp/heapdump.hprof

jcmd는 Java 7부터 제공되며, Oracle에서 jmap 대신 권장하는 도구다. jmap은 STW(Stop-the-World)를 유발할 수 있어 프로덕션에서 주의가 필요하다.


6. 힙 덤프 분석 — Eclipse MAT

가장 많이 쓰는 분석 도구는 **Eclipse MAT(Memory Analyzer Tool)**이다.

Shallow Size vs Retained Size

PLAINTEXT
A (100 bytes) → B (200 bytes) → C (300 bytes)
                → D (150 bytes)
지표A 객체 기준 값의미
Shallow Size100 bytesA 자체의 크기
Retained Size750 bytesA가 GC되면 B, C, D도 함께 해제 → 총 합

누수 원인을 찾을 때는 Retained Size가 큰 객체부터 추적한다.

MAT 분석 순서

  1. Leak Suspects Report — MAT가 자동으로 누수 의심 객체를 보여준다.
  2. Dominator Tree — Retained Size 기준으로 객체를 정렬. 가장 큰 객체 파악.
  3. Histogram — 클래스별 인스턴스 수와 메모리 사용량 확인.
  4. Path to GC Roots — 의심 객체에서 "exclude weak references"로 Strong Reference 체인 추적.
PLAINTEXT
[분석 흐름 예시]
Dominator Tree에서 가장 큰 객체:
  com.myapp.service.CacheManager → Retained: 800MB

Path to GC Roots:
  Thread "main"
    → static CacheManager.cache → HashMap → HashMap$Node[] (100만 개)

결론: static HashMap에 데이터가 계속 쌓여서 누수 발생

7. jstack으로 스레드 덤프 분석

스레드가 블로킹 상태에서 빠져나오지 못하면, 해당 스레드의 스택 프레임에 있는 객체들도 GC되지 않는다.

BASH
# 스레드 덤프 생성
jcmd <PID> Thread.print > threaddump.txt
# 또는
jstack <PID> > threaddump.txt

스레드 덤프 읽기

PLAINTEXT
"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 (Java Flight Recorder)

JVM에 내장된 프로파일링 도구로, 오버헤드가 매우 낮아 프로덕션에서도 사용 가능하다.

BASH
# JFR 시작
jcmd <PID> JFR.start duration=60s filename=/tmp/recording.jfr

# JVM 옵션으로 시작 시부터 기록
java -XX:StartFlightRecording=duration=300s,filename=app.jfr -jar myapp.jar

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: 누수 — 세션을 넣기만 하고 제거하지 않음
private static final Map<String, UserSession> sessions = new ConcurrentHashMap<>();

// After: 만료 정책이 있는 캐시 사용
private static final 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.jfr
jstat -gcutil <PID> 5000

10. 트러블슈팅 도구 & JVM 옵션 정리

도구 비교

도구용도프로덕션비고
jstatGC 통계 실시간 조회O오버헤드 낮음
jmap힙 덤프 생성STW 발생 가능
jcmd힙 덤프, JFR 등Ojmap 대체 권장
jstack스레드 덤프O데드락 자동 감지
Eclipse MAT힙 덤프 분석-Dominator Tree, Leak Suspects
VisualVM실시간 모니터링개발/스테이징 권장
JFR + JMC저오버헤드 프로파일링O프로덕션 권장

주요 JVM 옵션

옵션설명
-Xms / -Xmx초기/최대 힙 크기 (같은 값 권장)
-XX:MaxMetaspaceSize최대 Metaspace 크기
-XX:+HeapDumpOnOutOfMemoryErrorOOM 시 힙 덤프 자동 생성
-XX:HeapDumpPath힙 덤프 저장 경로
-Xlog:gc*:file=gc.logGC 로그 출력 (Java 9+)
-XX:NativeMemoryTracking=summary네이티브 메모리 추적

운영 환경 최소 권장:

BASH
java -Xms512m -Xmx512m \
     -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/var/log/heapdump/ \
     -Xlog:gc*:file=/var/log/gc.log \
     -jar myapp.jar

11. 체크리스트

예방:

  • static 컬렉션에는 크기 제한 또는 만료 정책을 둔다
  • ThreadLocal은 사용 후 반드시 remove() 호출한다
  • 리소스(Connection, Stream)는 try-with-resources로 닫는다
  • 이벤트 리스너는 등록 시 해제 시점도 함께 설계한다
  • 내부 클래스는 가능하면 static으로 선언한다

대응:

  • OOM 메시지로 문제 영역(힙/메타스페이스/스레드) 판별
  • jstat으로 GC 상태 빠르게 확인
  • 힙 덤프를 MAT로 분석 → Dominator Tree → Path to GC Roots
  • 스레드 관련 이슈면 jstack으로 스레드 덤프 분석

마무리

  1. 자바에서 메모리 누수는 참조를 놓지 않아서 발생한다.
  2. OOM 에러 메시지를 보면 문제 영역이 힌트로 나온다.
  3. WeakReference, SoftReference를 알면 캐시 설계에 활용할 수 있다.
  4. 트러블슈팅은 증상 확인 → 힙 덤프 → MAT 분석 → 코드 수정 → 검증 순서로 진행한다.
  5. -XX:+HeapDumpOnOutOfMemoryError는 운영 환경 필수 옵션이다.

공부하다 보니, GC가 있어도 메모리를 신경 써야 하는 이유가 "참조"에 있다는 게 핵심이었다. 결국 GC는 도달 불가능한 객체만 회수하니까, 우리가 실수로 참조를 유지하면 GC도 어쩔 수 없다. 힙 덤프 분석이 처음에는 어렵게 느껴지지만, MAT의 Dominator Tree → Path to GC Roots 흐름만 익히면 대부분의 누수는 찾을 수 있다.


다음 글에서는 성능 최적화와 프로파일링 — 자바가 느리다는 편견 깨기를 다룬다. JIT 컴파일러의 최적화, 벤치마킹 방법론(JMH), 그리고 실무에서 자주 쓰는 성능 개선 패턴까지 정리해보자.

댓글 로딩 중...