학습 목표

클러스터 구성 요소 — 브로커, 컨트롤러, 클라이언트

Kafka 클러스터는 서버 프로세스 여러 개와 그것에 붙는 클라이언트로 이루어집니다. Kafka 4.x에서 서버 프로세스가 맡는 역할은 process.roles 설정으로 정해지고 값은 broker, controller, 또는 둘 다인 broker,controller입니다. 역할 조합과 쿼럼 구성은 3장 KRaft와 클러스터 메타데이터에서 따로 다루고, 여기서는 "누가 무엇을 책임지는가"만 정리합니다.

KRaft 클러스터 전체 구조 — 컨트롤러 쿼럼, 브로커 3대, 클라이언트 위쪽은 컨트롤러 쿼럼입니다. controller 롤로 실행되는 노드 3대 가운데 하나가 액티브 컨트롤러이자 Raft 리더이고 나머지 둘은 팔로워이며, 메타데이터는 __cluster_metadata 내부 로그에 Raft 로 복제됩니다. 운영에서는 3대 또는 5대를 권장합니다. 가운데는 브로커 3대이며 토픽 orders 의 파티션 3개가 replication.factor 3 으로 배치되어 각 브로커가 하나의 리더와 두 개의 팔로워를 갖습니다. 브로커는 컨트롤러에서 메타데이터를 받아 가고, 쓰기와 읽기는 파티션 리더가 처리합니다. 아래쪽 프로듀서는 bootstrap.servers 로 접속해 메타데이터를 조회한 뒤 파티션 리더에 직접 전송하고, 컨슈머 그룹은 리더에서 fetch 하며 오프셋을 __consumer_offsets 에 커밋합니다. 클라이언트는 컨트롤러에 접속하지 않고 항상 브로커에 붙으며 ZooKeeper 는 4.0 에서 제거되었습니다. KRaft 클러스터 전체 구조 — 컨트롤러 쿼럼 · 브로커 · 클라이언트 컨트롤러 쿼럼 (process.roles=controller) — 권장 3대 또는 5대 controller-1 액티브 (Raft 리더) controller-2 팔로워 controller-3 팔로워 메타데이터는 __cluster_metadata 라는 내부 로그에 Raft 로 복제됩니다. 브로커는 컨트롤러에서 메타데이터를 받아 갑니다 (옵서버) 브로커 (process.roles=broker) — 토픽 orders · 파티션 3개 · replication.factor 3 broker-1 P0 리더 P1 팔로워 P2 팔로워 broker-2 P1 리더 P2 팔로워 P0 팔로워 broker-3 P2 리더 P0 팔로워 P1 팔로워 Producer bootstrap.servers 로 접속 → 메타데이터 조회 → 파티션 리더에 직접 전송 Consumer group 리더에서 fetch · 오프셋은 __consumer_offsets 에 커밋 초록색 = 파티션 리더. 쓰기와 읽기는 항상 리더가 처리하고 팔로워는 복제만 합니다. 클라이언트는 컨트롤러에 접속하지 않습니다 — 항상 브로커에 붙습니다. ZooKeeper 는 4.0 에서 제거되었습니다.
KRaft 기준 클러스터 전체 구조 — 브로커 3대와 컨트롤러 쿼럼, 그리고 Producer · Consumer · Admin 클라이언트가 각각 어디에 연결되는지

브로커 — 데이터 평면

브로커는 파티션의 로그를 디스크에 보관하고, 클라이언트의 읽기·쓰기 요청을 처리합니다. 저장 위치는 log.dirs로 지정하며 기본값은 /tmp/kafka-logs입니다. 기본값이 /tmp 아래라는 점은 실무에서 반드시 고쳐야 하는 부분입니다.

브로커는 자신이 리더인 파티션에 대해서만 쓰기를 받습니다. 팔로워 레플리카를 가진 파티션에 대해서는 리더로부터 데이터를 fetch해 복제합니다. 하나의 브로커는 어떤 파티션의 리더이면서 동시에 다른 파티션의 팔로워일 수 있고, 실제 운영 클러스터에서는 항상 그렇습니다.

컨트롤러 — 메타데이터 평면

컨트롤러는 클러스터 메타데이터를 관리합니다. 토픽·파티션 구성, 레플리카 배치, ISR 집합, 리더가 누구인지, 브로커가 살아 있는지가 모두 메타데이터입니다. 컨트롤러들은 쿼럼(quorum)을 이루고 그중 한 대가 활성(active) 컨트롤러, 나머지는 핫 스탠바이로 대기합니다. 공식 문서는 컨트롤러를 보통 3대 또는 5대 선택한다고 안내하며, 가용성을 유지하려면 과반이 살아 있어야 합니다.

