본문 바로가기
개발&프로그래밍

[JVM] [OutOfMemoryError 해결] - 원인 분석부터 완벽 해결까지 실전 가이드

by 재아군 2026. 2. 18.
반응형

안녕하세요! 재아군의 관찰인생 입니다.

오늘은 Java 개발자라면 누구나 한 번쯤 만나봤을 java.lang.OutOfMemoryError(OOM) 에 대해 알아보려고 합니다.

특히 Kubernetes 환경에서 Java 워크로드가 급증하면서 OOM 이슈는 더욱 빈번해지고 있는데요,

 

2025년 Akamas 조사에 따르면 프로덕션 JVM의 60% 이상이 GC 튜닝 없이 기본값으로 운영되고 있다고 합니다.

오늘 이 글 하나로 OOM의 모든 것을 정리해드리겠습니다.

 


OutOfMemoryError란?

  • 📎 공식 문서: Oracle JVM Troubleshooting Guide - OutOfMemoryError
  • java.lang.OutOfMemoryError는 JVM이 새로운 객체를 위한 메모리를 할당할 수 없을 때 발생하는 Error(Exception이 아닙니다!)입니다. 힙 공간 부족, 메타스페이스 초과, 네이티브 메모리 고갈 등 다양한 원인이 있으며, 단순히 힙 크기를 늘린다고 해결되지 않는 경우가 많습니다.
  • OOM은 메모리 누수(Memory Leak)의 신호일 수도 있고, 단순한 설정 부족일 수도 있습니다. 정확한 진단이 핵심입니다.
  • 📢 2025~2026년 주요 변화:
    • Java 21 LTS의 Virtual Threads 도입으로 스레드 기반 OOM 패턴 변화
    • Eclipse MAT 1.16.1 릴리즈 (2025년 1월)
    • Kubernetes 환경에서 JVM 컨테이너 인식(UseContainerSupport) 기본 활성화 (Java 10+)
    • ZGC, Shenandoah GC 등 저지연 GC의 프로덕션 도입 확대

 

 

 

OOM 에러 유형 한눈에 보기

에러 메시지 발생 영역 주요 원인 핵심 해결
Java heap space Heap 객체 과다 생성, 메모리 누수 -Xmx 증가 또는 누수 수정
GC overhead limit exceeded Heap GC가 98% 시간 소비, 2% 미만 회수 누수 수정, 힙 증가
Metaspace Non-Heap 클래스 과다 로딩, 클래스로더 누수 -XX:MaxMetaspaceSize 조정
Unable to create new native thread OS 스레드 과다 생성 스레드 풀 관리, ulimit 조정
Direct buffer memory Off-Heap NIO DirectByteBuffer 초과 -XX:MaxDirectMemorySize 조정
Out of swap space OS 시스템 메모리 + 스왑 고갈 시스템 리소스 확인
Requested array size exceeds VM limit Heap 배열 크기가 힙보다 큼 데이터 구조 변경
Killed (OOMKilled) Container 컨테이너 메모리 한도 초과 K8s 리소스 설정 조정

진단 도구 비교

도구 유형 가격 핵심 기능 추천 상황
Eclipse MAT 힙 덤프 분석기 무료 Leak Suspects 보고서, OQL, Dominator Tree 대용량 힙 덤프 분석
VisualVM 프로파일러 무료 실시간 모니터링, 힙 덤프, 스레드 분석 개발/테스트 환경 모니터링
JFR (Java Flight Recorder) 레코더 JDK 내장 저오버헤드 이벤트 기록, 메모리 추적 프로덕션 모니터링
HeapHero 온라인 분석기 무료/유료 자동 누수 감지, 시각적 보고서 빠른 온라인 분석
jcmd / jmap CLI 도구 JDK 내장 힙 덤프 생성, VM 정보 조회 서버 직접 접근 시
YourKit 프로파일러 유료 고급 프로파일링, 실시간 분석 엔터프라이즈 환경
GCeasy GC 로그 분석기 무료/유료 GC 로그 시각화, 튜닝 권장사항 GC 성능 분석

 

 

 

사전 체크리스트 (OOM 발생 전 준비)

  • JVM 시작 옵션에 -XX:+HeapDumpOnOutOfMemoryError 추가
  • 힙 덤프 경로 설정: -XX:HeapDumpPath=/path/to/dumps/
  • GC 로깅 활성화 (Java 9+: -Xlog:gc*:file=gc.log:time,uptime,level,tags)
  • 모니터링 도구 연동 (Prometheus + Micrometer, Datadog 등)
  • 컨테이너 환경이라면 resources.limits.memory 설정 확인
  • 현재 JVM 버전 확인 (java -version) — Java 11+ 권장

 

 

 

