끄적끄적

서버가 새벽마다 OOM으로 죽었다 — Java 메모리 누수 패턴 7가지와 Heap Dump로 범인 잡는 법 본문

Back-end/Java

서버가 새벽마다 OOM으로 죽었다 — Java 메모리 누수 패턴 7가지와 Heap Dump로 범인 잡는 법

mashko 2026. 2. 26. 20:37
반응형
☕ Java / Spring Boot 메모리 누수

서버가 새벽마다 OOM으로 죽었다
"Java 메모리 누수, 직접 잡아본 경험"

Heap Dump 분석 · Static 참조 함정 · ThreadLocal 누수 · Connection Pool 고갈 — 실무 트러블슈팅 총정리

☕ Java 17 / Spring Boot 3.x 🔧 JVM 튜닝 포함 ⏱ 읽기 약 18분 🔥 실무 장애 복기 포함

📋 목차

  1. 들어가며 — 새벽 3시 OOM 장애 복기
  2. Java 메모리 구조 — 이것만 알면 됩니다
  3. 메모리 누수가 생기는 대표 패턴 7가지
  4. Heap Dump 분석으로 범인 찾기
  5. 실무 트러블슈팅 BEST 4
  6. JVM 메모리 튜닝 설정값
  7. 메모리 누수 예방 체크리스트
📖 Section 01

들어가며 — 새벽 3시 OOM 장애 복기

운영 서버가 주기적으로 새벽에 죽는 일이 반복됐습니다. 처음엔 단순히 트래픽 문제라고 생각했어요. 스케일 아웃을 해도 며칠 지나면 또 죽었습니다. 로그를 보니 java.lang.OutOfMemoryError: Java heap space. Heap이 계속 올라가다가 GC가 감당 못하고 터지는 전형적인 메모리 누수 패턴이었습니다.

문제를 찾는 데 3일이 걸렸습니다. 범인은 캐시로 쓰던 static HashMap에 TTL 없이 계속 데이터를 쌓고 있던 것이었어요. 코드 한 줄의 실수가 며칠씩 서버를 죽이고 있었습니다.

💡
메모리 누수의 정의:
더 이상 사용하지 않는 객체가 GC에 의해 수거되지 못하고 Heap에 계속 쌓이는 현상.
Java는 GC가 있어 안전하다고 착각하기 쉽지만, 참조가 살아있으면 GC는 절대 수거하지 않습니다.
· · ·
🧠 Section 02

Java 메모리 구조 — 딱 알아야 할 것만

영역 역할 누수 발생 여부 모니터링 지표
🔴 Heap (Young/Old) 객체 인스턴스 저장 누수 주 발생 구역 heap.used, GC 횟수
🟡 Metaspace 클래스 메타정보 저장 동적 클래스 로딩 시 누수 metaspace.used
🟢 Stack 메서드 호출 스택, 지역변수 일반적으로 안전 StackOverflow 모니터링
🔵 Native/Off-Heap NIO, DirectByteBuffer GC 대상 아님 — 직접 관리 direct.memory.used
🔬
GC가 객체를 수거하는 기준:
GC Root(Static 변수, 스레드 스택, JNI 참조 등)에서 참조 체인이 끊긴 객체만 수거합니다.
아무도 참조 안 하는 객체 = 수거 대상. 참조가 1개라도 살아있으면 = 절대 수거 안 함.
· · ·
☠️ Section 03

메모리 누수가 생기는 대표 패턴 7가지

💣

패턴 1 — Static 컬렉션에 무한정 쌓기

🚨 가장 흔하고 가장 위험한 패턴. Static 변수는 애플리케이션이 죽을 때까지 GC Root로 살아있어 절대 수거 안 됩니다.
 
 
 
❌ 누수 코드
Java
public class UserCache {
    // ❌ TTL 없이 계속 쌓임 → OOM 직행
    private static final Map<String, UserInfo> cache
        = new HashMap<>();

    public static void put(String key, UserInfo value) {
        cache.put(key, value);  // ❌ 삭제 로직 없음
    }
}
 
 
 
✅ 개선 코드 — Caffeine Cache 사용
Java
@Bean
public Cache<String, UserInfo> userCache() {
    return Caffeine.newBuilder()
        .maximumSize(10_000)          // ✅ 최대 개수 제한
        .expireAfterWrite(10, TimeUnit.MINUTES)  // ✅ TTL 설정
        .recordStats()                // ✅ 통계 모니터링
        .build();
}
💣

패턴 2 — ThreadLocal 사용 후 미제거

