KafkaProducer 객체의 send() 메서드는 호출 시마다 하나의 ProducerRecord를 입력하지만 바로 전송되지않고 내부 메모리에서 단일 메시지를 토픽 파티션에 Record Batch단위로 묶인 뒤 전송된다.
메시지들은 Producer Client의 내부 메모리에 여러 개의 Batch들로 buffer.memory 설정 사이즈만큼 보관될 수 있으며 여러 개의 Batch들로 한꺼번에 전송될 수 있다.
// 카프카에게 보낼 키/값 쌍. 이는 레코드가 전송되는 주제 이름, 선택적 파티션 번호, 그리고 선택적 키와 값으로 구성됩니다.// 유효한 파티션 번호가 지정되면 레코드를 보낼 때 해당 파티션이 사용됩니다. 파티션이 지정되지 않았지만 키가 존재한다면, 키의 해시를 사용해 파티션을 선택합니다. 키와 파티션이 모두 없으면 라운드로빈 방식으로 파티션이 할당됩니다. 파티션 번호는 0-인덱스임을 유의하세요.// 레코드에는 타임스탬프도 함께 붙어 있습니다. 사용자가 타임스탬프를 제공하지 않았다면, 제작자는 현재 시간을 기록에 스탬핑합니다. 카프카가 최종적으로 사용하는 타임스탬프는 해당 주제에 맞게 설정된 타임스탬프 유형에 따라 달라집니다.// CreateTime설정되어 있다면, 프로듀서 레코드의 타임스탬프는 브로커가 사용합니다.// LogAppendTime설정되어 있다면, 프로듀서 레코드의 타임스탬프는 브로커가 메시지를 로그에 추가할 때 브로커의 현지 시간으로 덮어쓰게 됩니다.publicclassProducerRecord<K, V> {
privatefinalStringtopic;
privatefinalIntegerpartition;
privatefinalHeadersheaders;
privatefinalKkey;
privatefinalVvalue;
privatefinalLongtimestamp;
// ...
}
Record Batch와 Record Accumulator 이해
Record Accumulator는 Partitioner에 의해서 메시지 배치가 전송이 될 토픽과 파티션에 따라 저장되는 KafkaProducer 메모리 영역이다.
Sender Thread는 Record Accumulator에 누적된 메시지 배치를 꺼내서 브로커로 전송한다.
Main Thread는 send() 메서드를 호출하고 Record Accumulator에 데이터를 저장하고 Sender Thread는 별개로 데이터를 브로커에 전송한다.
linger.ms와 batch.size 파라미터 이해
Sender Thread는 기본적으로 전송할 준비가 되어 있다면 Record Accumulator에서 1개의 Batch를 가져갈수도, 여러 개의 Batch를 가져갈 수도 있다. 또한 Batch에 메시지가 다 차지 않더라도 가져갈 수 있다.
linger.ms를 0보다 크게 설정하면 Sender Thread가 하나의 Batch Record를 가져갈 때까지 일정 시간 대기하여 Record Batch에 메시지를 많이 채울 수 있도록 설정할 수 있다.
linger.ms는 보통 20ms 이하로 설정을 권장하며, 전송이 느리다고 판단된다면 linger.ms를 높여서 메시지가 배치로 적용될 수 있는 확률을 높이는 시도를 할 수 있다.
전송/재전송 내부 메커니즘
retries와 delivery.timeout.ms를 이용해 재전송 횟수를 조정한다.
retries는 재전송 횟수를 설정한다.
delivery.timeout.ms는 메시지 재전송을 멈출 때까지의 시간이다.
retries 설정 횟수만큼 재전송을 시도하다가 delivery.timeout.ms가 되면 재전송을 중지한다.
retry.backoff.ms는 재전송 주기 시간을 설정한다.
request.timeout.ms만큼 기다린 후 재전송하기 전 30ms 이후 재전송을 시도한다. 이와 같은 방식으로 총 10회를 시도하고 더 이상 retry를 하지 않는다.
만약 10회 이내에 delivery.timeout.ms에 도달하면 더 이상 retry를 하지 않는다.
max.in.flight.requests.per.connection이란 브로커 서버 응답없이 Producer의 Sender Thread가 한 번에 보낼 수 있는 메시지 배치의 개수로 기본값은 5이다.
최대 한 번 전송(at most once)
Producer는 Broker로부터 Ack 또는 에러 메시지 없이 다음 메시지를 연속적으로 보낸다.
메시지가 소실될 수 있지만 중복 전송은 하지 않는다.
적어도 한 번 전송(at least once)
Producer는 Broker로부터 Ack를 받은 다음에 다음 메시지를 전송한다.
메시지 소실은 없지만 중복 전송을 할 수 있다.
정확히 한 번 전송(exactly once)
Producer는 Broker로부터 Ack를 받은 다음에 다음 메시지를 전송하되, Producer ID와 메시지 Sequence를 Header에 저장하여 전송한다.
메시지 Sequence는 메시지 고유 Sequence 번호를 말하며 0부터 시작해 순차적으로 증가한다.
Producer ID는 Producer가 기동시마다 새롭게 생성된다.
브로커에서 메시지 Sequence가 중복될 경우 이를 메시지 로그에 기록하지 않고 Ack만 전송된다.
브로커는 Producer가 보낸 메시지의 Sequence가 브로커가 가지고 있는 메시지의 Sequence보다 1만큼 큰 경우에만 브로커에 저장한다.
Idempotence(중복 없이 전송) 설정 및 설정 시 유의사항
Kafka 3.0 버전부터는 Producer의 기본 설정이 Idempotence이다.
그러나 기본 설정 중에 enable.idempotence=true를 제외하고 다른 파라미터들을 잘못 설정하면 Producer는 정상적으로 메시지를 보내지만 Idempotence로는 동작하지 않는다.
명시적으로 enable.idempotence=true를 설정한 뒤 다른 파라미터들을 잘못 설정하면 Config 오류가 발생하면서 Producer가 기동되지 않게 된다.
Idempotence 기반에서 메시지 전송 순서 유지
위의 예시를 본다면 B0이 가장 먼저, B1, B2 순에서 Producer에서 생성된 메시지 배치이다.
Idempotence 기반에서 max.in.flight.requests.per.connection만큼 여러 개의 배치들이 Broker에 전송된다.
Broker는 메시지 배치를 처리 시에 write된 배치의 마지막 메시지의 Sequence에 +1만큼 더한 값이 아닌 배치 메시지가 올 경우 OutOfOrderSequenceException을 생성하며 Producer에 오류를 전달한다.
Partitioner 이해
@ComponentpublicclassCustomPartitionerimplementsPartitioner {
@Overridepublicintpartition(Stringtopic, Objectkey, byte[] keyBytes, Objectvalue, byte[] valueBytes, Clustercluster) {
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
intnumPartitions = partitions.size();
if (keyBytes == null) {
// 키가 없는 경우, 라운드 로빈 방식으로 파티션 선택 (DefaultPartitioner 동작 유사)returnUtils.toPositive(Utils.murmur2(valueBytes)) % numPartitions;
}
if ("special-key".equals(key)) {
returnnumPartitions - 1;
}
// 그 외의 모든 메시지는 키의 해시값을 기반으로 파티션을 결정합니다. (DefaultPartitioner 동작)returnUtils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
}
@Overridepublicvoidclose() {
// 리소스 정리
}
@Overridepublicvoidconfigure(Map<String, ?> configs) {
// 설정 초기화
}
}
KafkaProducer는 기본적으로 DefaultPartitioner 클래스를 이용해 메시지 전송 시 도착할 Partitioner을 지정한다.
DefaultPartitioner는 키를 가지는 메시지의 경우라면 키 값을 해싱하여 키 값에 따라 파티션별로 균일하게 전송한다.
Custom Partitioning을 구현하려면 Partitioner 인터페이스를 직접 구현해야 하며 partition() 메서드에 Custom Partitioning 로직을 직접 구현해야 한다.