Kafka 프로듀서 설정을 정리하다가 acks=all 하나만 켜면 메시지가 안 사라진다고 막연히 알고 있었는데, 실제로는 그렇지 않다는 걸 알게 됐다. 운영 중인 토픽에서 브로커 한 대가 내려갔을 때 잠깐 데이터 유실 가능성이 있다는 얘기를 듣고, "acks=all 인데 왜?"라는 의문에서 출발했다.
따라가 보니 acks는 프로듀서 쪽 설정이고, 실제 내구성(durability) 보장은 브로커 쪽 min.insync.replicas 와 짝을 이뤄야 완성된다는 구조였다. 이 글에서는 두 설정이 각각 무엇을 보장하고, 둘이 어떻게 맞물려야 "커밋된 메시지는 잃지 않는다"가 성립하는지를 직접 따라가 본 흐름으로 정리했다.
먼저 두 축을 분리해서 이해하는 게 중요했다.
acks는 프로듀서가 "이 메시지를 보냈다"를 언제 성공으로 칠지를 정하는 값이다. Kafka 공식 문서(Producer Configs)에 따르면 세 가지 값을 가진다.
| acks | 의미 | 유실 위험 |
|---|---|---|
0 | 보내고 응답 안 기다림 | 가장 높음 (네트워크 전송만 보장) |
1 | 리더가 자기 로그에 쓰면 성공 | 중간 (리더가 죽으면 유실 가능) |
all(=-1) | ISR의 모든 레플리카가 받아야 성공 | 가장 낮음 |
여기서 핵심 용어가 ISR(In-Sync Replicas) 다. ISR은 리더와 "충분히 동기화된" 레플리카들의 집합으로, 리더 자신을 포함한다. 어떤 팔로워가 리더의 로그를 일정 시간(replica.lag.time.max.ms) 안에 따라잡지 못하면 ISR에서 빠진다고 알려져 있다.
acks=all이 보장하는 건 "ISR에 속한 모든 레플리카가 받았다"이지, "설정된 모든 레플리카(replication factor)가 받았다"가 아니다.
이 한 줄이 내가 처음에 오해했던 지점이었다. replication factor가 3이어도 ISR이 어느 순간 1(리더만)로 쪼그라들면, acks=all은 사실상 acks=1처럼 동작한다. 리더 하나만 받으면 ISR 전체가 받은 게 되니까.
그래서 min.insync.replicas가 등장한다. 이건 토픽(또는 브로커) 단위 설정으로, "acks=all 쓰기를 허용하려면 ISR이 최소 몇 개여야 하는가"를 정한다.
동작 흐름을 단계로 정리하면 이렇다.
acks=all로 메시지를 보낸다.min.insync.replicas보다 작은지 확인한다.NotEnoughReplicasException(또는 NotEnoughReplicasAfterAppendException)을 돌려준다.여기서 자주 헷갈렸던 두 가지를 짚어둔다.
첫째, min.insync.replicas는 ISR 크기를 강제로 유지시키는 값이 아니다. 이건 게이트(gate)에 가깝다. ISR이 그 밑으로 떨어지면 쓰기를 막아서, "어차피 받을 레플리카가 부족한데 받아놓고 나중에 잃는" 상황을 차단하는 것이다. 즉 가용성을 일부 포기하고(쓰기 거부) 내구성을 지키는 트레이드오프다.
둘째, acks=all 없이 min.insync.replicas만 올리면 효과가 없다. Kafka 문서에서도 이 둘은 함께 써야 의미가 있다고 설명한다. min.insync.replicas는 오직 acks=all인 쓰기에만 적용된다. acks=1로 보내면 이 설정은 무시되고 리더만 받으면 성공이다.
흔히 거론되는 안전한 조합은 다음으로 정리했다.
replication.factor = 3
min.insync.replicas = 2
acks = all
이렇게 두면 "브로커 1대 장애까지는 무손실로 견딘다"가 된다. 직관적으로 따라가 보면:
min.insync.replicas=2를 만족하므로 쓰기 계속, 2개가 받아야 커밋.2를 못 채우므로 쓰기 거부. 데이터를 받아서 잃느니, 받지 않는 쪽을 택한다.min.insync.replicas를 replication factor와 같게(3=3) 두지 않는 이유도 여기서 보였다. 같게 두면 단 한 대만 빠져도 모든 쓰기가 막혀서 가용성이 너무 약해진다. 2는 무손실과 가용성 사이의 절충으로 알려져 있다.
프로듀서 쪽 설정과, 쓰기 실패를 어떻게 받는지 짧게 정리했다. (Java, KafkaProducer 기준)
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "broker1:9092,broker2:9092");
props.put(ProducerConfig.ACKS_CONFIG, "all"); // ISR 전체 ack 필요
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); // 중복/순서 깨짐 방지
props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
try (KafkaProducer<String, String> producer = new KafkaProducer<>(props)) {
producer.send(new ProducerRecord<>("orders", "key", "value"), (meta, ex) -> {
if (ex instanceof NotEnoughReplicasException) {
// ISR 부족 → 쓰기 거부됨. 재시도 or 알람.
log.error("ISR < min.insync.replicas, write rejected", ex);
} else if (ex != null) {
log.error("send failed", ex);
} else {
log.info("committed at offset {}", meta.offset());
}
});
}
토픽 쪽 min.insync.replicas는 보통 브로커 기본값보다 토픽 단위 오버라이드로 명시하는 편이 안전하다.
# 토픽 단위로 min.insync.replicas 설정
kafka-configs.sh --bootstrap-server broker1:9092 \
--entity-type topics --entity-name orders \
--alter --add-config min.insync.replicas=2
엣지 케이스 하나. acks=all이라도 enable.idempotence를 끄고 retries만 켜면, 재시도 과정에서 메시지가 중복 적재되거나 순서가 뒤바뀔 수 있다고 알려져 있다. 그래서 무손실을 노릴 때는 idempotent producer를 함께 켜는 조합이 권장된다. (최신 클라이언트에선 idempotence가 기본 활성화라는 설명도 있어, 버전별로 기본값은 확인이 필요하다.)
또 하나, NotEnoughReplicasAfterAppendException은 이름 그대로 "리더 로그에는 일단 append된 뒤" ISR 부족이 확인된 경우다. 이 경우 메시지가 리더엔 남아 있을 수 있어, 재시도 시 중복 가능성이 있다 — 이 역시 idempotence가 막아주는 영역이다.
핵심을 한 줄로 정리하면:
acks=all은 "ISR 전체가 받아야 성공"을 정하고,min.insync.replicas는 "그 ISR이 너무 작으면 아예 안 받는다"를 정한다. 둘이 같이 있어야 무손실 보장이 성립한다.
처음의 의문 — "acks=all인데 왜 유실?" — 에 대한 답은, min.insync.replicas를 같이 안 걸어두면 ISR이 1까지 줄어든 상태에서 리더만 받고 끝날 수 있기 때문이었다. 두 값을 짝으로 보지 않으면 보장은 반쪽짜리가 된다.
더 파고들 만한 주제로는 두 가지를 메모해 뒀다.
unclean.leader.election.enable — ISR에 없던 레플리카를 리더로 승격할지 여부. 켜면 가용성↑ 무손실↓. 위 보장과 직접 맞물린다.acks)min.insync.replicas)replica.lag.time.max.ms)