# Kafka

브로커가 빠지면 min.insync.replicas는 왜 쓰기를 막을까?

@Hong2026-10-01

이 글에 담긴 코드와 결과는 직접 실행해 측정한 값을 바탕으로 작성했습니다. 실측은 Hong과 팀원들이 진행했고, 그 결과를 바탕으로 AI가 글을 쓰고 Hong이 검토합니다.

실습 코드 내려받기

질문
Kafka에서 브로커 두 대가 빠지면 왜 NOT_ENOUGH_REPLICAS로 쓰기가 거절될까?
답
복제본 3, min.insync.replicas=2인 토픽은 브로커 한 대가 빠져도 acks=all 쓰기가 성공했지만, 두 대가 빠지자 재시도 37번이 모두 Not enough in-sync replicas로 거절됐다.
조건
2026-10-01, Kafka 4.3.1 브로커 3대와 컨트롤러 1대, confluent-kafka 2.15.1, replica.lag.time.max.ms=3000, 파티션 1개와 복제본 3개

환경

측정일
2026년 10월 1일
Kafka
4.3.1(apache/kafka:4.3.1) 브로커 3대(k1, k2, k3)와 컨트롤러 1대(c1), Docker
클라이언트
Python confluent-kafka 2.15.1
브로커 설정
replica.lag.time.max.ms=3000, ELR 켜짐
토픽
파티션 1개, 복제본 3개(orders, mi1, mi2)
목차
  1. 실습 준비
  2. 실습 1: 브로커 한 대가 빠져도 쓰기는 성공합니다
  3. 실습 2: 두 대가 빠지면 NOT_ENOUGH_REPLICAS로 거절합니다
  4. 실습 3: 같은 장애에서 min.insync.replicas=1이면
  5. 정리

이번 시간에는 토픽의 min.insync.replicas 설정이 브로커 장애 때 쓰기를 어떻게 거절하는지를, 복제본 3개와 min.insync.replicas=2인 토픽에서 브로커를 한 대씩 빼 가며 확인하고, min.insync.replicas=1이면 같은 장애에서 무엇이 달라지는지 비교하며 배워가는 시간입니다.

예를 들어서 주문 서비스가 Kafka에 이벤트를 보내다가 갑자기 NOT_ENOUGH_REPLICAS 오류를 받기 시작했다고 해볼게요. 마침 브로커 세 대 중 두 대에 문제가 생긴 시간이었어요.

이때 거절을 결정한 설정이 바로 토픽의 min.insync.replicas예요.

acks 값마다 성공 응답의 뜻이 어떻게 다른지 궁금하면 acks를 다룬 글을 참고하세요.

실습 준비

이 글의 명령은 모두 실습 코드의 kafka-min-insync-replicas/ 디렉터리에서 실행합니다.

  • compose.yaml: 브로커 세 대와 컨트롤러 한 대 정의
  • produce.py: 번호를 붙인 메시지를 보내고 성공한 메시지를 acked-<토픽>.txt에 적는 프로듀서
  • read.py: 파티션을 high watermark(복제가 끝나 컨슈머에게 보이는 마지막 위치)까지 읽어 성공 목록과 비교하는 컨슈머

compose.yaml로 브로커 세 대(k1, k2, k3)와 컨트롤러 한 대(c1)를 따로 띄울게요. 브로커와 컨트롤러를 겸하는 노드 세 대로 구성하면, 두 대를 내렸을 때 컨트롤러 쿼럼도 과반을 잃어요. 그러면 ISR(리더를 따라잡고 있는 복제본 목록) 변경을 컨트롤러가 확정하지 못해서, 이 글에서 보려는 "ISR이 줄어든 상태"를 만들 수 없거든요.

  • KAFKA_REPLICA_LAG_TIME_MAX_MS: 팔로워가 이 시간보다 오래 뒤처지면 리더가 ISR에서 빼요. 기본값은 30초인데 실습 시간을 줄이려고 3초로 낮췄습니다.
  • 고정 IP(ipv4_address): 브로커를 Docker 네트워크에서 떼어 냈다가 같은 IP와 이름으로 다시 붙이려고 고정했습니다.
  • KAFKA_JMX_PORT: 브로커 지표를 JMX로 읽으려고 열어 둔 포트예요. 이 글에서는 쓰지 않아요.