🚨 Spring Boot에서 ThreadPool을 쓰면 스레드가 재사용됩니다. ThreadLocal을 remove() 안 하면 이전 요청의 데이터가 다음 요청에 오염 + 메모리 누수 동시 발생!
 
 
 
❌ 누수 코드 vs ✅ 개선 코드
Java
// ❌ 잘못된 패턴 — remove() 누락
public class UserContext {
    private static final ThreadLocal<User> current
        = new ThreadLocal<>();

    public static void set(User user) { current.set(user); }
    public static User get() { return current.get(); }
    // ❌ clear() / remove() 없음!
}

// ✅ 올바른 패턴 — Filter/Interceptor에서 반드시 제거
@Override
public void afterCompletion(HttpServletRequest req,
    HttpServletResponse res, Object handler, Exception ex) {
    UserContext.clear();  // ✅ 요청 완료 후 반드시 제거!
}
💣

패턴 3 — 이벤트 리스너 / 콜백 미해제

🚨 이벤트 리스너를 등록하고 해제 안 하면, 이벤트 소스가 리스너 객체를 계속 참조합니다. 리스너를 등록한 객체는 GC 대상이 되지 않아요.
 
 
 
✅ WeakReference 또는 명시적 해제
Java
// ✅ 방법 1: @EventListener + Spring 관리 빈으로 등록
@Component
public class OrderEventListener {
    @EventListener
    public void onOrderCreated(OrderCreatedEvent event) {
        // Spring이 빈 생명주기 관리 → 누수 없음
    }
}

// ✅ 방법 2: Disposable/AutoCloseable 패턴으로 명시적 해제
@PreDestroy
public void cleanup() {
    eventBus.unregister(this);  // ✅ 빈 종료 시 해제
}
💣

패턴 4 — Stream / Connection 미닫기

🚨 DB Connection, InputStream, ResultSet 등을 닫지 않으면 Connection Pool 고갈 + 메모리 누수 동시 발생. 특히 예외 발생 경로에서 close()가 누락되는 케이스가 많습니다.
 
 
 
❌ 누수 코드 vs ✅ try-with-resources
Java
// ❌ 예외 발생 시 close() 미호출 → Connection 누수
Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql);
// 여기서 예외 발생하면 conn, ps 절대 닫히지 않음!

// ✅ try-with-resources — 예외 발생해도 자동 close()
try (Connection conn = dataSource.getConnection();
     PreparedStatement ps = conn.prepareStatement(sql)) {
    // 로직 수행
}  // ✅ 블록 끝에서 자동으로 close() 호출
💣

패턴 5 — 비정적 내부 클래스(Inner Class) 참조

🚨 Java의 비정적 내부 클래스는 외부 클래스에 대한 암묵적 참조를 가집니다. 내부 클래스 인스턴스가 오래 살아있으면 외부 클래스도 GC 수거 불가.
✅ 해결: 내부 클래스를 static 내부 클래스로 선언하거나, 별도 독립 클래스로 분리하세요.
Android 개발자에게 특히 자주 발생하며, Spring에서도 익명 클래스로 Listener 구현 시 주의해야 합니다.
· · ·
🔍 Section 04

Heap Dump 분석으로 범인 찾기

1

Heap Dump 생성 방법

OOM 발생 시 자동으로 Heap Dump를 생성하도록 JVM 옵션을 미리 설정해두는 것이 필수입니다. 나중에 문제 발생 후 설정하면 이미 늦어요.

운영 서버 JVM 옵션에 반드시 추가해두세요!
 
 
 
JVM 옵션 — OOM 시 자동 Heap Dump
Shell
# OOM 발생 시 자동 힙 덤프 생성
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heapdump/

# 수동으로 즉시 Heap Dump 생성 (서버 살아있을 때)
jmap -dump:format=b,file=/tmp/heap.hprof <PID>

# Java 9+ 권장 방식
jcmd <PID> GC.heap_dump /tmp/heap.hprof
2

Eclipse MAT(Memory Analyzer Tool)로 분석

Heap Dump(.hprof 파일)를 Eclipse MAT에 로드하면 "Leak Suspects Report"를 자동으로 생성해줍니다. 어떤 객체가 얼마나 메모리를 차지하는지 한눈에 볼 수 있어요.

✅ MAT의 "Dominator Tree" 뷰에서 Retained Heap이 가장 큰 객체가 범인일 확률이 높습니다. Shallow Heap(객체 자체) vs Retained Heap(참조 체인 전체) 차이를 이해하면 분석이 빠릅니다.
3