해결 방법: 유형별 완전 가이드

🔴 유형 1: Java heap space

가장 흔한 OOM 에러입니다. 힙 공간이 부족하여 새 객체를 할당할 수 없을 때 발생합니다.

1단계: 힙 덤프 자동 생성 설정

# JVM 시작 옵션에 추가
java -Xms512m -Xmx2g \
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/logs/heapdumps/ \
  -XX:OnOutOfMemoryError="/scripts/restart-app.sh" \
  -jar myapp.jar

💡 -XX:OnOutOfMemoryError를 활용하면 OOM 발생 시 자동으로 알림 전송이나 앱 재시작 스크립트를 실행할 수 있습니다.

 

 

 

2단계: 힙 덤프 분석 (Eclipse MAT)

# 힙 덤프 생성 (수동)
jcmd <PID> GC.heap_dump /tmp/heapdump.hprof

# 또는 jmap 사용
jmap -dump:live,format=b,file=/tmp/heapdump.hprof <PID>

# Eclipse MAT로 분석
# 1) File → Open Heap Dump → .hprof 파일 선택
# 2) "Leak Suspects Report" 자동 생성 확인
# 3) Dominator Tree에서 가장 큰 객체 확인
# 4) OQL로 상세 조회:
#    SELECT * FROM byte[] obj WHERE (obj.@length >= 1048576)

 

 

 

3단계: 코드 레벨 수정 (흔한 누수 패턴)

// ❌ 나쁜 예시: static 컬렉션에 계속 추가만 함
public class CacheManager {
    private static final Map<String, Object> cache = new HashMap<>();

    public void addToCache(String key, Object value) {
        cache.put(key, value);  // 영원히 쌓임!
    }
}

// ✅ 좋은 예시: 크기 제한 + 만료 정책 적용
public class CacheManager {
    private static final Map<String, Object> cache = 
        Collections.synchronizedMap(new LinkedHashMap<>(1000, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<String, Object> eldest) {
                return size() > 1000;  // 최대 1000개 유지
            }
        });

    // 또는 Caffeine, Guava Cache 사용 권장
    // Cache<String, Object> cache = Caffeine.newBuilder()
    //     .maximumSize(1000)
    //     .expireAfterWrite(Duration.ofMinutes(30))
    //     .build();
}
// ❌ 나쁜 예시: 리소스 미해제
public void readFile(String path) {
    InputStream is = new FileInputStream(path);
    // ... 처리 중 예외 발생 시 is가 닫히지 않음
}

// ✅ 좋은 예시: try-with-resources 사용
public void readFile(String path) {
    try (InputStream is = new FileInputStream(path)) {
        // 자동으로 close() 호출됨
    } catch (IOException e) {
        log.error("파일 읽기 실패", e);
    }
}

 

 

 

🟠 유형 2: GC overhead limit exceeded

GC가 전체 시간의 98% 이상을 소비하면서 힙의 2% 미만만 회수할 때 발생합니다.

# GC 로그 활성화 (Java 11+)
java -Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=10,filesize=50M \
  -jar myapp.jar

# GC 로그 분석: GCeasy (https://gceasy.io) 에 업로드
# 또는 커맨드라인에서 간단 확인
grep "Full GC" gc.log | tail -20
# 임시 조치: GC overhead 제한 비활성화 (근본 해결 아님!)
java -XX:-UseGCOverheadLimit -jar myapp.jar

# 근본 해결: GC 알고리즘 변경 (Java 17+)
java -XX:+UseZGC -Xmx4g -jar myapp.jar          # ZGC: 저지연
java -XX:+UseShenandoahGC -Xmx4g -jar myapp.jar  # Shenandoah: 저지연
java -XX:+UseG1GC -Xmx4g -jar myapp.jar          # G1: 범용 (Java 9+ 기본)

💡 GC 선택 가이드: 지연 시간이 중요하면 ZGC/Shenandoah, 처리량이 중요하면 G1GC 또는 ParallelGC를 선택하세요. Java 21에서는 Generational ZGC가 기본값이 되어 더 효율적입니다.


 

 

🟡 유형 3: Metaspace

