1. 배경
"카프카는 빠르다."
백엔드 개발자라면 누구나 한 번쯤 들어봤을 말이다.
그런데 왜 빠를까?
어떻게 데이터를 저장하길래 그 처리량을 감당할까?
이전 회사에서는 RabbitMQ를 주로 사용했다.
카프카는 현 직장에 와서 처음 운영했는데, 초반에는 단순히 "처리량이 큰 메시지 큐" 정도로 이해하고 접근했다.
운영하면서 예상과 달랐던 지점들이 있었다.
컨슈머 리밸런싱으로 인한 지연, 디스크 용량 관리 실패로 인한 브로커 다운 같은 문제들을 겪으며, RabbitMQ와는 근본적으로 다른 아키텍처임을 이해하게 됐다.
2. Kafka 3.x의 아키텍처 변화: KRaft 모드
Kafka 3.2 이전까지는 주키퍼(ZooKeeper)를 메타데이터 관리에 필수로 사용했다.
카프카 브로커를 운영하려면 주키퍼 앙상블도 함께 관리해야 했다.
분산 시스템 두 개를 동시에 운영하다 보니 관리 포인트가 두 배였고, 장애 발생 시 원인 파악이 복잡했다.
Kafka 3.3.0부터는 주키퍼 의존성을 제거한 KRaft(Kafka Raft) 모드가 프로덕션 레벨로 지원된다.
다만 3.3 이후로도 주키퍼 모드는 3.9까지 함께 남아 있었다.
주요 개선 사항
가장 큰 변화는 구조의 단순화다.
이제 브로커 프로세스 안에 Raft 합의 알고리즘이 내장되어 있다.
별도의 주키퍼 클러스터를 띄울 필요가 없어졌다.
- 메타데이터 전파 속도 향상: 이전에는 컨트롤러가 주키퍼에 쓰고 브로커가 다시 읽는 구조였다.
이제는 내부 Raft 프로토콜로 메모리에서 빠르게 동기화된다. - 파티션 확장성: 메타데이터 관리가 가벼워지면서 수십만, 수백만 개의 파티션도 관리할 수 있게 됐다.
운영자 입장에서 체감되는 변화는 관리 대상이 하나로 줄었다는 점이다.
주키퍼 없이 브로커만 띄우면 클러스터 구성이 완료된다.
이 흐름은 3.x에서 끝나지 않았다.
2025년 3월에 나온 Kafka 4.0은 주키퍼 모드를 완전히 제거했고, 이제 KRaft가 유일한 운영 모드다.
주키퍼 기반 클러스터를 4.0으로 올리려면 3.9 같은 브리지 버전에서 KRaft 마이그레이션을 먼저 마쳐야 한다.
3. 로그 세그먼트(Log Segment)는 무엇인가?
우리가 흔히 말하는 "파티션(Partition)"은 논리적인 개념이다.
서버 내부에서 파티션은 디렉터리로 존재한다.
/var/lib/kafka/data/topic-partition-0/ 같은 경로에는 여러 파일이 있다.
$ ls -lh /var/lib/kafka/data/my-topic-0/
-rw-r--r-- 1 kafka kafka 1.0G 00000000000000000000.log
-rw-r--r-- 1 kafka kafka 10M 00000000000000000000.index
-rw-r--r-- 1 kafka kafka 10M 00000000000000000000.timeindex카프카는 파티션 데이터를 하나의 거대한 파일에 몰아넣지 않는다.
일정 크기(기본값 1GB)나 시간 단위로 파일을 자른다.
이 잘린 파일 조각 하나하나를 로그 세그먼트(Log Segment)라고 부른다.
세그먼트 분할의 이유
핵심은 삭제 효율성(O(1))이다.
카프카는 데이터를 영원히 보관하지 않는다.
retention.ms나 retention.bytes 설정에 따라 오래된 데이터를 지워야 한다.
만약 100GB짜리 통파일 하나에 데이터를 계속 쌓고 있다고 가정하자.
여기서 가장 오래된 1GB를 지우려면 파일 앞부분을 잘라내고 뒤에 있는 99GB를 앞으로 당겨와야(Shifting) 한다.
이 과정은 대량의 디스크 I/O 부하로 이어진다.
세그먼트 단위로 나눠두면 이야기가 달라진다.
가장 오래된 .log 파일을 unlink(OS 파일 삭제)로 삭제하면 끝이다.
부하가 거의 없다.
그래서 카프카는 대량의 데이터를 받고 지워도 성능 저하가 없다.
파일명(0000...000.log)이 해당 세그먼트의 시작 오프셋(Base Offset)이라는 점도 흥미롭다.
파일명만 봐도 어느 오프셋부터 데이터가 저장되어 있는지 알 수 있다.
4. 희소 인덱스 (Sparse Index)
1GB짜리 로그 파일에서 offset=12345인 메시지를 찾아야 한다.
처음부터 끝까지 다 읽는 Full Scan은 당연히 느리고, DB처럼 B-Tree 인덱스를 두자니 그만한 오버헤드가 생긴다.
카프카의 선택은 희소 인덱스(Sparse Index)다.
인덱스 구조
.index 파일을 열어보면 모든 오프셋에 대한 위치를 다 저장하지 않는다.
기본적으로 4KB 데이터가 쌓일 때마다 인덱스를 하나씩만 기록한다.
- Dense Index(DB): 오프셋 1, 2, 3, 4... 모든 위치 기록. (용량 큼)
- Sparse Index(Kafka): 오프셋 1, 100, 200, 300... 띄엄띄엄 기록. (용량 작음)
이렇게 하면 인덱스 파일 크기가 매우 작아져서, 메모리(RAM)에 통째로 올릴 수 있다.
데이터 검색 과정
오프셋 12345를 찾는다고 가정하자.
먼저 메모리에 로드된 .index 파일에서 12345보다 작거나 같은 값 중 가장 큰 오프셋을 이진 탐색(Binary Search)으로 찾는다.
12300이 기록되어 있고 그 물리적 위치가 5678번지였다고 하자.
이제 디스크 헤더를 5678번지로 바로 이동시킨다(Seek).
거기서부터 실제 로그 파일(0000...log)을 순차적으로 읽으면서 12345가 나올 때까지 훑는다(Linear Scan).
최대 4KB(설정값) 정도만 순차 탐색하면 된다.
이진 탐색의 속도와 순차 읽기의 장점을 결합한 하이브리드 전략이다.
5. 순차 I/O와 Zero-Copy
카프카가 디스크를 쓰는데도 메모리 기반 저장소 못지않게 빠르다는 평가를 받는 이유는 OS의 특성을 최대한 활용하기 때문이다.
5.1 Sequential I/O (순차 입출력)
하드디스크(HDD)는 헤더 이동 시간 때문에 랜덤 I/O 성능이 크게 떨어진다.
SSD도 랜덤 I/O보다는 순차 I/O가 훨씬 빠르다.
카프카는 데이터를 수정하지 않는다.
오직 추가(Append)만 한다.
파일 끝에만 데이터를 추가한다.
이렇게 하면 디스크 헤더 이동 없이 연속적으로 쓰기만 하면 된다.
순차 쓰기 속도는 메모리 랜덤 액세스보다 빠를 수도 있다.
5.2 Zero-Copy (제로 카피)
카프카 성능의 핵심 기술이다.
데이터를 디스크에서 읽어서 네트워크로 전송할 때, 일반적인 애플리케이션은 복사 4번과 컨텍스트 스위칭 4번을 거친다.
- Disk -> Kernel Buffer (읽기)
- Kernel Buffer -> User Buffer (앱으로 복사)
- User Buffer -> Socket Buffer (다시 커널로 복사)
- Socket Buffer -> NIC (랜카드로 복사)
중간에 User Buffer(JVM)를 거치는 것이 비효율적이다.
데이터를 가공하지 않고 그대로 전송할 경우, 애플리케이션 영역으로 복사할 필요가 없다.
Zero-Copy는 sendfile 시스템 콜로 이 과정을 두 단계로 줄인다.
- Disk -> Kernel Buffer (Page Cache)
- Kernel Buffer -> NIC Buffer (복사)
커널에게 "파일을 소켓으로 직접 전송"하도록 지시하는 방식이다.
데이터가 JVM을 거치지 않는다.
CPU는 데이터 복사에 관여하지 않고, 컨텍스트 스위칭 비용도 줄어든다.
덕분에 카프카는 네트워크 대역폭을 최대로 활용할 수 있다.
6. 마치며
카프카의 성능은 결국 파일을 다루는 방식에서 나온다.
오래된 데이터는 세그먼트 파일 하나를 지우는 것으로 정리하고, 인덱스는 메모리에 올라갈 만큼만 희소하게 유지하고, 전송은 커널에 맡겨 복사를 줄인다.
카프카가 빠르다는 설명에 그치지 않고 내부 원리를 이해하면 튜닝 포인트를 찾기 쉬워진다.
예를 들어 log.index.interval.bytes를 조절해서 인덱스 밀도를 바꾼다거나, log.segment.bytes를 조절해서 파일 관리 효율을 높이는 식이다.