Kafka Producer: acks, retry, 그리고 idempotence 올바르게
1. 개요
Producer가 Kafka에 메시지를 보낼 때 세 가지 질문에 답해야 합니다: 전송을 언제 성공으로 간주할 것인가, 오류 발생 시 재시도를 어떻게 할 것인가, 재시도가 메시지 중복을 일으키지 않는가. 이 글은 acks, retries, enable.idempotence — 세 가지 설정이 producer의 신뢰성과 정확성을 어떻게 결정하는지 설명합니다. 이 글을 읽은 후에는 프로덕션 환경에 맞는 producer 설정이 무엇인지, 그리고 데이터 손실이나 중복을 유발하는 일반적인 실수를 어떻게 피할지 알게 될 것입니다.
2. 필요한 의존성
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>3.7.0</version>
</dependency>
이 글의 예제는 Spring Kafka가 아닌 순수 Kafka client(KafkaProducer)를 사용하여 각 설정이 어떻게 영향을 미치는지 명확히 보여줍니다.
3. acks: Producer가 전송 성공을 언제 간주하는가
acks는 producer가 메시지 전송을 완료된 것으로 간주하기 전에 몇 개의 broker로부터 확인을 기다려야 하는지를 결정합니다.
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
props.put(ProducerConfig.ACKS_CONFIG, "all");
acks=0: producer가 어떤 확인도 기다리지 않습니다. 가장 빠르지만, broker가 수신 직후 다운되면 메시지가 손실됩니다 — 손실 여부를 알 수도 없습니다.acks=1: leader partition이 로컬 로그에 기록하고 ACK를 반환할 때까지만 기다립니다. Leader가 follower 동기화 전에 크래시되면 메시지가 손실됩니다.acks=all(또는-1): ISR(in-sync replica set) 내의 모든 replica가 확인할 때까지 기다립니다. 가장 안전하지만 지연 시간이 더 깁니다.
주의:
acks=all이 정말로 안전하려면 broker의min.insync.replicas >= 2와 함께 설정되어야 합니다.min.insync.replicas=1이면,acks=all도 1개의 replica 확인만 필요합니다 — 안전성 면에서acks=1과 다를 바 없습니다.
3.1. 함께 필요한 broker 설정
# server.properties 또는 topic config
min.insync.replicas=2
replication factor = 3이고 min.insync.replicas=2이면, 시스템은 1개 broker가 다운되어도 계속 쓸 수 있으면서, 동시에 ACK 전에 데이터가 최소 2개 복제본에 있음을 보장합니다.
4. retries: 일시적 오류 처리
Producer는 NotLeaderForPartitionException 또는 단기 네트워크 끊김 같은 일시적 오류를 만날 수 있습니다. retries를 사용하면 producer가 코드 개입 없이 자동으로 재전송할 수 있습니다.
props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);
props.put(ProducerConfig.RETRY_BACKOFF_MS_CONFIG, 100);
props.put(ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG, 120_000);
retries: 최대 재시도 횟수. Kafka 2.1부터는 매우 높게(또는Integer.MAX_VALUE) 설정하고, 재시도 횟수 대신 총 대기 시간을 제어하기 위해delivery.timeout.ms를 사용하는 것이 좋습니다.retry.backoff.ms: 각 재시도 사이의 대기 시간. broker에 스팸을 보내는 것을 방지합니다.delivery.timeout.ms:send()호출부터 결과(성공 또는 최종 실패) 수신까지의 총 최대 시간.request.timeout.ms + linger.ms보다 커야 합니다.
문제: 주의하지 않으면 재시도가 메시지 중복 전송 또는 잘못된 순서 전송을 일으킬 수 있습니다.
4.1. 재시도가 메시지 중복을 일으키는 이유
1. Producer가 메시지 A를 포함한 배치를 전송
2. Broker가 A를 성공적으로 기록하고 ACK를 반환
3. ACK가 네트워크 상에서 손실 (network timeout)
4. Producer가 ACK를 받지 못함 -> 오류로 간주 -> 재시도
5. Broker가 요청을 다시 받음 -> A를 2번 기록
=> Topic에 message A의 복사본 2개 존재
이것은 전형적인 상황입니다: broker는 이미 성공적으로 기록했지만 producer는 모르므로 다시 전송합니다. 뒤에 나오는 idempotence가 이 문제를 올바르게 해결합니다.
4.2. 재시도가 순서를 잘못 배치하는 이유
max.in.flight.requests.per.connection > 1이면 producer가 여러 배치를 병렬로 전송할 수 있습니다. 배치 1이 오류가 나서 재시도 중이면 배치 2가 broker에 먼저 기록될 수 있습니다.
max.in.flight.requests.per.connection=5
이 설정에서 idempotence가 없으면, 재시도 중에 partition 내 메시지의 순서가 보장되지 않습니다.
5. Idempotence: 재시도로 인한 중복 해결
enable.idempotence=true는 Kafka가 각 배치에 **Producer ID (PID)**와 sequence number를 붙여서 producer가 재시도할 때 자동으로 중복 쓰기를 방지하도록 도와줍니다.
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
Idempotence를 활성화하면 Kafka는 다음 값들을 자동으로 설정합니다(직접 설정할 필요는 없지만, 알아두면 좋습니다):
acks = all
retries = Integer.MAX_VALUE (또는 매우 큼)
max.in.flight.requests.per.connection <= 5
5.1. 동작 메커니즘
각 producer 세션은 broker로부터 고유한 PID(Producer ID)를 할당받습니다. 하나의 partition으로 보내지는 각 메시지는 증가하는 sequence number를 가집니다. Broker는 각 PID + partition에 대해 마지막으로 기록한 sequence number를 저장합니다.
Producer가 partition 0에 배치 seq=5 전송
Broker: "내가 저장한 마지막 sequence는 4, 5가 다음 -> 기록, seq=5 저장"
Producer가 다시 전송 (재시도) 배치 seq=5
Broker: "seq=5는 이미 기록했음 -> 무시, 성공 ACK 반환"
덕분에 재시도가 중복 기록을 만들지 않습니다 — broker는 sequence number를 기반으로 중복을 자동으로 감지하고 제거하며, producer나 consumer가 직접 처리할 필요가 없습니다.
주의: Idempotence는 하나의 producer 세션 내에서만 그리고 하나의 partition 범위 내에서만 중복이 없음을 보장합니다. Producer가 재시작되면(새로운 PID가 할당됨), 이전 PID에 따른 dedup 메커니즘이 더 이상 적용되지 않습니다. 여러 partition/topic 전체에서 exactly-once를 원하면 transaction(
transactional.id)이 필요합니다 — 이는 순수 idempotence 범위를 벗어납니다.
5.2. 프로덕션 환경용 producer 설정 예제
public ProducerFactory() {
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
// idempotence 활성화 -> acks=all, 높은 retries, in-flight <= 5 자동 설정
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
props.put(ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG, 120_000);
this.producer = new KafkaProducer<>(props);
}
public void sendOrderEvent(String orderId, String payload) {
ProducerRecord<String, String> record =
new ProducerRecord<>("order-events", orderId, payload);
producer.send(record, (metadata, exception) -> {
if (exception != null) {
log.error("주문 {} 전송 실패", orderId, exception);
} else {
log.info("주문 {}이(가) partition {} offset {}에 기록됨",
orderId, metadata.partition(), metadata.offset());
}
});
}
ENABLE_IDEMPOTENCE_CONFIG = true가 가장 중요한 줄입니다 — 이것이 자동으로acks=all을 가져옵니다.send()내의 콜백은 실제 결과를 알려줍니다:exception != null이면,delivery.timeout.ms동안 재시도했지만 여전히 오류가 났다는 뜻입니다 — 이때만 비즈니스 로직을 처리해야 합니다(로깅, 알림, 오류 테이블 저장).
6. 테스트로 동작 확인하기
Dedup 부분을 설명하기 위해, application 레벨에서 sequence number 메커니즘을 모방하는 간단한 MessageDeduplicator를 만들어봅시다(idempotence를 활성화할 수 없을 때, 예를 들어 레거시 시스템에서 사용):
public class MessageDeduplicator {
private final Map<String, Long> lastSeenSequence = new ConcurrentHashMap<>();
public boolean shouldWrite(String partitionKey, long sequence) {
Long last = lastSeenSequence.get(partitionKey);
if (last != null && sequence <= last) {
return false; // 중복, 건너뛰기
}
lastSeenSequence.put(partitionKey, sequence);
return true;
}
}
class MessageDeduplicatorTest {
@Test
void shouldSkipDuplicateSequence() {
MessageDeduplicator deduplicator = new MessageDeduplicator();
assertThat(deduplicator.shouldWrite("order-1", 5)).isTrue();
assertThat(deduplicator.shouldWrite("order-1", 5)).isFalse(); // 같은 seq 재시도
assertThat(deduplicator.shouldWrite("order-1", 6)).isTrue();
}
@Test
void shouldAllowDifferentPartitionKeysIndependently() {
MessageDeduplicator deduplicator = new MessageDeduplicator();
assertThat(deduplicator.shouldWrite("order-1", 1)).isTrue();
assertThat(deduplicator.shouldWrite("order-2", 1)).isTrue();
}
}
첫 번째 테스트는 다음을 확인합니다: 같은 sequence를 다시 보내면 두 번째는 건너뜁니다 — 정확히 producer idempotence의 정신입니다. 두 번째 테스트는 dedup이 하나의 key/partition 범위 내에서만 적용되고, 다른 key에는 영향을 주지 않음을 확인합니다.
7. 요구사항별 설정 요약표
| 요구사항 | acks | enable.idempotence | 위험 |
|---|---|---|---|
| 중요하지 않은 로그, 메트릭 | 0 | false | 오류 시 메시지 손실, 하지만 처리량 최대 |
| 속도와 안전성의 균형 | 1 | false | Leader 크래시 전에 follower가 동기화하지 못하면 메시지 손실 |
| 중요한 비즈니스 데이터(주문, 결제) | all | true | 더 높은 지연, 하지만 손실 없음, 재시도로 인한 중복 없음 |
8. 일반적인 실수
acks=all을 설정했지만min.insync.replicas는 잊음: 안전하다고 생각하지만 broker는 여전히 1개 ISR만 필요해 ACK합니다 —acks=1과 다를 바 없습니다.- Kafka client의 기본 retry 로직과 함께 직접 retry 로직을 작성: 두 재시도 계층이 모두 작동하므로 중복 전송이 2배 일어날 수 있습니다.
- Idempotence는 활성화했지만
max.in.flight.requests.per.connection > 5유지: Kafka는 producer 초기화 시 이 설정을 거부합니다(ConfigException), idempotence가 지원하는 한계를 초과하기 때문입니다. - Idempotence를 전체 시스템의 exactly-once와 혼동: Idempotence는 producer-to-broker만 보호합니다. Consumer가 메시지를 처리한 후 멱등하지 않은 side-effect(이메일 발송, 금액 차감)를 호출하면, consumer 측에서 여전히 중복 처리가 발생할 수 있습니다.
주의: Kafka 0.11 이전 버전을 사용 중이면
enable.idempotence=true를 활성화하지 마세요 — 이 기능은 broker가 아직 지원하지 않으며, producer 초기화 시 오류가 발생합니다.
9. 결론
acks, retries, enable.idempotence는 신뢰할 수 있는 producer가 필요할 때 떼어낼 수 없는 세 가지 퍼즐 조각입니다. acks=all은 성공으로 간주하기 전에 데이터가 충분히 복제되도록 보장합니다; retries는 일시적 오류를 극복하도록 도와줍니다; 그리고 idempotence는 그 재시도가 중복 기록을 만들지 않도록 보장합니다. 중요한 비즈니스 데이터의 경우, 기본적으로 이 세 가지를 함께 사용하세요 — 지연 시간의 비용이 나중에 잘못되거나 중복된 데이터를 처리하는 비용보다 훨씬 작습니다.