Java 8부터 PermGen을 대체한 Metaspace 영역이 가득 찼을 때 발생합니다.

# Metaspace 크기 조정
java -XX:MaxMetaspaceSize=512m \
  -XX:MetaspaceSize=256m \
  -jar myapp.jar

# 클래스 로딩 모니터링
jcmd <PID> VM.classloader_stats

# 로드된 클래스 수 확인
jcmd <PID> VM.info | grep "classes"

💡 흔한 원인: Spring Boot DevTools의 자동 리로드, 동적 프록시 과다 생성, 리플렉션 남용, 핫 디플로이 반복 시 발생합니다.


 

 

🟣 유형 4: Unable to create new native thread

# 현재 프로세스의 스레드 수 확인
ps -eLf | grep java | wc -l

# OS 스레드 제한 확인/변경
ulimit -u          # 현재 최대 프로세스 수
ulimit -u 65535    # 증가 (임시)

# /etc/security/limits.conf (영구)
# username soft nproc 65535
# username hard nproc 65535
// ❌ 나쁜 예시: 스레드를 무한 생성
for (int i = 0; i < 100000; i++) {
    new Thread(() -> {
        try { Thread.sleep(Long.MAX_VALUE); } 
        catch (InterruptedException e) {}
    }).start();
}

// ✅ 좋은 예시: 스레드 풀 사용
ExecutorService executor = Executors.newFixedThreadPool(
    Runtime.getRuntime().availableProcessors() * 2
);

// ✅ Java 21+: Virtual Threads 활용
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> handleRequest());
}

 

 

🔵 유형 5: Killed / OOMKilled (Kubernetes 환경)

이것은 Java 예외가 아니라 컨테이너 런타임이 프로세스를 강제 종료한 것입니다. 가장 까다로운 유형입니다.

JVM 메모리 구조 이해 (컨테이너 환경)

┌─────────────────────────────────────────────┐
│          Container Memory Limit (2Gi)        │
│                                             │
│  ┌─────────────────────────────────────┐    │
│  │        JVM Process Memory            │    │
│  │                                     │    │
│  │  ┌───────────────────────┐          │    │
│  │  │     Heap (≈70%)       │ ← -Xmx  │    │
│  │  │  Eden, Survivor, Old  │          │    │
│  │  └───────────────────────┘          │    │
│  │  ┌───────────────────────┐          │    │
│  │  │   Non-Heap (≈20%)     │          │    │
│  │  │  Metaspace, CodeCache │          │    │
│  │  │  Thread Stacks        │          │    │
│  │  │  Direct Buffers       │          │    │
│  │  └───────────────────────┘          │    │
│  └─────────────────────────────────────┘    │
│  ┌──────────────┐                           │
│  │ OS Overhead  │ ≈ 10%                     │
│  └──────────────┘                           │
└─────────────────────────────────────────────┘

컨테이너 인식 JVM 설정

# ✅ 권장 설정: MaxRAMPercentage 사용 (Java 10+)
java -XX:+UseContainerSupport \
  -XX:MaxRAMPercentage=70.0 \
  -XX:InitialRAMPercentage=50.0 \
  -XX:MaxMetaspaceSize=256m \
  -XX:MaxDirectMemorySize=128m \
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/mount/heap/ \
  -jar myapp.jar
# Kubernetes Deployment 설정
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-java-app
spec:
  template:
    spec:
      containers:
      - name: app
        image: my-java-app:latest
        resources:
          requests:
            memory: "1.5Gi"   # 최소 보장 메모리
            cpu: "500m"
          limits:
            memory: "2Gi"     # 최대 허용 메모리
            cpu: "2"
        env:
        - name: JAVA_TOOL_OPTIONS
          value: >-
            -XX:MaxRAMPercentage=70.0
            -XX:InitialRAMPercentage=50.0
            -XX:+HeapDumpOnOutOfMemoryError
            -XX:HeapDumpPath=/tmp/heap/
            -XX:+ExitOnOutOfMemoryError

⚠️ 중요: JVM의 기본 MaxRAMPercentage25%로 매우 보수적입니다. 2Gi 컨테이너에서 힙이 512MB만 할당되는 이유가 이것입니다. 반드시 명시적으로 설정하세요!

OOMKilled 디버깅 체크리스트

# 1. OOMKilled 확인
kubectl describe pod <pod-name> | grep -A5 "State"
# 종료 코드 137 = OOMKilled

