Kafka producer: acks, idempotence와 batching
1. 개요
Kafka에 메시지를 보내는 것은 단순히 send() 한 줄이면 된다. 어려운 부분은 따라오는 질문들에 있다: 메시지가 정말 저장되었는가? Producer가 재시도할 때 중복으로 기록되지 않는가? 그리고 초당 수만 개의 메시지를 보내면서 broker를 막지 않으려면 어떻게 해야 하는가?
이 글에서는 이 세 질문에 답하는 세 가지 구성 그룹을 다룬다: acks, enable.idempotence, 그리고 batching (batch.size, linger.ms, compression.type). 이 글을 읽은 후에는 순수 Kafka client와 Spring Boot 3.x 모두에서 안전하고 빠른 producer를 구성하는 방법을 알게 되고, 이를 테스트하는 방법도 배운다.
2. 설정
순수 Java의 경우, kafka-clients만 필요하다:
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>3.9.0</version>
</dependency>
Spring Boot 3.x의 경우 spring-kafka를 사용한다. Spring Boot에서 관리하는 버전이며, kafka-clients를 포함한다:
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
이 글 전체에서 우리는 주문 이벤트를 보낸다:
public record Order(String id, String customerId, BigDecimal total) {
public String toJson() {
return """
{"id":"%s","customerId":"%s","total":%s}""".formatted(id, customerId, total);
}
}
3. acks: 메시지가 언제 전송된 것으로 간주되는가?
acks는 producer가 요청을 성공으로 간주하기 전에 broker로부터 몇 개의 확인을 기다려야 하는지를 결정한다.
| 값 | 의미 | 위험 |
|---|---|---|
acks=0 |
아무것도 기다리지 않음 | 메시지 손실을 모르고 지남 |
acks=1 |
Leader가 쓰기를 완료할 때까지 기다림 | Leader가 죽기 전에 follower가 복사하지 못하면 메시지 손실 |
acks=all (-1) |
ISR의 모든 replica가 쓰기를 완료할 때까지 기다림 | 약간 더 높은 latency |
순수 client로 구성하는 예:
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.ACKS_CONFIG, "all");
try (var producer = new KafkaProducer<String, String>(props)) {
var order = new Order("o-1001", "c-42", new BigDecimal("250000"));
RecordMetadata meta = producer
.send(new ProducerRecord<>("orders", order.customerId(), order.toJson()))
.get();
System.out.printf("partition=%d offset=%d%n", meta.partition(), meta.offset());
}
.get()을 호출하면 전송이 동기화된다. 여기서 이렇게 하는 것은 단지 결과를 출력하기 위함이다. acks=all일 때, get()은 ISR의 모든 replica가 쓰기를 완료한 후에만 반환된다:
partition=2 offset=17
우리는 customerId를 key로 사용하므로 같은 고객의 모든 주문이 같은 partition으로 가고 정확한 순서를 유지한다.
3.1. acks=all이 min.insync.replicas가 없으면 충분하지 않음
acks=all은 “ISR에 현재 있는 모든 replica”를 의미한다. ISR이 leader만 남으로 축소되었다고 가정하자. 이 경우 acks=all은 acks=1과 다르지 않다. Topic 또는 broker 레벨에서 추가 구성이 필요하다:
kafka-topics.sh --bootstrap-server localhost:9092 --create --topic orders \
--partitions 6 --replication-factor 3 \
--config min.insync.replicas=2
replication-factor=3이고 min.insync.replicas=2일 때, 각 쓰기마다 최소 2개의 복사본이 필요하다. ISR이 2 아래로 떨어지면, producer는 단일 복사본으로 몰래 쓰는 대신 NotEnoughReplicasException 오류를 받는다.
참고:
min.insync.replicas를replication-factor와 같게 설정하지 말자. 이렇게 하면 broker를 재시작하는 것만으로도 topic이 모든 쓰기를 거부한다.
4. Idempotence: 재시도해도 중복으로 기록되지 않음
acks=all은 재시도를 수반한다. 다음 시나리오를 생각해보자: Broker가 메시지를 이미 썼지만 응답이 도중에 손실되었다. Producer는 전송이 실패했다고 생각하고 다시 보내므로 메시지가 두 번 쓰인다.
Idempotent producer는 이 시나리오를 정확히 해결한다. enable.idempotence=true를 활성화하면:
- Broker는 producer에게 **producer ID (PID)**를 할당한다.
- Partition으로 전송되는 각 batch는 증가하는 sequence number를 포함한다.
- Broker는 이미 본 sequence를 가진 batch를 버리고, sequence를 건너뜀 batch를 거부한다. 따라서 많은 요청이 진행 중일 때도 순서가 유지된다.
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
props.put(ProducerConfig.ACKS_CONFIG, "all");
props.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5);
props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);
props.put(ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG, 120_000);
각 줄의 의미:
ENABLE_IDEMPOTENCE_CONFIG는 PID 및 sequence number 메커니즘을 활성화한다.- Idempotence는
acks=all을 의무화한다.enable.idempotence=true와 함께acks=1을 명시적으로 설정하면, producer는 초기화 시ConfigException을 던진다. max.in.flight.requests.per.connection은 ≤ 5여야 한다. 이 한계 내에서 Kafka는 여전히 partition 내 순서를 보장한다.retries를 매우 크게 설정한다. 실제 재시도 시간은delivery.timeout.ms에 의해 제한된다 (기본값 2분).
Kafka 3.0부터 acks=all과 enable.idempotence=true가 기본값이다. 그럼에도 불구하고 명시적으로 선언해야 한다. 그렇지 않으면 누군가 acks=1을 설정하는 것만으로도 idempotence가 조용히 비활성화된다.
4.1. Idempotence가 할 수 없는 것
여기가 자주 오해받는 부분이다:
- 그것은 단지 한 producer 세션 내에서, 각 partition에 대해 중복을 방지한다. 애플리케이션이 재시작되면 producer는 새로운 PID를 받는다. 재시작 후 다시 보낸 메시지는 여전히 중복으로 기록된다.
- 그것은 당신의 코드가
send()를 두 번 호출하는 것으로 인한 중복을 방지하지 않는다. 예를 들어catch에서 자체 재시도할 때. - 그것은 여러 partition에 원자적 쓰기를 제공하지 않는다.
위의 경우들은 transaction (transactional.id) 또는 orderId에 따라 중복을 제거할 수 있는 consumer가 필요하다.
5. Batching: Latency와 throughput의 트레이드오프
Producer는 각각의 메시지를 하나씩 보내지 않는다. send()는 단지 record를 메모리 내 buffer에 배치하고, 각 partition별로 정리한다. 백그라운드 thread (sender)는 여러 record를 batch로 모아서 보낸다. 세 가지 주요 매개변수:
| 매개변수 | 기본값 | 역할 |
|---|---|---|
batch.size |
16384 byte | Partition에 대한 batch의 최대 크기 |
linger.ms |
0 (Kafka 4.0부터 5) | Batch를 채우기 위해 기다릴 시간 |
compression.type |
none |
전체 batch 압축: gzip, snappy, lz4, zstd |
buffer.memory |
32 MB | 대기 중인 모든 batch에 대한 총 메모리 |
Batch는 batch.size를 채우거나 linger.ms가 만료될 때 전송된다. 둘 중 먼저 오는 조건에 따라. linger.ms=0일 때, batch는 거의 항상 즉시 전송되므로 일반적으로 몇 개의 record만 포함한다.
props.put(ProducerConfig.BATCH_SIZE_CONFIG, 64 * 1024);
props.put(ProducerConfig.LINGER_MS_CONFIG, 10);
props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4");
이 구성은 최대 약 10ms의 추가 latency를 허용한다. 대신, 각 요청은 압축된 64KB의 데이터를 포함한다. 주문 이벤트처럼 반복이 많은 JSON 데이터의 경우, lz4 또는 zstd는 보통 네트워크 대역폭과 broker의 디스크 용량을 크게 줄인다. Batch 압축은 각 메시지를 압축하는 것보다 훨씬 효율적이다. Batch가 클수록 활용할 수 있는 반복 문자열이 많아지기 때문이다.
Producer의 메트릭을 통해 검증할 수 있다:
producer.metrics().forEach((name, metric) -> {
if (name.name().equals("batch-size-avg") || name.name().equals("records-per-request-avg")) {
System.out.println(name.name() + " = " + metric.metricValue());
}
});
linger.ms=0으로 10,000개의 주문을 연속으로 보내고, linger.ms=10으로 반복하면 records-per-request-avg가 뚜렷이 증가하는 것을 볼 수 있다:
# linger.ms=0
batch-size-avg = 1830.4
records-per-request-avg = 21.7
# linger.ms=10
batch-size-avg = 41022.9
records-per-request-avg = 486.2
정확한 숫자는 시스템과 부하에 따라 다르지만, 추세는 항상 그렇다.
참고: Buffer가 가득 차면 (consumer가 느린 것은 관계없고, 여기서는 broker가 느리거나 네트워크가 막힌 경우),
send()은 최대max.block.ms(기본값 60초) 동안 block되었다가TimeoutException을 던진다. HTTP 요청 처리 thread에서send()을 호출할 때 이 가능성을 계산하자.
참고:
delivery.timeout.ms는linger.ms + request.timeout.ms이상이어야 한다. 그렇지 않으면 producer는 초기화를 거부한다.
6. Spring Boot 3.x에서의 구성
Spring Boot는 일반적인 매개변수에 대한 고유한 속성이 있다. 나머지 매개변수는 properties를 통한다:
spring:
kafka:
bootstrap-servers: localhost:9092
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
acks: all
batch-size: 64KB
compression-type: lz4
buffer-memory: 64MB
properties:
enable.idempotence: true
linger.ms: 10
max.in.flight.requests.per.connection: 5
delivery.timeout.ms: 120000
Publisher는 KafkaTemplate을 사용한다. Spring Kafka 3.0부터 send()는 CompletableFuture를 반환한다:
@Service
public class OrderEventPublisher {
private static final Logger log = LoggerFactory.getLogger(OrderEventPublisher.class);
private final KafkaTemplate<String, String> kafkaTemplate;
public OrderEventPublisher(KafkaTemplate<String, String> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public CompletableFuture<SendResult<String, String>> publish(Order order) {
return kafkaTemplate.send("orders", order.customerId(), order.toJson())
.whenComplete((result, ex) -> {
if (ex != null) {
log.error("Order {} 전송 실패", order.id(), ex);
} else {
var meta = result.getRecordMetadata();
log.info("Order {} -> partition {} offset {}",
order.id(), meta.partition(), meta.offset());
}
});
}
}
세 가지 점에 주의하자:
- Callback은 producer의 sender thread에서 실행된다.
whenComplete내에서 무거운 작업을 하거나 blocking을 호출하지 말자. 다른 모든 전송을 느리게 할 것이다. - Callback이 오류를 받으면, producer는
delivery.timeout.ms한계 내에서 재시도를 완료했다. 손으로 다시send()을 호출하는 식으로 추가 재시도를 하지 말자. 이것이 바로 idempotence가 막을 수 없는 중복의 원인이다. CompletableFuture를 반환하면 호출자가 기다릴지 여부를 스스로 결정할 수 있다.
7. Producer 행동 테스트
kafka-clients는 broker 없이 테스트하기 위한 MockProducer를 제공한다. 이를 사용하려면 로직을 Producer 인터페이스를 받는 클래스로 분리해야 한다:
public class OrderSender {
private final Producer<String, String> producer;
public OrderSender(Producer<String, String> producer) {
this.producer = producer;
}
public Future<RecordMetadata> send(Order order) {
return producer.send(new ProducerRecord<>("orders", order.customerId(), order.toJson()));
}
public static Properties reliableConfig(String bootstrap) {
Properties p = new Properties();
p.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrap);
p.put(ProducerConfig.ACKS_CONFIG, "all");
p.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
p.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5);
p.put(ProducerConfig.LINGER_MS_CONFIG, 10);
p.put(ProducerConfig.BATCH_SIZE_CONFIG, 64 * 1024);
p.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4");
return p;
}
}
JUnit 5와 AssertJ를 사용한 테스트:
class OrderSenderTest {
@Test
void whenSendingOrders_thenKeyIsCustomerId() {
var mock = new MockProducer<>(true, new StringSerializer(), new StringSerializer());
var sender = new OrderSender(mock);
sender.send(new Order("o-1", "c-42", new BigDecimal("100")));
sender.send(new Order("o-2", "c-42", new BigDecimal("200")));
assertThat(mock.history())
.hasSize(2)
.extracting(ProducerRecord::key)
.containsOnly("c-42");
}
@Test
void whenBrokerFails_thenFutureCompletesExceptionally() {
var mock = new MockProducer<>(false, new StringSerializer(), new StringSerializer());
var sender = new OrderSender(mock);
Future<RecordMetadata> future = sender.send(new Order("o-3", "c-7", BigDecimal.TEN));
mock.errorNext(new NotEnoughReplicasException("ISR < min.insync.replicas"));
assertThatThrownBy(future::get)
.hasCauseInstanceOf(NotEnoughReplicasException.class);
}
@Test
void reliableConfig_isAccepted() {
var props = OrderSender.reliableConfig("localhost:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
var config = new ProducerConfig(props);
assertThat(config.getString(ProducerConfig.ACKS_CONFIG)).isEqualTo("-1");
assertThat(config.getBoolean(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG)).isTrue();
}
}
각 테스트는 하나의 행동을 확인한다:
- 첫 번째 테스트는
autoComplete=true를 사용하므로 모든send()가 즉시 성공한다.history()를 사용하면 key를 확인할 수 있고, 따라서 고객별 순서가 유지되는지 확인할 수 있다. - 두 번째 테스트는
autoComplete=false를 사용한 후errorNext(...)로 broker가 쓰기를 거부하도록 시뮬레이션한다. 이것이 코드의 오류 처리를 테스트하는 방법이다. - 마지막 테스트는
ProducerConfig를 초기화하여 Kafka가 구성 조합을 자체 검증하도록 한다. 잘못된 조합, 예를 들어acks=1과 idempotence를 함께 사용하면 배포 시 발생하는 대신ConfigException을 던져서 테스트가 실패한다. Kafka는all을"-1"로 정규화하므로 assertion은"-1"과 비교한다.
8. 언제 느슨하게 할 것인가?
“최대 안전” 구성이 항상 맞는 것은 아니다:
- 메트릭, 로그, clickstream: 몇 기록의 손실은 허용된다. 이 경우
acks=1, 높은linger.ms, 그리고 최고의 throughput을 위한zstd압축을 사용한다. - 메시지별 낮은 latency가 필요한 시스템 (예: 실시간 가격 정보):
linger.ms를 작게 유지한다 (0–2ms). 이 경우 큰 batch를 기대하지 말자. - 결제, 주문: 항상
acks=all, idempotence, 그리고min.insync.replicas=2를 사용한다. 정확히 한 번의 읽기-처리-쓰기가 필요하면, transaction을 고려해야 한다.
참고:
max.in.flight.requests.per.connection=1을 설정하는 것은 idempotence가 이미 활성화되어 있을 때 “보험”이 아니다. 그것은 단지 throughput을 감소시킨다.
9. 결론
세 가지 구성 그룹은 세 가지 다른 문제를 해결한다:
acks=all과min.insync.replicas의 조합은 메시지 손실을 방지한다.enable.idempotence는 재시도가 중복을 일으키지 않도록 하고 partition 내의 순서를 유지한다. 그 범위는 단일 producer 세션이다.batch.size,linger.ms,compression.type은 throughput을 결정하고, 약간의 latency를 대가로 한다.
이러한 값들을 명시적으로 선언하자. 기본값에 의존하지 말자. Test로 검증하여 배포 시 놀라움을 피하자. 그리고 producer가 이미 당신을 위해 하고 있으므로 코드에서 send()을 손으로 재시도하지 말자.