학습 목표

전송 파이프라인 — send()에서 콜백까지

KafkaProducer.send()브로커에 데이터를 보내지 않습니다. 레코드를 직렬화하고 파티션을 정한 뒤 메모리 버퍼에 넣고 즉시 반환합니다. 실제 네트워크 전송은 별도의 Sender 스레드가 합니다. 이 분리가 Producer 동작 전체의 출발점입니다.

Producer 전송 파이프라인 — 애플리케이션 스레드와 Sender 스레드의 경계 send() 호출부터 콜백까지의 경로를 두 개의 수평 레인으로 나눈 그림입니다. 위 레인은 애플리케이션 스레드가 하는 일로, send() 호출, 인터셉터 onSend, 직렬화, 파티션 결정까지입니다. 가운데 RecordAccumulator 는 파티션별 배치 큐이며 두 스레드가 만나는 지점입니다. 애플리케이션 스레드는 여기에 레코드를 넣고 곧바로 반환하므로 send() 는 블록되지 않습니다. 아래 레인은 Sender 스레드(백그라운드 I/O 스레드)가 하는 일로, 배치를 꺼내 ProduceRequest 로 브로커에 보내고 응답을 받아 onCompletion 콜백을 실행합니다. 콜백은 애플리케이션 스레드가 아니라 Sender 스레드에서 실행됩니다. 맨 아래 띠는 다른 프로세스인 브로커이며, 리더가 로그에 append 하고 acks 설정에 따라 ISR 복제를 기다립니다. send() 한 번에 관여하는 스레드는 2개입니다 레인 A · 애플리케이션 스레드 (여러분의 코드가 도는 스레드) send() 호출 후 즉시 반환 인터셉터 onSend() 직렬화 Serializer 적용 파티셔너 파티션 번호 결정 RecordAccumulator — 파티션별 배치 큐 (두 스레드가 만나는 유일한 지점) batch.size=16384 도달 또는 linger.ms=5 만료 중 먼저 오는 쪽에 전송 경계 스레드 교체 레인 B · Sender 스레드 (KafkaProducer 가 내부에 띄우는 백그라운드 I/O 스레드) 배치 인출 전송 조건 충족분 요청 전송 ProduceRequest 응답 수신 offset 또는 오류 콜백 실행 onCompletion() 요청 응답 브로커 (파티션 리더) — 로그에 append 후 acks 설정만큼 ISR 복제를 기다린 뒤 응답 다른 프로세스 콜백은 레인 B(Sender 스레드)에서 실행됩니다. 애플리케이션 스레드로 돌아오지 않습니다 — 가장 자주 틀립니다. 증거: 콜백 안에서 flush() 를 부르면 KafkaException 이 납니다 — Sender 스레드라서 교착되기 때문입니다. 버퍼 총량은 buffer.memory=33554432. 가득 차면 send()max.block.ms=60000 까지 블록됩니다.
Producer 전송 파이프라인 — send() 호출부터 콜백까지. 인터셉터·직렬화·파티셔너·버퍼 적재는 호출 스레드, 배치 드레인·전송·응답 처리·콜백 실행은 Sender 스레드입니다

단계별로 무슨 일이 일어나는가

전송 파이프라인 단계와 실행 스레드
# 단계 실행 스레드 관련 설정
1 인터셉터 onSend() 호출 스레드 interceptor.classes
2 메타데이터 확보 — 토픽의 파티션 수를 모르면 여기서 블로킹 호출 스레드 max.block.ms, metadata.max.age.ms
3 키·값 직렬화 호출 스레드 key.serializer, value.serializer
4 파티션 결정 호출 스레드 partitioner.class, partitioner.ignore.keys
5 RecordAccumulator의 파티션별 배치에 append — 버퍼가 가득하면 블로킹 호출 스레드 buffer.memory, batch.size, max.block.ms
6 send() 반환Future<RecordMetadata> 호출 스레드
7 전송 가능한 배치 수집(drain) 후 압축 Sender linger.ms, batch.size, compression.type
8 브로커별로 묶어 ProduceRequest 전송 Sender max.request.size, max.in.flight.requests.per.connection
9 응답 대기 · 재시도 판단 Sender acks, request.timeout.ms, retries, retry.backoff.ms
10 Future 완료 + 인터셉터 onAcknowledgement() + 콜백 실행 Sender delivery.timeout.ms
기본 Producer — Kafka 4.3 클라이언트 API
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.Producer;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.serialization.StringSerializer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Properties;

public final class OrderProducer {

    private static final Logger log = LoggerFactory.getLogger(OrderProducer.class);

    public static void main(String[] args) {
        Properties props = new Properties();
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG,
                  "kafka-1:9092,kafka-2:9092,kafka-3:9092");   // 초기 연결 이중화
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());

        // 아래 세 개는 Kafka 4.x 기본값이므로 생략해도 동일합니다.
        // 의도를 코드에 남기기 위해 명시했습니다.
        props.put(ProducerConfig.ACKS_CONFIG, "all");
        props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
        props.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5);

        // try-with-resources 로 닫아야 버퍼에 남은 배치가 flush 됩니다.
        try (Producer<String, String> producer = new KafkaProducer<>(props)) {

            ProducerRecord<String, String> record =
                new ProducerRecord<>("orders", "order-1024", "{\"amount\":12900}");

            producer.send(record, (metadata, exception) -> {
                if (exception != null) {
                    // 콜백은 Sender 스레드에서 실행됩니다. 여기서 블로킹하면 전송 전체가 멈춥니다.
                    // 로깅과 메트릭 증가까지만 하고, 재처리는 큐로 넘깁니다.
                    log.error("send failed topic={} key={}", record.topic(), record.key(), exception);
                    return;
                }
                log.debug("sent partition={} offset={}", metadata.partition(), metadata.offset());
            });

            // 종료 직전 명시적 flush — close() 도 내부적으로 flush 합니다.
            // try-with-resources 를 쓰지 않는다면 shutdown hook 에서
            // producer.close(Duration.ofSeconds(30)) 을 호출해야 버퍼가 비워집니다.
            producer.flush();
        } catch (RuntimeException e) {
            log.error("producer terminated abnormally", e);
            throw e;
        }
    }
}

콜백은 언제, 어느 스레드에서 실행되는가

