들어가며
- Kafka 4.0부터 ZooKeeper가 완전히 제거되고 KRaft만 남았다. KRaft 동작을 시나리오별로 소스 코드와 함께 확인해 보고, 현시점(2026-09) 버전 선택과 업그레이드 경로를 핵심만 정리한 포스팅
- 코드 인용은 추천 버전인
apache/kafka4.2.1 태그 기준
KRaft란?
- Kafka + Raft. 클러스터 메타데이터(브로커 목록, 토픽/파티션, 파티션 리더/ISR, 설정, ACL 등)를 ZooKeeper 대신 Kafka 자체의 Raft 합의 로그로 관리하는 방식이다. (KIP-500)
- ZooKeeper 시절에는
- 메타데이터 저장소(ZK)와 이를 판단하는 컨트롤러(브로커 중 하나)가 분리되어 있었다.
- 컨트롤러가 죽으면 새 컨트롤러가 ZK에서 전체 메타데이터를 다시 읽어야 해서 파티션이 많을수록 페일오버가 느렸다.
- ZK라는 별도 분산 시스템을 따로 운영해야 했다.
- KRaft에서는 메타데이터가
__cluster_metadata라는 단일 파티션 내부 토픽에 이벤트 로그로 쌓이고, 컨트롤러들이 이 로그를 Raft로 복제한다.// clients/.../common/internals/Topic.java public static final String CLUSTER_METADATA_TOPIC_NAME = "__cluster_metadata";
Raft 3분 요약
- Raft는 여러 노드가 “같은 순서의 기록(로그)”을 갖게 하는 합의 알고리즘이다. 회의록에 비유하면 이해가 쉽다.
- 리더(서기) 1명만 기록한다. 나머지(팔로워)는 리더의 기록을 받아 사본을 만든다.
- 과반이 받아 적으면 확정(커밋) 된다. 3명 중 2명, 5명 중 3명.
- 리더 소식이 일정 시간 끊기면 선거를 한다. 선거마다 임기 번호(epoch, Raft 용어로 term)가 1씩 오른다. 더 높은 epoch를 보면 무조건 따른다.
- 가장 최신 기록을 가진 후보만 당선될 수 있다. 그래서 확정된 기록은 새 리더에게 반드시 있다.
- 과반 규칙 덕분에 3대 중 1대, 5대 중 2대가 죽어도 기록이 유실되지 않고 계속 동작한다.
누가 무엇을 복제하나? (컨트롤러는 데이터 복제를 안 하나?)
- 결론부터: 컨트롤러는 원래부터(ZK 시절에도) 사용자 데이터를 복제하지 않았다. KRaft에서도 마찬가지다. 복제는 두 층으로 나뉜다.
| 층 | 무엇을 | 누가 | 방식 |
|---|---|---|---|
| 메타데이터 | __cluster_metadata (토픽 생성, 리더/ISR 변경 등) |
컨트롤러끼리 복제, 브로커는 읽기만 | Raft |
| 데이터 | 사용자 토픽 (orders 등) |
브로커끼리 | 기존 파티션 리더/팔로워 + ISR |
- KRaft로 바뀐 점은 두 가지다.
- 메타데이터 저장소가 ZK에서 컨트롤러 자신으로 바뀌었다.
- 컨트롤러가 “브로커 중 하나가 겸직”하던 구조에서 전용 노드(
process.roles=controller)로 바뀌었다. 개발용으로는 겸직(combined)도 가능하다.
- 다만 컨트롤러는 데이터 복제의 “결정” 을 내린다. 파티션 리더를 누가 할지, ISR에서 누구를 뺄지 같은 결정이다. 실제 Produce/Consume 경로에는 컨트롤러가 끼지 않는다.
시나리오로 보는 KRaft
- 예시 클러스터: 컨트롤러
C1, C2, C3+ 브로커B1, B2, B3
┌───────── Controller Quorum (voter) ─────────┐
│ C1 C2 (Leader=Active) C3 │ ← __cluster_metadata를 Raft로 복제
└──────────────────┬──────────────────────────┘
│ Fetch (pull)
┌────────────┼────────────┐
B1 B2 B3 ← observer: 메타데이터 로그를 읽기만 함
└──── 사용자 데이터는 브로커끼리 복제 ────┘
시나리오 1. 첫 클러스터 형성
- 스토리지 포맷:
kafka-storage.sh format으로 각 노드에meta.properties(cluster.id, node.id)를 만든다. 컨트롤러에는 초기metadata.version등을 담은bootstrap.checkpoint도 만든다. - 컨트롤러 기동: C1~C3 모두 리더를 모르는
Unattached상태(epoch 0)로 뜬다. - 선거: 선거 타이머(기본 1초 + 랜덤 지연)가 가장 먼저 끝난 C2가 출마한다.
Prospective: 먼저 PreVote로 “나 뽑힐 수 있어?”를 묻는다. 과반이 OK하면Candidate: epoch를 1로 올리고 Vote 요청을 보낸다.- C1, C3는 C2의 로그가 자기 것보다 같거나 최신인지 확인하고 찬성한다.
// KafkaRaftClient.java lastEpochEndOffsetAndEpoch.compareTo(endOffset()) >= 0 - 과반 득표 →
Leader.
- 리더 공지: C2가
BeginQuorumEpoch로 “epoch 1의 리더는 나”라고 알리고LeaderChangeMessage를 로그에 기록한다.- 교과서 Raft는 리더가 팔로워에게 기록을 push하지만, KRaft는 팔로워가 리더에게 Fetch(pull) 한다. 그래서 팔로워가 새 리더를 알 수 있도록 이 공지 RPC가 따로 있다.
“Leader election is more or less pure Raft, but replication is driven by replica fetching” —
KafkaRaftClient.java
- 교과서 Raft는 리더가 팔로워에게 기록을 push하지만, KRaft는 팔로워가 리더에게 Fetch(pull) 한다. 그래서 팔로워가 새 리더를 알 수 있도록 이 공지 RPC가 따로 있다.
- Active Controller 시작: Raft 리더인 C2가 Active Controller가 된다. 로그가 비어 있으므로
bootstrap.checkpoint의 초기 레코드(metadata.version등)를 기록한다. (ActivationRecordsGenerator) - 브로커 등록: B1~B3가 뜨면 컨트롤러 리더를 찾아 observer로 로그를 Fetch하고,
BrokerRegistration을 보낸다. 컨트롤러는RegisterBrokerRecord를 기록한다. 이때 브로커는 아직 트래픽을 받지 않는 FENCED 상태다. - Unfence: 브로커는 2초마다 heartbeat로 “나 메타데이터 로그 몇 번까지 읽었어”를 보고한다. 자기 등록 레코드 offset까지 따라잡으면 컨트롤러가 unfence하고, 그때부터 파티션 리더를 맡을 수 있다.
// BrokerHeartbeatManager.java if (request.currentMetadataOffset() >= registerBrokerRecordOffset) { log.info("The request from broker {} to unfence has been granted " + "because it has caught up with the offset of its register broker record {}.", ...); return new BrokerControlStates(currentState, UNFENCED); }→ 최신 메타데이터를 모르는 브로커가 잘못된 정보로 서비스하는 걸 막는다.
시나리오 2. 정상 상태 - 토픽 하나 만들어 보기
- 평소에는 C1·C3가 C2에게, B1~B3가 C2에게 계속 Fetch하고, 브로커는 2초마다 heartbeat를 보낸다.
kafka-topics.sh --create --topic orders --partitions 3 --replication-factor 3실행 시- 요청을 받은 브로커(B1)는 직접 처리하지 않고 Active Controller로 forward한다. (
KafkaApis:case ApiKeys.CREATE_TOPICS => forwardToController(request)) - C2가 단일 스레드에서 레플리카 배치를 정하고
TopicRecord1개와PartitionRecord3개(파티션별 리더/ISR)를 로그에 append한다. 예: offset 500~503.“The QuorumController is single-threaded. … The future associated with each operation will not be completed until the results of the operation have been made durable to the metadata log.” —
QuorumController.java - C1·C3가 Fetch로 500~503을 가져간다. 3대 중 2대가 가져가면 커밋(High Watermark가 503까지 전진)되고, 그제야 클라이언트에 성공을 응답한다.
// LeaderState.java - voter들의 offset을 내림차순 정렬했을 때 가운데 = 과반이 가진 offset int indexOfHw = voterStates.size() / 2; - 브로커들도 Fetch로 503까지 읽는다.
MetadataLoader가 레코드를 delta → image로 만들고,BrokerMetadataPublisher가 반영한다. 이 시점에 B2는 “orders-0 리더는 나”를 알고 서비스를 시작하고, 나머지는 B2에게서 데이터를 복제하기 시작한다. - 이후
orders로의 Produce/Consume은 클라이언트 ↔ 파티션 리더 브로커 사이에서만 일어난다. 컨트롤러는 관여하지 않는다.
- 요청을 받은 브로커(B1)는 직접 처리하지 않고 Active Controller로 forward한다. (
- 브로커의 메타데이터 상태는 결국 “
__cluster_metadata를 몇 번 offset까지 읽었는가”로 표현된다. 브로커 간 차이는 잠깐의 offset 차이일 뿐이고 결국 같은 상태로 수렴한다.
시나리오 3. 장애 상황
3-1. 브로커 B2 다운 (orders-0의 리더)
- B2의 heartbeat가 끊기고 9초(
broker.session.timeout.ms)가 지나면 컨트롤러가 B2를 fence한다. - C2는 B2를 모든 ISR에서 빼고, B2가 리더였던 파티션은 ISR에 남은 브로커 중 새 리더를 선출한다. 결과는
PartitionChangeRecord+BrokerRegistrationChangeRecord(fenced)로 기록된다.// ReplicationControlManager.java void handleBrokerFenced(int brokerId, List<ApiMessageAndVersion> records) { generateLeaderAndIsrUpdates("handleBrokerFenced", brokerId, NO_LEADER, NO_LEADER, records, ...); records.add(new ApiMessageAndVersion(new BrokerRegistrationChangeRecord()... .setFenced(BrokerRegistrationFencingChange.FENCE.value()), (short) 0)); } - 커밋 후 브로커들이 이 레코드를 읽으면 B1이 orders-0의 새 리더가 된다. 클라이언트는
NOT_LEADER에러를 받고 메타데이터를 갱신해 B1으로 붙는다. - B2가 복구되면 시나리오 1의 6~7번처럼 등록 → 메타데이터 따라잡기 → unfence 순서를 거친다. 데이터도 따라잡으면 ISR에 복귀한다.
3-2. Active Controller C2 다운
- C1·C3의 Fetch가 실패한다. 2초(
controller.quorum.fetch.timeout.ms) 동안 리더 응답이 없으면Prospective로 전환한다. - PreVote →
Candidate(epoch 2) → Vote. 커밋된 레코드는 과반(C1 또는 C3 중 최소 1대)에 반드시 있고, 최신 로그를 가진 쪽만 당선되므로 새 리더는 커밋된 메타데이터를 전부 갖고 있다. - 새 리더 C1은 이미 로그를 계속 재생하며 메모리에 최신 상태를 들고 있던 standby라서 곧바로 Active Controller가 된다. ZK 시절처럼 전체 메타데이터를 다시 읽는 과정이 없다. 이게 KRaft 페일오버가 빠른 핵심 이유다.
“All other nodes remain in standby mode. … They just replay the metadata log entries that the current active controller has created.” —
QuorumController.java - 새 리더는 자기 epoch의 레코드(
LeaderChangeMessage)가 커밋되기 전까지 커밋 지점을 올리지 않는다. 이전 epoch의 애매한 레코드를 섣불리 확정하지 않기 위한 Raft 규칙이다.// LeaderState.java // The leader must commit one record from its own epoch before it is // allowed to expose records from any previous epoch. if (highWatermarkUpdateOffset > epochStartOffset) { ... } - 브로커들은 Fetch 응답에 실려 오는 새 리더/epoch 정보를 보고 C1으로 전환한다.
- C2가 커밋되지 못한 레코드를 갖고 있었다면 버려진다. 해당 요청은 클라이언트가 성공 응답을 못 받았으므로 재시도하면 된다. 복구된 C2는 더 높은 epoch를 보고 Follower가 되고, 어긋난 로그를 잘라낸 뒤 따라잡는다.
3-3. 리더가 네트워크에서 고립됨
- 리더는 일정 시간 과반 voter로부터 Fetch가 오지 않으면 스스로 고립됐다고 판단하고 물러난다(check quorum). 나머지는 새 리더를 뽑는다.
// LeaderState.java "Did not receive fetch request from the majority of the voters within {}ms. " - 고립됐던 노드가 돌아오면서 혼자 epoch를 올려 멀쩡한 리더를 끌어내리는 문제는 PreVote가 막는다. 과반이 “지금 리더 잘 있는데?”라며 거절하기 때문이다. (KIP-996)
3-4. 컨트롤러 과반 상실 (3대 중 2대 다운)
- 과반이 없으니 리더를 못 뽑고 메타데이터 변경이 전부 멈춘다. 토픽 생성, 파티션 리더 재선출 같은 작업이 안 된다.
- 기존 파티션 리더들의 데이터 트래픽은 마지막 메타데이터 기준으로 계속 흐른다. 하지만 이때 브로커까지 죽으면 리더 재선출을 할 수 없다.
- 그래서 컨트롤러는 3대(1대 장애 허용) 또는 5대(2대 장애 허용) 로 구성한다.
참고: 스냅샷
- 메타데이터 로그가 무한히 커지지 않도록, 마지막 스냅샷 이후 20MB 또는 1시간 중 먼저 도달하는 시점에 스냅샷을 찍고 앞부분 로그를 정리한다. (
metadata.log.max.record.bytes.between.snapshots,metadata.log.max.snapshot.interval.ms) - 너무 뒤처진 노드는 로그 대신
FetchSnapshot으로 스냅샷부터 받아간다.
운영 관점 핵심 설정
process.roles=controller # broker | controller | broker,controller(combined: 개발용)
node.id=3000
controller.listener.names=CONTROLLER
controller.quorum.bootstrap.servers=c1:9093,c2:9093,c3:9093 # 동적 쿼럼(3.9+, KIP-853)
# controller.quorum.voters=3000@c1:9093,... # 정적 쿼럼(기존 방식)
metadata.version은 메타데이터 레코드 포맷 버전(feature flag)이다.kafka-features.sh로 올리고, 올린 뒤에는 되돌릴 수 없다고 보는 게 안전하다.
타임라인
| 버전 | 내용 |
|---|---|
| 2.8 (2021) | KRaft early access |
| 3.3 (2022) | KRaft production-ready |
| 3.9 (2024-11) | ZK를 지원하는 마지막 버전 = 마이그레이션 브리지 |
| 4.0 (2025-03) | ZooKeeper 완전 제거 |
결론 1. 현시점 추천 버전 조합
- Kafka 4.2.x + Kafka Connect 4.2.x (같은 버전), Java 17+(21 LTS 권장), KRaft
- 지원 중인 라인은 4.3.x / 4.2.x / 4.1.x 세 개다. 4.2는 충분히 성숙했고 지원 기간도 남아 있다.
- 작성 시점에
4.2.2-rc1이 투표 중이다. 릴리즈되면 4.2.2로 올리자. - 최신 기능이 필요한 신규 구축이면 4.3.1도 괜찮다. (
4.4.0-rc1도 투표 중)
- Connect는 Kafka 배포판에 포함되어 있으므로 브로커와 버전을 맞추는 게 가장 안전하다. 업그레이드 순서는 브로커 먼저, Connect 나중.
- 커넥터 플러그인(Debezium 등)이 Kafka 4.x 클라이언트를 지원하는지 꼭 확인한다. 4.0에서 2.1 미만 클라이언트 프로토콜이 제거됐다.
결론 2. 3.x → 4.x 업그레이드 경로
| 현재 | 경로 |
|---|---|
| 3.x ZooKeeper 모드 | 3.9.x로 업그레이드 → KRaft 마이그레이션 → 4.2.x |
| 3.3+ KRaft | 4.2.x로 바로 롤링 업그레이드 |
| 3.3 미만 KRaft | 3.3+ 경유 후 4.x |
- ZK 모드는 4.x로 바로 못 간다. 4.x 바이너리에는 ZK 모드도 마이그레이션 기능도 없기 때문이다.
- ZK → KRaft 마이그레이션 흐름 (3.9.x에서)
- 3.9.x로 롤링 업그레이드,
inter.broker.protocol.version=3.9 - KRaft 컨트롤러 쿼럼 신규 구성 (기존 ZK 클러스터 ID로 포맷,
zookeeper.metadata.migration.enable=true) - 브로커를 마이그레이션 모드로 롤링 재시작 → 메타데이터가 ZK에서 KRaft로 복사됨
- 브로커를 KRaft 모드로 롤링 재시작 (듀얼 라이트 유지, 아직 롤백 가능)
- 컨트롤러에서 ZK 설정 제거 = Finalize (이후 롤백 불가) → ZK 폐기
- 4.2.x로 롤링 업그레이드 (컨트롤러 → 브로커) 후
metadata.version상향
- 3.9.x로 롤링 업그레이드,
결론 3. 왜 “3.3 미만 KRaft”는 바로 못 가나?
- 4.x가 읽을 수 있는 최소
metadata.version이 3.3-IV3이기 때문이다. 3.3 미만 KRaft의 메타데이터 포맷은 4.x가 이해하지 못한다.// server-common/.../MetadataVersion.java public static final MetadataVersion MINIMUM_VERSION = IBP_3_3_IV3; QuorumController주석에도 “Starting with 3.3, this version must be set before the controller can fully initialize.”라고 되어 있다. 3.3이metadata.version체계가 확립되고 KRaft가 production-ready가 된 기준점이다.
댓글남기기