이번 시간에는 토픽의 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번 남았고, 프로듀서의 재시도 횟수와 같습니다.
출력에서는 로그 줄 앞의 컨테이너 이름을 잘라 냈어요.
이제 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-0105개를 잃었습니다. 커밋된 메시지를 잃어도 되는 토픽이 아니라면unclean.leader.election.enable(기본false)을 켜지 마세요.