브로커는 컨트롤러에게 주기적으로 하트비트를 보냅니다 (broker.heartbeat.interval.ms 기본 2000ms). 컨트롤러가 broker.session.timeout.ms(기본 9000ms) 안에 하트비트를 받지 못하면 그 브로커를 fenced 상태로 처리하고, 그 브로커가 리더였던 파티션의 리더를 다른 레플리카로 옮깁니다.

클라이언트 — Producer, Consumer, Admin

클라이언트 종류와 브로커·컨트롤러 접속 대상
클라이언트 하는 일 접속 대상
Producer 레코드를 파티션 리더에게 보냄 브로커 (bootstrap.servers)
Consumer 파티션에서 레코드를 읽고 오프셋을 커밋 브로커 (bootstrap.servers)
Admin 토픽 생성·삭제, 설정 변경, 그룹 조회 브로커, 필요 시 컨트롤러 (--bootstrap-controller)

클라이언트는 메타데이터로 리더를 찾습니다

클라이언트가 처음 접속할 때 쓰는 것은 bootstrap.servers입니다. 여기 적힌 브로커 중 하나에 붙어 메타데이터 응답을 받고, 그 응답에 담긴 "어느 파티션의 리더가 어느 브로커인가"를 근거로 이후 요청을 직접 리더에게 보냅니다. 메타데이터는 metadata.max.age.ms(기본 300000ms) 주기로 갱신되고, 리더가 바뀌었다는 에러를 받으면 즉시 다시 가져옵니다.

토픽 → 파티션 → 세그먼트 → 레코드

Kafka의 저장 구조는 4단 중첩입니다. 위에서부터 토픽은 논리적 이름, 파티션은 순서가 보장되는 append-only 로그, 세그먼트는 그 로그를 잘라 놓은 실제 파일, 레코드 배치는 세그먼트 안에 기록되는 물리 단위이고 그 안에 레코드가 하나 이상 들어갑니다.

토픽에서 파티션, 세그먼트, 레코드까지의 4단 중첩 구조 가장 바깥이 토픽 orders 이며 토픽은 논리적 이름일 뿐 물리 실체가 아닙니다. 그 안에 파티션 0 이 있고 파티션은 디스크의 디렉터리 orders-0 에 대응하며 순서 보장은 파티션 안에서만 성립합니다. 파티션 안에는 세그먼트가 여러 개 있고 각 세그먼트는 확장자가 .log, .index, .timeindex 인 파일 묶음으로 이루어집니다. 파일 이름은 그 세그먼트의 첫 오프셋인 베이스 오프셋이며, 마지막 세그먼트만 활성 세그먼트로 현재 쓰기가 일어나는 곳입니다. .index 는 오프셋에서 파일 위치를, .timeindex 는 타임스탬프에서 오프셋을 찾는 희소 인덱스입니다. 맨 아래에는 활성 세그먼트 안의 레코드가 오프셋 8192부터 8196까지 나열되고 다음 쓰기 위치가 LEO 임을 보여 줍니다. retention 삭제와 컴팩션은 세그먼트 단위로 일어나며 활성 세그먼트는 대상이 아닙니다. 토픽 → 파티션 → 세그먼트 → 레코드 — 4단 중첩 구조 토픽 orders (논리적 이름 · 물리 실체가 아님) 파티션 0 — 디렉터리 /var/lib/kafka/orders-0 (순서는 파티션 안에서만 보장) 00000000000000000000.log 00000000000000000000.index 00000000000000000000.timeindex 봉인됨 베이스 오프셋 0 00000000000000004096.log 00000000000000004096.index 00000000000000004096.timeindex 봉인됨 베이스 오프셋 4096 00000000000000008192.log 00000000000000008192.index 00000000000000008192.timeindex 활성 세그먼트 (지금 쓰는 곳) 베이스 오프셋 8192 파티션 1 · 파티션 2 도 같은 구조입니다 (그림에서는 생략). .index 는 오프셋 → 파일 위치, .timeindex 는 타임스탬프 → 오프셋을 찾는 희소 인덱스입니다. 활성 세그먼트 안의 레코드 8192 8193 8194 8195 8196 다음 쓰기 위치 = LEO 오프셋은 파티션 안에서만 순차적입니다. 세그먼트 파일 이름은 그 세그먼트의 첫 오프셋(베이스 오프셋)입니다. retention 삭제와 컴팩션은 세그먼트 단위로 일어나며 활성 세그먼트는 그 대상이 아닙니다.
토픽 → 파티션 → 세그먼트 → 레코드 배치 → 레코드의 4단 중첩 구조 — 파티션 디렉터리 이름과 .log · .index · .timeindex 파일까지