# 2. 컨테이너 메모리 사용량 확인
kubectl top pod <pod-name>

# 3. 이벤트 확인
kubectl get events --field-selector involvedObject.name=<pod-name>

# 4. JVM 메모리 상세 확인 (실행 중인 Pod에서)
kubectl exec -it <pod-name> -- jcmd 1 VM.native_memory summary

# 5. 힙 덤프 추출 (PVC 마운트 필요)
kubectl cp <pod-name>:/tmp/heap/heapdump.hprof ./heapdump.hprof

 

 

활용 시나리오 (실전 예시)

시나리오 1: Spring Boot 앱에서 점진적 메모리 증가

📌 증상: 배포 후 6시간이 지나면 힙 사용량이 90%를 넘고 결국 OOM 발생
📌 원인: HTTP 세션 타임아웃 설정이 적용되지 않아 세션 객체가 누적

# 진단 과정
1) GC 로그 분석 → Full GC 빈도가 시간이 갈수록 증가
2) 힙 덤프 분석 (MAT) → Leak Suspects에서 HttpSession 객체 400MB 발견
3) Dominator Tree → StandardSession 객체 수천 개 확인

# 해결
# application.yml 수정
server:
  servlet:
    session:
      timeout: 30m    # 세션 타임아웃 명시

# 또는 세션 저장소를 Redis로 외부화
spring:
  session:
    store-type: redis
    redis:
      flush-mode: on_save

 

 

시나리오 2: 대용량 파일 처리 시 즉시 OOM

📌 증상: 500MB CSV 파일을 읽으려 하면 즉시 OutOfMemoryError: Java heap space
📌 원인: 파일 전체를 메모리에 한 번에 로드

// ❌ 전체 로드
List<String> lines = Files.readAllLines(Path.of("huge.csv"));

// ✅ 스트림 처리 (메모리 효율적)
try (Stream<String> stream = Files.lines(Path.of("huge.csv"))) {
    stream
        .skip(1)  // 헤더 스킵
        .map(line -> line.split(","))
        .filter(cols -> cols.length >= 3)
        .forEach(cols -> processRow(cols));
}

// ✅ 배치 처리 (Spring Batch 활용)
@Bean
public FlatFileItemReader<Record> reader() {
    return new FlatFileItemReaderBuilder<Record>()
        .name("csvReader")
        .resource(new FileSystemResource("huge.csv"))
        .delimited()
        .names("col1", "col2", "col3")
        .targetType(Record.class)
        .build();
}

 

 

시나리오 3: Kubernetes에서 반복되는 OOMKilled

📌 증상: Pod가 몇 시간마다 OOMKilled (exit code 137)로 재시작
📌 원인: MaxRAMPercentage 미설정 → 힙 25% + Non-Heap이 컨테이너 한도 초과

# 진단
kubectl describe pod my-app-xyz | grep "OOMKilled"
# Last State: Terminated, Reason: OOMKilled, Exit Code: 137

# JVM 실제 힙 크기 확인
kubectl exec -it my-app-xyz -- \
  java -XX:+PrintFlagsFinal -version 2>&1 | grep MaxHeapSize
# MaxHeapSize = 536870912 (512MB) ← 2Gi의 25%만 사용 중!

# 해결: JAVA_TOOL_OPTIONS 환경변수 설정
env:
- name: JAVA_TOOL_OPTIONS
  value: "-XX:MaxRAMPercentage=70.0 -XX:+ExitOnOutOfMemoryError"

# Pod 메모리 한도도 적절히 조정
resources:
  limits:
    memory: "2Gi"
  requests:
    memory: "2Gi"    # requests = limits로 설정하면 QoS가 Guaranteed

 

 

트러블슈팅 Quick Reference

문제: 힙 덤프가 너무 커서 MAT에서 열리지 않음

# MAT의 메모리 설정 증가 (MemoryAnalyzer.ini 수정)
# -Xmx 값을 힙 덤프 크기의 1.5배 이상으로 설정
-Xmx8g

# 또는 커맨드라인에서 MAT 보고서만 생성
./ParseHeapDump.sh /path/to/dump.hprof \
  org.eclipse.mat.api:suspects \
  org.eclipse.mat.api:overview \
  org.eclipse.mat.api:top_components

 

 

문제: 프로덕션에서 힙 덤프를 뜨면 앱이 멈춤

# 해결: JFR(Java Flight Recorder)로 저오버헤드 모니터링
jcmd <PID> JFR.start duration=60s filename=recording.jfr