onCompletion() 호출 시점 — 콜백은 Sender 스레드에서 실행됩니다 시간이 왼쪽에서 오른쪽으로 흐르는 두 개의 수평 레인입니다. 위 레인은 애플리케이션 스레드입니다. send() 를 호출하면 레코드를 RecordAccumulator 에 넣고 Future 를 즉시 반환하며, 그 뒤로는 다른 레코드를 계속 처리합니다. 브로커 응답을 기다리지 않습니다. 아래 레인은 Sender 스레드입니다. 배치 조건이 충족될 때까지 기다렸다가 ProduceRequest 를 보내고, 브로커가 처리한 응답을 받은 다음 onCompletion 콜백을 실행합니다. 세로 파선이 콜백 실행 시점이며, 그 시점에 애플리케이션 스레드는 이미 다른 일을 하고 있습니다. 즉 콜백은 애플리케이션 스레드로 돌아오지 않고 Sender 스레드에서 실행됩니다. 따라서 콜백 안에서 오래 걸리는 작업을 하면 Sender 스레드가 막혀 프로듀서 전체 전송이 느려집니다. 동기 전송이 필요하면 send() 가 돌려준 Future 의 get() 을 호출해야 하고, 그때 블록되는 것은 애플리케이션 스레드입니다. 전송이 실패하면 콜백이 받는 metadata 의 모든 필드는 -1 이며, 직렬화 실패는 콜백이 아니라 send() 가 예외로 바로 던집니다. Kafka 4.3 에서는 콜백 안에서 flush() 를 부르면 KafkaException 이 발생합니다. 콜백이 실행될 때 애플리케이션 스레드는 이미 다른 일을 하고 있습니다 레인 A · 애플리케이션 스레드 send() Future 반환 그동안 다른 레코드를 계속 처리합니다 — 응답을 기다리지 않습니다 ← 여기까지가 send() 가 붙잡는 시간 (버퍼에 넣는 시간뿐) → 레코드를 배치 큐에 넣습니다 레인 B · Sender 스레드 (백그라운드 I/O 스레드) 배치 대기 linger 요청 전송 브로커 처리 응답 onCompletion() 콜백을 오래 잡으면 이 레인이 막혀 전송 전체가 느려집니다. 무거운 작업은 콜백 안에서 직접 하지 말고 별도 Executor 로 넘기세요. 콜백 실행 시점 → 시간 두 레인은 같은 시간축을 공유합니다 콜백은 Sender 스레드에서 실행됩니다. 애플리케이션 스레드로 돌아오지 않습니다. 동기 전송이 필요하면 send(...).get() 을 호출합니다. 이때 블록되는 것은 애플리케이션 스레드입니다. 실패 시 콜백의 metadata 는 모든 필드가 -1 입니다(null 아님). 같은 파티션의 콜백 순서는 보장됩니다. SerializationException 은 콜백이 아니라 send() 가 바로 던집니다. 콜백 안에서 flush() 는 금지입니다.
onCompletion() 호출 시점 타임라인 — send()의 즉시 반환, 배치 대기, 브로커 응답, 그리고 Sender 스레드에서의 콜백 실행까지의 시간 축

Callback 인터페이스의 공식 javadoc은 두 문장으로 핵심을 정리합니다 — 이 콜백은 일반적으로 백그라운드 I/O 스레드에서 실행되므로 빨라야 하고, 레코드가 서버에서 acknowledge된 시점에 호출됩니다.

콜백의 계약

배치와 지연 — batch.size × linger.ms

배치와 linger.ms — batch.size 도달과 linger.ms 만료 중 먼저 오는 쪽에 전송 두 시나리오를 같은 12밀리초 시간축 위에 놓고 비교합니다. 각 시나리오에는 두 개의 막대가 있습니다. 위 막대는 batch.size 16384 바이트가 채워지기까지 걸리는 시간, 아래 막대는 linger.ms 5 밀리초 타이머가 만료되기까지 걸리는 시간입니다. 막대가 더 짧은 쪽, 즉 먼저 끝나는 조건이 전송을 유발합니다. 시나리오 1은 트래픽이 많아 3 밀리초에 batch.size 가 먼저 채워지므로 linger 타이머가 만료되기 전에 전송되고 배치가 꽉 찹니다. 시나리오 2는 트래픽이 적어 12 밀리초 동안 4200 바이트밖에 모이지 않으므로 5 밀리초에 linger.ms 만료가 먼저 와서 부분만 찬 배치가 전송됩니다. linger.ms 기본값은 Kafka 4.0부터 0 이 아니라 5 입니다. 전송을 유발하는 조건은 2개 — batch.size=16384 도달, linger.ms=5 만료. 막대가 짧은 쪽이 이깁니다. ① 트래픽 많음 — batch.size 가 먼저 도달 (3ms) batch.size 16384 B 16384 B 도달 → 전송 linger.ms 5 ms 타이머 5ms — 도달 전에 리셋됨 3ms 에 전송 · 배치가 꽉 찼습니다 0 2 ② 트래픽 적음 — linger.ms 만료가 먼저 도달 (5ms) batch.size 16384 B 12ms 동안 4,200 B — 16384 미달, 계속 기다려야 함 linger.ms 5 ms 5ms 만료 → 전송 5ms 에 전송 · 배치는 4,200 B 뿐 0 2 경과 시간 (ms) linger.ms 기본값은 5 입니다. Kafka 4.0에서 05 로 바뀌었습니다. 3.x 기준 자료는 대부분 0 으로 적혀 있습니다. ①처럼 배치가 꽉 차면 처리량이 좋고, ②처럼 linger 로 나가면 지연은 짧지만 요청 수가 늘어납니다. batch.size 는 파티션별 상한입니다. 이보다 큰 레코드는 배치로 묶지 않고 단독으로 보냅니다. 브로커 백프레셔가 걸리면 linger.ms 설정값보다 실제 대기 시간이 길어질 수 있습니다.
배치 형성 타임라인 — batch.size 도달과 linger.ms 만료 중 먼저 오는 쪽에서 전송이 일어나는 구조

RecordAccumulator는 파티션별로 배치를 만듭니다. 하나의 배치가 전송되는 조건은 두 가지이고 먼저 충족되는 쪽이 이깁니다.

  1. 그 파티션에 batch.size(기본 16384바이트) 분량이 쌓임
  2. linger.ms(기본 5ms) 경과

공식 문서의 표현은 명확합니다 — "한 파티션에 대해 batch.size 분량의 레코드를 얻으면 이 설정과 무관하게 즉시 전송하고, 그보다 적으면 지정한 시간만큼 linger하며 더 기다린다."

batch.size의 미묘한 성질 세 가지

buffer.memory와 역압

buffer.memory(기본 33554432 = 32MiB)는 전송 대기 레코드를 담는 전체 버퍼 크기입니다. 생산 속도가 전송 속도보다 빠르면 이 버퍼가 차고, 그때 send()max.block.ms만큼 블로킹된 뒤 TimeoutException으로 실패합니다. 공식 문서는 압축과 in-flight 요청 유지에도 추가 메모리가 쓰이므로 이 값이 프로듀서 전체 메모리의 엄격한 상한은 아니라고 명시합니다.

배치·버퍼 관련 설정과 처리량·지연 방향
설정 기본값 올리면 내리면
batch.size 16384 처리량 ↑ 압축률 ↑ 메모리 ↑ 지연 ↓ 처리량 ↓ (0이면 배치 없음)
linger.ms 5 처리량 ↑ 압축률 ↑ 지연 ↑ 지연 ↓ 요청 수 ↑ 처리량 ↓
buffer.memory 33554432 버스트 흡수력 ↑ 힙 사용 ↑ 버스트 시 send() 블로킹·실패 ↑
max.block.ms 60000 버스트 시 스레드가 오래 대기 빨리 실패 — 애플리케이션이 역압을 인지
max.request.size 1048576 큰 배치 허용 (브로커 설정과 정합 필요) RecordTooLargeException

acks와 내구성 — min.insync.replicas와의 결합

