# Kafka

Kafka acks=1이면 성공 응답을 받은 메시지도 사라질까?

@Hong2026-10-01

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

실습 코드 내려받기

질문
Kafka 프로듀서가 acks=1로 성공 응답을 받은 메시지도 브로커 장애로 사라질 수 있나?
답
팔로워 둘을 떼어 낸 뒤 acks=1로 성공을 받은 10개는 리더가 죽자 오류 없이 사라졌고, 같은 상황의 acks=all은 네 번 모두 잃은 메시지가 0개였다.
조건
Kafka 4.3.1 KRaft 노드 3대, Python confluent-kafka 2.15.1, 복제본 3개 토픽, replica.lag.time.max.ms 3초, 장애 실습 4회

환경

Kafka
4.3(apache/kafka:4.3.1), KRaft 노드 3대(노드마다 브로커와 컨트롤러를 같이 실행)
클라이언트
Python confluent-kafka 2.15.1
토픽
파티션 1개, 복제본 3개
설정
replica.lag.time.max.ms 3초(기본 30초)
실행
Docker, 장애 실습 4회 반복
목차
  1. 실습 준비
  2. 실습 1: acks=1로 성공을 받은 메시지가 리더와 함께 사라집니다
  3. 실습 2: 같은 상황을 acks=all로 실행하면
  4. 실습 3: acks=all은 min.insync.replicas와 함께 씁니다
  5. 정리

이번 시간에는 Kafka 프로듀서가 성공 응답을 받은 메시지가 리더 장애로 사라지는 과정을 직접 재현해 보며 배워가는 시간입니다.

예를 들어서 결제 서비스가 payment-completed 이벤트를 Kafka에 보냈다고 해볼게요. 프로듀서는 성공 응답과 함께 offset까지 받았고, 로그에도 전송 성공이 남았어요.

그런데 몇 초 뒤 브로커 한 대가 죽고 나서, 정산 서비스는 이 이벤트를 끝내 받지 못했습니다. 왜 그럴까요?

프로듀서가 성공 응답을 받았다고 해서 메시지가 복제까지 끝났다고 볼 수는 없기 때문이에요. 응답을 언제 돌려받을지는 프로듀서의 acks 설정이 정합니다. acks=1이면 리더 브로커 한 대만 메시지를 가진 상태에서도 성공이 와요.

그 상황을 재현한 뒤에는 acks=all로 다시 실행해 결과를 비교해 볼게요. acks=all이 기다리는 대상은 그 순간의 ISR(in-sync replicas, 리더를 충분히 따라오고 있는 복제본의 목록)이에요.

ISR이 줄었을 때 쓰기를 거절하게 하는 설정이 궁금하면 min.insync.replicas를 다룬 글을 참고하세요.

이 글에서 자주 나오는 high watermark는 복제가 끝나서 컨슈머가 읽을 수 있는 마지막 위치라고 보시면 됩니다.

실습 준비

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

  • compose.yaml: KRaft 노드 세 대 정의
  • produce.py: 번호를 붙인 메시지를 정한 acks로 보내는 프로듀서
  • isr.py: 메타데이터에서 리더와 ISR 출력
  • read.py: 파티션을 high watermark까지 읽어 성공 목록과 비교하는 컨슈머

Docker로 KRaft 노드 세 대를 띄울게요. 노드마다 브로커와 컨트롤러를 한 컨테이너에서 같이 실행하니까, 컨트롤러 쿼럼도 세 대로 구성됩니다.

compose.yaml에서 눈여겨보실 설정은 이렇습니다.

  • PLAINTEXT(k1:9092 등): 브로커끼리 복제할 때와, 컨테이너 안에서 Kafka 명령 도구를 쓸 때 씁니다.
  • HOST(localhost:9511~9513): 호스트의 Python 프로듀서와 컨슈머가 씁니다.
  • KAFKA_REPLICA_LAG_TIME_MAX_MS: 3000: 팔로워가 이 시간보다 오래 뒤처지면 리더가 ISR에서 뺍니다. 기본값은 30초인데, 실습에서 기다리는 시간을 줄이려고 3초로 낮췄습니다.
  • 고정 IP(ipv4_address): 실습 중에 노드를 Docker 네트워크에서 떼어 냈다가 다시 붙입니다. 다시 붙일 때 같은 IP와 이름을 쓰려고 고정했습니다.

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

