Engineering Note
Kafka는 메모리가 남아 있는데 왜 OOM이 났을까
Kafka broker의 메모리를 heap과 page cache만으로 보면 놓치기 쉬운 direct buffer를 다룹니다. 시스템 메모리와 heap이 남아 있어도 direct memory 한도 때문에 allocation failure가 날 수 있습니다.
Kafka broker의 메모리를 이야기할 때 자주 나오는 구분이 있습니다.
Kafka heap
OS page cache
이 구분은 틀리지 않습니다. Kafka는 JVM 애플리케이션이고, segment file을 많이 읽고 쓰는 시스템이기 때문에 page cache도 매우 중요합니다. 그래서 Kafka broker에서 heap을 너무 크게 잡지 말고 page cache가 숨 쉴 공간을 남겨야 한다는 조언은 여전히 유효합니다.
그런데 실제 운영에서는 이 둘만으로 설명되지 않는 메모리 문제가 생길 수 있습니다.
제가 겪었던 문제도 그랬습니다. 시스템 메모리는 충분해 보였습니다. JVM heap도 꽉 차지 않았습니다. GC 지표도 “heap이 터졌다”고 말하지 않았습니다. 그런데 Kafka broker는 메모리 할당 실패를 냈습니다.
처음에는 이상해 보였습니다.
OS available memory는 남아 있다.
Kafka heap usage도 여유가 있다.
그런데 broker는 memory allocation failure를 낸다.
빠진 항목은 direct buffer였습니다.
이 지점이 이 글의 핵심입니다. 물리 메모리가 남아 있어도 Kafka는 OOM을 낼 수 있습니다. broker가 요청한 direct buffer가 JVM의 direct memory 한도를 넘으면, OS가 아직 메모리를 더 줄 수 있는 상황처럼 보여도 JVM 관점에서는 할당 실패입니다.
Heap이 남아 있어도 OOM은 날 수 있다
Java 애플리케이션에서 OOM이라고 하면 가장 먼저 heap을 떠올립니다.
java.lang.OutOfMemoryError: Java heap space
이 에러라면 heap dump, object histogram, GC log, allocation path를 보면 됩니다. Kafka broker에서도 heap 문제는 당연히 생길 수 있습니다.
하지만 제가 본 문제는 heap OOM이 아니었습니다. 더 가까운 형태는 direct memory allocation failure였습니다.
java.lang.OutOfMemoryError: Direct buffer memory
또는 JVM과 라이브러리 조합에 따라 다음처럼 보일 수 있습니다.
Cannot reserve ... bytes of direct buffer memory
이 경우 heap usage가 낮아 보이는 것은 이상한 일이 아닙니다. direct buffer는 Java heap 밖에 있기 때문입니다.
Java ByteBuffer 문서는 direct buffer가 native I/O를 더 직접 수행하기 위해 쓰이며, 그 내용이 일반적인 garbage-collected heap 밖에 있을 수 있다고 설명합니다. 그래서 애플리케이션의 memory footprint에서 그 영향이 명확히 보이지 않을 수 있습니다.
즉, heap이 남아 있다는 사실은 Kafka process 전체가 메모리 문제에서 안전하다는 뜻이 아닙니다.
Kafka 메모리 계산에서 빠진 칸
Kafka broker의 실제 메모리 예산은 적어도 다음처럼 봐야 합니다.
Java heap
JVM native memory
Direct buffer
Thread stack
Metaspace
Code cache
OS page cache
OS와 파일 시스템을 위한 여유
운영자가 자주 보는 지표는 heap과 OS memory입니다. 하지만 direct buffer는 그 사이에 조금 애매하게 숨어 있습니다.
- Java heap graph에는 잘 보이지 않습니다.
- OS RSS에는 포함될 수 있지만, 어떤 항목이 direct buffer인지 바로 보이지 않습니다.
- page cache와도 다릅니다.
-XX:MaxDirectMemorySize한도에 걸리면 시스템 메모리가 남아 있어도 allocation이 실패할 수 있습니다.
이 마지막 지점이 중요합니다.
메모리 할당 실패는 항상 “물리 메모리가 완전히 고갈됐다”는 뜻이 아닙니다. JVM이 direct buffer에 대해 허용한 한도에 도달해도 실패할 수 있습니다. 그러면 운영자는 OS memory와 heap만 보고 “왜?”라는 질문에 갇힙니다.
MaxDirectMemorySize의 함정
JVM에는 direct buffer 메모리 한도를 제어하는 -XX:MaxDirectMemorySize 옵션이 있습니다.
문제는 이 값을 명시하지 않았을 때입니다. OpenJDK의 JDK-8345611 release note는 MaxDirectMemorySize가 JVM instance에서 direct buffer에 사용할 수 있는 최대 총량을 제어하며, 기본값은 maximum heap size라고 설명합니다. JDK 24부터 내부 temporary direct buffer accounting이 바뀐 부분도 있으므로, 세부 동작은 JDK 버전에 따라 확인해야 합니다.
실제 Kafka 운영에서는 이 옵션을 따로 지정하지 않는 경우가 많습니다. Kafka heap은 KAFKA_HEAP_OPTS나 JVM 옵션으로 명시하지만, direct memory 한도까지 별도로 설계해 -XX:MaxDirectMemorySize를 넣는 경우는 상대적으로 드뭅니다. 그러면 broker는 JVM 기본값에 기대게 됩니다.
제가 겪은 문제도 이 지점에 가까웠습니다. 시스템에 물리 메모리가 완전히 부족했던 것이 아니라, JVM이 direct buffer에 허용한 최대치 이상을 요구하는 순간 allocation이 실패한 것으로 보였습니다. 운영자가 보는 OS memory와 heap graph는 아직 여유가 있어 보였지만, direct memory limit은 다른 축에서 이미 벽에 닿아 있던 셈입니다.
그래도 운영 관점에서 중요한 사실은 남습니다.
Direct memory 한도는 heap usage 그래프와 별개다.
명시하지 않으면 max heap과 연동된 기본값을 가질 수 있다.
대부분 별도 설정하지 않으면 이 기본값을 그대로 탄다.
따라서 heap sizing과 direct memory sizing은 분리해서 생각해야 한다.
예를 들어 Kafka broker를 64GiB 메모리 서버에서 단독으로 운영한다고 해보겠습니다. heap을 48GiB로 잡으면 page cache가 줄어드는 문제만 있는 것이 아닙니다. 기본 direct memory 한도도 heap 크기와 연동될 수 있습니다. JVM native memory, direct buffer, thread stack, metaspace, page cache, OS 여유가 모두 같은 물리 메모리를 두고 경쟁합니다.
반대로 heap을 너무 작게 잡으면 Kafka 내부 객체, request handling, controller/broker state, compression/decompression 작업에서 다른 문제가 생길 수 있습니다. 그래서 결론은 “heap을 무조건 줄이라”가 아닙니다.
핵심은 heap만 보고 메모리를 다 줬다고 생각하지 말자는 것입니다.
그리고 direct memory를 명시하지 않았다고 해서 direct memory가 “무제한”이 되는 것도 아닙니다. 대부분의 문제는 설정하지 않은 값이 없어서가 아니라, 설정하지 않은 값이 JVM 기본값으로 존재한다는 사실을 잊을 때 생깁니다.
큰 메시지가 direct memory pressure를 키운다
direct buffer 문제는 큰 메시지를 허용할 때 더 쉽게 드러날 수 있습니다.
Kafka에서 큰 메시지를 허용하려면 producer, broker, consumer, replication 관련 설정이 함께 움직입니다. 예를 들어 Apache Kafka 문서의 max.request.size는 producer가 한 request에 보낼 수 있는 최대 크기를 제한합니다. broker에는 message.max.bytes, socket.request.max.bytes, replica.fetch.max.bytes 같은 제한이 있습니다.
이 설정들은 단순히 “큰 메시지를 받을 수 있게 한다”에서 끝나지 않습니다. 큰 record batch와 큰 request를 허용하면 broker가 한 번에 다뤄야 하는 buffer 단위도 커질 수 있습니다. 여기에 동시 연결, produce request, fetch request, replica fetch, TLS, compression/decompression 경로가 겹치면 heap 밖 메모리 압력이 눈에 띄게 커질 수 있습니다.
정확한 사용량은 workload와 Kafka/JDK 버전, 네트워크 경로, TLS 여부, 압축 방식, 동시성에 따라 달라집니다. 그래서 “메시지 크기 × 연결 수 = direct memory” 같은 단순 공식으로 말하기는 어렵습니다.
다만 운영 기준은 분명합니다.
큰 메시지를 허용할수록 heap만 보는 메모리 계산은 더 위험해진다.
큰 메시지는 Kafka에서 항상 신중해야 합니다. 보통은 메시지 본문 자체를 Kafka에 크게 싣기보다 object storage 등에 payload를 두고 reference만 Kafka로 전달하는 방식이 더 나은 경우가 많습니다. 하지만 어쩔 수 없이 큰 record batch를 허용해야 한다면, direct memory와 native memory budget까지 같이 봐야 합니다.
page cache도 여전히 중요하다
direct buffer 이야기를 한다고 해서 page cache의 중요성이 줄어드는 것은 아닙니다.
Kafka broker는 log segment를 디스크에 쓰고, consumer와 follower replica에게 읽어줍니다. OS page cache는 이 경로에서 큰 역할을 합니다. heap을 크게 잡아 page cache를 밀어내면 disk I/O가 늘고, broker latency와 throughput이 흔들릴 수 있습니다.
여기서 direct buffer는 세 번째 칸입니다.
Heap만 크게 잡으면 page cache가 줄어든다.
Page cache만 생각하면 direct/native memory 한도를 놓친다.
Direct memory만 키우면 OS 전체 memory budget이 흔들린다.
Kafka broker 메모리는 하나의 숫자가 아니라 예산표에 가깝습니다.
Kafka heap은 RAM 비율로 키우는 값이 아니다
Kafka broker만 올라가는 서버라면 heap을 전체 메모리의 큰 비율로 잡는 것은 보통 좋은 출발점이 아닙니다.
Confluent의 production deployment 문서도 같은 방향을 말합니다. Kafka는 파일 시스템을 많이 활용하고 page cache에 크게 의존하며, heap을 매우 조심스럽게 사용하기 때문에 heap size를 6GB보다 크게 설정할 필요가 없다고 설명합니다. 32GB machine에서는 그 결과 28~30GB 정도의 file system cache를 확보할 수 있다는 예시도 함께 듭니다.
이 설명은 heap과 page cache 관점에서는 매우 실용적입니다. 하지만 여기에도 direct buffer 한도는 별도 항목으로 뚜렷하게 드러나지 않습니다. 그래서 문서대로 heap을 크게 잡지 않았고, 물리 메모리도 남아 있는데도, 큰 request와 direct buffer 사용이 겹치면 JVM의 direct memory limit에서 OOM을 만날 수 있습니다.
이 기준은 중요한 메시지를 줍니다.
Kafka heap은 RAM에 비례해서 계속 키우는 값이 아니다.
큰 broker 서버에서도 heap은 보통 작게 유지하고,
남은 메모리는 page cache와 native/off-heap 영역에 남겨야 한다.
이 글의 direct buffer 관점까지 합치면 이유가 더 분명해집니다.
- Kafka heap 자체는 필요하지만, 무작정 크게 잡을 필요는 적습니다.
- OS page cache는 Kafka throughput과 latency에 직접적인 영향을 줍니다.
- direct buffer와 JVM native memory도 heap 밖에서 메모리를 씁니다.
- OS와 파일 시스템을 위한 여유도 필요합니다.
- container/cgroup 환경이라면 이 모든 항목이 limit 안에서 경쟁합니다.
만약 -XX:MaxDirectMemorySize를 명시하지 않은 환경에서 direct memory 한도가 max heap과 연동된다면, heap을 크게 잡는 것은 direct memory의 가능한 한도도 같이 키울 수 있습니다. 실제로 그만큼을 항상 쓴다는 뜻은 아니지만, 최악의 순간에 heap, direct memory, page cache, OS 여유가 같은 물리 메모리 안에서 경쟁할 수 있다는 뜻입니다.
그래서 Kafka 단독 broker에서 heap을 작게 유지하는 것은 “Kafka에 메모리를 적게 준다”가 아닙니다.
Heap에만 메모리를 몰아주지 않고,
page cache와 direct/native 영역에도 숨 쉴 공간을 남기는 선택이다.
무엇을 봐야 하나
이 문제를 피하려면 heap 지표만으로는 부족합니다.
먼저 JMX에서 java.nio:type=BufferPool,name=direct를 봐야 합니다.
대표적으로 다음 값을 확인할 수 있습니다.
Count
MemoryUsed
TotalCapacity
이 값은 direct buffer가 실제로 어느 정도 사용되고 있는지 보는 데 도움이 됩니다. 다만 모든 native memory를 설명하는 것은 아니므로 RSS, cgroup memory, OS available memory와 함께 봐야 합니다.
남은 양은 MBean이 직접 제공하지 않습니다. 대신 MaxDirectMemorySize와 교차해서 운영상 의미 있는 headroom을 계산할 수 있습니다.
direct_used_bytes = BufferPool direct MemoryUsed
direct_limit_bytes = -XX:MaxDirectMemorySize
또는 MaxDirectMemorySize를 명시하지 않았다면 JVM 기본값
direct_headroom_bytes = direct_limit_bytes - direct_used_bytes
direct_usage_ratio = direct_used_bytes / direct_limit_bytes
MaxDirectMemorySize를 따로 설정하지 않았다면, 앞에서 본 것처럼 일반적으로 max heap에 해당하는 값을 effective limit으로 두고 해석할 수 있습니다. 이 값은 JVM native memory 전체의 free가 아니라 direct buffer pool 기준의 headroom입니다.
운영 중 문제를 깊게 볼 수 있는 환경이라면 jcmd도 도움이 됩니다.
jcmd <pid> VM.native_memory summary
단, Native Memory Tracking은 미리 켜야 의미 있는 정보를 얻을 수 있고, 운영 환경에서는 overhead와 정책을 고려해야 합니다.
컨테이너에서는 다음도 같이 봐야 합니다.
- container memory usage
- memory limit
- OOM kill 여부
- process RSS
- page cache accounting
- JVM heap/non-heap
- direct buffer pool JMX
- effective direct memory limit
- direct buffer headroom
핵심은 한 화면의 heap graph만 보고 판단하지 않는 것입니다.
운영에서 얻은 기준
제가 이 문제를 겪고 난 뒤 Kafka broker 메모리를 볼 때 기준이 바뀌었습니다.
이전에는 이렇게 봤습니다.
Kafka memory = heap + page cache
이제는 이렇게 봅니다.
Kafka memory budget
= heap
+ direct/native memory
+ thread/metaspace/code cache
+ page cache
+ OS safety margin
그리고 큰 메시지나 큰 request를 허용하는 클러스터에서는 direct buffer와 native memory를 반드시 별도 항목으로 둡니다.
Konduo에서도 Kafka 같은 리소스를 볼 때 broker-level metric, host metric, JVM metric을 함께 보는 이유가 여기에 있습니다. 장애 원인은 한 계층에 깔끔하게 붙어 있지 않은 경우가 많습니다. heap은 멀쩡한데 process memory가 커지고, OS memory는 남아 있는데 JVM direct memory 한도에 걸리는 식의 문제는 계층을 건너서 봐야 설명됩니다.
공식 sizing 문서가 page cache를 강조하는 것은 맞습니다. 다만 운영자는 그 문서에 직접 드러나지 않는 direct buffer 한도까지 자기 메모리 예산표에 추가해야 합니다. 그렇지 않으면 “heap도 작게 잡았고 page cache도 남겼는데 왜 OOM이 났지?”라는 질문을 다시 만나게 됩니다.
결론
Kafka broker에서 “메모리가 남아 있다”는 말은 생각보다 조심해서 써야 합니다.
OS available memory가 남아 있어도 direct memory 한도에 닿으면 allocation failure가 날 수 있습니다. heap usage가 낮아도 heap 밖 direct buffer가 문제일 수 있습니다. page cache를 충분히 남겼다고 생각해도 JVM native memory와 direct buffer 예산이 빠져 있으면 계산은 틀릴 수 있습니다.
Kafka의 실제 메모리는 heap과 page cache만으로 설명되지 않습니다.
Heap은 Kafka 메모리의 전부가 아니다.
Page cache도 전부가 아니다.
Direct buffer는 그 사이에서 조용히 실패를 만들 수 있다.
Kafka broker를 sizing할 때는 heap을 크게 잡는 것이 항상 안전한 선택이 아닙니다. 특히 Kafka 단독 서버라면 Confluent 문서의 6GB 기준처럼 heap은 작게 유지하는 쪽에서 시작하고, direct buffer, native memory, page cache, OS 여유를 함께 관측하면서 조정하는 편이 더 안전합니다.
함께 읽기 좋은 글
이 글의 관점과 이어지는 주제를 더 보려면 아래 글도 함께 읽어볼 수 있습니다.
- Kafka Broker가 느려졌지만 Kafka가 원인은 아니었다 - Kafka 증상 뒤에 숨어 있던 storage path와 I/O 문제를 추적합니다.
- Kafka가 잘하는 대용량과 보내면 안 되는 대용량 - 큰 payload를 Kafka로 직접 보낼 때 생기는 비용을 설명합니다.
- JVM 메트릭만으로는 컨테이너를 설명할 수 없다 - JVM 밖의 cgroup, filesystem, fd, I/O 신호를 함께 봐야 하는 이유를 다룹니다.