Java 서비스에서 OutOfMemoryError 가 났을 때 가장 흔한 실수는 모든 경우를 일반적인 heap 문제로 보는 것입니다. Java 메모리 장애는 단순히 “메모리가 꽉 찼다” 가 아니라, 어느 메모리 영역이 압박을 받았는지, 그리고 왜 그 압박이 생겼는지를 구분해야 풀리는 경우가 많습니다.
짧게 말하면 핵심은 이것입니다. 정확한 OutOfMemoryError variant 를 먼저 잡아야 합니다. heap pressure, metaspace growth, direct memory exhaustion, large queued backlog 는 서로 다른 해결 경로를 가리킵니다.
Java 전체 증상 분기부터 다시 보고 싶다면 Java Troubleshooting Guide 로 먼저 돌아가도 좋습니다.
정확한 에러 모양부터 본다
OutOfMemoryError 메시지마다 뜻이 다릅니다.
예를 들면:
- Java heap space
- GC overhead limit exceeded
- Metaspace
- Direct buffer memory
이것들은 같은 원인을 뜻하지 않으며, 같은 대응을 해도 안 됩니다.
그래서 첫 단계는 “heap 을 늘리자” 가 아니라 “어느 메모리 영역이 실패했는가” 입니다.
OOM 장애는 운영에서 어떻게 보이나
프로세스가 죽거나 재시작되기 전에 보통 이런 조짐이 있습니다.
- queue backlog 가 계속 커진다
- GC activity 가 급격히 늘어난다
- 프로세스가 죽기 전 latency 가 먼저 악화된다
- heap 설정은 그럴듯한데 container memory limit 에 닿는다
- 배포나 traffic 변화가 기존 retention 가정을 깨뜨린다
즉 OOM 은 갑자기 생기는 사건이 아니라, retention, backlog, class loading, native pressure 가 먼저 쌓여 오다가 마지막에 터지는 경우가 많습니다.
흔한 원인
1. 애플리케이션 객체가 heap 을 오래 잡고 있다
가장 익숙한 패턴입니다.
아래처럼 큰 객체나 너무 많은 reference 가 heap 을 천천히 채울 수 있습니다.
- 큰 collection
- bound 없는 cache
- in-memory queue
- request payload retention
- response aggregation buffer
영원히 leak 되는 것이 아니더라도, 객체가 예상보다 오래 살아남으면 실제 traffic 에서는 JVM 이 버티지 못할 수 있습니다.
2. metaspace 가 커진다
모든 OOM 이 일반 객체 문제는 아닙니다.
과도한 class loading, dynamic proxy, bytecode generation, repeated classloader churn 이 metaspace 를 예상보다 높게 밀어 올릴 수 있습니다.
특히 아래 같은 시스템에서 더 중요합니다.
- plugin loading
- dynamic framework
- 반복적인 redeploy 패턴
- custom classloader 사용
3. direct 또는 native memory pressure
일부 장애는 일반적인 heap 바깥에서 생깁니다.
예를 들면:
- direct byte buffer
- JNI allocation
- off-heap cache
- container 안의 process memory overhead
이 경우 heap metric 은 그럴듯해 보여도 프로세스는 여전히 죽을 수 있습니다.
4. queue backlog 와 retained work 가 메모리를 부풀린다
자주 놓치는 패턴입니다.
thread pool, messaging buffer, request backlog 가 계속 커지면 queued work 자체가 많은 객체를 한꺼번에 붙잡게 됩니다.
즉 근본 문제는 object leak 보다 throughput collapse 인데, 마지막 모습만 메모리 장애로 보일 수 있습니다.
5. capacity assumption 이 틀어졌다
traffic, payload size, tenant 수, data shape 가 원래 JVM sizing 과 queue 설계를 넘어섰을 수 있습니다.
코드는 크게 안 바뀌었는데 workload 가 변해서 터지는 경우도 꽤 많습니다.
실전 점검 순서
1. 정확한 OutOfMemoryError variant 를 잡는다
그냥 “OOM 났다” 로 뭉개면 안 됩니다.
정확한 메시지 하나가 탐색 범위를 크게 줄여 줍니다.
2. pressure 가 heap 인지, metaspace 인지, native 인지 구분한다
이 구분 하나로 낭비되는 시간이 크게 줄어듭니다.
heap tuning 으로 direct buffer exhaustion 은 안 풀리고, metaspace 조정으로 queue retention 도 안 풀립니다.
3. cache, collection, queue, payload-heavy path 를 본다
애플리케이션이 예상보다 훨씬 많은 데이터를 붙잡을 수 있는 지점을 찾아야 합니다.
질문은 이렇습니다.
- traffic 과 함께 무엇이 커지는가
- retry 나 backlog 와 함께 무엇이 커지는가
- upper bound 가 없는 곳은 어디인가
4. 최근 traffic 과 deployment 변화를 비교한다
incident 는 아래 변화 뒤에 더 잘 설명되는 경우가 많습니다.
- 새 기능 경로 추가
- 더 큰 request payload
- 더 많은 concurrent work
- 바뀐 cache 동작
- 달라진 classloading 패턴
OOM 은 workload 변화로 보면 더 잘 이해되는 경우가 많습니다.
5. pressure source 가 분명해진 뒤에만 JVM sizing 을 바꾼다
메모리를 더 주면 시간을 벌 수는 있습니다. 하지만 진단을 대신해서는 안 됩니다.
pressure source 가 retention 이나 runaway backlog 라면, 더 큰 heap 은 같은 실패를 조금 늦출 뿐입니다.
예시: queue backlog 가 heap pressure 로 번지는 경우
ExecutorService pool = Executors.newFixedThreadPool(8);
for (Task t : tasks) {
pool.submit(() -> process(t));
}
process(t) 가 느려지고 incoming work 는 계속 들어오면, queued task 가 payload object, reference, closure 를 오래 붙잡아 두면서 throughput incident 가 heap incident 로 바뀔 수 있습니다.
그래서 어떤 OOM 은 사실 backlog 문제가 메모리 문제처럼 보이는 경우도 많습니다.
유용한 JVM 옵션 하나
java -XX:+HeapDumpOnOutOfMemoryError -jar app.jar
첫 OOM 에서 heap dump 를 남기면, 프로세스가 죽은 뒤 감으로 추정하는 대신 실제 object graph 를 볼 수 있습니다.
저장 공간과 운영 정책이 허용한다면, 상당히 가치가 큰 안전장치입니다.
pressure source 를 찾은 뒤 무엇을 바꾸면 좋나
heap retention 이 문제라면
retention 을 줄이고 cache 와 queue 를 bound 하며 오래 사는 reference 를 없애야 합니다.
metaspace 가 문제라면
classloader 동작, dynamic code generation, redeploy 패턴을 봐야 합니다.
native 또는 direct memory 가 문제라면
heap 차트만 보지 말고 off-heap usage 와 container memory 가정을 추적해야 합니다.
backlog 가 문제라면
queue growth 와 throughput collapse 를 2차 증상이 아니라 1차 incident 로 다뤄야 합니다.
workload 가 sizing 을 넘어선 것이라면
어떤 경로가 메모리를 먹는지 이해한 뒤 의도적으로 resize 해야 합니다.
장애 중에 던져볼 질문
이 질문이 꽤 유용합니다.
실제로 실패한 메모리 영역은 어디였고, JVM 이 죽기 전에 그 영역에서 무엇이 자라고 있었나?
이 질문이 “heap 을 키울까?” 보다 훨씬 행동 가능한 답을 줍니다.
FAQ
Q. 그냥 heap size 를 늘리면 되나
어느 메모리 영역이 실패했는지 알기 전에는 권하지 않습니다.
Q. thread pool 도 memory pressure 를 만들 수 있나
그렇습니다. 큰 backlog 와 queued work 는 많은 객체를 한꺼번에 붙잡을 수 있습니다.
Q. 가장 빠른 첫 단계는 무엇인가
정확한 error variant 를 잡고 affected memory area 와 연결하는 것입니다.
Q. 모든 OOM 이 memory leak 인가
아닙니다. 고전적인 leak 도 있지만, backlog, workload 증가, sizing mismatch 때문에 나는 경우도 많습니다.
Read Next
- queued work 와 backlog 가 pure retention 보다 더 수상하다면 Java Thread Pool Queue Keeps Growing 를 이어서 보세요.
- metaspace 가 더 의심된다면 Java Metaspace Usage High 와 비교해 보세요.
- incident 에서 GC behavior 도 컸다면 Java GC Pauses Too Long 을 이어서 보는 편이 좋습니다.
- 전체 Java 분기 지도를 다시 보려면 Java Troubleshooting Guide 로 돌아가면 됩니다.
Related Posts
- Java Thread Pool Queue Keeps Growing
- Java Metaspace Usage High
- Java GC Pauses Too Long
- Java Troubleshooting Guide
Sources:
- https://docs.oracle.com/javase/8/docs/api/java/lang/OutOfMemoryError.html
- https://docs.oracle.com/en/java/javase/21/troubleshoot/
심사 대기 중에는 광고 대신 관련 가이드를 먼저 보여줍니다.
먼저 읽어볼 가이드
검색 유입이 많은 핵심 글부터 이어서 보세요.
- 미들웨어 트러블슈팅 가이드: Redis vs RabbitMQ vs Kafka 개발자를 위한 미들웨어 트러블슈팅 허브 글입니다. Redis, RabbitMQ, Kafka 중 어떤 증상부터 먼저 봐야 하는지와 어떤 문제 패턴이 각 시스템에 가까운지 정리합니다.
- Kubernetes CrashLoopBackOff: 먼저 볼 것들 startup failure, probe, config, resource limit 관점에서 CrashLoopBackOff를 어떻게 나눠서 봐야 하는지 정리한 가이드입니다.
- Kafka consumer lag가 계속 늘 때: 트러블슈팅 가이드 Kafka consumer lag가 계속 늘어날 때 무엇부터 봐야 하는지 정리합니다. poll 주기, 처리 속도, rebalance, consumer 설정까지 실전 기준으로 다룹니다.
- Kafka Rebalancing Too Often 가이드 Kafka consumer group에서 rebalance가 너무 자주 일어날 때 membership flapping, poll timing, protocol, assignment churn을 어떤 순서로 봐야 하는지 설명하는 실전 가이드입니다.
- Docker container가 계속 재시작될 때: 먼저 확인할 것들 exit code, command failure, environment mistake, health check 관점에서 Docker restart loop를 푸는 실전 가이드입니다.
심사 대기 중에는 광고 대신 관련 가이드를 먼저 보여줍니다.