isolate는 노드를 Docker 네트워크에서 떼어 내고, rejoin은 같은 IP와 이름으로 다시 붙이는 함수예요. 떼어 낸 노드는 프로세스가 살아 있어도 다른 노드와 주고받을 수 없으니, 네트워크가 끊긴 상황과 같다고 보시면 됩니다.

docker compose -p kacks up -d
export COMPOSE_PROJECT_NAME=kacks

kafka() { docker compose exec -T k1 /opt/kafka/bin/"$@"; }

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

rejoin() { for n in "$@"; do docker network connect --ip "172.30.0.1${n#k}" --alias "$n" kacks_kafka "kacks-$n-1"; done; }

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

실습 1: acks=1로 성공을 받은 메시지가 리더와 함께 사라집니다

--replica-assignment 1:2:3으로 파티션 1개, 복제본 3개인 토픽을 만들어요.

목록의 첫 번째 브로커가 선호 리더라서, 처음에는 k1이 리더가 됩니다. 실습마다 리더를 k1로 고정하려고 이렇게 만들었어요.

produce.py는 m-001처럼 번호를 붙인 메시지를 보내고, 전송 결과가 올 때마다 걸린 시간과 offset을 출력해요. 성공한 메시지는 acked-<토픽>.txt에 적어 두었다가, 나중에 실제로 남아 있는지 비교합니다.

프로듀서는 localhost:9511, 곧 k1에만 처음 접속하고, 나머지 브로커 주소는 메타데이터로 받아 와요. 멱등성은 이 글의 비교에서 빼려고 껐습니다. librdkafka의 기본값도 false예요.

이제 리더 혼자 메시지를 가진 상황을 만들어 볼게요.

실제 장애에서는 리더가 응답한 뒤 팔로워가 가져가기 전까지의 짧은 시간에 리더가 죽어야 이런 상황이 됩니다. 원하는 순간에 맞추기는 어렵죠. 그래서 여기서는 팔로워 k2와 k3을 네트워크에서 떼어 내서 이 시간을 늘리는 방식으로 가볼게요.

새 토픽 acks1을 만들고, 정상 상태에서 acks=1로 10개를 보냅니다. 그다음 팔로워 둘을 떼어 내고 10개를 더 보내요.

중간에 실행하는 isr.py는 메타데이터에서 리더와 ISR을 읽어 출력하는 짧은 스크립트예요.

kafka kafka-topics.sh --bootstrap-server k1:9092 --create --topic acks1 --replica-assignment 1:2:3
python produce.py acks1 1 1 10 | tail -1
python isr.py acks1 9511
isolate k2 k3
python produce.py acks1 1 11 10
Created topic acks1.
  0.0s m-010 성공 offset=9
acks1 파티션 0: leader=1 replicas=[1, 2, 3] isr=[1, 2, 3]
  0.1s m-011 성공 offset=10
  0.1s m-012 성공 offset=11
  0.1s m-013 성공 offset=12
  0.1s m-014 성공 offset=13
  0.1s m-015 성공 offset=14
  0.1s m-016 성공 offset=15
  0.1s m-017 성공 offset=16
  0.1s m-018 성공 offset=17
  0.1s m-019 성공 offset=18
  0.1s m-020 성공 offset=19

팔로워가 끊겨 있는데도 m-011부터 m-020까지 0.1초 만에 성공을 받았어요. acks=1이니까 리더 k1이 자기 로그에 쓰자마자 응답한 거예요.

이 상태에서 리더 k1을 SIGKILL로 강제 종료하고, 떼어 냈던 k2와 k3을 네트워크에 다시 붙여 볼게요.

새 리더가 뽑힐 때까지 기다린 뒤 다시 읽습니다. k1이 죽었으니 이번에는 k2의 포트(9512)로 접속해요. read.py는 그룹 없이 파티션 0을 처음부터 high watermark까지 읽고, 성공을 받은 메시지 목록과 비교해서 토픽에 없는 메시지를 셉니다.

