자바는 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으로 스레드 덤프 분석

정리

개념핵심
메모리 누수 원인GC Root에서 참조 체인이 끊어지지 않아 회수하지 못하는 상태
대표 누수 패턴static 컬렉션, 리스너 미해제, ThreadLocal 미정리, 비정적 내부 클래스
OOM 유형 판별에러 메시지로 문제 영역(힙/메타스페이스/스레드/네이티브) 구분
참조 유형Strong → Weak(즉시 수거) → Soft(메모리 부족 시) → Phantom(정리 알림)
트러블슈팅 순서jstat 증상 확인 → 힙 덤프 → MAT 분석 → 코드 수정 → 검증
필수 JVM 옵션-XX:+HeapDumpOnOutOfMemoryError는 운영 환경 기본값으로 설정
MAT 핵심 흐름Dominator Tree(Retained Size 큰 객체) → Path to GC Roots(참조 체인 추적)

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

댓글 로딩 중...