acks 0 · 1 · all 비교 — 각 설정에서 유실이 생기는 지점 같은 구성(리더 1대와 팔로워 2대)에 대해 acks 값 세 가지를 나란히 놓은 시퀀스 비교입니다. 각 열에는 프로듀서가 전송을 완료로 간주하는 지점을 가로 파선으로 표시했습니다. 이 선이 아래로 내려갈수록 보장이 강해집니다. acks=0 은 소켓 버퍼에 쓴 직후 완료로 보므로 요청이 브로커에 닿지 않아도 프로듀서가 알 수 없고 유실됩니다. offset 은 항상 -1 로 돌아오고 retries 설정도 동작하지 않습니다. acks=1 은 리더가 자기 로그에 기록한 직후 완료로 보므로, 복제가 끝나기 전에 리더가 죽으면 그 레코드는 새 리더에 없어 유실됩니다. acks=all 은 현재 ISR 전원이 응답한 뒤 완료로 보므로 리더가 죽어도 팔로워에 데이터가 있어 유실되지 않습니다. 단 ISR 이 1대로 줄어든 상태에서는 acks=all 도 1대만 확인하므로, min.insync.replicas 를 2 이상으로 두어야 레플리카 한 대 손실을 실제로 견딜 수 있습니다. 가로 파선 = 프로듀서가 전송을 완료로 간주하는 지점. 아래로 갈수록 보장이 강합니다. acks=0 확인 없음 프로듀서 완료 지점 · 소켓 버퍼 여기서 사라져도 모릅니다 리더 — 못 받았을 수도 팔로워 2 팔로워 3 유실 지점: 전송 직후 브로커에 닿지 않아도 성공으로 처리됩니다 retries 무효 · offset -1 acks=1 리더만 확인 프로듀서 ack 리더 — 로컬 로그 기록 완료 지점 · 리더 기록 후 ↓ 복제 전에 리더 다운 미복제 미복제 유실 지점: 복제 이전 ack 을 받은 레코드가 새 리더에는 없습니다 성공으로 보고된 뒤 유실 acks=all ISR 전원 확인 프로듀서 ack 리더 — 로컬 로그 기록 복제 완료 팔로워 2 ✔ 팔로워 3 ✔ 완료 지점 · ISR 전원 응답 결과: 무손실 (조건부) 리더가 죽어도 팔로워에 데이터가 남아 있습니다 단, ISR 이 1대면 위험 acks=all 만으로는 무손실이 아닙니다. ISR 이 1대로 줄면 확인 대상도 1대뿐입니다. min.insync.replicas=2 를 함께 두어야 레플리카 1대 손실을 견딥니다. acks 기본값은 all 입니다.
acks=0 / 1 / all 시퀀스 비교 — 각 설정에서 프로듀서가 응답을 받는 시점과 어디서 유실이 발생할 수 있는지

세 값의 정확한 의미

acks 값별 동작 (공식 문서 서술 기준). 기본값은 all입니다
언제 성공으로 응답하는가 반환되는 오프셋 유실 시나리오
acks=0 서버 응답을 전혀 기다리지 않음. 소켓 버퍼에 넣는 순간 전송된 것으로 간주 항상 -1 네트워크 유실·리더 다운을 알 수도 없음. retries 설정이 효력이 없습니다
acks=1 리더가 자기 로컬 로그에 기록하면 응답. 팔로워 복제는 기다리지 않음 실제 오프셋 ack 직후 복제 전에 리더가 죽으면 유실
acks=all (= -1) 현재 in-sync 레플리카 전원이 ack하면 응답 실제 오프셋 in-sync 레플리카가 하나도 남지 않는 경우에만 유실

min.insync.replicas와의 조합

min.insync.replicas토픽·브로커 설정이고 acks프로듀서 설정입니다. 소속이 다릅니다. 공식 문서는 이 설정이 acks=all일 때만 효력을 가진다고 명시합니다. 미달 시 프로듀서는 NotEnoughReplicasException 또는 NotEnoughReplicasAfterAppendException을 받습니다 — 둘 다 retriable입니다.

RF 3 토픽에서 acks × min.insync.replicas 조합의 결과
조합 ISR 3 (정상) ISR 2 ISR 1 평가
acks=0 (minISR 무관) 성공 (확인 안 함) 성공 (확인 안 함) 성공 (확인 안 함) at-most-once. 유실 감지 불가
acks=1 (minISR 무관) 성공 (리더만) 성공 (리더만) 성공 (리더만) 리더 장애 시 유실 가능
acks=all · minISR 1 성공 (3대 ack) 성공 (2대 ack) 성공 (1대만) "RF 3인데 유실"의 전형적 원인
acks=all · minISR 2 성공 (3대 ack) 성공 (2대 ack) 거부 권장. 1대 장애를 견디며 유실 방지
acks=all · minISR 3 성공 (3대 ack) 거부 거부 브로커 1대 재시작에 쓰기 중단

멱등성 프로듀서 — 무엇을 보장하고 무엇을 보장하지 않는가

멱등성 중복 판별 — PID 와 파티션별 시퀀스 번호로 브로커가 중복을 폐기 프로듀서와 브로커 두 개의 생명선을 가진 시퀀스 그림입니다. 프로듀서는 producer id 7391 과 epoch 0 을 부여받고, 파티션 orders-0 에 대해 시퀀스 번호 4 와 5 를 담은 배치를 보냅니다. 브로커는 이를 로그에 append 하고 그 파티션의 마지막 시퀀스를 5 로 기록한 뒤 응답을 보냅니다. 그 응답이 네트워크에서 유실되어 프로듀서는 실패로 판단하고 같은 producer id 와 같은 시퀀스 번호 4, 5 로 재전송합니다. 브로커는 들어온 배치의 시작·끝 시퀀스가 이미 기록한 마지막 시퀀스와 일치하는 것을 보고 중복으로 판단해 로그에 쓰지 않고 성공 응답만 돌려줍니다. 그래서 로그에는 오프셋 100 과 101 두 건만 남고 중복이 생기지 않습니다. 중복 판별의 열쇠는 producer id, epoch, 파티션 세 값의 조합으로 관리되는 시퀀스 번호이며, 판정하는 주체는 프로듀서가 아니라 브로커입니다. 중복을 걸러내는 주체는 브로커 입니다. 판단 근거는 PID + 파티션별 시퀀스 번호입니다. 프로듀서 PID=7391 epoch=0 브로커 (리더) PID 별 마지막 seq 보관 ① 최초 전송 배치 [seq 4, 5] ② 로그에 append 이 PID 의 lastSeq = 5 ③ 응답 유실 응답이 도중에 사라짐 프로듀서는 실패로 판단 ④ 재시도 같은 PID · 같은 seq 4, 5 ⑤ 중복으로 판정 → 폐기 기록한 마지막 seq 와 일치 ⑥ 성공 응답 (로그는 그대로) 파티션 orders-0 로그 offset 100 seq 4 offset 101 seq 5 (비어 있음) 중복 안 생김 재시도가 몇 번이든 로그에는 2건만 남습니다 시퀀스는 (PID, epoch, 파티션) 조합별로 따로 관리됩니다. 파티션이 다르면 시퀀스도 따로 셉니다. 기대보다 큰 seq 가 오면 브로커가 OUT_OF_ORDER_SEQUENCE_NUMBER 로 거부합니다. 기본값 enable.idempotence=trueacks=all · retries>0 · max.in.flight≤5 를 요구합니다. 막아 주는 것은 재시도로 생기는 중복 뿐입니다. send() 를 두 번 부르면 2건이 남습니다. max.in.flight… 를 6 이상으로 명시 하면 ConfigException. 다른 설정 충돌은 조용히 비활성화 됩니다. 상한이 5인 이유: 브로커가 파티션별로 최근 배치 5개의 시퀀스만 보관합니다.
멱등성 중복 판별 — PID(producer id)와 파티션별 시퀀스 번호로 브로커가 재시도된 배치를 중복으로 판정해 폐기하는 과정

