이 케이스에서 얻어 갈 것

상황

회원 포인트 적립 파이프라인입니다. member-points 토픽은 파티션 18개, 복제 계수 3, 브로커 3대에서 일 90만 건의 적립·차감 이벤트를 받습니다. 포인트 잔액은 이 토픽을 소비해 만드는 원장이라 유실이 곧 금전 문제입니다.

반년 전 다른 토픽에서 유실 사고를 겪은 뒤 팀은 프로듀서 설정을 전면 점검했습니다. 결과는 정확했습니다.

producer.properties — 이 부분은 문제가 없었습니다
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건이 사라졌습니다.

case06 — min.insync.replicas 를 1 로 둔 채 acks=all 을 믿다가 유실되는 흐름 정상 흐름에서 replication.factor 3, acks=all, min.insync.replicas 1 조합은 겉보기에 가장 강한 설정처럼 보입니다. 어긋나는 지점에서 팔로워 두 대가 지연되어 ISR 에서 빠지면 ISR 이 리더 한 대만 남지만, min.insync.replicas 가 1 이므로 쓰기는 계속 성공합니다. 이때 성공한 쓰기는 사실상 acks=1 과 같습니다. 결과적으로 리더가 죽으면 그 구간이 유실되고, 프로듀서는 acks=all 로 성공 응답을 받았기 때문에 사고를 인지하지 못합니다. 처방은 min.insync.replicas 를 2 로 올려 ISR 이 부족할 때 쓰기가 거부되게 하고 UnderMinIsrPartitionCount 를 감시하는 것입니다. 1. 정상 흐름 설정만 보면 가장 안전해 보입니다 RF 3 · ISR 3 acks=all ISR 전원 응답 후 성공 유실 없음 acks=all 은 "현재 ISR 전원"의 응답을 기다립니다 — ISR 이 3이면 3대 모두입니다. min.insync.replicas 는 그 ISR 이 최소 몇 개여야 쓰기를 받아 줄지의 하한선입니다. 2. 어긋나는 지점 ISR 이 줄어드는 순간이 문제입니다 팔로워 2대 지연 ISR = {리더} 1개 min.insync=1 이라 통과 사실상 acks=1 ISR 이 1로 줄어도 min.insync.replicas=1 이면 조건을 만족하므로 쓰기가 계속 성공합니다. 이 구간의 데이터는 어느 팔로워에도 복제되어 있지 않습니다. 3. 결과 acks=all 인데도 유실됩니다 리더 장애 복제 안 된 구간 소실 프로듀서는 성공으로 인지 문서상 acks=all 은 "ISR 중 하나라도 살아 있으면 유실 없음"인데, ISR 이 1이면 그 하나가 곧 리더입니다 — 리더가 죽으면 남는 것이 없습니다. min.insync.replicas 를 함께 올리지 않은 acks=all 은 보장을 만들지 못합니다. 처방 replication.factor 3 · min.insync.replicas 2 · acks=all 을 한 세트로 설정합니다. min.insync=2 면 ISR 부족 시 NotEnoughReplicas 로 쓰기가 거부됩니다. 유실 대신 가용성을 희생하는 선택이라는 점을 팀과 합의해 둡니다. 감시: UnderMinIsrPartitionCount 와 IsrShrinksPerSec 를 알람에 넣습니다. 확인: kafka-topics.sh --bootstrap-server :9092 --describe --at-min-isr-partitions
min.insync.replicas=1에서 acks=all이 무력화되는 과정 — ISR 3일 때의 정상 쓰기, ISR이 1로 줄어든 뒤에도 성공 응답이 나가는 구간, 그리고 그 리더가 사라지는 시점

관측된 증상

메트릭이 어떻게 보였는가

브로커 로그

kafka-1 server.log 02:04 — ISR이 리더 하나로 줄어듭니다
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단계 — 공식 정의를 정확히 읽는다

공식 설명을 문장 단위로 나누면 상호작용 범위가 분명해집니다.

min.insync.replicas의 동작 (기본값 1, 브로커·토픽 양쪽에서 설정 가능)
공식 서술 의미
"프로듀서가 acksall(또는 -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단계 — 두 예외의 차이

ISR 부족 시 발생하는 두 예외 (둘 다 RetriableException)
예외 에러 코드 / 프로토콜 메시지 발생 시점
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.insync.replicas 조합별 결과 (acks=all 기준)
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는 자기 내부 토픽에는 이미 강한 기본값을 씁니다.

내부 토픽 관련 브로커 설정 기본값 (Apache Kafka 4.3)
설정기본값대상
offsets.topic.replication.factor3__consumer_offsets
transaction.state.log.replication.factor3__transaction_state
transaction.state.log.min.isr2__transaction_state
share.coordinator.state.topic.replication.factor3__share_group_state
share.coordinator.state.topic.min.isr2__share_group_state
min.insync.replicas1일반 토픽 (브로커·토픽 기본값)

재현 방법

3노드 KRaft 클러스터가 필요합니다. 아래 compose는 Apache Kafka의 공식 3노드 combined 모드 예제를 YAML 앵커로 축약한 것입니다 (자세한 구성은 예제 1).

docker-compose.yml — 3노드 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'

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는 동적 토픽 설정이므로 재시작 없이 즉시 적용됩니다. 재배포도 필요 없습니다. 장애 대응 중이라면 이것이 가장 빠른 조치입니다.

즉시 조치 — 중요 토픽에 min ISR 을 심는다
# 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대가 내려간 동안 쓰기가 실패합니다. 이것은 의도된 동작이지만, 애플리케이션이 그 실패를 삼키면 결국 같은 유실이 됩니다.

콜백에서 실패를 반드시 다룬다 — 삼키면 min ISR 설정이 무의미해집니다
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 + 재시도 패턴에 있습니다.

예방 체크리스트

시험 포인트

공식 문서 출처