이제 컨테이너를 띄우고 셸 함수를 만들어 놓을게요.

  • kafka_at, kafka: 지정한 노드(기본 k1) 컨테이너 안에서 Kafka 도구를 실행해요.
  • desc: 토픽의 파티션 상태를 한 줄로 보여 줍니다.
  • isolate, rejoin: 브로커를 Docker 네트워크에서 떼어 내고, 같은 IP와 이름으로 다시 붙여요. 떼어 낸 브로커는 프로세스가 살아 있어도 다른 노드와 주고받을 수 없습니다.
docker compose -p kmisr up -d
export COMPOSE_PROJECT_NAME=kmisr

kafka_at() { local n=$1; shift; docker compose exec -T "$n" /opt/kafka/bin/"$@"; }

kafka() { kafka_at k1 "$@"; }

desc() {
  kafka_at "${2:-k1}" kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic "$1" | tail -n +2 | sed $'s/^\t//'
}

isolate() { for n in "$@"; do docker network disconnect kmisr_kafka "kmisr-$n-1"; done; }

rejoin() { for n in "$@"; do docker network connect --ip "172.30.21.1${n#k}" --alias "$n" kmisr_kafka "kmisr-$n-1"; done; }

python3 -m venv venv && source venv/bin/activate
pip install confluent-kafka

프로듀서 스크립트 produce.py는 m-001처럼 번호를 붙인 메시지를 보내고, 결과가 올 때마다 걸린 시간과 offset을 출력해요. 마지막 인자로는 전송 제한 시간(librdkafka의 message.timeout.ms, Java 클라이언트의 delivery.timeout.ms)을 정해요.

librdkafka는 브로커가 돌려준 오류를 바로 애플리케이션에 넘기지 않고 내부에서 재시도합니다. 그 과정을 보려고 디버그 로그(debug=msg)를 켜고, 브로커 오류가 담긴 줄만 골라 처음 한 번 출력한 뒤 횟수를 세도록 했어요. 비교를 단순하게 하려고 멱등성은 껐습니다.

컨슈머 쪽은 read.py로 봐요. 파티션 0을 처음부터 high watermark까지 읽고, 성공을 받은 메시지 목록과 비교해서 토픽에 없는 메시지를 셉니다.

실습 1: 브로커 한 대가 빠져도 쓰기는 성공합니다

--replica-assignment 1:2:3으로 파티션 1개, 복제본 3개인 orders 토픽을 만들고 min.insync.replicas=2를 줄게요. 목록의 첫 번째 브로커가 선호 리더라서 k1이 리더가 됩니다.

여기에 acks=all로 3개를 보낸 다음, k3을 내리고 ISR에서 k3이 빠질 때까지 기다린 뒤 3개를 더 보냅니다.

kafka kafka-topics.sh --bootstrap-server k1:9092 --create --topic orders --replica-assignment 1:2:3 --config min.insync.replicas=2
desc orders
python produce.py orders all 1 3
docker compose stop k3
until desc orders | grep -qE 'Isr: 1,2[[:space:]]'; do sleep 1; done
desc orders
python produce.py orders all 4 3
Created topic orders.
Topic: orders	Partition: 0	Leader: 1	Replicas: 1,2,3	Isr: 1,2,3	Elr: 	LastKnownElr:
  0.2s m-001 성공 offset=0
  0.2s m-002 성공 offset=1
  0.2s m-003 성공 offset=2
Topic: orders	Partition: 0	Leader: 1	Replicas: 1,2,3	Isr: 1,2	Elr: 	LastKnownElr:
  0.1s m-004 성공 offset=3
  0.1s m-005 성공 offset=4
  0.1s m-006 성공 offset=5

ISR이 1,2로 줄었지만 min.insync.replicas=2를 채우니까 쓰기는 바로 성공해요. 이때 리더는 ISR에 남은 k1과 k2 둘을 기다립니다.

실습 2: 두 대가 빠지면 NOT_ENOUGH_REPLICAS로 거절합니다

이번에는 k2를 네트워크에서 떼어 낼게요.

프로세스를 내리는 대신 연결을 끊어서, 리더가 k2의 장애를 바로 알지 못하는 상황을 만드는 거예요. 떼어 내자마자 m-007 하나를 전송 제한 시간 60초로 보내고, 10초 뒤 상태를 본 다음 프로듀서가 끝나기를 기다립니다.