재시도는 중복을 만듭니다. 프로듀서가 배치를 보냈고 브로커가 기록까지 했는데 응답이 유실되면, 프로듀서는 실패로 판단해 같은 배치를 다시 보냅니다. 결과는 로그에 두 번 기록된 같은 레코드입니다. 멱등성 프로듀서는 이것을 막습니다.

PID와 시퀀스 번호

브로커는 각 프로듀서에게 producer id(PID)를 부여하고, 프로듀서는 파티션마다 단조 증가하는 시퀀스 번호를 붙여 배치를 보냅니다. 2장에서 본 레코드 배치 헤더producerId · producerEpoch · baseSequence가 바로 이것입니다.

브로커는 들어온 배치의 첫·마지막 시퀀스 번호를 그 프로듀서의 마지막 값과 비교해 중복을 판정합니다. 이미 기록한 시퀀스면 기록하지 않고 성공으로 응답합니다. 프로듀서는 재시도가 성공한 것으로 보고 진행하고, 로그에는 사본이 하나만 남습니다.

멱등성이 요구하는 세 조건

공식 문서는 멱등성 활성화의 전제 조건을 명시합니다.

enable.idempotence=true가 요구하는 설정 (공식 문서 기준)
설정요구 조건4.x 기본값이유
max.in.flight.requests.per.connection 5 이하 5 브로커가 프로듀서별로 최대 5개 배치까지만 보관합니다. 6 이상이면 이전 배치가 브로커 쪽에서 밀려나 중복 판별이 불가능해집니다
retries 0보다 커야 함 2147483647 재시도가 없으면 멱등성이 막을 대상 자체가 없습니다
acks all이어야 함 all ISR 전원 기록을 확인해야 중복 판별 상태가 레플리카 간에 일관됩니다

멱등성의 경계

멱등성 프로듀서가 보장하는 것과 보장하지 않는 것
항목보장설명
같은 프로듀서 인스턴스의 재시도로 인한 중복 막습니다 PID + 시퀀스 번호로 판별
단일 파티션 안의 쓰기 순서 보장합니다 in-flight 5 이하 조건 하에서
여러 파티션에 걸친 원자성 보장하지 않습니다 트랜잭션이 필요합니다 (6장)
애플리케이션이 같은 이벤트를 두 번 send()한 경우 막지 못합니다 서로 다른 시퀀스 번호이므로 정상 레코드 두 개입니다
프로듀서 재시작 후의 중복 막지 못합니다 새 PID를 받습니다. 재시작 경계를 넘으려면 transactional.id가 필요합니다
컨슈머 쪽 중복 처리 범위 밖입니다 컨슈머 멱등성은 별도 문제입니다 (6장, 케이스 7)

순서 보장 — in-flight와 재시도의 관계

순서가 깨지는 메커니즘은 단순합니다. 같은 파티션으로 배치 A와 B를 연달아 보냈는데 A가 실패해 재시도되고 B가 먼저 성공하면, 로그에는 B가 앞에 기록됩니다. 공식 문서는 retries 설명에서 이 시나리오를 그대로 서술합니다.

설정 조합별 순서 보장 여부
enable.idempotence in-flight 재시도 파티션 내 순서 중복
true (4.x 기본) 1 ~ 5 있음 보장 제거됨
true 6 이상 구성 불가 — ConfigException
false 1 있음 보장 발생 가능
false 2 이상 있음 깨질 수 있음 발생 가능
false 2 이상 retries=0 보장 없음 (대신 유실)

retries는 이제 조절 대상이 아닙니다

retries의 기본값은 2147483647(Integer.MAX_VALUE)입니다. 공식 문서는 "이 설정은 그대로 두고 대신 delivery.timeout.ms로 재시도 동작을 제어하는 것을 일반적으로 권장한다"고 명시합니다. 즉 실질적인 상한은 횟수가 아니라 시간입니다.

파티셔너 — 키가 있을 때와 없을 때

키 있음/없음 파티셔닝 — murmur2 해시와 sticky batching 비교 파티션 여러 개에 레코드를 보내며 분배를 관찰하는 인터랙티브 그림입니다. 위쪽 막대는 파티션별 누적 건수이고, 아래쪽 스트립은 최근 24건이 어떤 순서로 어느 파티션에 갔는지 보여 줍니다. 모드는 세 가지입니다. 첫째 키 있음 모드에서는 toPositive(murmur2(key)) 를 파티션 수로 나눈 나머지가 파티션이 되므로 같은 키는 언제나 같은 파티션으로 가고, 그래서 같은 키의 순서가 보장됩니다. 둘째 키 없음 sticky batching 모드는 Kafka 의 실제 기본 동작입니다. 한 파티션에 batch.size 만큼 보낸 뒤 다음 파티션으로 전환하므로 스트립에 같은 번호가 연속으로 나타납니다. 셋째 키 없음 순수 라운드로빈 모드는 비교용입니다. 매 건마다 파티션이 바뀌므로 스트립이 0,1,2 순서로 반복됩니다. 시험 문제에서는 키가 없을 때를 라운드로빈이라고 표현하기도 하지만, 실제 구현은 배치 단위로 몰아 보내는 sticky 방식이며 결과적으로 분포는 고르게 됩니다. 초기 상태는 키 order-42 로 6개 파티션에 8건을 보낸 모습이며 전부 파티션 0 으로 갔습니다. Kafka 4.0 에서 DefaultPartitioner 와 UniformStickyPartitioner 클래스는 제거되었고 내장 파티셔너가 이 동작을 담당합니다. 키가 있으면 결정적, 키가 없으면 배치 단위로 몰아 보냅니다 키 있음 — 같은 키는 언제나 같은 파티션으로 갑니다 (파티션 단위 순서 보장) toPositive(murmur2("order-42")) % 6 = 0 p0 8 p1 0 p2 0 p3 0 p4 0 p5 0 p6 0 p7 0 전송 순서 (최근 24건) — 같은 번호가 연속이면 sticky, 번호가 매번 바뀌면 라운드로빈 0 0 0 0 0 0 0 0 · · · · · · · · · · · · · · · · 키가 있으면 toPositive(murmur2(key)) % 파티션수 로 결정됩니다 — 총 전송 8 파티션 수를 바꾸면 같은 키가 다른 파티션으로 갑니다. 파티션을 늘리면 그 키의 순서 보장이 끊깁니다. Kafka 4.0 에서 DefaultPartitioner·UniformStickyPartitioner 는 제거되었습니다.
키 있음/없음 파티셔닝 비교 — 키가 있으면 murmur2(key) % N으로 결정적, 키가 없으면 batch.size 분량이 쌓일 때까지 같은 파티션에 sticky하게 몰아 보내는 동작. 버튼으로 메시지를 보내며 분배를 관찰할 수 있습니다

키가 있으면 — 결정적 해시

공식 문서는 기본 파티셔닝 로직을 이렇게 설명합니다 — "파티션이 지정되지 않았고 키가 있으면 키의 해시를 기준으로 파티션을 고른다." 실제 구현은 Kafka 소스의 BuiltInPartitioner에 있고, 계산식은 다음과 같습니다.