docker compose kill k1
rejoin k2 k3
until python isr.py acks1 9512 2> /dev/null | grep -qE 'leader=(2|3)'; do sleep 1; done
python isr.py acks1 9512
python read.py acks1 9512
acks1 파티션 0: leader=2 replicas=[1, 2, 3] isr=[2, 3]
high watermark=10, 읽은 메시지 10개 (m-001 ~ m-010)
ack 받은 메시지 20개, 그중 토픽에 없는 메시지 10개 m-011 m-012 m-013 m-014 m-015 m-016 m-017 m-018 m-019 m-020

k2가 새 리더가 됐고, ISR에서 k1이 빠졌어요. k2는 offset 9까지만 복제했으니 토픽에는 m-001부터 m-010까지만 남았습니다.

프로듀서가 성공을 받은 20개 가운데 10개가 사라진 거예요. 프로듀서에는 어떤 오류도 오지 않았으니, 애플리케이션 입장에서는 보낸 이벤트가 조용히 없어진 셈이죠.

이 실습은 같은 클러스터에서 토픽 이름만 바꿔 세 번 더 반복했어요. 반복 실행의 명령과 출력은 이 글에 싣지 않았습니다. 네 번 모두 성공을 받은 메시지가 사라졌는데, 두 번은 10개, 나머지 두 번은 20개 전부였습니다.

20개가 사라진 두 번은 팔로워를 떼어 낸 직후 high watermark가 0이었어요. 정상 상태에서 보낸 앞의 10개도 팔로워가 가져가기 전에 연결이 끊긴 거예요.

이 10개 역시 0.0초 만에 성공을 받았어요. 그러니 장애가 없는 동안에도 acks=1의 성공 응답만으로는 복제가 끝났는지 알 수 없습니다.

실습 2: 같은 상황을 acks=all로 실행하면

먼저 죽였던 k1을 다시 켜고 ISR이 셋으로 돌아오기를 기다린 다음, 토픽 acksall에 같은 순서를 acks=all로 실행해 볼게요.

팔로워를 떼어 낸 뒤 보내는 10개는 응답이 바로 오지 않아요. 그래서 프로듀서를 백그라운드로 실행하고 5초 뒤 상태를 봅니다. 프로듀서가 남기는 연결 오류 로그는 2> /dev/null로 버렸어요.

docker compose start k1
until python isr.py acks1 9512 2> /dev/null | grep -qE 'isr=\[[0-9], [0-9], [0-9]\]'; do sleep 1; done
kafka kafka-topics.sh --bootstrap-server k1:9092 --create --topic acksall --replica-assignment 1:2:3
python produce.py acksall all 1 10 | tail -1
isolate k2 k3
python produce.py acksall all 11 10 > produce-acksall.out 2> /dev/null &
sleep 5
echo "5초 동안 받은 전송 결과: $(wc -l < produce-acksall.out | tr -d ' ')개"
python isr.py acksall 9511
python read.py acksall 9511
Created topic acksall.
  0.4s m-010 성공 offset=9
5초 동안 받은 전송 결과: 0개
acksall 파티션 0: leader=1 replicas=[1, 2, 3] isr=[1, 2, 3]
high watermark=10, 읽은 메시지 10개 (m-001 ~ m-010)
ack 받은 메시지 10개, 그중 토픽에 없는 메시지 0개 

acks=1과 다른 점은 프로듀서가 아직 성공을 하나도 받지 못했다는 거예요. ISR이 [1, 2, 3]이니 리더는 k2와 k3이 가져가기를 기다리고 있습니다.

이 상태에서 k1을 죽이고 팔로워를 다시 붙여 볼게요.

docker compose kill k1
rejoin k2 k3
until python isr.py acksall 9512 2> /dev/null | grep -qE 'leader=(2|3)'; do sleep 1; done
python isr.py acksall 9512
wait
cat produce-acksall.out
python read.py acksall 9512
acksall 파티션 0: leader=2 replicas=[1, 2, 3] isr=[2, 3]
 29.3s m-011 성공 offset=10
 29.3s m-012 성공 offset=11
 29.3s m-013 성공 offset=12
 29.3s m-014 성공 offset=13
 29.3s m-015 성공 offset=14
 29.3s m-016 성공 offset=15
 29.3s m-017 성공 offset=16
 29.3s m-018 성공 offset=17
 29.3s m-019 성공 offset=18
 29.3s m-020 성공 offset=19
