성능 분석의 고전 클래식, Heap Dump
K8s 환경에서 프로젝트를 진행하던 도중, 특정 파드가 반복적으로 재시작되는 케이스를 발견했습니다. 파드가 왜 재시작되는지 확인하기 위해 Last State 속성을 살펴본 결과, 원인은 당연하게도 OOM이었습니다. 보통 파드가 이런 식으로 무한 재시작되는 경우는 대부분 OOM Killed가 주 원인이었습니다.
이 상황에서 OOM을 유발한 원인을 찾기 위해, 먼저 Log를 확인합니다. 그런데 이렇게 Log를 확인해도 OOM의 원인은 쉽게 발견되지 않습니다. 왜냐하면 OOM의 주요 원인은 Error가 아닌 쓰레드 병목, 즉 DB I/O가 너무 오래 걸려 비동기 처리 과정에서 성능 병목이 발생하는 것이기 때문입니다. 에러 로그 한 줄 없이 메모리만 서서히 차오르다 파드가 죽는 상황에서는, 로그만으로 원인을 특정하기 어렵습니다.
이렇게 Log를 통해 OOM의 원인을 분석할 수 없을 때 유용하게 도움을 줄 수 있는 것이 바로 Heap Dump입니다. 가장 전통적인 프로세스 분석 기법이자, 현재까지도 백엔드 성능 분석에 가장 많이 쓰이는 기술 중 하나입니다. Heap Dump는 특정 시점에 JVM 힙에 존재하는 모든 객체와 그 참조 관계를 스냅샷으로 담아내기 때문에, 어떤 객체가 메모리를 점유하고 있고 왜 회수되지 않는지를 정적인 형태로 들여다볼 수 있습니다.
그러나 순수한 Heap Dump를 그대로 분석하기에는 파일 자체가 사용자에게 친절하지 않아, 쉽게 읽을 수 없는 문제가 있습니다. 수십만 개의 객체와 참조가 바이너리로 엉켜 있는 원본 파일을 사람이 직접 해석하는 것은 사실상 불가능에 가깝습니다. 이런 경우 유용하게 사용할 수 있는 것이 Memory Analyzer Tool(MAT)이라는 오픈소스 분석 도구입니다.
그래서 OOM이란 무엇인가
본격적으로 MAT를 살펴보기 전에, 우리가 마주한 OOM(Out Of Memory)이 정확히 어떤 현상인지 짚고 넘어갈 필요가 있습니다. OOM은 말 그대로 애플리케이션이 사용할 수 있는 메모리가 고갈되어, 더 이상 새로운 객체를 할당할 공간이 없을 때 발생하는 상황을 가리킵니다. 같은 메모리 부족이라도 그것이 발생하는 계층은 크게 두 가지로 나뉘며, 이 둘을 구분하는 것이 트러블슈팅의 첫걸음입니다.
* 이미지 출처: https://www.cnblogs.com/java1024/p/12381457.html
첫 번째는 JVM 내부에서 발생하는 OutOfMemoryError입니다. JVM은 기동 시 힙(Heap)의 최대 크기를 정해두고 그 한도 안에서 객체를 할당하는데, 회수되지 못한 객체가 계속 쌓여 힙이 한계에 다다르면 JVM은 java.lang.OutOfMemoryError를 던지며 정상적인 동작을 멈춥니다.
* 이미지 출처: https://suneeta-mall.github.io/blog/2021/03/14/wth-who-killed-my-pod---whodunit/
두 번째는 컨테이너 레벨에서 발생하는 OOM Killed입니다. K8s 환경에서 파드는 메모리 Limit을 부여받는데, 컨테이너가 사용하는 실제 메모리가 이 한도를 초과하면 호스트의 OOM Killer가 해당 프로세스를 강제로 종료시킵니다. 이때 파드는 종료 코드 137(128 + SIGKILL 9)과 함께 Last State에 OOMKilled를 남기고, 재시작 정책에 따라 다시 기동되기를 반복하게 됩니다.
문제를 까다롭게 만드는 것은, OOM이 대부분 특정 순간의 사고가 아니라 서서히 진행되는 누적의 결과라는 점입니다. 처리되지 못한 객체가 조금씩 힙에 쌓이고, Garbage Collector가 회수를 시도하지만 여전히 어딘가에서 강하게 참조되고 있어 끝내 회수에 실패합니다. 이렇게 회수되지 못한 객체가 임계점을 넘기는 순간 OOM이 터지기 때문에, 정작 에러가 발생한 시점의 로그에는 진범이 아니라 마지막으로 메모리를 요청하다 실패한 무고한 코드가 찍히는 경우가 많습니다. 결국 OOM의 진짜 원인을 밝히려면 에러가 난 순간이 아니라, 그 직전까지 힙에 무엇이 어떻게 쌓여 있었는지를 들여다보아야 합니다. 바로 이 지점에서 Heap Dump와 이를 분석해주는 MAT가 필요해집니다.
Memory Analyzer Tool
Memory Analyzer Tool(이하 MAT)은 Eclipse 재단에서 제공하는 오픈소스 Heap Dump 분석 도구입니다. 사람이 읽기 어려운 원시 Heap Dump(.hprof)를 파싱하여, 어떤 객체가 얼마나 많은 메모리를 점유하고 있는지, 그리고 그 객체가 왜 GC 대상이 되지 못하는지를 시각적으로 보여줍니다.
MAT의 핵심은 Shallow Heap과 Retained Heap이라는 두 가지 지표입니다. Shallow Heap은 객체 자신이 차지하는 메모리 크기를, Retained Heap은 해당 객체가 GC될 경우 함께 회수되는 메모리의 총량을 의미합니다. OOM의 원인을 찾을 때는 Retained Heap이 비정상적으로 큰 객체를 추적하는 것이 핵심입니다. 단일 객체가 차지하는 크기보다, 그 객체가 붙들고 놓아주지 않는 메모리의 총합이 실질적인 누수의 규모를 말해주기 때문입니다.
* 이미지 출처: AnyLogic Help, “Memory analyzer”, https://anylogic.help/advanced/debug/memory-analyzer.html
해당 View는 Heap Dump 파일을 MAT(Memory Analyzer Tool) 에 Import 해주면 확인할 수 있습니다. Memory Analyzer Tool의 핵심 기능을 담당하는 View로 MAT이 Heap Dump를 보고 메모리 누수로 예상되는 포인트를 짚어줍니다. 원 모양의 그래프를 통해 Process의 Heap에서 차지하는 비율을 보여주고, 어느 데이터가 Heap을 과도하게 점유하고 있는지 확인할 수 있습니다.
* 이미지 출처: AnyLogic Help, “Memory analyzer”, https://anylogic.help/advanced/debug/memory-analyzer.html
또한 다음과 같은 화면도 확인할 수 있습니다.
위 화면은 MAT이 Heap Dump를 분석한 뒤 자동으로 생성해주는 Leak Suspects 리포트입니다. MAT은 단순히 객체 목록을 나열하는 데 그치지 않고, 비정상적으로 많은 메모리를 점유하고 있는 객체를 “누수 의심 지점(Leak Suspect)”으로 지목해 보여줍니다. 화면 상단의 원형 그래프는 전체 Heap에서 각 의심 객체가 차지하는 비중을 시각적으로 나타내며, 하나의 객체 혹은 특정 클래스의 인스턴스 군집이 대부분을 점유하고 있다면 그 영역이 큰 비중으로 강조되어 한눈에 들어옵니다.
그래프 하단에는 각 의심 지점에 대한 요약 설명이 함께 제공됩니다. 어떤 클래스가 얼마만큼의 Retained Heap을 점유하고 있는지, 그리고 그 객체가 어떤 ClassLoader나 참조 경로를 통해 유지되고 있는지를 자연어에 가까운 형태로 정리해주기 때문에, Heap Dump 분석에 익숙하지 않은 개발자도 누수의 원인 후보를 빠르게 좁혀나갈 수 있습니다. 본격적인 Dominator Tree 분석에 들어가기 전, 가장 먼저 살펴보며 분석의 방향을 잡는 출발점으로 활용하기 좋은 화면입니다.
Heap Dump 추출 방법
Heap Dump는 OOM이 발생하는 시점에 자동으로 생성하도록 설정하거나, 실행 중인 프로세스에 직접 명령을 내려 추출할 수 있습니다. 운영 환경에서는 OOM이 터지는 그 순간의 상태를 포착하는 것이 가장 중요하므로, JVM 옵션을 통한 자동 덤프 설정이 유효합니다.
-XX:+HeapDumpOnOutOfMemoryError 옵션을 추가하면 OutOfMemoryError가 발생하는 시점에 자동으로 .hprof 파일이 생성되며, -XX:HeapDumpPath로 저장 경로를 지정할 수 있습니다. 실행 중인 프로세스에서 즉시 추출이 필요한 경우에는 jmap -dump:live,format=b,file=heap.hprof <pid> 명령을 사용합니다. live 옵션을 주면 덤프 전에 Full GC가 한 차례 수행되어, 살아남은 객체만을 대상으로 분석할 수 있습니다.
다만 K8s 환경에서는 한 가지 고려할 점이 있습니다. 파드가 OOM으로 Kill되는 순간 컨테이너가 종료되면서 내부에 생성된 덤프 파일도 함께 사라질 수 있다는 점입니다. 따라서 HeapDumpPath를 PersistentVolume과 같은 영속 스토리지 경로로 지정하거나, 별도의 디버그 컨테이너를 통해 종료 직전의 파일 시스템에 접근하는 방식을 함께 고려해야 합니다.
Dominator Tree를 활용한 누수 객체 추적
메모리 사용량이 지속적으로 증가하던 문제 상황에서 추출한 Heap Dump를 MAT의 Dominator Tree로 열어본 결과, 특정 클래스의 인스턴스가 비정상적으로 많은 Retained Heap을 점유하고 있다는 사실을 확인했습니다. Dominator Tree는 객체 간 지배 관계를 트리 형태로 보여주어, 어떤 객체가 다른 객체들의 메모리를 붙들고 있는지를 한눈에 파악할 수 있게 해줍니다.
원래라면 일정 수준에서 회수되어야 할 객체들이 왜 계속 쌓이는지 확인하기 위해 Path to GC Roots 기능을 활용했습니다. 이는 해당 객체가 GC Root로부터 어떤 참조 경로로 연결되어 있는지를 역으로 추적하는 기능으로, 누가 객체를 붙들고 놓아주지 않는지를 정확히 짚어줍니다. 분석 결과, 처리 속도를 초과해 유입되던 메시지 객체들이 컬렉션에 계속 적재되면서도 소비되지 못한 채 강한 참조로 남아 있어, Garbage Collector가 메모리를 회수하지 못하는 상황이었습니다.
결국 비동기 처리 과정에서 DB I/O 병목으로 인해 메시지 소비 속도가 생산 속도를 따라가지 못했고, 미처 처리되지 못한 객체가 힙에 무한히 적재되어 OOM으로 이어진 것이었습니다. 다행히 이러한 사실을 Heap Dump 분석 과정을 통해 발견할 수 있었고, 비즈니스 로직의 DB I/O를 개선해 처리 속도를 단축시킴으로써 객체가 무한히 누적되는 상황을 방지할 수 있었습니다.
마치며
Heap Dump는 "이미 발생한 OOM의 범인이 누구인가"를 가장 확실하게 밝혀주는 도구입니다. 로그로는 보이지 않고, 실시간 지표로는 원인까지 좁히기 어려운 메모리 누수 문제를 마주했을 때, 그 시점의 힙을 통째로 들여다보며 객체 참조의 실타래를 풀어내는 고전적인 분석 기법으로 생각하면 좋을 것 같습니다. 무엇보다 Heap Dump 분석은 단순히 당장의 장애를 해소하는 데 그치지 않고, 객체가 어떤 생애주기로 생성되고 소멸하는지, 그리고 어떤 참조 관계가 메모리 회수를 가로막는지를 깊이 이해하는 계기가 되어줍니다. 이렇게 한 번 쌓인 분석 경험은 이후 비슷한 징후를 더 빠르게 알아채고, 나아가 누수가 발생하지 않는 구조를 설계하는 안목으로 이어진다는 점에서 그 가치가 크다고 생각합니다.
jungboke