실시간 모니터링 — JVM Actuator + Prometheus

Heap Dump는 사후 분석이고, 평소에는 실시간 모니터링이 중요합니다. Spring Boot Actuator + Micrometer + Prometheus + Grafana 조합으로 Heap 사용량 추이를 그래프로 볼 수 있습니다.

💡 GC 후 Old Gen 메모리가 완전히 줄어들지 않고 매번 조금씩 높아진다면 → 메모리 누수 의심 신호!
 
 
 
application.yml — Actuator 메모리 모니터링 설정
YAML
management:
  endpoints:
    web:
      exposure:
        include: health,metrics,prometheus
  metrics:
    export:
      prometheus:
        enabled: true

# Grafana에서 핵심 쿼리
# jvm_memory_used_bytes{area="heap"}     → Heap 사용량
# jvm_gc_pause_seconds_sum               → GC 소요 시간
# jvm_memory_max_bytes{area="heap"}      → Heap 최대값
· · ·
⚙️ Section 05

JVM 메모리 튜닝 설정값

 
 
 
운영 환경 JVM 옵션 (Spring Boot 3 / Java 17)
Shell
# 기본 메모리 설정
-Xms2g                    # 초기 Heap (시작부터 2GB 할당 → GC 부담 감소)
-Xmx2g                    # 최대 Heap (Xms와 동일하게 → 동적 조정 오버헤드 제거)
-XX:MetaspaceSize=256m    # Metaspace 초기값
-XX:MaxMetaspaceSize=512m # Metaspace 최대값 제한 (무한 증가 방지)

# GC 설정 (Java 17 기본: G1GC 권장)
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200  # GC 최대 pause 목표값 200ms
-XX:G1HeapRegionSize=16m  # G1 Region 크기

# OOM 시 자동 Heap Dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heapdump/

# OOM 시 프로세스 재시작 (컨테이너 환경)
-XX:+ExitOnOutOfMemoryError
⚠️
컨테이너(Docker/k8s) 환경 주의사항:
Java 8 이하에서는 컨테이너 메모리 제한을 인식 못하고 호스트 메모리 기준으로 Heap을 잡아 OOM이 발생합니다.
Java 11+에서는 -XX:+UseContainerSupport (기본 활성화)로 컨테이너 메모리를 자동 인식합니다.
컨테이너 메모리 제한의 75% 이하로 Xmx를 설정하는 것이 안전합니다.
· · ·
✅ Section 06

메모리 누수 예방 체크리스트

코드 리뷰 시 반드시 확인할 것들:
☑️ Static 컬렉션에 사이즈 제한 또는 TTL 설정 여부
☑️ ThreadLocal 사용 후 remove() 호출 여부 (특히 Filter/Interceptor)
☑️ InputStream, Connection, ResultSet try-with-resources 사용 여부
☑️ 이벤트 리스너 등록/해제 쌍으로 구현 여부
☑️ 캐시 라이브러리(Caffeine, Redis) 사용 시 eviction 정책 설정 여부
☑️ JVM 옵션에 HeapDumpOnOutOfMemoryError 설정 여부
☑️ Grafana/Prometheus로 Heap 사용량 실시간 모니터링 여부
🚨
Heap 사용량이 GC 후에도 계속 오른다면 즉시 대응 순서:
1️⃣ jcmd <PID> GC.heap_dump 로 현재 Heap Dump 채취
2️⃣ Eclipse MAT → Leak Suspects Report 확인
3️⃣ Dominator Tree에서 Retained Heap 상위 객체 확인
4️⃣ 해당 객체의 GC Root 참조 체인 역추적 → 누수 위치 특정
5️⃣ 코드 수정 후 로컬에서 JMeter/Gatling으로 부하 테스트 → Heap 증가 없음 확인

🔍 "메모리 누수는 갑자기 죽는 게 아니라, 천천히 죽어가는 것입니다"

서버가 배포 직후엔 멀쩡하다가 며칠 후 죽는다면 메모리 누수를 의심하세요.
GC 후 Heap이 완전히 내려가지 않고 조금씩 올라가는 패턴 — 그게 신호입니다.
처음부터 Heap Dump 설정과 모니터링을 갖춰두면 발생해도 두렵지 않습니다 💪

비슷한 경험 있으시면 댓글로 공유해 주세요!

#메모리누수 #JavaMemoryLeak #OOM #HeapDump #JVM튜닝 #SpringBoot #ThreadLocal #GC #백엔드개발 #자바개발 #개발블로그 #트러블슈팅
반응형
Comments