high watermark=20, 읽은 메시지 20개 (m-001 ~ m-020)
ack 받은 메시지 20개, 그중 토픽에 없는 메시지 0개 

리더 k1이 혼자 가진 offset 10~19는 이번에도 새 리더 k2에 없었어요.

하지만 프로듀서는 그 메시지들의 성공을 받은 적이 없죠. 그래서 연결이 끊기자 메타데이터를 다시 받아서 새 리더 k2에 다시 보냈습니다.

전송을 시작한 지 29.3초 만에 10개 모두 성공했고, 토픽에는 20개가 모두 있어요.

결과적으로 성공을 받은 메시지는 하나도 잃지 않았어요. 이 실습도 세 번 더 반복했고, 네 번 모두 사라진 메시지는 0개였습니다.

다만 멱등성이 꺼진 상태의 재시도는 상황에 따라 같은 메시지를 두 번 저장할 수 있어요. 이번에는 첫 기록이 새 리더 k2에 없어서 중복이 생기지 않았습니다.

그리고 복제가 막힌 동안 성공 응답이 29.3초 동안 오지 않았다는 점도 기억해 두세요.

실습 3: acks=all은 min.insync.replicas와 함께 씁니다

앞의 acksall 실습에서 ISR은 왜 [1, 2, 3]으로 남았을까요?

리더 k1의 로그를 보면 k1도 팔로워를 빼려고 했어요. 출력에서는 로그 줄 앞의 컨테이너 이름과 Leader: 뒤를 잘라 냈습니다.

docker compose logs k1 | grep -E "Partition (acks1|acksall)-0 broker=1\] Shrinking"
2026-10-01 01:26:45,452 acks1-0 Shrinking ISR from 3,1,2 to 1.
2026-10-01 01:34:31,230 acksall-0 Shrinking ISR from 3,2,1 to 1.

k1은 ISR을 자기 혼자(1)로 줄이려 했지만, 컨트롤러가 있는 k2, k3과도 연결이 끊겨 있어서 이 변경이 확정되지 않았어요.

만약 컨트롤러에 닿을 수 있어서 ISR이 [1]로 줄었다면 어땠을까요? acks=all은 k1 혼자 기록한 메시지에도 성공을 돌려줬을 거고, 결과는 acks=1과 같았을 거예요. 이 실습의 토픽은 min.insync.replicas가 기본값 1이라 이걸 막지 못합니다.

그래서 acks=all은 min.insync.replicas와 짝으로 써요. 복제본이 3개라면 보통 min.insync.replicas=2로 두어서, ISR이 2개보다 적으면 리더가 쓰기를 거절하게 합니다.

정리

범위: Kafka 4.3.1 KRaft 노드 세 대, Python confluent-kafka 2.15.1, 복제본 3개 파티션 1개 토픽, replica.lag.time.max.ms 3초, 장애 실습은 네 번 반복.

  • 팔로워 둘을 떼어 낸 뒤 acks=1로 보낸 10개는 0.1초 만에 성공을 받았어요. 성공 응답을 커밋으로 읽지 마세요.
  • 그 상태에서 리더 k1을 죽이자 offset 9까지만 가진 k2가 리더가 되어 성공을 받은 10개가 오류 없이 사라졌고, 네 번 반복 중 두 번은 20개 전부가 사라졌어요. 잃으면 안 되는 이벤트에는 acks=1을 쓰지 마세요.
  • 같은 상황에서 acks=all은 성공을 보고하지 않고 기다리다 새 리더로 다시 보내 20개를 모두 남겼고, 네 번 모두 잃은 메시지가 0개였지만 성공 응답은 29.3초 늦었어요. acks=all을 쓰면 복제가 막힌 동안의 응답 지연을 감안하세요.
  • 리더 k1은 두 실습 모두 ISR을 자기 혼자(1)로 줄이려 했고, 컨트롤러와 끊겨 있어서 확정되지 않았을 뿐 min.insync.replicas 기본값 1로는 막지 못했어요. acks=all에는 min.insync.replicas를 함께 설정하고, 복제본이 3개면 2로 두세요.
  • 멱등성을 끈 acks=all의 재시도는 이번에는 첫 기록이 새 리더에 없어 중복이 생기지 않았지만, 상황에 따라 같은 메시지를 두 번 저장할 수 있습니다.
내 링크 만들기