키 기반 파티션 결정 (Apache Kafka 4.3 BuiltInPartitioner)
// 직렬화된 키 바이트 → murmur2 해시 → 양수화 → 파티션 수로 나눈 나머지
partition = Utils.toPositive(Utils.murmur2(serializedKey)) % numPartitions;

세 가지 결론이 여기서 나옵니다.

키가 없으면 — sticky 배치, 라운드로빈이 아닙니다

공식 문서의 서술입니다 — "파티션도 키도 없으면 그 파티션에 batch.size 이상이 생산될 때 바뀌는 sticky 파티션을 고른다." 같은 문서는 기본 전략을 "한 파티션에 최소 batch.size 바이트가 생산될 때까지 그 파티션으로 레코드를 보내는 전략"으로 요약합니다.

즉 레코드마다 파티션을 바꾸는 것이 아니라, 배치가 찰 때까지 한 파티션에 몰아넣고 그다음 다른 파티션으로 전환합니다. 목적은 배치 효율입니다 — 레코드를 파티션마다 흩뿌리면 배치가 작게 조각나 요청 수가 늘고 압축률도 떨어집니다.

파티셔너 관련 설정

파티셔너 설정 — Apache Kafka 4.3 기준
설정기본값설명
partitioner.class null 지정하지 않으면 기본 파티셔닝 로직을 씁니다. 공식 문서가 제시하는 대안은 org.apache.kafka.clients.producer.RoundRobinPartitioner이며, 커스텀은 Partitioner 인터페이스를 구현합니다
partitioner.ignore.keys false true키가 있어도 무시하고 파티션을 고릅니다. 커스텀 파티셔너를 쓰면 효과가 없습니다
partitioner.adaptive.partitioning.enable true 빠른 브로커가 담당한 파티션에 더 많이 보내려고 시도합니다. false면 균등 분배를 시도합니다. 커스텀 파티셔너를 쓰면 효과가 없습니다
partitioner.availability.timeout.ms 0 0이면 비활성. 이 시간 이상 응답이 없는 브로커의 파티션을 회피합니다 (adaptive.partitioningfalse면 효과 없음)

커스텀 파티셔너

VIP 고객 트래픽을 전용 파티션으로 격리하는 커스텀 파티셔너
import org.apache.kafka.clients.producer.Partitioner;
import org.apache.kafka.common.Cluster;
import org.apache.kafka.common.PartitionInfo;
import org.apache.kafka.common.utils.Utils;

import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * VIP 고객의 레코드를 마지막 파티션에 격리하고, 나머지는 기본과 동일하게
 * murmur2 해시로 분배합니다. 격리 목적은 일반 트래픽 폭주가 VIP 처리 지연에
 * 영향을 주지 않게 하는 것입니다.
 */
public class VipIsolatingPartitioner implements Partitioner {

    private Set<String> vipKeys = Set.of();

    @Override
    public void configure(Map<String, ?> configs) {
        Object raw = configs.get("vip.customer.ids");
        if (raw != null) {
            vipKeys = Set.of(raw.toString().split(","));
        }
    }

    @Override
    public int partition(String topic, Object key, byte[] keyBytes,
                         Object value, byte[] valueBytes, Cluster cluster) {
        List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
        int numPartitions = partitions.size();
        if (numPartitions == 0) {
            throw new IllegalStateException("no partition metadata for topic " + topic);
        }

        int vipPartition = numPartitions - 1;

        if (keyBytes == null) {
            // 키가 없으면 VIP 판별이 불가능합니다. VIP 파티션을 제외한 범위로 보냅니다.
            // (기본 sticky 배치의 이점은 포기하게 됩니다 — 그래서 키를 항상 넣는 편이 낫습니다)
            return numPartitions == 1 ? 0
                 : Utils.toPositive(Utils.murmur2(new byte[0])) % vipPartition;
        }

        if (vipKeys.contains(key.toString())) {
            return vipPartition;
        }

        // VIP 파티션을 제외한 나머지에 기본과 같은 방식으로 분배
        return numPartitions == 1 ? 0
             : Utils.toPositive(Utils.murmur2(keyBytes)) % vipPartition;
    }

    @Override
    public void close() {
        // 정리할 자원이 없습니다.
    }
}

압축 — 배치 단위로 적용됩니다

압축 트레이드오프 — 압축률과 CPU 비용은 서로 반대 방향 가로축이 CPU 비용, 세로축이 압축률인 좌표 위에 Kafka 가 지원하는 압축 코덱 none, snappy, lz4, gzip, zstd 의 상대적인 경향을 배치한 그림입니다. 왼쪽 아래는 CPU 를 거의 쓰지 않지만 압축도 되지 않는 none 이고, 오른쪽 위로 갈수록 압축률이 높아지면서 CPU 비용도 커집니다. snappy 와 lz4 는 CPU 비용이 낮고 압축률이 중간이며, gzip 은 압축률이 높지만 CPU 비용도 큽니다. zstd 는 압축 수준을 조절할 수 있어 gzip 에 가까운 압축률을 더 낮은 CPU 로 얻을 수 있는 범위를 가집니다. 절대 수치는 메시지 모양과 크기에 따라 크게 달라지므로 반드시 자기 데이터로 측정해야 하며, 이 그림은 순서와 방향만 나타냅니다. 아래에는 반드시 알아야 할 사실이 정리되어 있습니다. 압축은 배치 단위로 이뤄지므로 배치가 클수록 압축률이 좋아지고, 토픽과 브로커의 compression.type 기본값은 producer 로 프로듀서가 압축한 형태를 그대로 저장하며, 다른 코덱을 지정하면 브로커가 풀고 다시 압축하므로 브로커 CPU 를 더 씁니다. 압축 비용은 프로듀서와 컨슈머 양쪽에서 발생합니다. 압축률을 올리면 CPU 를 더 씁니다 — 어느 쪽이 비싼 자원인지로 고릅니다 CPU 비용 (프로듀서 · 컨슈머 양쪽) → 압축률 높음 없음 none snappy lz4 zstd 수준 조절 gzip CPU 는 안 쓰지만 네트워크·디스크 사용은 최대 고르는 기준 지연이 가장 중요하다 → lz4 · snappy 저장·네트워크 비용이 크다 → zstd (수준으로 조절) CPU 여유가 많고 압축률 우선 → gzip 이미 압축된 데이터(이미지 등) → none 위 배치는 상대적 경향 입니다. 절대 수치는 데이터 모양에 따라 달라지므로 자기 데이터로 측정하세요. 압축은 배치 단위로 이뤄집니다. 배치가 클수록 압축률이 좋아지므로 linger.ms·batch.size 와 함께 봅니다. 프로듀서 compression.type 기본값은 none, 토픽·브로커 기본값은 producer (프로듀서가 압축한 그대로 저장). 토픽에 다른 코덱을 지정하면 브로커가 풀고 다시 압축 하므로 브로커 CPU 를 더 씁니다. 유효 값은 none · gzip · snappy · lz4 · zstd 이고, 토픽 설정에는 uncompressed·producer 도 있습니다. 수준 조절: compression.gzip.level(-1) · compression.lz4.level(9) · compression.zstd.level(3)
압축 코덱 트레이드오프 — gzip · snappy · lz4 · zstd의 압축률과 CPU 비용의 상대 경향. 공식 문서에 구체적 수치가 없으므로 실제 값은 워크로드로 실측해야 합니다