파티션은 디렉터리 하나입니다

공식 문서는 파티션이 두 개인 my-topic 토픽이 my-topic-0my-topic-1 두 디렉터리로 존재한다고 설명합니다. 디렉터리 이름은 {토픽명}-{파티션번호}입니다. 각 디렉터리 안에는 세그먼트 파일들이 들어 있습니다.

log.dirs 아래의 실제 구조 (파티션 24개 토픽의 일부)
/var/lib/kafka/data/
├── orders-0/
│   ├── 00000000000000000000.log        ← 세그먼트 데이터 (봉인됨)
│   ├── 00000000000000000000.index      ← 오프셋 → 파일 위치 인덱스
│   ├── 00000000000000000000.timeindex  ← 타임스탬프 → 오프셋 인덱스
│   ├── 00000000000000538211.log        ← 활성 세그먼트 (지금 append 중)
│   ├── 00000000000000538211.index
│   ├── 00000000000000538211.timeindex
│   └── leader-epoch-checkpoint
├── orders-1/
│   └── ...
└── meta.properties                     ← cluster.id, node.id, directory.id

세그먼트 파일 이름은 시작 오프셋입니다

공식 문서는 각 로그 파일이 그 파일이 담고 있는 첫 메시지의 오프셋으로 이름 붙는다고 명시합니다. 그래서 처음 만들어지는 파일이 00000000000000000000.log입니다. 오프셋으로 읽기 요청이 오면 브로커는 (1) 파일 이름을 이진 탐색해 해당 세그먼트를 찾고, (2) .index로 파일 안 위치를 좁힌 뒤 (3) 그 위치부터 읽습니다.

세그먼트가 롤링되는 기준은 크기와 시간입니다. log.segment.bytes의 기본값은 1073741824(1GiB)입니다. 세그먼트 단위 구조가 리텐션·컴팩션의 동작을 어떻게 좌우하는지는 7장 스토리지·리텐션·컴팩션에서 자세히 다룹니다.

활성 세그먼트는 하나뿐입니다

파티션마다 지금 append되고 있는 세그먼트가 정확히 하나 있고 이를 활성 세그먼트(active segment)라고 부릅니다. 나머지는 봉인된 상태입니다. 활성 세그먼트는 삭제와 컴팩션의 대상이 아닙니다. "리텐션을 1시간으로 줄였는데 디스크가 안 비워진다"는 신고의 대부분이 이 사실 때문입니다.

오프셋 4종 — 이 장에서 가장 중요한 절

"오프셋"이라는 한 단어가 실제로는 최소 네 가지 서로 다른 지점을 가리킵니다. 시험에서도, 장애 대응에서도 이 네 개를 섞어 쓰면 결론이 완전히 달라집니다. 아래 다이어그램의 슬라이더로 복제 진행과 컨슈머 커밋을 움직여 보면 네 지점이 어떻게 벌어지는지 보입니다.

오프셋 4종 구분 — log-start-offset · committed offset · high watermark · LEO 하나의 파티션 로그를 12칸 막대로 표현한 그림입니다. 리더 로그 위에 네 지점이 표시됩니다. log-start-offset은 리텐션으로 삭제된 경계, committed offset은 컨슈머 그룹이 커밋한 위치, high watermark는 ISR 내 모든 레플리카가 복제를 마쳐 컨슈머가 읽을 수 있는 상한, LEO는 리더가 기록한 마지막 위치입니다. committed offset과 high watermark 사이는 아직 읽지 않은 구간이고, high watermark와 LEO 사이는 복제가 끝나지 않아 컨슈머에게 보이지 않는 구간입니다. 아래에는 팔로워 두 대의 복제 진행 막대가 있으며, high watermark가 그중 최소 LEO에 맞춰지는 것을 점선으로 잇습니다. 리더 (broker-1) 의 파티션 로그 — orders-0 LEO 12 HW 7 0 1 2 3 4 5 6 7 8 9 10 11 리더 아직 안 읽음 3건 아직 복제 안 됨 5건 시작 0 커밋 4 팔로워 복제 진행 (ISR 멤버) — high watermark 는 이 중 최소 LEO 로 결정 팔로워 B LEO 9 팔로워 C LEO 7 high watermark = ISR 내 최소 LEO → 팔로워 C(7) 시작 = log-start-offset (리텐션 삭제 경계) 커밋 = committed offset (컨슈머 그룹) HW = high watermark (읽기 가능 상한) LEO = log end offset (리더 기록 끝) 읽고 커밋함 읽을 수 있음 복제 미완 · 안 보임 미기록 삭제됨 컨슈머는 high watermark 를 넘어서 읽지 못합니다.
오프셋 4종 구분 — log-start-offset · committed offset · high watermark · LEO를 하나의 파티션 로그 위에 배치하고, high watermark가 ISR 내 최소 LEO로 결정되는 과정을 표시

