acks=all로 프로듀서 응답까지 받은, 분명히 "커밋된" 메시지가 리더 장애 한 번에 조용히 사라질 수 있다 — 적어도 Kafka 0.11 이전에는 그랬다. 어떻게 커밋된 데이터가 없어지고, 왜 High Watermark만으로는 이 문제를 못 막으며, Leader Epoch가 그 구멍을 어떻게 정확히 메웠는지를 복제 프로토콜의 오프셋 세 개부터 따라가 정리했다.
Kafka에서 하나의 파티션은 리더(leader) 하나와 여러 팔로워(follower) 복제본으로 이뤄진다. 각 복제본은 자기 로그에 대해 LEO(Log End Offset) 를 가진다. LEO는 "다음에 append될 오프셋", 즉 마지막 레코드 오프셋 + 1이다.
그 위에 High Watermark(HW) 가 있다. HW는 리더가 관리하는 값으로, 현재 ISR(In-Sync Replicas)에 속한 모든 복제본이 복제를 마친 오프셋의 경계다. 정확히는 ISR 복제본들의 LEO 중 최솟값이다.
HW = min(LEO of all replicas in ISR)
컨슈머는 HW 미만까지만 읽을 수 있다. HW 이상은 "아직 모든 ISR에 퍼지지 않아 유실 가능성이 있는" 구간이라 노출하지 않는다. 즉 HW가 곧 committed 여부의 경계다.
리더 로그: [0][1][2][3][4][5] LEO=6
팔로워B: [0][1][2][3] LEO=4
팔로워C: [0][1][2][3][4] LEO=5
ISR={L,B,C} → HW = min(6,4,5) = 4
컨슈머는 오프셋 0~3까지만 볼 수 있음
여기서 리더 LEO(6)와 HW(4)가 다르다는 점이 핵심이다. 리더가 방금 append한 레코드는 아직 어떤 팔로워도 받지 못했으니 HW 위에 떠 있고, ISR 전체가 따라잡아야 HW가 그 위로 올라간다. 그래서 리더 LEO ≥ HW는 항상 성립한다.
팔로워는 리더에게 Fetch 요청을 보낼 때 자기 LEO를 fetch offset으로 실어 보낸다. 이게 "나는 여기까지 받았다"는 ACK 역할을 겸한다. 별도의 ACK 채널이 없다는 점이 Kafka 복제의 단순하면서도 미묘한 부분이다.
Fetch(offset=4)를 보낸다 → 리더는 "B의 LEO=4"로 기록한다.여기에 뒤에 나올 사고의 씨앗이 되는 비대칭이 하나 있다.
팔로워의 HW는 리더의 HW보다 항상 한 fetch 라운드 늦다.
팔로워는 방금 올라간 HW를 알려면 다음 fetch 응답을 받아야 한다. 리더는 이미 HW를 올렸지만, 그 사실이 팔로워에 전파되기까지 한 왕복의 지연이 존재한다. 이 "HW 전파 지연"이 리더 교체 시 잘못된 truncation을 일으킨다.
acks=all은 이 그림 위의 프로듀서 응답 조건이다. 리더는 min.insync.replicas 이상의 ISR에 복제될 때까지 프로듀서에게 ack를 주지 않는다. 그래서 acks=all + min.insync.replicas>=2이면 "컨슈머가 볼 수 있는 데이터(HW 미만)는 최소 2벌 존재"가 보장된다 — 정상 동작에서는.
리더가 죽고 팔로워 하나가 새 리더로 승격하면, 옛 복제본들의 로그가 서로 어긋날 수 있다. Kafka 0.11 이전의 규칙은 단순했다.
새 리더에 붙는 팔로워는 자기 HW까지 로그를 자르고(truncate) 그 뒤를 리더에서 다시 받는다.
문제는 앞서 본 HW 전파 지연 때문에 팔로워가 기억하는 HW가 실제보다 낮을 수 있다는 것이다. 그러면 실제로는 커밋됐던 레코드를 팔로워가 "안 커밋된 것"으로 착각하고 잘라버린다.
① L(리더) LEO=2, HW=2 / F(팔로워) LEO=2, 하지만 F의 HW는 아직 1
L은 오프셋1을 ISR 전체가 받아 HW=2로 올렸지만,
그 HW=2가 F에 전파되기 전이라 F는 HW=1로 알고 있다
② L 다운. F가 새 리더로 승격 (F의 LEO=2)
③ 옛 L이 살아나 F에 붙는다. 규칙대로 옛 L은 자기 로그를 F의 상태에 맞추려 재조정
④ HW 기준으로 자르는 낡은 규칙 아래에서, F는 자기 HW(=1)까지 잘라 오프셋1을 버린다.
→ 커밋됐던 오프셋1이 사라지거나, F가 그 자리를 다른 내용으로 덮으면 두 로그가 조용히 분기(divergence)
근본 원인은 정보의 부족이다.
HW는 "높이(offset)" 정보일 뿐, 그 오프셋을 어느 리더가 썼는가는 담지 못한다.
오프셋 1이 옛 리더가 쓴 1인지 새 리더가 쓴 1인지 구분할 수 없으니, 잘라야 할지 유지해야 할지를 안전하게 판단할 수 없다. HW라는 단일 스칼라로는 리더 교체 경계에서 벌어지는 이력 충돌을 표현하지 못하는 것이다.
KIP-101이 도입한 해법은 오프셋 높이 대신 세대(generation) 로 자르는 것이다. Leader Epoch는 단조 증가하는 정수로, 리더가 새로 선출될 때마다 컨트롤러가 +1 해서 부여한다. 각 복제본은 로그와 별도로 leader-epoch-checkpoint 파일에 (epoch, 그 epoch가 처음 쓰기 시작한 오프셋) 쌍의 목록을 유지한다.
leader-epoch-checkpoint (예)
epoch=5 startOffset=0
epoch=6 startOffset=2 # epoch 6 리더가 오프셋 2부터 쓰기 시작
epoch=7 startOffset=5
리더 교체 후, 팔로워는 HW로 자르지 않고 OffsetsForLeaderEpoch 요청으로 자른다.
이러면 HW 전파 지연과 무관하게, 같은 epoch에서 리더와 팔로워가 공유하는 지점까지는 보존되고 분기가 시작된 지점만 정확히 잘린다. 자르는 기준이 "offset 높이"가 아니라 "세대 경계"이기 때문이다.
분기 예: 팔로워가 epoch=6에서 offset 5,6,7을 더 갖고 있는데
새 리더의 epoch=6 끝이 offset 5라면
→ 팔로워는 offset 5까지 유지, 6·7만 truncate
→ 그 위는 새 리더의 새 epoch 레코드로 채워진다 → 분기 제거
KIP-101만으로도 대부분 해결됐지만, 리더가 연달아 두 번 바뀌어 팔로워가 물어본 epoch를 새 리더가 아예 모르는(그 epoch를 건너뛴) 경우가 남았다. KIP-279는 이때 리더가 "그 epoch를 모르면 자기가 아는, 그보다 작은 epoch의 시작 오프셋을 돌려주도록" 해서, 팔로워가 더 아래로 안전하게 내려가 다시 맞추게 했다. 공식 KIP 설명에 따르면 이로써 어떤 리더 교체 시퀀스에서도 로그가 수렴한다.
개념이 실제 코드의 어디에 있는지 짚으면 흐림이 걷힌다. Kafka 소스를 따라가면 다음과 같이 알려져 있다.
| 관심사 | 위치 | 하는 일 |
|---|---|---|
| HW 전진 | kafka.cluster.Partition#maybeIncrementLeaderHW | ISR 각 복제본의 LEO를 모아 min으로 HW를 올림. ISR에서 빠진 느린 복제본은 제외되므로, ISR 축소가 HW를 전진시키는 것도 여기서 보인다 |
| truncation 경로 | kafka.server.ReplicaFetcherThread | 팔로워가 truncate 전에 OffsetsForLeaderEpoch를 호출하는 경로 |
| epoch 끝 조회 | LeaderEpochFileCache#endOffsetFor(epoch) | 주어진 epoch의 끝(다음 epoch 시작) 오프셋 반환 — KIP-101 truncation의 판단 지점 |
로그로도 관찰할 수 있는 개념 검증 시나리오다.
# 3-broker 클러스터, RF=3, min.insync.replicas=2
# 팔로워 하나를 network partition으로 격리하면
# → ISR에서 빠지고, HW는 남은 ISR의 min(LEO)로 계속 전진
# → 격리 복제본이 복귀하면 OffsetsForLeaderEpoch로 자기 로그를 맞춘 뒤 ISR 재합류
kafka-log-dirs.sh --describe ... # 각 replica의 LEO 확인
# 대상 토픽 파티션의 leader-epoch-checkpoint 파일을 열면
# (epoch, startOffset) 목록이 쌓이는 것을 직접 볼 수 있다
핵심을 한 줄로 남긴다.
HW는 "모든 ISR이 복제를 마친 오프셋"으로 컨슈머 가시성을 정하고, Leader Epoch는 "각 로그 구간을 어느 세대 리더가 썼는가"를 기록해, HW만 보고 자르던 방식의 손실·분기를 없앤다.
공부하며 바로잡은 오해 셋을 덧붙인다.
더 파고들 만한 주제로는 ① replica.lag.time.max.ms 기반 ISR 축소/확장 판정과 그것이 HW 전진·가용성에 주는 영향, ② unclean.leader.election.enable=true에서 ISR 밖 복제본이 리더가 되며 커밋 데이터가 날아가는 — Leader Epoch로도 못 막는 의도적 트레이드오프, ③ KRaft 모드에서 리더/epoch 메타데이터 자체가 Raft 로그로 관리될 때 이 그림이 어떻게 바뀌는지가 있다.
core/src/main/scala/kafka/cluster/Partition.scala, ReplicaFetcherThread, LeaderEpochFileCache