compression.type의 프로듀서 기본값은 none이고 허용 값은 none, gzip, snappy, lz4, zstd입니다.

핵심은 공식 문서의 이 문장입니다 — "압축은 데이터의 전체 배치 단위로 수행되므로, 배치가 잘 되는지가 압축 효율에도 영향을 준다."batch.sizelinger.ms를 올리면 압축률도 함께 올라갑니다. 압축만 켜고 배치 설정을 그대로 두면 기대한 효과가 나오지 않습니다.

압축 코덱과 레벨 설정 (기본값은 Apache Kafka 4.3 공식 문서 기준)
코덱 레벨 설정 레벨 기본값 레벨 범위 일반적 특성
none 프로듀서 기본값. CPU 비용 없음, 네트워크·디스크 사용 최대
gzip compression.gzip.level -1 (구현 기본) 1~9 또는 -1 압축률이 높은 편, CPU 비용도 높은 편
snappy (레벨 설정 없음) 속도 지향. 레벨 조절 불가
lz4 compression.lz4.level 9 1~17 속도 지향. 레벨로 압축률과 CPU를 조절
zstd compression.zstd.level 3 -131072~22 레벨 범위가 가장 넓어 압축률과 속도의 조절 폭이 큼

토픽 레벨 compression.type

토픽에도 같은 이름의 설정이 있고 기본값은 producer입니다. 허용 값은 uncompressed, zstd, lz4, snappy, gzip, producer입니다. producer프로듀서가 지정한 코덱을 그대로 유지한다는 뜻입니다.

에러 처리 — retriable, non-retriable, 그리고 시간 예산

재시도 예산 — delivery.timeout.ms 가 request.timeout.ms 와 재시도를 모두 포함 delivery.timeout.ms 120000 밀리초를 큰 막대로 그리고, 그 안에 request.timeout.ms 30000 밀리초 단위의 시도들을 중첩해 넣은 그림입니다. 바깥 막대는 send() 호출 이후 성공이나 실패를 보고할 최종 기한이고, 안쪽 칸은 각 전송 시도입니다. 30000 밀리초짜리 시도를 세 번 하면 90000 밀리초를 쓰고 30000 밀리초가 남습니다. 남은 예산 안에 성공하지 못하면 콜백에 TimeoutException 이 전달되며, retries 값이 아무리 커도 여기서 끝납니다. linger.ms 기본값 5 밀리초와 retry.backoff.ms 기본값 100 밀리초는 이 축척에서 선 하나보다 얇아 보이지 않지만 예산에 포함됩니다. 공식 문서의 요구 조건은 delivery.timeout.ms 가 request.timeout.ms 와 linger.ms 의 합보다 크거나 같아야 한다는 것입니다. 재시도 횟수가 아니라 delivery.timeout.ms 예산이 종료 시점을 정합니다 바깥 = 전체 예산 · 안쪽 = 개별 시도 delivery.timeout.ms = 120000send() 이후 성공/실패를 보고할 최종 기한 시도 1 30000 시도 2 (재시도) 30000 시도 3 (재시도) 30000 남은 예산 30000 시도 단위 request 0 30000 60000 90000 120000 linger.ms=5retry.backoff.ms=100 도 예산에 포함되지만 이 축척에서는 선보다 얇습니다. delivery.timeout.ms ≥ request.timeout.ms + linger.ms retries 기본값은 2147483647 입니다. 그래도 무한 재시도가 아닌 이유가 이 예산입니다. 예산을 넘기면 콜백에 TimeoutException 이 전달됩니다. retries 대신 이 값으로 조절하세요. 복구 불가 오류는 예산이 남아도 즉시 실패합니다 (예: RecordTooLargeException). request.timeout.msreplica.lag.time.max.ms(30000) 보다 크게 — 불필요한 재시도 중복을 줄입니다.
재시도 시간 예산 — delivery.timeout.ms 안에 linger.ms 대기와 request.timeout.ms × 재시도가 포함되는 관계

시간 예산의 포함 관계

delivery.timeout.ms(기본 120000ms)는 공식 문서의 정의대로 send()가 반환한 뒤 성공 또는 실패를 보고하기까지의 상한입니다. 여기에 세 가지가 모두 포함됩니다.

  1. 전송 전에 배치를 기다린 시간 (linger.ms)
  2. 브로커 응답을 기다린 시간 (request.timeout.ms, 기본 30000ms)
  3. 재시도 가능한 실패로 인한 재시도 전체 시간 (retry.backoff.ms 기본 100, retry.backoff.max.ms 기본 1000)

그래서 공식 문서는 delivery.timeout.msrequest.timeout.ms + linger.ms의 합보다 크거나 같아야 한다고 명시합니다.

재시도 예산을 명시적으로 설계한 예 — 최대 3분 안에 결론
# 요청 하나가 브로커 응답을 기다리는 시간
request.timeout.ms=30000

# 배치 대기
linger.ms=5

# 전체 예산: 30s 요청 × 재시도 + 배치 대기 <= 180s
# 30s 요청이 대략 5~6회 반복될 수 있는 예산입니다.
delivery.timeout.ms=180000

# 재시도 간 백오프 (지수 증가, 상한 1s)
retry.backoff.ms=100
retry.backoff.max.ms=1000

# retries 는 건드리지 않습니다 — 기본값(사실상 무한)을 두고 시간으로 제어합니다

retriable과 non-retriable

판정 기준은 예외가 RetriableException을 상속하는지입니다. Apache Kafka 4.3 소스의 계층 구조는 다음과 같습니다.

예외 계층 (Apache Kafka 4.3)
KafkaException
├── SerializationException                      ← non-retriable
└── ApiException
    ├── RetriableException                      ← 프로듀서가 자동 재시도
    │   ├── TimeoutException
    │   ├── NotEnoughReplicasException
    │   ├── NotEnoughReplicasAfterAppendException
    │   ├── CorruptRecordException
    │   └── RefreshRetriableException            ← 메타데이터 갱신 후 재시도
    │       └── InvalidMetadataException
    │           ├── LeaderNotAvailableException
    │           ├── NotLeaderOrFollowerException
    │           ├── UnknownTopicOrPartitionException
    │           └── NetworkException
    ├── ApplicationRecoverableException          ← 애플리케이션이 복구해야 함
    │   └── ProducerFencedException
    ├── RecordTooLargeException                  ← non-retriable
    ├── OutOfOrderSequenceException              ← non-retriable
    └── InvalidConfigurationException
        ├── InvalidTopicException                ← non-retriable
        ├── RecordBatchTooLargeException         ← non-retriable
        └── AuthorizationException
            └── TopicAuthorizationException      ← non-retriable

Callback 인터페이스의 공식 javadoc은 콜백에 전달될 수 있는 예외를 두 그룹으로 나열합니다. 시험 대비로는 이 목록이 가장 정확한 근거입니다.

