JVM Heap 설정과 OOMKilled

JVM Heap 설정과 OOMKilled

1. 개요

본 프로젝트에서는 선박의 센서 데이터 이상치를 감지하여 알람을 발생시키는 기능의 도입이 필요했습니다. 이를 구현하기 위해 선박별 센서 데이터를 조회하고 알람 발생 조건을 탐지한 뒤 알람을 발생시키는 일 배치 로직을 새롭게 개발하였습니다.

이는 개발 환경의 Kubernetes 클러스터에 Pod 형태로 배포된 서비스에서 실행되었고, 하루에 한 번 실행되어 선박별 알람 발생 통계 데이터를 쌓을 것으로 생각했습니다. 하지만 예상과 달리, 로직 수행 도중 Pod가 재시작되는 문제가 반복하여 발생하였습니다.

2. 문제 상황

로직이 실행될 때마다 Pod가 재시작되는 원인을 확인해 보니 OOMKilled(Out Of Memory Killed), 즉 컨테이너가 메모리 제한을 초과하여 OOM Killer에 의해 강제로 종료되고 있던 것이었습니다. 선박의 대용량 데이터를 처리하기에는 Memory Limit 설정이 너무 작은 것인가 싶어 Pod의 Memory Limit을 늘려봤지만 여전히 문제는 해결되지 않았습니다.

결국 Pod가 재시작되는 상황은 OOMKilled가 분명한 원인이었으나, Limit 설정과는 직접적인 연관이 없었습니다. Memory Limit이 부족했던 것도 아닌데 왜 Pod는 OOMKilled로 인해 강제 종료되었을까요?

3. JVM의 메모리: Heap과 Non-Heap

JVM 메모리는 크게 Heap 영역과 Non-Heap 영역으로 나누어 설명할 수 있습니다.

3.1 Heap 영역

Java 객체가 저장되는 메모리 영역으로, Java 애플리케이션에서 new 키워드로 객체를 생성하면 대부분 Heap 영역에 저장됩니다. 이는 GC(Garbage Collector)의 주요 관리 대상이며, 더 이상 참조되지 않는 객체는 GC가 정리합니다.

Heap은 다시 크게 Young Generation과 Old Generation으로 나뉩니다.

1) Young Generation

새로 생성된 객체가 먼저 저장되는 영역입니다. 대부분의 객체는 생성된 뒤 짧은 시간 안에 사용이 끝납니다. 예를 들어, 요청 처리 중 임시로 만든 DTO, 문자열, 컬렉션 등이 여기에 해당합니다.

Young Generation은 Eden 영역과 Survivor 영역으로 나눌 수 있는데, 객체는 보통 먼저 Eden 영역에 생성됩니다. Eden 영역이 가득 차면 Minor GC가 발생하고 Minor GC 후에도 살아남은 객체는 Survivor 영역으로 이동합니다.

여러 번의 Minor GC 이후에도 계속 살아남은 객체는 장기 생존 객체로 판단되어 Old Generation으로 이동하는데, 이 과정을 승격(Promotion)이라고 합니다.

2) Old Generation

오래 살아남은 객체가 저장되는 영역입니다. Spring Bean, 캐시 데이터, 여러 번의 Minor GC를 통과한 객체 등이 Old Generation에 남을 수 있습니다.

이 영역이 부족해지면 Full GC 또는 Major GC가 발생할 수 있습니다. Full GC는 Young Generation과 Old Generation을 넓게 검사하기 때문에 Minor GC보다 무겁고, 애플리케이션이 일시 정지되는 Stop-The-World 시간이 길어질 수 있습니다.

3.2 Non-Heap 영역

image2.png

Java 객체가 저장되는 Heap 외부의 JVM 메모리 영역으로, 이 영역은 GC의 주된 대상이 되는 일반 객체 저장 공간은 아니지만 JVM이 Java 애플리케이션을 실행하기 위해 반드시 사용하는 메모리입니다. Metaspace, Code Cache, Thread Stack, JVM Native Memory 등이 Non-Heap 영역에 속합니다.

4. Heap Size와 GC의 관계성

JVM의 Heap Size는 GC 동작 방식과 밀접한 관계를 가집니다. 애플리케이션에서 생성된 객체는 대부분 Heap에 저장되고 GC는 이 Heap 영역을 대상으로 더 이상 참조되지 않는 객체를 정리하여 메모리를 회수합니다. 따라서 Heap Size가 어떻게 설정되어 있는지에 따라 GC가 발생하는 시점, 빈도, 수행 시간 등이 달라질 수 있습니다.

image3.png

4.1 Heap Size가 작게 설정되어 있는 경우