# JFR 결과 분석
jfr print --events jdk.OldObjectSample recording.jfr

 

 

문제: OOM은 안 나지만 GC 때문에 느림

# GC 일시정지 시간 확인
jstat -gcutil <PID> 1000 10

# 출력 예시:
#  S0     S1     E      O      M     CCS    YGC   YGCT    FGC   FGCT    CGC   CGCT     GCT
# 0.00  98.44  45.21  87.33  95.12  92.31  1024  12.345   15   8.901    42   1.234  22.480
# ↑ FGC(Full GC)가 15회, FGCT가 8.9초 → 문제!

# G1GC 튜닝 (Java 17+)
java -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=200 \
  -XX:G1HeapRegionSize=16m \
  -XX:InitiatingHeapOccupancyPercent=45 \
  -jar myapp.jar

 

 

문제: 컨테이너에서 Native Memory Tracking

# NMT 활성화 (약 5~10% 오버헤드)
java -XX:NativeMemoryTracking=summary -jar myapp.jar

# NMT 보고서 확인
jcmd <PID> VM.native_memory summary

# 출력 예시 (어디서 메모리를 쓰는지 한눈에 파악):
# Total: reserved=2456MB, committed=1234MB
# - Java Heap: reserved=1024MB, committed=900MB
# - Class:     reserved=256MB,  committed=180MB
# - Thread:    reserved=128MB,  committed=128MB
# - GC:        reserved=64MB,   committed=64MB

 

 

 

자주 묻는 질문 (FAQ)

 

 

Q1: -Xmx를 무조건 크게 잡으면 되는 거 아닌가요?
A: 아닙니다. 힙이 너무 크면 Full GC 시 긴 일시정지가 발생하고, 컨테이너 환경에서는 Non-Heap 메모리(Metaspace, 스레드 스택, Direct Buffer 등)가 쓸 공간이 부족해져 오히려 OOMKilled를 유발합니다. 컨테이너 메모리의 70%를 힙에, 나머지 30%를 Non-Heap + OS 오버헤드에 남기는 것이 일반적인 권장사항입니다.

 

 

Q2: OutOfMemoryError를 try-catch로 잡아도 되나요?
A: 기술적으로 가능하지만 대부분의 경우 권장하지 않습니다. OOM 발생 후 JVM은 불안정한 상태일 수 있으며, catch 블록 안에서도 추가 메모리 할당이 필요한 작업은 실패할 수 있습니다. 대신 -XX:+ExitOnOutOfMemoryError로 깔끔하게 종료 후 재시작하는 것이 프로덕션에서는 더 안전합니다.

 

 

 

 

Q3: Java 버전에 따라 OOM 대응이 달라지나요?
A: 네, 크게 달라집니다. Java 8 이전에는 PermGen space OOM이 흔했지만 Java 8부터 Metaspace로 대체되었습니다. Java 10부터 컨테이너 인식이 기본 활성화되었고, Java 11+에서는 ZGC 등 저지연 GC를 사용할 수 있습니다. Java 21에서는 Generational ZGC가 도입되어 메모리 관리가 더 효율적입니다. 최소 Java 17 이상을 권장합니다.

 

 

Q4: GC 로그는 프로덕션에서도 켜놔도 되나요?
A: 반드시 켜두세요. GC 로깅의 성능 오버헤드는 매우 미미하며(1% 미만), OOM이나 성능 이슈 발생 시 가장 먼저 확인해야 할 자료입니다. Java 11+에서는 -Xlog:gc* 통합 로깅을 사용하면 됩니다.

 

 

 

Q5: Eclipse MAT 말고 더 간편한 분석 방법은 없나요?
A: 온라인 도구인 HeapHero(https://heaphero.io)에 힙 덤프를 업로드하면 자동으로 분석 보고서를 생성해줍니다. 또한 JFR + JDK Mission Control 조합도 강력합니다. IntelliJ IDEA Ultimate에도 프로파일러가 내장되어 있습니다.

 


OOM은 무섭지만, 체계적으로 접근하면 반드시 해결할 수 있습니다. "힙 덤프 자동 생성 → 분석 → 근본 원인 수정" 이 3단계만 기억하세요. 특히 컨테이너 환경에서는 -XX:MaxRAMPercentage=70.0 설정 하나만으로도 많은 OOMKilled 문제를 예방할 수 있습니다!


 

 

 

반응형

댓글