isolate k2
python produce.py orders all 7 1 60 > produce-orders.out &
sleep 10
desc orders
wait
cat produce-orders.out
Topic: orders	Partition: 0	Leader: 1	Replicas: 1,2,3	Isr: 1	Elr: 2	LastKnownElr:
 30.1s   브로커 응답: Broker: Request timed out (actions Retry,MsgPossiblyPersisted)
 30.2s   브로커 응답: Broker: Not enough in-sync replicas (actions Retry,MsgNotPersisted)
        Broker: Request timed out (actions Retry,MsgPossiblyPersisted): 1번
        Broker: Not enough in-sync replicas (actions Retry,MsgNotPersisted): 37번
 60.2s m-007 실패: _MSG_TIMED_OUT

ISR이 리더 k1 하나로 줄었어요. Elr: 2는 k2가 ISR에서 빠졌지만 ELR(Eligible Leader Replicas), 곧 리더 후보 목록에는 남아 있다는 뜻입니다.

  • 첫 요청: 30.1초 만에 Request timed out이 왔어요. librdkafka는 이 경우를 MsgPossiblyPersisted, 곧 저장됐을 수도 있는 상태로 분류합니다. 30초는 librdkafka의 request.timeout.ms 기본값이에요.
  • 재시도: 그 뒤 37번 보낸 요청은 모두 Not enough in-sync replicas로 거절됐습니다. 이번에는 MsgNotPersisted, 곧 저장되지 않았다고 분류해요.

그런데 애플리케이션은 이 오류를 언제 알게 될까요?

NOT_ENOUGH_REPLICAS는 재시도할 수 있는 오류라서, 클라이언트는 이 오류를 애플리케이션에 넘기지 않고 전송 제한 시간까지 다시 보내요. 60초가 다 되자 그제야 실패가 보고됐고, 애플리케이션이 받은 오류 이름은 librdkafka의 타임아웃(_MSG_TIMED_OUT)이었습니다.

그래서 애플리케이션의 오류 처리만 보고 있으면 원인이 ISR 부족이라는 것을 놓치기 쉽습니다. 브로커 쪽 로그에는 거절한 이유가 그대로 남아 있어요.

docker compose logs k1 | grep -B1 -m1 "NotEnoughReplicasException"
[2026-10-01 02:15:50,806] ERROR [ReplicaManager broker=1] Error processing append operation on partition 3uDuzLQuQhawbXST5AwFsA:orders-0 (kafka.server.ReplicaManager)
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 orders-0, live replica(s) broker.id are : Set(1)

현재 ISR 크기 1이 min.isr 요구치 2에 못 미친다는 내용이에요. 같은 문장이 k1 로그에 37번 남았고, 프로듀서의 재시도 횟수와 같습니다.

출력에서는 로그 줄 앞의 컨테이너 이름을 잘라 냈어요.

ISR = 1, 2, 3셋 모두 받으면 성공ISR = 1, 2둘 모두 받으면 성공ISR = 1NOT_ENOUGH_REPLICAS로 거절k3 중지k2 분리
acks=all은 그 순간의 ISR 전체를 기다립니다. min.insync.replicas는 쓰기를 받아 줄 ISR 크기의 바닥입니다.

이제 k2를 다시 붙이고 k3도 켜서 모든 복제본을 되돌려 놓을게요. 그 사이에 orders를 한 번 읽어 봅니다.

rejoin k2
until desc orders | grep -qE 'Isr: (1,2|2,1)[[:space:]]'; do sleep 1; done
desc orders
python read.py orders 9521
docker compose start k3
until desc orders | grep -qE 'Isr: [0-9],[0-9],[0-9][[:space:]]'; do sleep 1; done
desc orders
Topic: orders	Partition: 0	Leader: 1	Replicas: 1,2,3	Isr: 1,2	Elr: 	LastKnownElr:
high watermark=7, 읽은 메시지 7개 (m-001 ~ m-007)
ack 받은 메시지 6개, 그중 토픽에 없는 메시지 0개
Topic: orders	Partition: 0	Leader: 1	Replicas: 1,2,3	Isr: 1,2,3	Elr: 	LastKnownElr:

k2가 ISR에 돌아오자 high watermark가 7이 됐고, 컨슈머는 m-007까지 읽었어요. 프로듀서는 실패를 보고했지만 메시지는 토픽에 커밋된 거예요.

그래서 실패 응답을 받을 때마다 같은 메시지를 다시 보내면 중복이 생길 수 있어요.

실습 3: 같은 장애에서 min.insync.replicas=1이면

이번에는 거절이 없으면 어떻게 되는지 비교해 볼게요. min.insync.replicas=1인 mi1과 2인 mi2를 같은 배치로 만들고, 정상 상태에서 5개씩 보냅니다.

그다음 k3과 k2를 차례로 내려 ISR을 리더 k1 하나로 만들고, 두 토픽에 acks=all로 5개씩 보내요. 전송 제한 시간은 15초입니다.

kafka kafka-topics.sh --bootstrap-server k1:9092 --create --topic mi1 --replica-assignment 1:2:3 --config min.insync.replicas=1
kafka kafka-topics.sh --bootstrap-server k1:9092 --create --topic mi2 --replica-assignment 1:2:3 --config min.insync.replicas=2
python produce.py mi1 all 1 5 | tail -1
python produce.py mi2 all 1 5 | tail -1
docker compose stop k3
docker compose stop k2
until desc mi1 | grep -qE 'Isr: 1[[:space:]]'; do sleep 1; done
until desc mi2 | grep -qE 'Isr: 1[[:space:]]'; do sleep 1; done
desc mi1; desc mi2
python produce.py mi1 all 6 5 15
python produce.py mi2 all 6 5 15
Created topic mi1.
Created topic mi2.
  0.1s m-005 성공 offset=4
  0.3s m-005 성공 offset=4
Topic: mi1	Partition: 0	Leader: 1	Replicas: 1,2,3	Isr: 1	Elr: 	LastKnownElr:
Topic: mi2	Partition: 0	Leader: 1	Replicas: 1,2,3	Isr: 1	Elr: 2	LastKnownElr:
  0.1s m-006 성공 offset=5
  0.1s m-007 성공 offset=6
  0.1s m-008 성공 offset=7
  0.1s m-009 성공 offset=8
  0.1s m-010 성공 offset=9
  0.1s   브로커 응답: Broker: Not enough in-sync replicas (actions Retry,MsgNotPersisted)
        Broker: Not enough in-sync replicas (actions Retry,MsgNotPersisted): 18번
 15.2s m-006 실패: _MSG_TIMED_OUT
 15.2s m-007 실패: _MSG_TIMED_OUT
 15.2s m-008 실패: _MSG_TIMED_OUT
 15.2s m-009 실패: _MSG_TIMED_OUT
 15.2s m-010 실패: _MSG_TIMED_OUT

두 토픽 모두 ISR은 1인데 결과가 갈렸어요.

  • mi1: ISR 크기 1이 바닥 1을 채우니까 acks=all인데도 0.1초 만에 5개 모두 성공했습니다. ISR 전체가 k1 하나이니, k1이 기록하자마자 성공이에요.
  • mi2: 브로커를 정상적으로 내렸기 때문에 ISR이 이미 1로 줄어 있었고, 첫 요청부터 NOT_ENOUGH_REPLICAS로 거절됐어요. 재시도 18번 끝에 15초가 지나 5개 모두 실패했습니다.

이 상태에서 리더 k1을 SIGKILL로 강제 종료하고, k2와 k3을 다시 켜 볼게요. k1이 죽었으니 이제부터는 k2에서 명령을 실행하고, 컨슈머는 k2의 포트(9522)로 접속합니다.

mi1은 리더 없이 기다리게 되는데, 여기에 unclean.leader.election.enable을 켜고 바로 선출해 볼게요. 이 설정은 ISR(ELR이 켜져 있으면 ELR까지)에 없는 복제본도 마지막 수단으로 리더가 될 수 있게 해요. 기본값은 false입니다.

