이 케이스에서 얻어 갈 것

상황

결제 알림 파이프라인입니다. payment-events 토픽은 파티션 12개, 복제 계수 3, 브로커 3대에서 일 200만 건을 받습니다. 결제 게이트웨이의 웹훅을 받아 이 토픽에 넣고, 알림·정산·리스크 세 서비스가 각각 소비합니다.

프로듀서 설정은 3년 전 다른 팀에서 복사해 온 것이었습니다. acks=1retries=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 응답이 남아 있었습니다.

case03 — acks=1 과 unclean leader election 이 겹쳐 유실이 확정되는 흐름 정상 흐름에서 프로듀서가 acks=1 로 보내면 파티션 리더가 자기 로그에 기록한 직후 성공으로 응답하므로 팔로워 복제 완료를 기다리지 않습니다. 어긋나는 지점에서 그 레코드가 아직 복제되지 않은 상태로 리더 브로커가 죽고, unclean.leader.election.enable 이 true 이면 ISR 에 없던 레플리카까지 리더가 될 수 있습니다. 결과적으로 새 리더의 로그에는 그 레코드가 없어 영구 유실되지만 프로듀서는 이미 성공 응답을 받았기 때문에 애플리케이션은 실패를 알지 못합니다. 처방은 acks=all 과 min.insync.replicas 2, replication.factor 3 조합을 쓰고 unclean.leader.election.enable 을 기본값 false 로 유지하는 것입니다. 1. 정상 흐름 acks=1 은 리더만 기록하면 성공입니다 프로듀서 acks=1 리더 로그에 append 즉시 성공 응답 팔로워는 아직 복제 중 acks=1 은 리더가 자기 로그에 쓰기만 하면 성공으로 응답합니다 — 복제 완료를 기다리지 않습니다. 공식 문서도 이 구간에서 리더가 죽으면 레코드가 사라진다고 명시합니다. 2. 어긋나는 지점 두 설정이 겹쳐야 사고가 됩니다 리더 브로커 장애 팔로워에 그 레코드 없음 unclean=true ISR 밖 레플리카가 리더 unclean.leader.election.enable=true 는 ISR 에 없는 레플리카도 마지막 수단으로 리더가 되도록 허용합니다. 기본값은 false 이며, 가용성을 위해 켜는 순간 데이터 유실을 허용한다는 뜻이 됩니다. 3. 결과 성공 응답을 받은 데이터가 사라집니다 새 리더 로그에 없음 영구 유실 프로듀서는 성공으로 알고 있음 새 리더가 기준이 되므로 그 뒤에 원래 리더가 복귀해도 자기 로그를 잘라내고 새 리더를 따릅니다. 애플리케이션 로그에는 아무 오류가 남지 않아 사후 추적이 매우 어렵습니다. 처방 내구성이 필요하면 acks=all · min.insync.replicas=2 · replication.factor=3 을 함께 씁니다. unclean.leader.election.enable 은 기본값 false 로 유지합니다 (KRaft 에서도 지원되는 설정입니다). 확인: kafka-topics.sh --bootstrap-server :9092 --describe --under-min-isr-partitions 감시: kafka.server:type=ReplicaManager,name=UnderMinIsrPartitionCount 를 0 으로 유지
acks=1 + unclean 리더 선출이 유실을 만드는 순서 — 정상 복제 구간, 리더만 기록한 구간, 그리고 뒤처진 레플리카가 리더가 되어 로그가 잘리는 시점

관측된 증상

메트릭이 어떻게 보였는가

브로커 로그 — ISR 축소

리더 브로커가 팔로워를 ISR에서 제거할 때 남기는 로그입니다. Partition 클래스가 출력하며 뒤처진 레플리카의 endOffsetlastCaughtUpTimeMs를 함께 찍어 줍니다.

kafka-2 server.log 03:14 — 팔로워 3이 ISR에서 빠집니다
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로 남깁니다. 소스 주석에도 그 이유가 적혀 있습니다. 즉 이 줄이 보이면 유실을 의심해야 합니다.

액티브 컨트롤러 로그 03:21 — 이 줄이 유실의 확정 증거입니다
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은 "이미 컨슈머에게 보였던 데이터를 버린다"는 뜻입니다.

kafka-2 server.log 03:22
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가 이를 감지합니다.

notification-service 로그 03:23
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)까지 보여 주므로 기본값과 오버라이드를 구분할 수 있습니다.

설정 조회 — 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가 무엇을 보장하는지 정리한다

acks 값별 보장 범위. 프로듀서 acks 기본값은 Kafka 3.0부터 all입니다.
응답 시점 리더 장애 시 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 클러스터에 있습니다.

docker-compose.yml — 3노드 KRaft, unclean 선출을 일부러 켠 구성
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'
재현 절차 — log-end-offset 이 감소하는 것을 확인하는 것이 목표입니다
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가 기본으로 일관성을 택했다고 명시합니다.

내구성 우선 / 가용성 우선 설정 조합의 결과 비교 (RF=3, 브로커 3대 기준)
상황 내구성 우선
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 소스에서 확인했습니다.