콜백에 전달되는 예외 (org.apache.kafka.clients.producer.Callback javadoc 기준)
분류 예외 대표적 원인과 조치
retriable
(일시적)
CorruptRecordException 전송 중 손상. 재전송으로 해결되는 경우가 많습니다
InvalidMetadataException
(및 하위 클래스)
리더 이동·브로커 재시작. 메타데이터를 갱신하고 재시도합니다
NotEnoughReplicasException ISR < min.insync.replicas. 복제 문제를 해결해야 근본 해결입니다
NotEnoughReplicasAfterAppendException 리더는 기록했지만 필요한 ISR 수에 미달. 재전송 시 멱등성이 중복을 막습니다
OffsetOutOfRangeException 요청 오프셋이 범위 밖
TimeoutException 브로커 응답 지연 또는 버퍼 대기 초과. request.timeout.ms·max.block.ms 확인
UnknownTopicOrPartitionException 토픽이 아직 없거나 메타데이터가 낡음. 오타 토픽일 수도 있습니다
BufferExhaustedException buffer.memory 소진. 생산 속도를 낮추거나 버퍼를 키웁니다
non-retriable
(치명적 — 이 레코드는 절대 전송되지 않습니다)
InvalidTopicException 토픽 이름이 규칙 위반. 이름을 고쳐야 합니다
OffsetMetadataTooLarge 커밋 메타데이터 문자열이 상한 초과
RecordBatchTooLargeException 배치가 브로커의 message.max.bytes 초과
RecordTooLargeException 레코드가 max.request.size 또는 브로커 상한 초과. 5개 설정 정합을 확인하세요(케이스 10)
UnknownServerException 브로커가 분류하지 못한 오류. 브로커 로그를 봐야 합니다
UnknownProducerIdException 브로커가 이 PID의 상태를 잃음. 프로듀서를 재생성해야 합니다
InvalidProducerEpochException 같은 transactional.id의 새 인스턴스가 이 프로듀서를 밀어냄
AuthenticationException 인증 실패. 자격증명을 고쳐야 합니다
AuthorizationException 인가 실패. ACL을 부여해야 합니다 (11장)
동기 예외와 비동기 예외를 모두 처리하는 형태
void publish(Order order) {
    ProducerRecord<String, Order> record =
        new ProducerRecord<>("orders", order.customerId(), order);
    try {
        producer.send(record, (metadata, exception) -> {
            if (exception == null) {
                sentCounter.increment();
                return;
            }
            // 비동기 실패: 재시도 예산을 모두 소진했거나 non-retriable 입니다.
            // 여기서 다시 send() 하지 마세요 — Sender 스레드를 재귀적으로 막습니다.
            if (exception instanceof org.apache.kafka.common.errors.RetriableException) {
                // delivery.timeout.ms 를 다 쓰고도 실패 → 클러스터 쪽 문제로 취급
                deadLetterQueue.offer(order);
            } else {
                // 데이터 자체가 문제 → 다시 보내도 같은 결과입니다
                poisonPillStore.save(order, exception);
            }
        });
    } catch (org.apache.kafka.common.errors.SerializationException e) {
        // 직렬화 실패는 호출 스레드에서 동기적으로 발생합니다.
        poisonPillStore.save(order, e);
    } catch (org.apache.kafka.common.errors.TimeoutException e) {
        // 메타데이터 미확보 또는 buffer.memory 소진 (max.block.ms 초과)
        backpressureSignal.raise();
        throw e;
    }
}

전송 파이프라인 관측 — 어느 메트릭이 어디를 보는가

앞의 파이프라인 각 단계는 서로 다른 메트릭으로 관측됩니다. 어떤 메트릭이 어느 구간을 덮는지 알아야 “느리다”는 증상을 단계로 좁힐 수 있습니다. CCDAK의 Application Observability 도메인이 이 구분을 묻습니다.

프로듀서 핵심 메트릭 지도 — 어느 메트릭이 어느 단계를 관측하는가 Producer 전송 파이프라인을 한 줄로 압축해 그리고, 그 아래에 네 개의 관측 지점을 번호로 표시한 그림입니다. 관측 지점 1 은 RecordAccumulator 버퍼이고 buffer-available-bytes 로 남은 버퍼를 봅니다. 이 값이 0 에 가까워지면 send() 가 max.block.ms 까지 블록되어 애플리케이션이 멈춥니다. 관측 지점 2 는 버퍼에서 Sender 로 넘어가는 경계이고 record-queue-time-avg 로 배치가 버퍼에 머문 평균 시간을 봅니다. linger.ms 와 batch.size 를 조정한 결과가 여기 나타납니다. 관측 지점 3 은 Sender 와 브로커 사이의 요청 왕복이고 request-latency-avg 로 평균 요청 지연을 봅니다. acks 가 all 이면 ISR 복제 대기 시간이 여기 포함됩니다. 관측 지점 4 는 응답과 콜백 단계이고 record-send-rate 로 초당 전송 성공 레코드 수를, record-error-rate 로 초당 오류로 끝난 전송 수를 봅니다. MBean 이름은 kafka.producer 타입 producer-metrics 이며 client-id 로 구분되고, record-send-rate 와 record-error-rate 는 토픽별 producer-topic-metrics 로도 제공됩니다. 메트릭 하나하나가 파이프라인의 어느 지점을 보는지 알면 원인 추적이 빨라집니다 전송 파이프라인 (D-030 축약) send() 앱 스레드 직렬화 파티셔너 배치 버퍼 Accumulator Sender I/O 스레드 브로커 append 응답 → 콜백 1 2 3 4 1 · buffer-available-bytes 쓰지 않고 남아 있는 버퍼 바이트 수 0 에 가까우면 send() 가 블록됩니다 → 브로커 지연 · buffer.memory 부족을 의심 2 · record-queue-time-avg 배치가 전송 버퍼에 머문 평균 시간(ms) linger.ms·batch.size 조정 결과가 보입니다 → 크게 튀면 Sender 가 못 따라가는 상태 3 · request-latency-avg 요청 하나의 평균 왕복 시간(ms) acks=all 이면 ISR 복제 대기가 포함됩니다 → 브로커·네트워크·ISR 상태를 봅니다 4 · record-send-rate / record-error-rate 초당 전송 레코드 수 / 초당 오류로 끝난 수 처리량 기준선과 실패율을 함께 봅니다 → error-rate 는 0 이 아니면 바로 조사 MBean: kafka.producer:type=producer-metrics,client-id="{client-id}" record-send-rate·record-error-rate 는 토픽별 producer-topic-metrics 로도 나옵니다. 함께 보면 좋은 값: batch-size-avg (배치가 실제로 커졌는지) · compression-rate-avg (압축 효과)
프로듀서 핵심 메트릭 지도 — record-send-rate · record-error-rate · request-latency-avg · buffer-available-bytes가 전송 파이프라인의 어느 단계를 관측하는지
프로듀서 지연 분해 — send() 부터 콜백까지 시간이 어디서 쓰이는가 send() 호출부터 onCompletion 콜백까지의 시간을 다섯 구간으로 나눈 가로 막대입니다. 첫 구간은 배치 대기로 linger.ms 만료나 batch.size 도달을 기다리는 시간이며 record-queue-time-avg 로 관측합니다. 두 번째는 요청을 브로커로 보내는 네트워크 상행, 세 번째는 브로커가 로그에 append 하고 acks 설정만큼 ISR 복제를 기다리는 시간, 네 번째는 응답이 돌아오는 네트워크 하행입니다. 이 세 구간을 묶어 request-latency-avg 가 관측합니다. 마지막 구간은 Sender 스레드에서 실행되는 콜백이며 별도 메트릭이 없고 애플리케이션 코드가 소요 시간을 정합니다. acks 가 all 이면 브로커 처리 구간에 ISR 복제 대기가 들어가므로 전체 지연의 큰 부분을 차지할 수 있습니다. 막대의 길이 비율은 설명을 위한 예시이며 실제 비율은 환경에 따라 달라집니다. 지연을 줄이려면 먼저 어느 구간이 긴지 봐야 합니다 send() → 콜백 전체 시간 막대 비율은 예시입니다 ① 배치 대기 linger.ms / batch.size ② 상행 네트워크 ③ 브로커 처리 append + ISR 복제 대기 ④ 하행 네트워크 ⑤ 콜백 Sender 스레드 record-queue-time-avg ①만 측정합니다 request-latency-avg ② + ③ + ④ 를 한 값으로 묶어 측정합니다 전용 메트릭 없음 내 코드가 결정 긴 구간별 조치 ① 이 길다 linger.ms 를 낮추거나 트래픽이 적은지 확인 지연 ↔ 처리량 교환입니다 ③ 이 길다 acks·ISR 상태·디스크 확인 느린 팔로워가 있는지 봅니다 복제 대기가 여기 포함됩니다 ⑤ 가 길다 콜백에서 무거운 작업 제거 별도 Executor 로 넘기기 Sender 전체가 막힙니다 종단 지연은 여기에 컨슈머 쪽 대기가 더해집니다 — fetch.max.wait.ms=500, fetch.min.bytes=1. 컨슈머는 high watermark 까지만 읽으므로 ③의 복제 대기는 읽기 가능 시점에도 영향을 줍니다.
프로듀서 지연 분해 — 배치 대기 · 전송 · 브로커 처리 · 응답 구간과 각 구간을 덮는 메트릭