LEO와 high watermark

LEO(log end offset)는 로그에 다음에 쓰일 오프셋, 즉 마지막 레코드의 오프셋 + 1입니다. 리더의 LEO는 프로듀서가 쓴 순간 올라갑니다. 팔로워도 각자 자신의 LEO를 가지며, 복제가 뒤처지면 리더보다 작습니다.

high watermark컨슈머가 읽을 수 있는 상한입니다. 공식 문서는 "committed된 메시지만 컨슈머에게 주어진다"고 명시합니다. 즉 ISR 안의 모든 레플리카가 복제를 마친 지점까지만 노출됩니다. 리더가 방금 기록했지만 아직 복제되지 않은 구간(high watermark ~ LEO)은 컨슈머에게 존재하지 않는 것과 같습니다.

이 설계 덕분에 리더가 죽어도 컨슈머가 이미 읽은 데이터가 사라지는 일이 없습니다. 컨슈머가 읽은 데이터는 정의상 ISR 전체에 복제된 데이터이기 때문입니다.

committed offset — 컨슈머 쪽 진행 상황

committed offset은 컨슈머 그룹이 "여기까지 처리했다"고 기록해 둔 위치입니다. 브로커의 내부 토픽 __consumer_offsets에 저장되며, 로그 자체와는 아무 관계가 없습니다. 같은 파티션에 대해 컨슈머 그룹이 세 개면 committed offset도 세 개입니다.

여기서 용어 충돌이 하나 있습니다. Kafka는 복제 관점의 "committed message" (ISR 전체가 복제를 마친 메시지)와 컨슈머 관점의 "committed offset"을 같은 단어로 부릅니다. 공식 문서도 컨슈머 쪽을 "혼란스럽게도 committed offset이라 불린다"고 표현합니다. 문장에서 주체가 브로커인지 컨슈머 그룹인지를 먼저 확인하세요.

log-start-offset — 남아 있는 가장 오래된 지점

log-start-offset은 파티션에 아직 남아 있는 가장 작은 오프셋입니다. 리텐션으로 세그먼트가 삭제되면 이 값이 올라갑니다. 항상 0이 아니라는 점이 중요합니다.

컨슈머의 committed offset이 log-start-offset보다 작아지면 — 예를 들어 컨슈머가 3일 멈춰 있었고 리텐션이 1일이라면 — 그 오프셋은 더 이상 존재하지 않습니다. 이때 auto.offset.reset이 발동하고, 기본값이 latest이므로 중간 데이터를 조용히 건너뜁니다. 이 사고의 전말은 케이스 1에 있습니다.

4종 비교표

오프셋 4종 — 누가 소유하고, 언제 움직이고, 어디서 조회하는가
지표 의미 소유 주체 언제 움직이는가 조회 방법
log-start-offset 남아 있는 가장 작은 오프셋 파티션 (브로커) 리텐션으로 세그먼트가 삭제될 때 kafka-get-offsets.sh --time -2 / JMX kafka.log:type=Log,name=LogStartOffset
committed offset 컨슈머 그룹이 커밋한 위치 컨슈머 그룹 그룹이 커밋할 때 (그룹마다 별개) kafka-consumer-groups.sh --describe
high watermark 컨슈머가 읽을 수 있는 상한 파티션 리더 ISR 전체가 복제를 마칠 때 kafka-get-offsets.sh --time -1 (클라이언트 요청은 read_uncommitted 기준으로 high watermark를 반환)
LEO (log end offset) 다음에 쓰일 오프셋 각 레플리카 (리더·팔로워 각각) 레코드가 append될 때 JMX kafka.log:type=Log,name=LogEndOffset — 클라이언트 API로는 노출되지 않습니다

복제와 ISR

복제 단위는 토픽이 아니라 토픽-파티션입니다. 파티션마다 리더 하나와 팔로워 0개 이상이 있고, 모든 읽기·쓰기는 리더가 처리합니다. 팔로워는 리더에게 fetch 요청을 보내 데이터를 따라옵니다 — 즉 복제도 결국 하나의 컨슈머 동작입니다.

