기본개념 · 4장
Producer 심화
CCDAK에서 가장 비중이 큰 도메인이 Application Development(28%)이고,
그 안에서 가장 많이 출제되는 컴포넌트가 Producer입니다.
send()가 비동기이며 즉시 반환한다는 사실 하나에서
배치·순서·중복·유실·콜백 스레드 문제가 전부 파생됩니다.
이 장은 그 파생 관계를 순서대로 풀어 갑니다.
4.x에서 조용히 바뀐 기본값(linger.ms=5)과
제거된 클래스(DefaultPartitioner)도 함께 짚습니다.
학습 목표
send()호출부터 콜백까지의 단계를 나열하고, 각 단계가 어느 스레드에서 실행되는지 구분할 수 있습니다.batch.size·linger.ms·buffer.memory·max.block.ms의 상호작용을 설명할 수 있습니다.acks세 값의 정확한 의미와min.insync.replicas와의 결합 결과를 판단할 수 있습니다.- 멱등성 프로듀서가 무엇을 보장하고 무엇을 보장하지 않는지 구분하고,
max.in.flight.requests.per.connection이 5 이하여야 하는 이유를 설명할 수 있습니다. - 키가 있을 때와 없을 때의 파티션 결정 방식을 구분하고, "라운드로빈"이라는 서술이 왜 부정확한지 설명할 수 있습니다.
- retriable · non-retriable 에러를 구분하고
delivery.timeout.ms가 전체 상한이 되는 구조를 설명할 수 있습니다.
전송 파이프라인 — send()에서 콜백까지
KafkaProducer.send()는 브로커에 데이터를 보내지 않습니다.
레코드를 직렬화하고 파티션을 정한 뒤 메모리 버퍼에 넣고 즉시 반환합니다.
실제 네트워크 전송은 별도의 Sender 스레드가 합니다.
이 분리가 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 |
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() 호출 시점 타임라인 — send()의 즉시 반환,
배치 대기, 브로커 응답, 그리고 Sender 스레드에서의 콜백 실행까지의 시간 축
Callback 인터페이스의 공식 javadoc은 두 문장으로 핵심을 정리합니다 —
이 콜백은 일반적으로 백그라운드 I/O 스레드에서 실행되므로 빨라야 하고,
레코드가 서버에서 acknowledge된 시점에 호출됩니다.
콜백의 계약
- 실행 스레드는 Sender(I/O) 스레드입니다. 애플리케이션 스레드가 아닙니다. 콜백 안에서 DB 호출·HTTP 호출·락 획득을 하면 그 프로듀서의 모든 파티션 전송이 함께 지연됩니다.
(metadata, exception)중 한쪽은 항상 null입니다. 예외가 있을 때metadata는 모든 필드가-1인 빈 메타데이터가 전달됩니다(null이 아닙니다). 파티션을 정할 수 없었던 경우에도-1이 들어갑니다.- 같은 파티션으로 보낸 레코드들의 콜백은 순서가 보장됩니다.
공식 javadoc의 예시대로
callback1이callback2보다 먼저 실행됩니다. 다른 파티션 사이에는 순서 보장이 없습니다. - 호출 시점은 브로커 응답을 받았을 때 또는 최종 실패가 확정되었을 때입니다.
최종 실패의 상한이
delivery.timeout.ms입니다.
배치와 지연 — batch.size × linger.ms
batch.size 도달과 linger.ms 만료 중
먼저 오는 쪽에서 전송이 일어나는 구조
RecordAccumulator는 파티션별로 배치를 만듭니다. 하나의 배치가 전송되는 조건은 두 가지이고 먼저 충족되는 쪽이 이깁니다.
- 그 파티션에
batch.size(기본 16384바이트) 분량이 쌓임 linger.ms(기본 5ms) 경과
공식 문서의 표현은 명확합니다 — "한 파티션에 대해 batch.size 분량의 레코드를
얻으면 이 설정과 무관하게 즉시 전송하고, 그보다 적으면
지정한 시간만큼 linger하며 더 기다린다."
batch.size의 미묘한 성질 세 가지
batch.size보다 큰 레코드는 배치되지 않습니다. 공식 문서가 "이 크기보다 큰 레코드를 배치하려는 시도는 하지 않는다"고 명시합니다. 큰 레코드는 자기 혼자 하나의 요청이 됩니다.batch.size=0은 배치를 완전히 끕니다. 처리량이 크게 떨어지므로 진단 목적 외에는 쓰지 않습니다.- 버퍼는 지정한 크기만큼 미리 할당됩니다.
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 시퀀스 비교 —
각 설정에서 프로듀서가 응답을 받는 시점과 어디서 유실이 발생할 수 있는지
세 값의 정확한 의미
| 값 | 언제 성공으로 응답하는가 | 반환되는 오프셋 | 유실 시나리오 |
|---|---|---|---|
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입니다.
| 조합 | 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(PID)를 부여하고,
프로듀서는 파티션마다 단조 증가하는 시퀀스 번호를 붙여 배치를 보냅니다.
2장에서 본 레코드 배치 헤더의
producerId · producerEpoch · baseSequence가 바로 이것입니다.
브로커는 들어온 배치의 첫·마지막 시퀀스 번호를 그 프로듀서의 마지막 값과 비교해 중복을 판정합니다. 이미 기록한 시퀀스면 기록하지 않고 성공으로 응답합니다. 프로듀서는 재시도가 성공한 것으로 보고 진행하고, 로그에는 사본이 하나만 남습니다.
멱등성이 요구하는 세 조건
공식 문서는 멱등성 활성화의 전제 조건을 명시합니다.
| 설정 | 요구 조건 | 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(key) % N으로 결정적,
키가 없으면 batch.size 분량이 쌓일 때까지 같은 파티션에 sticky하게
몰아 보내는 동작. 버튼으로 메시지를 보내며 분배를 관찰할 수 있습니다
키가 있으면 — 결정적 해시
공식 문서는 기본 파티셔닝 로직을 이렇게 설명합니다 —
"파티션이 지정되지 않았고 키가 있으면 키의 해시를 기준으로 파티션을 고른다."
실제 구현은 Kafka 소스의 BuiltInPartitioner에 있고, 계산식은 다음과 같습니다.
BuiltInPartitioner)// 직렬화된 키 바이트 → murmur2 해시 → 양수화 → 파티션 수로 나눈 나머지
partition = Utils.toPositive(Utils.murmur2(serializedKey)) % numPartitions;
세 가지 결론이 여기서 나옵니다.
- 같은 키는 항상 같은 파티션으로 갑니다. 그래서 키 단위 순서가 보장됩니다.
- 대상은 직렬화된 키 바이트입니다. 직렬화기를 바꾸면(예: String → Avro) 같은 논리적 키가 다른 파티션으로 갈 수 있습니다.
- 파티션 수(
numPartitions)가 나눗셈에 들어갑니다. 파티션을 24개에서 32개로 늘리면 대부분의 키가 다른 파티션으로 이동하고, 그 순간 키 단위 순서 보장이 끊깁니다 (케이스 5).
키가 없으면 — sticky 배치, 라운드로빈이 아닙니다
공식 문서의 서술입니다 — "파티션도 키도 없으면
그 파티션에 batch.size 이상이 생산될 때 바뀌는 sticky 파티션을 고른다."
같은 문서는 기본 전략을 "한 파티션에 최소 batch.size 바이트가
생산될 때까지 그 파티션으로 레코드를 보내는 전략"으로 요약합니다.
즉 레코드마다 파티션을 바꾸는 것이 아니라, 배치가 찰 때까지 한 파티션에 몰아넣고 그다음 다른 파티션으로 전환합니다. 목적은 배치 효율입니다 — 레코드를 파티션마다 흩뿌리면 배치가 작게 조각나 요청 수가 늘고 압축률도 떨어집니다.
파티셔너 관련 설정
| 설정 | 기본값 | 설명 |
|---|---|---|
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.partitioning이 false면 효과 없음) |
커스텀 파티셔너
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() {
// 정리할 자원이 없습니다.
}
}
압축 — 배치 단위로 적용됩니다
compression.type의 프로듀서 기본값은 none이고
허용 값은 none, gzip, snappy,
lz4, zstd입니다.
핵심은 공식 문서의 이 문장입니다 — "압축은 데이터의 전체 배치 단위로
수행되므로, 배치가 잘 되는지가 압축 효율에도 영향을 준다."
즉 batch.size와 linger.ms를 올리면 압축률도 함께
올라갑니다. 압축만 켜고 배치 설정을 그대로 두면 기대한 효과가 나오지 않습니다.
| 코덱 | 레벨 설정 | 레벨 기본값 | 레벨 범위 | 일반적 특성 |
|---|---|---|---|---|
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 안에
linger.ms 대기와 request.timeout.ms × 재시도가 포함되는 관계
시간 예산의 포함 관계
delivery.timeout.ms(기본 120000ms)는 공식 문서의 정의대로
send()가 반환한 뒤 성공 또는 실패를 보고하기까지의 상한입니다.
여기에 세 가지가 모두 포함됩니다.
- 전송 전에 배치를 기다린 시간 (
linger.ms) - 브로커 응답을 기다린 시간 (
request.timeout.ms, 기본 30000ms) - 재시도 가능한 실패로 인한 재시도 전체 시간
(
retry.backoff.ms기본 100,retry.backoff.max.ms기본 1000)
그래서 공식 문서는 delivery.timeout.ms가
request.timeout.ms + linger.ms의 합보다
크거나 같아야 한다고 명시합니다.
# 요청 하나가 브로커 응답을 기다리는 시간
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 소스의 계층 구조는 다음과 같습니다.
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은 콜백에 전달될 수 있는 예외를
두 그룹으로 나열합니다. 시험 대비로는 이 목록이 가장 정확한 근거입니다.
| 분류 | 예외 | 대표적 원인과 조치 |
|---|---|---|
| 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 도메인이 이 구분을 묻습니다.
record-send-rate ·
record-error-rate · request-latency-avg ·
buffer-available-bytes가 전송 파이프라인의 어느 단계를 관측하는지
Producer 설정 정리
기본값은 Apache Kafka 4.3 공식 문서(Producer Configs) 기준입니다. 전체 목록과 프리셋은 설정 치트시트에 있습니다.
| 설정 | 기본값 | 범위·타입 | 설명 | 튜닝 포인트 |
|---|---|---|---|---|
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,…] | 메타데이터 강제 갱신 주기 | 리더 변경은 에러 응답으로 즉시 감지되므로 보통 기본값 유지 |
목적별 프리셋
| 설정 | 내구성 우선 | 처리량 우선 | 지연 우선 |
|---|---|---|---|
acks | all (기본) | all (기본) | all (기본) |
enable.idempotence | true (기본, 명시 권장) | true (기본) | true (기본) |
linger.ms | 5 (기본) | 20~100 | 0 |
batch.size | 16384 (기본) | 65536 이상 | 16384 (기본) |
compression.type | 선택 | lz4 또는 zstd | none (기본) 또는 lz4 |
delivery.timeout.ms | 충분히 크게 | 120000 (기본) | 짧게 — 빨리 실패 |
buffer.memory | 33554432 (기본) | 키우기 | 33554432 (기본) |
토픽 min.insync.replicas | 2 (RF 3 기준) | 2 | 2 |
처리량 우선 프리셋의 구체적 값은 워크로드에 따라 달라집니다. 위 범위는 방향을 나타내는 출발점이며, 공식 문서가 제시하는 권장값은 아닙니다. 측정 절차는 예제 3에 있습니다.
흔한 오해
시험 포인트 정리
확인 문제
CCDAK Application Development 도메인의 중심 내용입니다. 단일 선택 · 복수 선택 · 연결형 · 순서 배열이 섞여 있으며 키보드만으로 완주할 수 있습니다.
이어서 볼 곳
- 5장 · Consumer 심화 poll 루프, 타임아웃 4종, 리밸런스, KIP-848 새 프로토콜.
-
6장 · 전달 보장과 트랜잭션
멱등성과 트랜잭션의 차이, EOS의 경계,
read_committed와 LSO. - 예제 3 · 무손실 프로듀서 돌아가는 코드로 acks·minISR·재시도 예산을 직접 검증합니다.
- 케이스 4 · 키 없이 보낸 이벤트 순서가 뒤바뀐 상태 변경 이벤트. 원인 추적과 복구.
- 케이스 3 · 프로듀서 전송 실패의 진짜 원인 ISR 부족과 unclean leader election이 프로듀서에 어떻게 보이는가.
- 설정 치트시트 Producer 설정 전체 표와 처리량·지연·내구성 3가지 프리셋.
공식 문서 출처
이 장의 설정 기본값·동작 서술·예외 목록은 모두 아래에서 확인했습니다 (Apache Kafka 4.3 기준).
- Producer Configs — 이 장의 모든 프로듀서 설정 기본값과 설명
- Producer Configs —
acks— 세 값의 정확한 정의,acks=0에서 오프셋-1과retries무효,all=-1 - Producer Configs —
linger.ms— 기본값이 4.0에서 0 → 5로 변경된 사실과 근거 - Producer Configs —
batch.size— 크기 초과 레코드는 배치하지 않음, 0이면 배치 비활성, 브로커 backpressure 영향 - Producer Configs —
enable.idempotence— in-flight 5 이하 ·retries> 0 ·acks=all요구, 충돌 시ConfigException또는 조용한 비활성화 - Producer Configs —
max.in.flight.requests.per.connection— 브로커가 프로듀서별로 최대 5개 배치를 보관하는 이유 - Producer Configs —
delivery.timeout.ms—request.timeout.ms+linger.ms이상 요구 - Producer Configs —
partitioner.class— 기본 sticky 전략 서술,RoundRobinPartitioner,Partitioner인터페이스 - Producer Configs —
max.block.ms— 메타데이터·버퍼 대기 포함, 사용자 직렬화기·파티셔너 내부 블로킹 미포함 - Topic Configs —
compression.type— 기본값producer의 의미와 허용 값 - Topic Configs —
min.insync.replicas—acks=all에서 ISR 전원 ack 요구,NotEnoughReplicas/NotEnoughReplicasAfterAppend - Design — Message Delivery Semantics — 멱등 전달, PID와 시퀀스 번호로 중복 제거
- Design — Availability and Durability Guarantees —
acks=all이 "현재 ISR 전원" 기준이라는 서술과 예시 - Implementation — Record Batch —
producerId·producerEpoch·baseSequence, 중복 판별에 첫·마지막 시퀀스를 쓰는 방식 - Javadoc —
Callback— 백그라운드 I/O 스레드 실행, 예외 시 metadata의-1, retriable / non-retriable 예외 목록 - Javadoc —
KafkaProducer.send()— 같은 파티션 콜백의 순서 보장 - Upgrading Apache Kafka — 4.0에서
DefaultPartitioner·UniformStickyPartitioner·NotLeaderForPartitionException제거,Partitioner.onNewBatch제거