안녕하세요! 재아군의 관찰인생 입니다.
오늘은 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의 기본
MaxRAMPercentage는 25%로 매우 보수적입니다. 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 문제를 예방할 수 있습니다!
'개발&프로그래밍' 카테고리의 다른 글
| [Gradle] 빌드 속도 개선 방법 - 느린 빌드, 설정 하나로 50~70% 단축하기 (0) | 2026.02.19 |
|---|---|
| [SpringBoot] 로그 잘림 해결 방법 - 콘솔 / 파일 로그 길이 제한 완전 정리 (0) | 2026.02.19 |
| [Codex] 프롬프트 작성법 - Codex를 200% 활용하는 비법 완전 가이드 (0) | 2026.02.18 |
| [Codex] 설치방법 & 사용방법 - 바이브코딩의 시대, AI와 함께 코딩 시작하기 (0) | 2026.02.17 |
| [Claude] CLAUDE.md 작성법 - 프로젝트별 최적화 컨텍스트 만들기 (0) | 2026.02.13 |
댓글