실수 케이스 · 6
RF=3인데 브로커 1대 죽자 유실됐다
직전 사고의 교훈으로 프로듀서를 acks=all, enable.idempotence=true로
모두 고쳤습니다. 그런데도 브로커 한 대의 장애로 다시 유실이 났습니다.
아무도 손대지 않은 설정이 하나 남아 있었습니다 —
min.insync.replicas는 여전히 기본값 1이었습니다.
그 상태에서 acks=all은 acks=1과 같습니다.
이 케이스에서 얻어 갈 것
min.insync.replicas가 쓰기 거부라는 형태로는acks=all과만 상호작용한다는 것을 정확히 설명할 수 있습니다.acks설정과 무관하게 적용되는 부분(컨슈머 가시성)도 함께 구분할 수 있습니다.NotEnoughReplicasException과NotEnoughReplicasAfterAppendException의 차이와 브로커가 뱉는 실제 메시지를 압니다.RF와min.insync.replicas를 어떻게 조합해야 하는지, 그리고 같게 두면 왜 위험한지 압니다.
상황
회원 포인트 적립 파이프라인입니다. member-points 토픽은
파티션 18개, 복제 계수 3, 브로커 3대에서 일 90만 건의 적립·차감 이벤트를 받습니다.
포인트 잔액은 이 토픽을 소비해 만드는 원장이라 유실이 곧 금전 문제입니다.
반년 전 다른 토픽에서 유실 사고를 겪은 뒤 팀은 프로듀서 설정을 전면 점검했습니다. 결과는 정확했습니다.
acks=all
enable.idempotence=true
max.in.flight.requests.per.connection=5
delivery.timeout.ms=120000
compression.type=lz4
그런데 토픽 설정은 아무도 확인하지 않았습니다.
member-points는 3년 전 만들어진 그대로였습니다.
$ kafka-configs.sh --bootstrap-server kafka-1:9092 \
--entity-type topics --entity-name member-points --describe
Dynamic configs for topic member-points are:
retention.ms=1209600000 sensitive=false synonyms={DYNAMIC_TOPIC_CONFIG:retention.ms=1209600000}
# min.insync.replicas 가 없습니다 → 기본값 1 이 적용됩니다.
수요일 02:00, 정기 OS 패치를 위해 kafka-3을 계획대로 내렸습니다.
롤링 재시작이므로 문제가 없어야 했습니다. 그런데 02:04에
kafka-2에서 18초짜리 GC 일시 정지가 발생해 ISR에서 빠졌습니다.
그 순간 여러 파티션의 ISR이 리더 하나가 되었습니다.
min.insync.replicas=1이므로 브로커는 쓰기를 계속 받았고,
acks=all인 프로듀서도 "ISR 전원 = 리더 1대"의 기록만으로 성공 응답을 받았습니다.
02:06에 kafka-1의 EBS 볼륨이 분리되면서
02:04~02:06 사이의 12,447건이 사라졌습니다.
min.insync.replicas=1에서 acks=all이 무력화되는 과정 —
ISR 3일 때의 정상 쓰기, ISR이 1로 줄어든 뒤에도 성공 응답이 나가는 구간,
그리고 그 리더가 사라지는 시점
관측된 증상
메트릭이 어떻게 보였는가
UnderReplicatedPartitions: 02:00에 6(계획된 브로커 다운), 02:04에 18로 급등.-
UnderMinIsrPartitionCount: 끝까지 0이었습니다.min.insync.replicas=1이므로 ISR이 1이어도 "min ISR 미달"이 아닙니다. 이 지표에 알림을 걸어 두었는데도 울리지 않은 이유가 여기 있습니다. -
AtMinIsrPartitionCount: 02:04에 18로 급등. "ISR = min ISR"인 파티션 수이므로 이쪽이 울렸어야 했지만 알림 대상이 아니었습니다. - 프로듀서
record-error-rate: 0. 단 한 건도 실패하지 않았습니다. - 포인트 원장 대조: 02:04~02:06 구간 12,447건 누락.
브로커 로그
INFO [Partition member-points-3 broker=1] Shrinking ISR from 1,2 to 1. Leader: (highWatermark: 41288052, endOffset: 41288231). Out of sync replicas: (brokerId: 2, endOffset: 41287004, lastCaughtUpTimeMs: 1761789871442).
그리고 그다음에 아무 로그도 나오지 않습니다.
min.insync.replicas=1이므로 브로커는 쓰기를 거부할 이유가 없고,
경고를 남길 이유도 없습니다. 이 조용함이 이 사고의 특징입니다.
제대로 설정했다면 나왔을 로그
min.insync.replicas=2였다면 브로커는 쓰기를 거부하고
프로듀서에게 오류를 반환했을 것입니다.
브로커가 던지는 NotEnoughReplicasException의 메시지는 상황을 그대로 설명합니다.
WARN [Producer clientId=points-writer-1] Got error produce response with correlation id 88213 on topic-partition member-points-3, retrying (2147483646 attempts left). Error: NOT_ENOUGH_REPLICAS
# 재시도가 delivery.timeout.ms 를 넘기면 최종 실패로 콜백에 전달됩니다
org.apache.kafka.common.errors.NotEnoughReplicasException: The size of the current ISR : 1 is insufficient to satisfy the min.isr requirement of 2 for partition member-points-3, live replica(s) broker.id are : [1]
원인 분석
1단계 — 공식 정의를 정확히 읽는다
공식 설명을 문장 단위로 나누면 상호작용 범위가 분명해집니다.
| 공식 서술 | 의미 |
|---|---|
"프로듀서가 acks를 all(또는 -1)로 설정했을 때 쓰기가 성공하기 위해 필요한 최소 in-sync 레플리카 수(리더 포함)" |
쓰기 거부는 acks=all일 때만 일어납니다. acks=0·acks=1에는 이 설정이 적용되지 않습니다. |
"acks=all인 경우 모든 in-sync 레플리카가 쓰기를 ack 해야 성공으로 간주된다. RF=3이고 ISR에 3개가 다 있으면 min.insync.replicas가 3보다 작아도 3개 모두 ack 해야 한다" |
min.insync.replicas는 하한선일 뿐입니다. "2만 받으면 된다"는 뜻이 아닙니다. |
"acks=all이고 현재 ISR이 min.insync.replicas보다 적으면 프로듀서가 NotEnoughReplicas 또는 NotEnoughReplicasAfterAppend 예외를 받는다" |
예외 이름이 두 개인 이유는 append 전에 걸리는 경우와 append 후에 걸리는 경우가 나뉘기 때문입니다. |
"acks 설정과 무관하게, 메시지는 모든 in-sync 레플리카에 복제되고 min.insync.replicas 조건이 충족될 때까지 컨슈머에게 보이지 않는다" |
여기는 acks와 무관합니다. 컨슈머 가시성(high watermark 전진)에는 acks=1이어도 이 조건이 걸립니다. |
2단계 — 두 예외의 차이
| 예외 | 에러 코드 / 프로토콜 메시지 | 발생 시점 |
|---|---|---|
NotEnoughReplicasException |
NOT_ENOUGH_REPLICAS (19)"Messages are rejected since there are fewer in-sync replicas than required." |
로그에 쓰기 전 — ISR 크기 검사에서 거부. 데이터가 로그에 들어가지 않습니다. |
NotEnoughReplicasAfterAppendException |
NOT_ENOUGH_REPLICAS_AFTER_APPEND (20)"Messages are written to the log, but to fewer in-sync replicas than required." |
로그에 쓴 뒤 — append는 됐지만 필요한 수만큼 복제되지 않아 성공으로 인정하지 못함. |
3단계 — RF와 min ISR의 조합
min.insync.replicas를 올리면 안전해지지만, 너무 올리면 가용성이 사라집니다.
| RF | min ISR | 견딜 수 있는 브로커 손실 | 평가 |
|---|---|---|---|
| 3 | 1 | 2대 (쓰기는 계속되지만 무보장) | 위험 — 이 케이스의 설정. acks=all이 무력화됩니다 |
| 3 | 2 | 1대 — 쓰기 정상, 유실 없음 | 권장 — 공식 문서가 제시하는 전형적 시나리오 |
| 3 | 3 | 0대 — 1대만 내려가도 쓰기 중단 | 과도 — 롤링 재시작조차 못 합니다 |
| 2 | 2 | 0대 | 과도 — RF=2에서는 안전과 가용성을 동시에 얻을 수 없습니다 |
| 5 | 3 | 2대 | 고가용 — 브로커 5대 이상 클러스터의 중요 토픽 |
4단계 — 내부 토픽은 이미 안전하게 기본 설정되어 있다
여기서 팀이 놓친 힌트가 있었습니다. Kafka는 자기 내부 토픽에는 이미 강한 기본값을 씁니다.
| 설정 | 기본값 | 대상 |
|---|---|---|
offsets.topic.replication.factor | 3 | __consumer_offsets |
transaction.state.log.replication.factor | 3 | __transaction_state |
transaction.state.log.min.isr | 2 | __transaction_state |
share.coordinator.state.topic.replication.factor | 3 | __share_group_state |
share.coordinator.state.topic.min.isr | 2 | __share_group_state |
min.insync.replicas | 1 | 일반 토픽 (브로커·토픽 기본값) |
재현 방법
3노드 KRaft 클러스터가 필요합니다. 아래 compose는 Apache Kafka의 공식 3노드 combined 모드 예제를 YAML 앵커로 축약한 것입니다 (자세한 구성은 예제 1).
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'
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'
acks=all로 두 토픽의 반응 차이를 봅니다docker compose up -d
K=/opt/kafka/bin
# 1. min ISR 을 다르게 준 토픽 두 개를 만든다
docker exec -it kafka-1 $K/kafka-topics.sh --create --topic minisr-1 \
--partitions 1 --replication-factor 3 --bootstrap-server localhost:9092
docker exec -it kafka-1 $K/kafka-topics.sh --create --topic minisr-2 \
--partitions 1 --replication-factor 3 --config min.insync.replicas=2 \
--bootstrap-server localhost:9092
# 2. 브로커 두 대를 내려 ISR 을 리더 하나로 만든다
docker stop kafka-2 kafka-3
docker exec -it kafka-1 $K/kafka-topics.sh --describe --bootstrap-server localhost:9092 \
--topic minisr-1 --topic minisr-2
# → 둘 다 Isr: 1
# 3. acks=all 로 minisr-1 (min ISR=1) 에 쓴다 → 성공합니다. 이게 사고의 원인입니다.
echo "danger" | docker exec -i kafka-1 $K/kafka-console-producer.sh \
--topic minisr-1 --bootstrap-server localhost:9092 \
--producer-property acks=all
# → 아무 오류 없이 끝납니다.
# 4. 같은 acks=all 로 minisr-2 (min ISR=2) 에 쓴다 → 거부됩니다.
echo "safe" | docker exec -i kafka-1 $K/kafka-console-producer.sh \
--topic minisr-2 --bootstrap-server localhost:9092 \
--producer-property acks=all --producer-property delivery.timeout.ms=10000
# → NOT_ENOUGH_REPLICAS 로 재시도한 뒤 타임아웃으로 실패합니다:
# org.apache.kafka.common.errors.NotEnoughReplicasException: The size of the current ISR : 1
# is insufficient to satisfy the min.isr requirement of 2 for partition minisr-2-0, ...
# 5. acks=1 로 minisr-2 에 쓴다 → 성공합니다.
# min.insync.replicas 는 쓰기 거부 측면에서 acks=all 과만 상호작용합니다.
echo "acks-1-bypass" | docker exec -i kafka-1 $K/kafka-console-producer.sh \
--topic minisr-2 --bootstrap-server localhost:9092 \
--producer-property acks=1
# → 성공. 이것이 "min ISR 만 설정해도 안 되는" 이유입니다.
# 6. 브로커를 복구하고 각 토픽의 데이터를 확인한다
docker start kafka-2 kafka-3
sleep 20
docker exec -it kafka-1 $K/kafka-console-consumer.sh --topic minisr-2 \
--from-beginning --timeout-ms 8000 --bootstrap-server localhost:9092
# → "safe" 는 없고 "acks-1-bypass" 만 있습니다.
해결
즉시 조치 — 토픽 설정을 동적으로 올린다
min.insync.replicas는 동적 토픽 설정이므로 재시작 없이 즉시 적용됩니다.
재배포도 필요 없습니다. 장애 대응 중이라면 이것이 가장 빠른 조치입니다.
# 1. 개별 토픽에 적용
kafka-configs.sh --bootstrap-server kafka-1:9092 \
--entity-type topics --entity-name member-points \
--alter --add-config min.insync.replicas=2
# 2. RF 가 2 이하인 토픽에 2를 걸면 즉시 쓰기가 멈춥니다. 먼저 RF 를 확인하세요.
kafka-topics.sh --bootstrap-server kafka-1:9092 --describe \
| awk '/^Topic:/ {print $1, $2, $6, $8}'
# 3. min ISR 이 없는 토픽 목록을 뽑는다 (점검용)
for t in $(kafka-topics.sh --bootstrap-server kafka-1:9092 --list | grep -v '^__'); do
v=$(kafka-configs.sh --bootstrap-server kafka-1:9092 \
--entity-type topics --entity-name "$t" --describe --all \
| grep -o 'min.insync.replicas=[0-9]*' | head -1)
echo "$t $v"
done
# 4. 클러스터 기본값도 올린다 (신규 토픽에 적용)
kafka-configs.sh --bootstrap-server kafka-1:9092 \
--entity-type brokers --entity-default \
--alter --add-config min.insync.replicas=2
근본 해결 — 프로듀서·토픽·알림을 한 세트로 관리한다
프로듀서는 완벽했지만 토픽 설정이 반쪽이었습니다. 알림도 감지하지 못하는 지표에 걸려 있었습니다.
# producer — 문제 없음
acks=all
enable.idempotence=true
# 토픽 member-points
retention.ms=1209600000
# min.insync.replicas 없음 → 1
# 알림
# UnderMinIsrPartitionCount > 0 ← min ISR=1 이면 절대 울리지 않음
세 가지를 한 세트로 봅니다.
프로듀서 acks=all + 토픽 min.insync.replicas=2 + RF 3.
알림은 실제로 울리는 지표에 겁니다.
# producer
acks=all
enable.idempotence=true
delivery.timeout.ms=120000 # ISR 회복을 기다릴 시간
# 토픽 member-points
retention.ms=1209600000
min.insync.replicas=2 # RF=3 이므로 브로커 1대 손실을 견딘다
# 브로커 (동적 기본값 — 신규 토픽 보호)
min.insync.replicas=2
unclean.leader.election.enable=false # 4.x 기본값. 오버라이드하지 않는다
# 알림
# UnderReplicatedPartitions > 0
# UnderMinIsrPartitionCount > 0
# AtMinIsrPartitionCount > 0 ← 이게 조기 경보 역할을 합니다
# OfflinePartitionsCount > 0
애플리케이션이 실패를 제대로 다루게 만든다
min.insync.replicas=2를 켜면 브로커 2대가 내려간 동안 쓰기가 실패합니다.
이것은 의도된 동작이지만, 애플리케이션이 그 실패를 삼키면 결국 같은 유실이 됩니다.
producer.send(record, (metadata, exception) -> {
if (exception == null) {
return;
}
if (exception instanceof RetriableException) {
// delivery.timeout.ms 안에서는 클라이언트가 이미 재시도했습니다.
// 여기까지 왔다면 그 시간이 끝난 것입니다.
metrics.counter("kafka.produce.retriable_exhausted").increment();
}
// 유실을 만들지 않으려면 로컬에 보존하고 나중에 재전송해야 합니다.
outbox.save(record); // 예: DB outbox 테이블
log.error("produce failed, saved to outbox: topic={} key={}",
record.topic(), record.key(), exception);
});
DLQ·재시도·outbox 패턴의 구현은 예제 6 · DLQ + 재시도 패턴에 있습니다.
예방 체크리스트
시험 포인트
이어서 볼 곳
공식 문서 출처
- Topic Configs —
min.insync.replicas— 기본값 1,acks=all과의 상호작용, 두 예외,acks와 무관한 컨슈머 가시성 조건, RF=3 / min ISR=2 /acks=all전형 시나리오 - Broker Configs —
min.insync.replicas— 브로커 기본값 1, cluster-wide 동적 변경 가능 - Producer Configs —
acks—all이 "현재 ISR 전원"을 의미한다는 정의 - Broker Configs — 내부 토픽 복제 설정 —
transaction.state.log.min.isr=2 등 - Monitoring —
UnderReplicatedPartitions/UnderMinIsrPartitionCount/AtMinIsrPartitionCount의 정의와 정상값 - Operations — Adding and removing topics — 토픽 생성 시 설정 지정
- Apache Kafka 소스 (4.3) —
Partition의 ISR 검사 조건(inSyncSize < minIsr && requiredAcks == -1)과NotEnoughReplicasException메시지,Errors의 에러 코드 19·20