Heap Size가 작게 설정되어 있으면 객체를 저장할 수 있는 공간이 빨리 부족해지고 JVM은 더 자주 GC를 수행하게 됩니다. 특히 Young Generation의 Eden 영역이 빠르게 가득 차면 Minor GC가 자주 발생하고, Old Generation의 여유 공간이 부족해지면 Full GC가 발생할 수 있습니다. Heap이 작으면 GC가 자주 발생한다는 단점이 있지만, 한 번의 GC에서 검사하고 정리해야 하는 메모리 범위가 상대적으로 작기 때문에 GC 수행 시간이 짧을 수 있습니다.

4.2 Heap Size가 크게 설정되어 있는 경우

반대로 Heap Size가 크게 설정되어 있으면 객체를 더 많이 저장할 수 있기 때문에 GC 발생 빈도가 낮아질 수 있습니다. 특히 Old Generation 영역이 커지면 장시간 살아남은 객체가 많이 누적되어도 공간 부족 상태에 늦게 도달하고, 이로 인해 Full GC가 자주 발생하지 않을 수 있습니다. 이때 Heap이 큰 상태에서 Full GC가 발생하면 정리해야 할 메모리 범위가 커지므로 한 번의 Full GC에 걸리는 시간이 길어질 수 있습니다. 또한 여러 GC Thread를 사용해 병렬로 처리할 수 있는 Parallel GC를 사용하더라도 Full GC 중에는 애플리케이션 Thread가 멈추는 Stop-The-World가 발생할 수 있습니다.

Heap을 크게 설정하면 Full GC 발생 빈도는 낮아질 수 있지만 GC가 늦게 발생하는 동안 불필요한 객체가 Heap에 오래 남아 있을 수 있습니다. 그리고 Full GC가 발생했을 때 더 큰 메모리 영역을 대상으로 정리해야 하므로 GC 시간이 길어질 수 있습니다. 반대로 Heap을 너무 작게 설정하면 GC가 지나치게 자주 발생하여 애플리케이션 처리 성능이 저하될 수 있습니다. 따라서 Heap Size는 크거나 작다고 좋은 것이 아니며, 애플리케이션 상황에 따라 적절히 조절하는 것이 필요합니다.

5. 문제 Pod의 JVM 실행 옵션

Pod의 Memory Limit은 2GB였고 JVM 실행 옵션은 -XX:MaxRAMPercentage=75로 컨테이너 메모리 기준으로 Heap 최대 비율을 75%로 설정하고 있었습니다. 단순 계산으로는 2GB의 75%인 약 1.5GB까지 Heap이 커질 수 있고, 나머지 약 0.5GB가 Non-Heap 및 Native 영역에 사용될 수 있는 구조였습니다. Kubernetes 환경에서 이는 비교적 높은 수준의 Heap 비율이며, Heap 영역이 필요 이상으로 크게 설정되면서 Old Generation 영역도 상대적으로 크게 확보되었고 그 결과 Full GC 발생 빈도가 낮아져 OOMKilled가 발생한 것이 아닐지 의심되었습니다.

따라서 -XX:MaxRAMPercentage=60으로 낮춘 뒤 일 배치 로직 실행 및 Pod 상태를 모니터링해 본 결과, Pod의 재시작 없이 로직이 정상적으로 수행되는 것을 확인했습니다. JVM이 사용할 수 있는 최대 메모리, 할당된 메모리 중 여유 메모리, 실제 사용 중인 메모리를 애플리케이션 로그로 확인해 보았을 때도 이전과 다르게 Full GC가 정상적으로 이루어지는 것을 확인했습니다. -XX:MaxRAMPercentage=75였을 때는 로직 실행 중 사용 중인 메모리가 최대 메모리에 다다랐을 때 GC가 일어나지 않고 Pod가 재시작되는 현상이 있었지만, -XX:MaxRAMPercentage=60으로 낮춘 뒤 애플리케이션 로그를 확인해 본 결과 이전과 다르게 사용 중인 메모리가 최대 메모리에 다다랐을 때 Full GC가 수행되면서 사용 중인 메모리가 현저히 떨어지는 것을 볼 수 있었습니다.

image4.png

6. 마치며

본 사례를 통해 Kubernetes Pod의 OOMKilled와 관련하여 Memory Limit 설정만이 해당 문제를 해결하는 것은 아니라는 것을 깨달았습니다. 물론 적절한 Memory Limit을 설정하는 것은 보다 중요하지만, 이와 더불어 JVM Heap Size 설정도 반드시 고려해야 한다는 것을 새롭게 알 수 있었습니다.

따라서 우리는 Kubernetes 환경에서 Java 애플리케이션을 운영할 때 OOMKilled 문제에 직면한 경우, 무작정 Memory Limit을 늘리기보다는 Heap Size와 Non-Heap 영역이 적절하게 설정되었는지, GC가 제대로 발생하고 있는지를 함께 확인해야 합니다. 이를 통해 불필요한 리소스 낭비를 줄이고, 보다 안정적이고 효율적인 애플리케이션 운영 환경을 구성할 수 있을 것입니다.

deeenee

Site footer