실수 케이스 · 3
브로커 장애 후 메시지가 유실됐다
복제 계수 3에 브로커 3대. 그중 한 대가 디스크 장애로 내려갔을 뿐인데
결제 알림 이벤트 4만여 건이 사라졌습니다.
프로듀서는 성공 응답을 받았고 애플리케이션 로그에는 실패 기록이 하나도 없었습니다.
원인은 acks=1과 unclean.leader.election.enable=true라는
서로 무관해 보이는 두 설정의 조합이었습니다.
이 케이스에서 얻어 갈 것
acks=1이 "리더가 자기 로그에 썼다"까지만 보장하고 복제를 보장하지 않는다는 것을 설명할 수 있습니다.unclean.leader.election.enable의 Kafka 4.3 기본값과, 그것을true로 바꿀 때 무엇을 포기하는지 알 수 있습니다.- ISR 축소·unclean 리더 선출·로그 트렁케이션이 로그에 어떻게 남는지 읽을 수 있습니다.
- 4.x의 Eligible Leader Replicas(KIP-966)가 이 문제를 어디까지 완화하는지 압니다.
상황
결제 알림 파이프라인입니다. payment-events 토픽은
파티션 12개, 복제 계수 3, 브로커 3대에서 일 200만 건을 받습니다.
결제 게이트웨이의 웹훅을 받아 이 토픽에 넣고, 알림·정산·리스크 세 서비스가 각각 소비합니다.
프로듀서 설정은 3년 전 다른 팀에서 복사해 온 것이었습니다.
acks=1과 retries=3이 명시되어 있었고 아무도 다시 검토하지 않았습니다.
더 중요한 것은 클러스터 쪽입니다. 운영팀은 예전 런북에 따라
"가용성 우선"이라는 이유로 unclean.leader.election.enable=true를
클러스터 전역 동적 설정으로 켜 두고 있었습니다.
# producer.properties (결제 웹훅 수신기)
acks=1
retries=3
enable.idempotence=false # acks=1 을 쓰려고 명시적으로 껐음
# 브로커 동적 설정 (클러스터 전역)
unclean.leader.election.enable=true
# 토픽 payment-events
# min.insync.replicas 미설정 → 기본값 1
화요일 03:12, kafka-3의 데이터 디스크에서 I/O 오류가 발생했습니다.
브로커 프로세스는 살아 있었지만 로그 디렉터리 쓰기가 느려졌고 03:14에 ISR에서 빠졌습니다.
03:19에 프로세스가 죽었고, 03:21에는 kafka-1도 같은 스토리지 볼륨 그룹 문제로 재시작됐습니다.
03:23에 클러스터는 정상으로 복귀했습니다. 알림은 다시 나갔고 lag도 정상이었습니다.
문제는 09:00 정산 대조에서 드러났습니다.
03:10~03:21 사이 결제 게이트웨이가 보낸 웹훅 중 41,882건이 payment-events에 없었습니다.
게이트웨이 전송 로그에는 전부 200 응답이 남아 있었습니다.
acks=1 + unclean 리더 선출이 유실을 만드는 순서 —
정상 복제 구간, 리더만 기록한 구간, 그리고 뒤처진 레플리카가 리더가 되어 로그가 잘리는 시점
관측된 증상
메트릭이 어떻게 보였는가
UnderReplicatedPartitions: 03:14에 0에서 4로, 03:19에 12로 올라갔습니다. 이 지표에는 알림이 걸려 있었고 정상적으로 울렸습니다.OfflinePartitionsCount: 03:21~03:22 사이 짧게 3까지 올랐습니다.ActiveControllerCount: 항상 1. 컨트롤러 쿼럼 자체는 정상이었습니다.- 파티션 log-end-offset: 이것이 결정적입니다.
payment-events-7의 log-end-offset이 03:21에 88213904에서 88208442로 감소했습니다. 오프셋은 단조 증가해야 하므로 줄어들었다면 로그가 잘렸다는 뜻입니다. - 프로듀서
record-error-rate: 0. 프로듀서는 실패를 하나도 겪지 않았습니다.
브로커 로그 — ISR 축소
리더 브로커가 팔로워를 ISR에서 제거할 때 남기는 로그입니다.
Partition 클래스가 출력하며 뒤처진 레플리카의 endOffset과
lastCaughtUpTimeMs를 함께 찍어 줍니다.
INFO [Partition payment-events-7 broker=2] Shrinking ISR from 2,1,3 to 2,1. Leader: (highWatermark: 88210110, endOffset: 88213904). Out of sync replicas: (brokerId: 3, endOffset: 88208442, lastCaughtUpTimeMs: 1761023641118).
INFO [Partition payment-events-4 broker=2] Shrinking ISR from 2,3,1 to 2,1. Leader: (highWatermark: 88109770, endOffset: 88112390). Out of sync replicas: (brokerId: 3, endOffset: 88106001, lastCaughtUpTimeMs: 1761023641131).
컨트롤러 로그 — unclean 리더 선출
여기가 핵심입니다. KRaft 컨트롤러는 clean 선출은 TRACE로만 남기고, unclean 선출은 데이터 유실을 유발할 수 있으므로 반드시 INFO로 남깁니다. 소스 주석에도 그 이유가 적혀 있습니다. 즉 이 줄이 보이면 유실을 의심해야 합니다.
INFO [QuorumController id=2] Setting new leader for topicId hR9pQ2vLT4ac-5Wm0eXpZQ, partition 7 to 3 using an unclean election. Previous partition: PartitionRegistration(replicas=[2, 1, 3], isr=[2], leader=2, leaderEpoch=41, ...), change record: PartitionChangeRecord(partitionId=7, topicId=hR9pQ2vLT4ac-5Wm0eXpZQ, isr=[3], leader=3, leaderRecoveryState=1, ...)
팔로워 로그 — 트렁케이션
기존 리더였던 브로커 2가 복귀하면 새 리더(브로커 3)를 따라야 하므로 자기 로그를 잘라냅니다.
UnifiedLog가 아래 형태로 남깁니다.
high watermark 아래로 자르는 WARN은 "이미 컨슈머에게 보였던 데이터를 버린다"는 뜻입니다.
WARN [UnifiedLog partition=payment-events-7, dir=/var/lib/kafka/data] Truncating payment-events-7 to offset 88208442 below high watermark 88210110
INFO [UnifiedLog partition=payment-events-7, dir=/var/lib/kafka/data] Truncating to offset 88208442
컨슈머 로그 — 오프셋이 범위를 벗어난다
이미 88210500까지 읽고 커밋한 컨슈머는 로그가 88208442로 잘린 뒤
자기 커밋 오프셋이 로그 끝보다 큰 상태가 됩니다. FetchCollector가 이를 감지합니다.
INFO [Consumer clientId=notifier-1, groupId=payment-notifier] Fetch position FetchPosition{offset=88210500, offsetEpoch=Optional[41], currentLeader=LeaderAndEpoch{leader=Optional[kafka-3:9092 (id: 3 rack: null)], epoch=42}} is out of range for partition payment-events-7, resetting offset
원인 분석
1단계 — 토픽 상태를 본다
kafka-topics --describe는 4.x에서 Elr·LastKnownElr 컬럼을 함께 출력합니다.
ISR이 리더 하나로 줄어든 파티션이 바로 보입니다.
Isr이 한 대뿐인 파티션이 위험 구간입니다$ kafka-topics.sh --bootstrap-server kafka-1:9092 --describe --topic payment-events
Topic: payment-events TopicId: hR9pQ2vLT4ac-5Wm0eXpZQ PartitionCount: 12 ReplicationFactor: 3 Configs: retention.ms=604800000
Topic: payment-events Partition: 0 Leader: 1 Replicas: 1,2,3 Isr: 1,2,3 Elr: N/A LastKnownElr: N/A
Topic: payment-events Partition: 4 Leader: 3 Replicas: 2,3,1 Isr: 3 Elr: N/A LastKnownElr: N/A
Topic: payment-events Partition: 7 Leader: 3 Replicas: 2,1,3 Isr: 3 Elr: N/A LastKnownElr: N/A
$ # 위험 파티션만 골라 보는 옵션
$ kafka-topics.sh --bootstrap-server kafka-1:9092 --describe --under-replicated-partitions
$ kafka-topics.sh --bootstrap-server kafka-1:9092 --describe --unavailable-partitions
$ kafka-topics.sh --bootstrap-server kafka-1:9092 --describe --at-min-isr-partitions
2단계 — 그 설정이 정말 켜져 있었는지 확인한다
unclean.leader.election.enable은 클러스터 전역 동적 설정으로도, 토픽별로도 지정할 수 있습니다.
둘 다 확인해야 합니다. --all을 붙이면 값의 출처(synonyms)까지 보여 주므로
기본값과 오버라이드를 구분할 수 있습니다.
$ kafka-configs.sh --bootstrap-server kafka-1:9092 \
--entity-type brokers --entity-default --describe
Dynamic configs for broker default are:
unclean.leader.election.enable=true sensitive=false synonyms={DYNAMIC_DEFAULT_BROKER_CONFIG:unclean.leader.election.enable=true}
$ kafka-configs.sh --bootstrap-server kafka-1:9092 \
--entity-type topics --entity-name payment-events --describe --all
All configs for topic payment-events are:
min.insync.replicas=1 sensitive=false synonyms={DEFAULT_CONFIG:min.insync.replicas=1}
unclean.leader.election.enable=true sensitive=false synonyms={DYNAMIC_DEFAULT_BROKER_CONFIG:unclean.leader.election.enable=true, DEFAULT_CONFIG:unclean.leader.election.enable=false}
3단계 — acks가 무엇을 보장하는지 정리한다
| 값 | 응답 시점 | 리더 장애 시 | min.insync.replicas 상호작용 |
|---|---|---|---|
0 |
소켓 버퍼에 넣은 즉시. 서버 응답을 기다리지 않음 | 유실. retries도 동작하지 않음(실패를 알 수 없음). 반환 오프셋은 항상 -1 |
없음 |
1 |
리더가 자기 로그에 쓴 직후 | 복제되지 않은 구간 유실. 프로듀서는 이미 성공을 받아 재시도하지 않음 | 없음 |
all (= -1)기본값 |
현재 ISR의 모든 레플리카가 기록한 뒤 | 미응답 구간은 재시도로 복구 가능. 응답을 받은 것은 다른 ISR 멤버에도 존재 | 있음 — ISR이 부족하면 쓰기 거부 |
4단계 — 4.x의 Eligible Leader Replicas
재현 방법
3노드 KRaft 클러스터가 필요합니다. 아래 compose는 Apache Kafka가 배포하는 공식 3노드 combined 모드 예제를 YAML 앵커로 축약한 것입니다. 더 자세한 구성은 예제 1 · 로컬 KRaft 클러스터에 있습니다.
x-kafka-env: &kenv
KAFKA_PROCESS_ROLES: 'broker,controller'
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT'
KAFKA_CONTROLLER_QUORUM_VOTERS: '1@kafka-1:9093,2@kafka-2:9093,3@kafka-3:9093'
KAFKA_LISTENERS: 'PLAINTEXT://:19092,CONTROLLER://:9093,PLAINTEXT_HOST://:9092'
KAFKA_INTER_BROKER_LISTENER_NAME: 'PLAINTEXT'
KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER'
CLUSTER_ID: '4L6g3nShT-eMCtK--X86sw'
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 3
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 3
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 2
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
KAFKA_LOG_DIRS: '/tmp/kraft-combined-logs'
# 재현 포인트 — 4.x 기본값은 false 입니다. 일부러 켭니다.
KAFKA_UNCLEAN_LEADER_ELECTION_ENABLE: 'true'
services:
kafka-1:
image: apache/kafka:4.3.1
hostname: kafka-1
container_name: kafka-1
ports: ['29092:9092']
environment:
<<: *kenv
KAFKA_NODE_ID: 1
KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://kafka-1:19092,PLAINTEXT_HOST://localhost:29092'
kafka-2:
image: apache/kafka:4.3.1
hostname: kafka-2
container_name: kafka-2
ports: ['39092:9092']
environment:
<<: *kenv
KAFKA_NODE_ID: 2
KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://kafka-2:19092,PLAINTEXT_HOST://localhost:39092'
kafka-3:
image: apache/kafka:4.3.1
hostname: kafka-3
container_name: kafka-3
ports: ['49092:9092']
environment:
<<: *kenv
KAFKA_NODE_ID: 3
KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://kafka-3:19092,PLAINTEXT_HOST://localhost:49092'
docker compose up -d
K=/opt/kafka/bin
# 1. RF=3, min.insync.replicas 는 기본값 1
docker exec -it kafka-1 $K/kafka-topics.sh --create --topic loss-demo \
--partitions 1 --replication-factor 3 --bootstrap-server localhost:9092
docker exec -it kafka-1 $K/kafka-topics.sh --describe --topic loss-demo \
--bootstrap-server localhost:9092
# → Leader 와 Isr 를 기록합니다. 아래는 Leader=1, Isr=1,2,3 을 가정합니다.
# 2. acks=1 로 5000건 적재
seq 1 5000 | docker exec -i kafka-1 $K/kafka-console-producer.sh --topic loss-demo \
--bootstrap-server localhost:9092 --producer-property acks=1
# 3. 팔로워 두 대를 내린다 → ISR 이 리더 하나로 줄어든다
docker stop kafka-2 kafka-3
docker exec -it kafka-1 $K/kafka-topics.sh --describe --topic loss-demo \
--bootstrap-server localhost:9092
# → Isr: 1
# 4. 리더만 살아 있는 상태에서 4000건 더 적재
# acks=1 이므로 프로듀서는 전부 성공을 받습니다. 어디에도 복제되지 않습니다.
seq 5001 9000 | docker exec -i kafka-1 $K/kafka-console-producer.sh --topic loss-demo \
--bootstrap-server localhost:9092 --producer-property acks=1
docker exec -it kafka-1 $K/kafka-get-offsets.sh --topic loss-demo \
--bootstrap-server localhost:9092
# → loss-demo:0:9000
# 5. 리더를 내리고 뒤처진 팔로워만 올린다
docker stop kafka-1
docker start kafka-2
sleep 60 # unclean 선출 스레드 주기를 기다립니다
docker logs kafka-2 2>&1 | grep -i "unclean election"
# → Setting new leader for topicId ..., partition 0 to 2 using an unclean election. ...
# 6. 오프셋을 다시 본다 — 줄어들어 있습니다
docker exec -it kafka-2 $K/kafka-get-offsets.sh --topic loss-demo \
--bootstrap-server localhost:9092
# → loss-demo:0:5000 (4000건이 사라졌습니다)
# 7. 예전 리더를 복귀시키면 트렁케이션 로그가 남습니다
docker start kafka-1
docker logs kafka-1 2>&1 | grep -i "Truncating"
해결
즉시 조치 — unclean 선출을 끈다
장애 대응 중이라면 순서가 중요합니다. 먼저 unclean 선출을 끄고, 그다음에 프로듀서 설정을 고칩니다. 반대로 하면 그사이 또 유실됩니다.
# 1. 클러스터 전역 오버라이드를 삭제해 기본값(false)으로 되돌린다
kafka-configs.sh --bootstrap-server kafka-1:9092 \
--entity-type brokers --entity-default \
--alter --delete-config unclean.leader.election.enable
# 2. 확인 — DEFAULT_CONFIG:false 만 남아야 합니다
kafka-configs.sh --bootstrap-server kafka-1:9092 \
--entity-type topics --entity-name payment-events --describe --all
# 3. 중요한 토픽에는 min.insync.replicas 를 명시한다
kafka-configs.sh --bootstrap-server kafka-1:9092 \
--entity-type topics --entity-name payment-events \
--alter --add-config min.insync.replicas=2
# 4. 리더 쏠림을 정리한다 (preferred leader 로 복귀)
kafka-leader-election.sh --bootstrap-server kafka-1:9092 \
--election-type preferred --all-topic-partitions
근본 해결 — 프로듀서와 토픽을 같이 고친다
프로듀서는 복제를 기다리지 않고, 브로커는 뒤처진 레플리카를 리더로 승격할 수 있습니다. 어느 한쪽만 고쳐도 유실은 남습니다.
# producer
acks=1
retries=3
enable.idempotence=false
# 토픽 payment-events
min.insync.replicas=1 # 기본값 방치
# 브로커 (동적 기본값)
unclean.leader.election.enable=true
프로듀서는 ISR 전원의 기록을 기다리고, 브로커는 ISR이 2 미만이면 쓰기를 거부하고, 뒤처진 레플리카는 리더가 되지 못합니다.
# producer — 4.x 기본값이 이미 안전한 조합입니다
acks=all
enable.idempotence=true
max.in.flight.requests.per.connection=5
delivery.timeout.ms=120000
# retries 는 명시하지 않습니다 (기본 2147483647 + delivery.timeout.ms 로 통제)
# 토픽 payment-events
min.insync.replicas=2 # RF=3 에서 브로커 1대 손실을 견딘다
# 브로커
unclean.leader.election.enable=false # 4.x 기본값. 오버라이드하지 않는다
무손실 프로듀서 설정 조합의 전체 배경은 4장 Producer 심화와 예제 3 · 안전한 Producer 설정에 있습니다. 복제·ISR의 원리는 2장 아키텍처와 핵심 개념을 보세요.
가용성과 일관성의 실제 트레이드오프
unclean.leader.election.enable=false는 공짜가 아닙니다.
ISR의 모든 레플리카가 내려가면 그 파티션은 리더가 없어 읽기·쓰기 모두 불가가 됩니다.
공식 설계 문서는 이를 "가용성과 일관성의 단순한 트레이드오프"라고 설명하며,
Kafka가 기본으로 일관성을 택했다고 명시합니다.
| 상황 | 내구성 우선acks=all, min.insync=2, unclean=false |
가용성 우선acks=1, min.insync=1, unclean=true |
|---|---|---|
| 브로커 1대 손실 | 정상 동작 (ISR 2 유지) | 정상 동작 |
| 브로커 2대 손실 | 쓰기 거부 — NotEnoughReplicasException. 프로듀서가 재시도하며 버팀 |
쓰기 계속. 그 구간은 복제 없음 → 유실 위험 |
| 뒤처진 레플리카만 남음 | 파티션 오프라인. 데이터는 보존 | 그 레플리카가 리더. 차이만큼 유실 + 트렁케이션 |
| 복구 후 데이터 | 커밋된 것은 모두 존재 | 커밋되었던 것도 사라질 수 있음 |
예방 체크리스트
시험 포인트
이어서 볼 곳
공식 문서 출처
설정 기본값·설계 설명은 Apache Kafka 4.3 문서에서, 로그 문자열은 4.3 소스에서 확인했습니다.
- Broker Configs —
unclean.leader.election.enable— 기본값false, KRaft 동적 활성화 시 선출 스레드 주기 주의사항 - Topic Configs —
unclean.leader.election.enable— 토픽 레벨 기본값도false - Producer Configs —
acks— 0/1/all 각각의 보장 범위 - Broker Configs —
replica.lag.time.max.ms— 기본값 30000 - Design — Unclean leader election — 0.11.0.0부터 일관성 우선을 기본으로 선택
- Operations — Eligible Leader Replicas — KIP-966, 4.1 이후 기본 활성화, 선출 순서,
min.insync.replicas제약 - Monitoring —
UnderReplicatedPartitions,UnderMinIsrPartitionCount,AtMinIsrPartitionCount,OfflinePartitionsCount - Apache Kafka 소스 (4.3) —
Partition(ISR 축소),PartitionChangeBuilder(unclean 선출),UnifiedLog(트렁케이션),FetchCollector의 로그 문자열