[Kafka 3.x] KRaft 모드 전환과 로그 세그먼트 동작 원리
1. 배경
"카프카는 빠르다." 백엔드 개발자라면 누구나 한 번쯤 들어봤을 말이다. 근데 왜 빠를까? 그리고 어떻게 데이터를 저장하길래 그 엄청난 처리량을 감당할까?
이전 회사에서는 RabbitMQ를 주로 사용했다. 카프카는 이번에 처음 도입했는데, 처음엔 단순히 "처리량이 큰 메시지 큐" 정도로 이해하고 접근했다.
운영하면서 예상과 달랐던 지점들이 있었다. 컨슈머 리밸런싱으로 인한 지연, 디스크 용량 관리 실패로 인한 브로커 다운 같은 문제들을 겪으며, RabbitMQ와는 근본적으로 다른 아키텍처임을 이해하게 됐다.
이 글은 Kafka 3.x의 내부 동작 원리를 제대로 이해하기 위해 정리한 기록이다.
2. Kafka 3.x의 아키텍처 변화: KRaft 모드
Kafka 2.x 이전 버전에서는 주키퍼(ZooKeeper)를 메타데이터 관리에 필수적으로 사용했다. 카프카 브로커를 운영하려면 주키퍼 앙상블도 함께 관리해야 했다. 분산 시스템 두 개를 동시에 운영하다 보니 관리 포인트가 두 배였고, 장애 발생 시 원인 파악이 복잡했다.
Kafka 3.3.0부터는 주키퍼 의존성을 제거한 KRaft(Kafka Raft) 모드가 프로덕션 레벨로 지원된다.
주요 개선 사항
가장 큰 변화는 구조의 단순화다. 이제 브로커 프로세스 안에 Raft 합의 알고리즘이 내장되어 있다. 별도의 주키퍼 클러스터를 띄울 필요가 없어졌다.
graph TD
subgraph Legacy ["Legacy: Kafka + ZooKeeper"]
Z1((ZK 1)) --- Z2((ZK 2)) --- Z3((ZK 3))
B1[Broker 1]
B2[Broker 2]
B3[Broker 3]
Z1 --> B1
Z1 --> B2
Z1 --> B3
end
subgraph KRaft ["KRaft: Kafka Only"]
KB1["Broker 1<br/>Controller"]
KB2["Broker 2<br/>Controller"]
KB3["Broker 3<br/>Controller"]
KB1 --- KB2 --- KB3
end- 메타데이터 전파 속도 향상: 예전엔 컨트롤러가 주키퍼에 쓰고, 다시 브로커들이 읽어가는 구조였다면, 이젠 내부 Raft 프로토콜로 메모리 상에서 빠르게 동기화된다.
- 파티션 확장성: 메타데이터 관리가 가벼워지면서 수십만, 수백만 개의 파티션도 거뜬해졌다.
운영 측면에서는 관리 대상이 하나로 줄어든 것이 가장 큰 장점이다. 주키퍼 없이 브로커만 띄우면 클러스터 구성이 완료된다.
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를 찾는다고 가정하자.
- 메모리 탐색(Binary Search): 메모리에 로드된
.index파일에서12345보다 작거나 같은 값 중 가장 큰 오프셋을 이진 탐색으로 찾는다. 예를 들어12300이 기록되어 있고 그 물리적 위치가5678번지라고 찾았다. - 디스크 점프(Seek): 디스크 헤더를
5678번지로 바로 이동시킨다. - 순차 탐색(Linear Scan): 거기서부터 실제 로그 파일(
0000...log)을 순차적으로 읽으면서12345가 나올 때까지 훑는다.
최대 4KB(설정값) 정도만 순차 탐색하면 된다. 이진 탐색의 속도와 순차 읽기의 장점을 결합한 하이브리드 전략이다.
5. 성능의 비밀: 순차 I/O와 Zero-Copy
카프카가 디스크를 쓰는데도 메모리 기반인 Redis만큼 빠르다는 소릴 듣는 이유는 OS의 특성을 극한으로 활용하기 때문이다.
5.1 Sequential I/O (순차 입출력)
하드디스크(HDD)는 헤더 이동 시간 때문에 랜덤 I/O 성능이 크게 떨어진다. SSD도 랜덤 I/O보다는 순차 I/O가 훨씬 빠르다. 카프카는 데이터를 수정하지 않는다. 오직 추가(Append)만 한다. 파일 끝에만 데이터를 추가한다.
이렇게 하면 디스크 헤더 이동 없이 연속적으로 쓰기만 하면 된다. 순차 쓰기 속도는 메모리 랜덤 액세스보다 빠를 수도 있다.
5.2 Zero-Copy (제로 카피)
카프카 성능의 핵심 기술이다. 데이터를 디스크에서 읽어서 네트워크로 전송할 때, 일반적인 애플리케이션은 불필요한 복사를 4번이나 수행한다.
일반적인 전송(Context Switching 4회, Copy 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는 데이터 복사에 관여하지 않고, 컨텍스트 스위칭 비용도 줄어든다. 카프카가 네트워크 대역폭을 최대로 활용할 수 있는 핵심 기술이다.
sequenceDiagram
participant Disk
participant Kernel as Kernel Buffer
participant App as User App (JVM)
participant Socket as Socket Buffer
participant NIC
Note over Disk, NIC: 일반적인 전송 방식
Disk->>Kernel: 1. Read
Kernel->>App: 2. Copy (Kernel -> User)
App->>Socket: 3. Write (User -> Kernel)
Socket->>NIC: 4. Copy to Device
Note over Disk, NIC: Zero-Copy 방식 (sendfile)
Disk->>Kernel: 1. Read (Page Cache)
Kernel->>NIC: 2. Direct Transfer (DMA)
Note right of Kernel: 어플리케이션(App) 개입 06. 마치며
카프카를 공부하면서 느낀 건, 엄청난 성능 뒤에는 Log Segment를 통한 효율적인 파일 관리, Sparse Index를 이용한 영리한 검색, 그리고 OS 커널 레벨의 최적화(Zero-Copy)를 적극적으로 활용한 설계가 있었다.
단순히 "카프카 쓰니까 빨라"라고 퉁치지 말고, 이런 내부 원리를 알고 쓰면 튜닝 포인트가 훨씬 잘 보인다. 예를 들어 log.index.interval.bytes를 조절해서 인덱스 밀도를 바꾼다거나, log.segment.bytes를 조절해서 파일 관리 효율을 높이는 식으로 말이다.
참고 자료
Loading comments...