복제와 ISR — ISR 축소가 min.insync.replicas 에 걸리는 순간 replication.factor 3 인 파티션 orders-0 을 세 단계로 보여 줍니다. 1단계는 리더 broker-1 과 팔로워 broker-2, broker-3 이 모두 따라잡아 ISR 이 3 이고 acks=all 쓰기가 성공합니다. 2단계는 broker-3 이 replica.lag.time.max.ms 30000 밀리초 동안 따라오지 못해 ISR 에서 빠지고 ISR 이 2 로 줄지만 min.insync.replicas 가 2 이므로 아직 성공합니다. 3단계는 broker-2 까지 빠져 ISR 이 1 이 되어 min.insync.replicas 미달이 되고, acks=all 프로듀서는 NOT_ENOUGH_REPLICAS 오류를 받습니다. 이때도 리더는 살아 있으므로 컨슈머 읽기는 계속됩니다. replication.factor 는 3 그대로이고 줄어드는 것은 ISR 집합이라는 점이 핵심입니다. 파티션 orders-0 · replication.factor=3 · min.insync.replicas=2 · acks=all ① 정상 — ISR 3 리더 broker-1 · LEO 120 팔로워 broker-2 · LEO 120 팔로워 broker-3 · LEO 120 ISR = {1, 2, 3} → 크기 3 3 ≥ 2 (충족) 쓰기 성공 ISR 3대 전원이 응답 ② ISR 축소 — 아직 충족 리더 broker-1 · LEO 168 팔로워 broker-2 · LEO 168 팔로워 broker-3 · LEO 88 (지연) replica.lag.time.max.ms 초과 ISR = {1, 2} → 크기 2 2 ≥ 2 (경계, 충족) 쓰기 성공 · 여유 0 한 대만 더 빠지면 중단 ③ 미달 — 쓰기 거부 리더 broker-1 · LEO 168 팔로워 broker-2 · 다운 팔로워 broker-3 · 지연 ISR = {1} → 크기 1 1 < 2 (미달) 쓰기 거부 NOT_ENOUGH_REPLICAS 줄어드는 것은 ISR 집합입니다. replication.factor 는 계속 3 이고, 레플리카가 따라잡으면 ISR 에 다시 들어옵니다. 거부되는 것은 쓰기뿐입니다. 리더가 살아 있으므로 컨슈머는 high watermark 까지 계속 읽을 수 있습니다. ③ 을 피하려면 replication.factor=3 + min.insync.replicas=2 조합으로 레플리카 1대 손실을 허용합니다. 쓰기 성공 조건: ISR 크기 ≥ min.insync.replicas 이고, 그 ISR 전원이 응답. 지연 판정 기준은 30000ms 입니다.
복제와 ISR — 리더·팔로워 구조, ISR 집합, 팔로워가 replica.lag.time.max.ms를 넘겨 뒤처졌을 때 ISR이 축소되고 min.insync.replicas에 미달하는 과정

리더와 팔로워, 그리고 replicas 순서

kafka-topics.sh --describe는 파티션마다 Leader, Replicas, Isr 세 값을 보여줍니다.

정상 상태 — RF 3, ISR 3
$ bin/kafka-topics.sh --bootstrap-server localhost:9092 --topic foo --describe
Topic:foo	PartitionCount:1	ReplicationFactor:3	Configs:
  Topic: foo	Partition: 0	Leader: 5	Replicas: 5,6,7	Isr: 5,6,7

Replicas할당된 레플리카 전체이고 Isr은 그중 현재 따라오고 있는 것들입니다. Replicas첫 번째 항목이 preferred replica이며, 정상 상황에서는 그것이 리더가 됩니다(아래 preferred 리더 선출).

ISR에서 빠지는 조건

공식 문서는 in-sync 판정을 두 조건으로 설명합니다. 하나는 세션이 살아 있는가, 다른 하나는 리더의 로그 끝을 제한 시간 안에 따라잡는가입니다. 후자를 제어하는 것이 replica.lag.time.max.ms이고 기본값은 30000(30초)입니다. 이 시간 안에 리더의 로그 끝까지 따라오지 못한 레플리카는 리더가 ISR에서 제거합니다.

ISR이 바뀌면 그 사실이 클러스터 메타데이터에 기록됩니다. 그래서 ISR 축소·확대가 잦으면 메타데이터 쓰기가 늘고, 컨트롤러 부하로 이어집니다. 운영에서 IsrShrinksPerSec를 감시하는 이유입니다 (메트릭 치트시트).