docker compose kill k1
docker compose start k2 k3
sleep 15
desc mi1 k2; desc mi2 k2
python read.py mi2 9522
kafka_at k2 kafka-configs.sh --bootstrap-server k2:9092 --alter --entity-type topics --entity-name mi1 --add-config unclean.leader.election.enable=true
desc mi1 k2
kafka_at k2 kafka-leader-election.sh --bootstrap-server k2:9092 --election-type unclean --topic mi1 --partition 0
desc mi1 k2
python read.py mi1 9522
Topic: mi1	Partition: 0	Leader: none	Replicas: 1,2,3	Isr: 	Elr: 1	LastKnownElr: 1
Topic: mi2	Partition: 0	Leader: 2	Replicas: 1,2,3	Isr: 2,3	Elr: 	LastKnownElr:
high watermark=5, 읽은 메시지 5개 (m-001 ~ m-005)
ack 받은 메시지 5개, 그중 토픽에 없는 메시지 0개
Completed updating config for topic mi1.
Topic: mi1	Partition: 0	Leader: none	Replicas: 1,2,3	Isr: 	Elr: 1	LastKnownElr: 1
Successfully completed leader election (UNCLEAN) for partitions mi1-0
Topic: mi1	Partition: 0	Leader: 2	Replicas: 1,2,3	Isr: 2,3	Elr: 	LastKnownElr:
high watermark=5, 읽은 메시지 5개 (m-001 ~ m-005)
ack 받은 메시지 10개, 그중 토픽에 없는 메시지 5개 m-006 m-007 m-008 m-009 m-010

mi2는 ELR에 있던 k2가 새 리더가 되어, 성공을 받은 5개를 그대로 지켰어요. ISR이 바닥 아래인 동안에는 high watermark가 올라가지 않으니, 그때 빠진 복제본은 커밋된 메시지를 모두 가지고 있다고 볼 수 있습니다.

반면 mi1은 처음에 리더가 없었어요(Leader: none). 성공을 받은 m-006~m-010을 가진 복제본은 k1뿐이고, ELR에도 k1만 남아 있었거든요.

unclean 선출로 k2가 리더가 되면서 mi1은 다시 쓸 수 있게 됐어요. 대신 k2가 가진 것은 offset 4까지라서, acks=all로 성공을 받은 m-006~m-010 5개가 토픽에서 사라졌습니다.

min.insync.replicas=2는 같은 순간에 쓰기를 거절하니까 성공 응답과 실제 복제 상태가 어긋나지 않아요. 그 대가로 브로커 두 대가 빠진 동안은 쓸 수 없습니다.

정리

범위: Kafka 4.3.1(apache/kafka:4.3.1) 브로커 3대와 컨트롤러 1대, replica.lag.time.max.ms=3000, confluent-kafka 2.15.1, 파티션 1개와 복제본 3개인 토픽 orders, mi1, mi2.

  • 복제본 3, min.insync.replicas=2인 orders에서 k3을 내려 ISR이 1,2가 돼도 acks=all 쓰기는 0.1초에 성공했습니다.
  • k2까지 떼어 내자 재시도 37번이 모두 Not enough in-sync replicas로 거절됐지만, 애플리케이션이 받은 것은 60초 뒤의 _MSG_TIMED_OUT이었습니다. 프로듀서 실패의 원인은 브로커 로그의 NotEnoughReplicasException과 클라이언트 디버그 로그에서 찾으세요.
  • 타임아웃으로 실패한 m-007은 리더 로그 offset 6에 남아 있다가 k2가 돌아오자 커밋되어 읽혔습니다. 실패한 메시지를 다시 보내면 중복이 생길 수 있으니, 내부 재시도 중복은 프로듀서 멱등성으로 막고 애플리케이션 재전송은 컨슈머가 멱등하게 처리하세요.
  • ISR이 리더 하나로 줄자 mi1(min.insync.replicas=1)은 5개에 0.1초 만에 성공을 돌려줬고, mi2(min.insync.replicas=2)는 재시도 18번 끝에 15초 뒤 5개 모두 실패했습니다. min.insync.replicas=2를 쓴다면 거절된 메시지를 다시 보낼지, 사용자에게 실패를 알릴지 애플리케이션에서 정해 두세요.
  • 리더 k1을 강제 종료하자 mi2는 ELR에 있던 k2가 리더가 되어 성공한 5개를 지켰고, mi1은 Leader: none이 됐다가 unclean 선출 뒤 성공을 받은 m-006~m-010 5개를 잃었습니다. 커밋된 메시지를 잃어도 되는 토픽이 아니라면 unclean.leader.election.enable(기본 false)을 켜지 마세요.
내 링크 만들기