IT·리눅스/Java

[Java] JVM 메모리 누수 - 원인과 진단 도구 한 번에 정리

jmineekim 2026. 5. 12. 15:15

[Java] JVM 메모리 누수 - 원인과 진단 도구 한 번에 정리

운영 중인 자바 서버가 며칠에 한 번씩 OOM으로 죽어서 새벽마다 알람이 울린 적이 있습니다. 처음엔 그냥 힙을 늘렸어요. 6GB → 8GB → 12GB. 그런데 결국 또 터지더라고요. GC 로그를 보니 Full GC가 분 단위로 돌면서도 Old 영역이 90% 아래로 안 떨어지는 패턴이 반복되고 있었습니다. 그제서야 "아, 이건 누수구나" 싶었죠. 자바는 GC가 알아서 청소해 주니까 누수가 없을 거라고 생각하기 쉬운데, 실은 자바 개발자라면 한 번쯤은 꼭 마주치는 문제입니다. 오늘은 지난 G1 GC 글에 이어서, JVM 메모리 누수가 왜 생기는지 그리고 힙 덤프부터 MAT까지 어떻게 추적하는지 한 번에 정리해 볼게요.

개념 / 원리

자바의 GC는 똑똑하지만, 한 가지 규칙만 따릅니다. GC Root에서 도달 가능한(reachable) 객체는 살아 있다고 본다. 그러니 누군가가 어디선가 그 객체를 참조하고 있으면 GC는 절대 회수하지 않습니다. 메모리 누수란, 이미 쓸 일이 없는데도 누군가 참조를 붙들고 있어서 GC가 회수하지 못하는 객체들이 쌓이는 현상을 말합니다.

비유하자면 호텔 청소부(GC)가 체크아웃 명단을 받고 빈 방만 청소하러 가는데, 손님이 떠나도 명단에서 이름이 안 빠지는 상황입니다. 방은 비어 있는데 청소부가 영원히 못 들어가는 거죠.

자바에서 누수가 자주 나는 패턴은 대체로 정해져 있습니다.

패턴 설명
정적(static) 컬렉션 static Map에 계속 put만 하고 remove를 안 함
ThreadLocal 미해제 스레드 풀 환경에서 remove() 누락
만료 없는 캐시 TTL/사이즈 제한이 없는 자체 캐시
리스너/콜백 등록만 하고 해제(unregister) 안 함
클래스로더 누수 핫 리로드 환경에서 옛 클래스로더가 안 죽음

핵심은 "객체가 많은 것"이 아니라 "객체를 누군가 놓아주지 않는 것"입니다.

실전 예제

다음은 누수가 일부러 나도록 만든 코드입니다. static Map에 1MB짜리 배열을 계속 쌓습니다.

import java.util.HashMap;
import java.util.Map;

public class LeakSample {
    private static final Map<Integer, byte[]> CACHE = new HashMap<>();

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        while (true) {
            CACHE.put(i++, new byte[1024 * 1024]); // 1MB씩 적재
            Thread.sleep(50);
        }
    }
}

힙을 작게 잡고 OOM 발생 시 힙 덤프를 자동 추출하도록 켜고 돌립니다.

java -Xmx256m \
     -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=./heap.hprof \
     LeakSample

실행 중이라면 살아 있는 객체의 클래스별 인스턴스 개수와 점유 바이트를 먼저 봅니다.

# PID 찾기
jps -l

# 살아 있는 객체 히스토그램 (상위 20개)
jmap -histo:live <PID> | head -n 20

# 운영 서버에서 가볍게 힙 덤프 (Java 11+)
jcmd <PID> GC.heap_dump /tmp/heap.hprof

jmap -histo:live에서 byte[]가 압도적이고 java.util.HashMap$Node가 같이 많으면 거의 십중팔구 HashMap 누수입니다. 정확히 누가 잡고 있는지는 힙 덤프를 MAT(Eclipse Memory Analyzer) 로 열어서 Leak Suspects 리포트와 Dominator Tree, 그리고 의심 객체의 Path to GC Roots → exclude weak/soft references를 확인합니다. 위 예제라면 GC Root 경로가 LeakSample.CACHE로 곧장 떨어집니다.

자주 하는 실수

저도 처음 누수를 잡을 때 했던 실수들입니다.

  • OOM = 누수라고 단정. 단순히 -Xmx가 작거나, 캐시 워밍업 중이거나, 한 번에 큰 배치를 돌렸을 수도 있습니다. GC 로그에서 Full GC 후에도 Old가 안 빠지는 패턴이 여러 번 반복될 때 누수로 의심해야 합니다.
  • jmap -histo만 보고 결론. 인스턴스가 많은 건 결과일 뿐, 누가 참조하고 있는지(Path to GC Root)를 봐야 진짜 원인이 나옵니다.
  • 운영에서 무거운 덤프. jmap -dump는 안전 지점에서 STW를 유발해 큰 힙은 수십 초가 멈출 수 있습니다. 가능하면 -XX:+HeapDumpOnOutOfMemoryError로 죽을 때 자동 생성하게 하고, 운영 중 분석이 필요하면 jcmd ... GC.heap_dump를 쓰세요.
  • ThreadLocal remove() 누락. 톰캣처럼 스레드를 재사용하는 환경에서 ThreadLocal에 큰 객체를 넣고 안 빼면, 스레드 풀 크기만큼 객체가 영원히 살아남습니다. finally 블록에서 반드시 remove() 호출.
  • 클래스로더 누수. 배포 핫 리로드 환경에서 옛 클래스의 정적 필드나 ThreadLocal 때문에 옛 클래스로더가 죽지 않으면 Metaspace OOM이 납니다. 이때는 힙이 아니라 Metaspace를 봐야 합니다.

마무리

정리하면 세 줄입니다.

  • 메모리 누수는 "GC Root에서 도달 가능하지만 실제로는 안 쓰는" 객체가 쌓이는 현상이다.
  • 진단 순서는 GC 로그 → jmap -histo:live → 힙 덤프 → MAT의 Path to GC Roots로 좁혀 간다.
  • 운영 서버에는 -XX:+HeapDumpOnOutOfMemoryError를 기본으로 켜 두자. 사고 났을 때 절반은 먹고 들어간다.

다음 글에서는 실무에서 자주 만나는 OOM 종류(Java heap space, Metaspace, GC overhead limit, Direct buffer memory)를 하나씩 풀어볼게요.

"Java", "JVM", "memory leak", "메모리누수", "OOM", "OutOfMemoryError", "힙덤프", "MAT", "ThreadLocal", "GC"

반응형