Kafka는 과반 투표(majority vote) 방식을 쓰지 않고 ISR 방식을 씁니다. 공식 문서의 표현대로, 쓰기는 ISR 안의 모든 레플리카가 받아야 committed로 간주되고, ISR 멤버만 리더가 될 수 있습니다. 레플리카가 f+1개면 f대 장애까지 committed 메시지를 잃지 않습니다.

min.insync.replicasacks=all과만 맞물립니다

min.insync.replicas프로듀서가 acks=all(또는 -1)로 보냈을 때 쓰기가 성공하기 위해 필요한 최소 ISR 크기입니다(리더 포함). 기본값은 1이며 브로커 설정과 토픽 설정 양쪽에 존재합니다.

조건을 만족하지 못하면 프로듀서는 NotEnoughReplicasException 또는 NotEnoughReplicasAfterAppendException을 받습니다. 프로토콜 에러 코드로는 NOT_ENOUGH_REPLICAS(19)와 NOT_ENOUGH_REPLICAS_AFTER_APPEND(20)이며 둘 다 재시도 가능한 에러입니다.

공식 문서가 권장하는 조합은 RF 3 · min.insync.replicas 2 · acks=all입니다. 이 조합에서 각 값이 무엇을 보장하는지 정리하면 이렇습니다.

RF 3 토픽에서 ISR 크기 변화에 따른 acks=all 쓰기 결과
현재 ISR min.insync.replicas=1 min.insync.replicas=2 min.insync.replicas=3
3 (정상) 성공 — 3대 모두 ack 성공 — 3대 모두 ack 성공 — 3대 모두 ack
2 (1대 이탈) 성공 — 2대 ack 성공 — 2대 ack 거부 — NotEnoughReplicas
1 (리더만 남음) 성공 — 유실 위험 노출 거부 — NotEnoughReplicas 거부 — NotEnoughReplicas

unclean leader election — 가용성과 일관성의 교환

ISR에 있던 모든 레플리카가 죽으면 선택지는 둘뿐입니다.

  1. 기다린다 — ISR에 있던 레플리카가 돌아올 때까지 파티션은 사용 불가.
  2. 뒤처진 레플리카를 리더로 올린다 — 파티션은 살아나지만 그 레플리카에 없던 committed 메시지는 영구 손실.

이 선택을 정하는 것이 unclean.leader.election.enable이고 기본값은 false입니다. 즉 Kafka는 기본적으로 1번, 일관성을 선택합니다. 브로커 설정과 토픽 설정 모두에 존재합니다.

Eligible Leader Replicas (KIP-966)

Kafka 4.0부터 ELR(Eligible Leader Replicas)가 도입되었습니다. "strict min ISR" 규칙 덕분에 ISR 크기가 min.insync.replicas보다 작으면 high watermark가 전진하지 못합니다. 그렇다면 ISR에서 빠졌지만 데이터 유실 없이 리더가 될 수 있는 레플리카가 존재하게 되고, KRaft 컨트롤러가 그것들을 PartitionRecord의 ELR 필드에 기록해 둡니다.

공식 문서가 밝히는 리더 선출 순서입니다.

  1. ISR이 비어 있지 않으면 그중에서 고른다.
  2. ELR이 비어 있지 않으면 fenced되지 않은 것을 고른다.
  3. 마지막으로 알려진 리더가 unfenced면 그것을 고른다.

파티션 할당과 리더 선출

레플리카 배치와 랙 인식

토픽을 만들면 컨트롤러가 각 파티션의 레플리카를 브로커에 배분합니다. 목표는 두 가지입니다 — 레플리카가 고르게 퍼지는 것리더 수가 브로커별로 균등한 것입니다.

브로커에 broker.rack을 지정하면 랙 인식(rack awareness)이 켜집니다. 공식 문서는 이때 하나의 파티션이 min(랙 수, 복제 계수)개의 서로 다른 랙에 걸치도록 배치된다고 명시합니다. AWS 가용 영역 같은 그룹에도 그대로 적용됩니다.

preferred 리더와 자동 리밸런싱

브로커가 죽으면 그 브로커가 리더였던 파티션들의 리더십이 다른 레플리카로 넘어갑니다. 그 브로커가 돌아와도 자동으로 리더를 되찾지는 않습니다 — 모든 파티션에서 팔로워로 복귀하므로 클라이언트 트래픽을 전혀 받지 않습니다. 클러스터가 불균형해집니다.