Producer 설정 정리

기본값은 Apache Kafka 4.3 공식 문서(Producer Configs) 기준입니다. 전체 목록과 프리셋은 설정 치트시트에 있습니다.

이 장에서 다룬 Producer 설정 전체
설정 기본값 범위·타입 설명 튜닝 포인트
acks all string — all · -1 · 0 · 1 성공 응답 전에 필요한 확인 수준 3.0부터 기본값이 1에서 변경. 멱등성은 all을 요구합니다
enable.idempotence true boolean 재시도로 인한 중복 제거 3.0부터 기본 활성. 충돌 설정이 있으면 조용히 꺼질 수 있으니 명시를 권장
linger.ms 5 long — [0,…] 배치를 모으기 위해 기다리는 최대 시간 4.0에서 0 → 5로 변경. 지연 민감 경로는 명시적으로 0
batch.size 16384 int — [0,…] 파티션별 배치 크기 상한 (바이트) 올리면 처리량·압축률 ↑. 0이면 배치 비활성
buffer.memory 33554432 long — [0,…] 전송 대기 레코드용 전체 버퍼 프로듀서 총 메모리의 엄격한 상한은 아닙니다
max.block.ms 60000 long — [0,…] send() 등이 블로킹될 수 있는 상한 사용자 직렬화기·파티셔너 내부 블로킹은 포함되지 않습니다
max.in.flight.requests.per.connection 5 int — [1,…] 커넥션당 미확인 요청 수 멱등성 사용 시 5 이하 필수. 초과 시 ConfigException
retries 2147483647 int — [0,…,2147483647] 일시적 오류 재시도 횟수 공식 권장은 건드리지 않고 delivery.timeout.ms로 제어
delivery.timeout.ms 120000 int — [0,…] send() 후 성공·실패 보고까지의 전체 상한 request.timeout.ms + linger.ms 이상이어야 합니다
request.timeout.ms 30000 int — [0,…] 요청 하나의 응답 대기 시간 올릴 때 delivery.timeout.ms를 함께 올리세요
retry.backoff.ms 100 long — [0,…] 재시도 전 대기 시간 retry.backoff.max.ms까지 지수적으로 증가합니다
retry.backoff.max.ms 1000 long — [0,…] 재시도 백오프의 상한 브로커 장애가 길어질 때 재시도 폭주를 억제합니다
max.request.size 1048576 int — [0,…] 요청 하나의 최대 크기 브로커 message.max.bytes(1048588)와 정합해야 합니다
compression.type none string — none · gzip · snappy · lz4 · zstd 배치 압축 코덱 배치 설정과 함께 올려야 효과가 큽니다
compression.gzip.level -1 int — [1,…,9] 또는 -1 gzip 압축 레벨 -1은 구현 기본 레벨
compression.lz4.level 9 int — [1,…,17] lz4 압축 레벨
compression.zstd.level 3 int — [-131072,…,22] zstd 압축 레벨 조절 폭이 가장 넓습니다
partitioner.class null class 파티션 결정 전략 지정하지 않는 것이 기본 sticky 최적화를 쓰는 방법입니다
partitioner.ignore.keys false boolean 키를 무시하고 파티션 결정 커스텀 파티셔너 사용 시 효과 없음
partitioner.adaptive.partitioning.enable true boolean 빠른 브로커 쪽 파티션에 더 많이 보냄 커스텀 파티셔너 사용 시 효과 없음
partitioner.availability.timeout.ms 0 long — [0,…] 응답이 없는 브로커의 파티션 회피 기준 0이면 비활성. adaptive가 꺼져 있으면 효과 없음
interceptor.classes "" list ProducerInterceptor 구현 목록 onSend()는 호출 스레드, onAcknowledgement()는 Sender 스레드
transactional.id null string — 비어 있지 않은 문자열 트랜잭션 프로듀서 식별자 설정하면 멱등성이 필수로 켜집니다. 상세는 6장
transaction.timeout.ms 60000 int 트랜잭션 코디네이터가 강제 abort하기까지의 시간 브로커의 상한을 넘길 수 없습니다 (6장)
metadata.max.age.ms 300000 long — [0,…] 메타데이터 강제 갱신 주기 리더 변경은 에러 응답으로 즉시 감지되므로 보통 기본값 유지

목적별 프리셋

세 가지 목적별 Producer 설정 프리셋. 4.x 기본값과 다른 항목만 표기했습니다
설정 내구성 우선 처리량 우선 지연 우선
acksall (기본)all (기본)all (기본)
enable.idempotencetrue (기본, 명시 권장)true (기본)true (기본)
linger.ms5 (기본)20~1000
batch.size16384 (기본)65536 이상16384 (기본)
compression.type선택lz4 또는 zstdnone (기본) 또는 lz4
delivery.timeout.ms충분히 크게120000 (기본)짧게 — 빨리 실패
buffer.memory33554432 (기본)키우기33554432 (기본)
토픽 min.insync.replicas2 (RF 3 기준)22

처리량 우선 프리셋의 구체적 값은 워크로드에 따라 달라집니다. 위 범위는 방향을 나타내는 출발점이며, 공식 문서가 제시하는 권장값은 아닙니다. 측정 절차는 예제 3에 있습니다.

흔한 오해

시험 포인트 정리

확인 문제

CCDAK Application Development 도메인의 중심 내용입니다. 단일 선택 · 복수 선택 · 연결형 · 순서 배열이 섞여 있으며 키보드만으로 완주할 수 있습니다.

공식 문서 출처

이 장의 설정 기본값·동작 서술·예외 목록은 모두 아래에서 확인했습니다 (Apache Kafka 4.3 기준).