그래서 Kafka는 preferred replica 개념을 씁니다. 레플리카 목록이 5,6,7이면 목록의 앞에 있는 5번이 preferred 리더입니다. auto.leader.rebalance.enable의 기본값이 true이므로 클러스터가 주기적으로 리더십을 preferred 레플리카로 되돌립니다. 점검 주기는 leader.imbalance.check.interval.seconds(기본 300초)입니다.

수동으로 즉시 실행할 수도 있습니다.

preferred 리더 선출 수동 실행
$ bin/kafka-leader-election.sh --bootstrap-server localhost:9092 \
    --election-type preferred --all-topic-partitions

controlled shutdown

브로커를 정상 종료하면 (1) 로그를 디스크에 동기화하고 (2) 자신이 리더인 파티션의 리더십을 다른 레플리카로 넘긴 뒤 종료합니다. controlled.shutdown.enable이 이 동작을 켭니다. 단 공식 문서는 해당 브로커의 모든 파티션에 살아 있는 다른 레플리카가 있을 때만 성공한다고 명시합니다. RF 1 토픽이 하나라도 있으면 controlled shutdown이 실패합니다.

레코드 구조 — key, value, timestamp, headers

레코드(record)는 Kafka가 저장하는 최소 단위이지만, 디스크에 혼자 저장되는 일은 없습니다. 공식 문서의 표현대로 "메시지는 항상 배치로 기록"되고, 그 배치를 레코드 배치(record batch)라고 부릅니다. 레코드가 하나뿐인 배치도 배치입니다.

레코드 구조 — key, value, timestamp, headers 와 브로커가 붙이는 메타데이터 왼쪽은 프로듀서가 만드는 ProducerRecord 입니다. key 는 파티션 결정과 컴팩션의 기준이 되는 바이트 배열, value 는 본문이며 null 이면 tombstone 이 됩니다. timestamp 를 비우면 전송 시각인 CreateTime 이 쓰이고, headers 는 키와 바이트 배열의 쌍을 여러 개 담을 수 있으며, partition 을 비우면 파티셔너가 결정합니다. 오른쪽은 컨슈머가 받는 ConsumerRecord 에서 브로커 쪽이 채우는 부분입니다. offset 은 파티션 리더가 append 하면서 부여하는 순번이고, timestampType 은 CreateTime 또는 LogAppendTime, leaderEpoch 는 리더가 바뀔 때마다 올라가는 세대 번호이며 직렬화된 key 와 value 의 바이트 크기도 함께 전달됩니다. key 와 value 는 브로커에게 단순한 바이트 배열이므로 스키마 검사는 브로커가 하지 않습니다. 레코드 구조 — 프로듀서가 채우는 것과 브로커가 붙이는 것 프로듀서가 만드는 부분 (ProducerRecord) key byte[] · 파티션 결정과 컴팩션의 기준 value byte[] · 본문. null 이면 tombstone timestamp long · 비우면 전송 시각(CreateTime) headers Iterable
· key=byte[] 쌍 여러 개
topic / partition partition 을 비우면 파티셔너가 결정 브로커·컨슈머 쪽에서 채워지는 부분 (ConsumerRecord) offset 브로커가 파티션 안에서 부여하는 순번 timestampType CreateTime 또는 LogAppendTime leaderEpoch 리더가 바뀔 때마다 올라가는 세대 번호 serializedKeySize 직렬화된 바이트 수 (Size 계열) serializedValueSize 역직렬화는 컨슈머의 Deserializer 가 key 와 value 는 브로커에게 그냥 바이트 배열입니다 — 스키마 검사는 브로커가 하지 않습니다. offset 은 프로듀서가 정할 수 없습니다. 파티션 리더가 append 하면서 부여합니다. value 가 null 인 레코드는 컴팩션 토픽에서 그 키의 삭제 표시(tombstone)로 쓰입니다.
레코드 구조 — 레코드 배치 헤더(baseOffset · producerId · baseSequence 등)와 그 안의 개별 레코드(key · value · timestamp delta · headers)

레코드가 담는 것

레코드의 구성 요소 (Apache Kafka 4.3 message format v2 기준)
필드 null 허용 역할
key 가능 파티션 결정의 근거이자 컴팩션의 기준. 없으면 순서 보장과 컴팩션의 근거가 사라집니다
value 가능 실제 페이로드. null value는 컴팩션 토픽에서 tombstone(삭제 표시)입니다
timestamp 불가 배치의 baseTimestamp에 대한 delta로 저장. 리텐션과 시간 기반 조회의 기준
headers value만 가능 키·값 쌍의 목록. 헤더 키는 non-null 보장, 헤더 값은 null 가능. 순서가 보존됩니다
offset 불가 브로커가 배치의 baseOffset + offsetDelta로 부여

배치 헤더가 왜 중요한가

레코드 배치 헤더에는 baseOffset, partitionLeaderEpoch, magic(현재 값 2), crc, attributes, producerId, producerEpoch, baseSequence 등이 들어갑니다. 여기서 세 가지가 다른 장과 직접 연결됩니다.

이 장에서 나온 설정

기본값은 Apache Kafka 4.3 공식 문서(Broker Configs / Topic Configs) 기준입니다. 전체 목록은 설정 치트시트를 보세요.

복제·내구성 관련 핵심 설정
설정 범위 기본값 설명 튜닝 포인트
default.replication.factor 브로커 1 자동 생성 토픽의 복제 계수 1이면 브로커 한 대 손실로 데이터가 사라집니다. 프로덕션 토픽은 명시 생성하고 3을 쓰세요
num.partitions 브로커 1 자동 생성 토픽의 파티션 수 소비 병렬성의 상한. 나중에 늘릴 수는 있지만 줄일 수 없고, 늘리면 키 분배가 바뀝니다
min.insync.replicas 브로커 · 토픽 1 acks=all 쓰기가 성공하기 위한 최소 ISR 크기(리더 포함) RF 3이면 2를 권장. RF와 같게 두면 브로커 1대 재시작에 쓰기가 멈춥니다
unclean.leader.election.enable 브로커 · 토픽 false ISR 밖 레플리카를 리더로 올릴지 여부 true는 가용성을 얻고 유실을 허용하는 선택입니다. 기본값 유지가 안전합니다
replica.lag.time.max.ms 브로커 30000 이 시간 안에 리더의 로그 끝을 따라오지 못한 레플리카를 ISR에서 제거 너무 짧으면 ISR이 자주 흔들려 메타데이터 쓰기가 늘고, 너무 길면 뒤처진 레플리카가 ISR에 남습니다
num.replica.fetchers 브로커 1 팔로워가 복제에 쓰는 fetcher 스레드 수 파티션이 많거나 처리량이 높아 복제가 뒤처질 때 늘립니다
replica.fetch.max.bytes 브로커 1048576 파티션당 복제 fetch 응답 최대 크기 절대 상한이 아닙니다 — 첫 배치가 이 값보다 커도 그대로 반환해 복제 진행을 보장합니다(This is not an absolute maximum). 값이 작으면 배치가 잘게 쪼개져 복제 처리량이 떨어질 뿐 멈추지는 않습니다
offsets.topic.replication.factor 브로커 3 __consumer_offsets의 복제 계수 브로커가 3대 미만인 개발 클러스터에서는 낮춰야 토픽이 생성됩니다
auto.leader.rebalance.enable 브로커 true 리더십을 preferred 레플리카로 자동 복구 끄면 브로커 재시작 후 리더가 편중된 상태가 유지됩니다
broker.rack 브로커 null 브로커가 속한 랙/가용 영역 식별자 지정하면 레플리카가 min(랙 수, RF)개 랙에 분산됩니다. 랙별 브로커 수를 같게 두세요
log.segment.bytes 브로커 1073741824 세그먼트 파일 롤링 크기 삭제는 세그먼트 단위이므로 이 값이 실제 보관량을 좌우합니다(7장)
message.max.bytes 브로커 1048588 허용되는 최대 레코드 배치 크기 (압축 후) 토픽의 max.message.bytes(기본 동일)가 오버라이드합니다
broker.session.timeout.ms 브로커 9000 컨트롤러가 브로커를 죽었다고 판정하는 시간 broker.heartbeat.interval.ms(기본 2000)와 함께 조정합니다

내구성을 최우선으로 할 때의 표준 조합은 다음과 같습니다. 프로듀서 쪽 설정의 의미는 4장에서 다룹니다.

무손실 지향 토픽 · 프로듀서 조합
# 토픽 (kafka-topics.sh --create --replication-factor 3 ... 로 생성)
min.insync.replicas=2
unclean.leader.election.enable=false

# 프로듀서 — 아래 세 개는 4.x 기본값이므로 명시하지 않아도 동일합니다
acks=all
enable.idempotence=true
max.in.flight.requests.per.connection=5

흔한 오해

시험 포인트 정리

확인 문제

단일 선택 · 복수 선택 · 연결형 · 순서 배열이 섞여 있습니다. 키보드만으로 완주할 수 있습니다 — 숫자키로 선택, Enter로 제출입니다.

공식 문서 출처

이 장의 설정 기본값과 동작 서술은 모두 아래에서 확인했습니다 (Apache Kafka 4.3 문서 기준).