Data & Platform/Event Streaming

Kafka - 03. producer

producer — send가 반환됐다고 도달한 게 아니다

주문 API가 producer.send(orderEvent)를 호출했다. 메서드는 즉시 반환됐다. 그런데 이 순간 메시지는 broker에 도달한 게 아니다 — accumulator라는 배치 버퍼에 쌓여 있을 뿐이고, 배경 스레드가 나중에 보낸다. "send가 반환됐으니 안전하겠지"라고 가정하면, 장애 순간에 accumulator에 남아 있던 메시지들이 전부 날아간다. 이게 producer를 다룰 때 가장 먼저 부딪히는 함정이다.

producer는 애플리케이션에 내장되는 클라이언트다. 별도 서버가 아니라 주문 API·배치 잡·로그 수집기 같은 앱 프로세스 안에서 동작한다. 비동기 send, batch, acks, idempotent producer가 어떻게 엮여서 "메시지를 잃지 않으면서도 빠르게"를 만드는지를 추적한다. acks=all 한 줄만 보고 "안전하다"고 믿으면 ISR이 1개 남았을 때 구멍이 드러나므로, 각 설정이 지키는 경계와 그 경계가 무너지는 조건을 정확히 짚어야 한다.

producer는 앱에 내장되는 클라이언트다

producer는 앱에 내장되는 클라이언트 라이브러리다. 별도 서버가 아니라, 주문 API·배치 잡·로그 수집기 같은 앱 프로세스 안에서 동작한다.

Java producer 기본 코드

// 1. 설정
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("acks", "all");
props.put("enable.idempotence", "true");

// 2. producer 생성
Producer<String, String> producer = new KafkaProducer<>(props);

// 3. 메시지 전송
producer.send(new ProducerRecord<>("orders", "order-123", "주문 데이터"));

// 4. 종료
producer.close();

결제 버튼이 producer를 부를 때

사용자가 "결제" 버튼을 누르면: 주문 API가 DB에 저장 → producer로 orders topic에 이벤트 전송 → 주문 처리·분석·알림 서비스가 각자 소비(장 02 시나리오).

flowchart LR
    U[결제 클릭] --> API[주문 API]
    API -->|1. DB 저장| DB[(주문 DB)]
    API -->|2. producer.send| P[producer]
    P -->|메시지| T[orders topic]
    T --> C1[주문 처리]
    T --> C2[분석]

send는 비동기다 — 즉시 반환, 뒤에서 전송

send()는 비동기. 즉시 Future를 반환하고, 실제 전송은 배경 스레드(sender)가 처리. 애플리케이션이 broker 응답을 기다리며 멈추지 않도록 한다.

flowchart LR
    A["send(record)"] --> ACC["accumulator<br/>(batch 모음)"]
    ACC --> SND["sender 스레드<br/>(배경 전송)"]
    SND --> L["broker"]

send 콜백으로 성공/실패 확인

producer.send(new ProducerRecord<>("orders", "order-123", "data"), (metadata, exception) -> {
    if (exception == null) {
        System.out.println("전송 성공: " + metadata.partition() + "/" + metadata.offset());
    } else {
        System.err.println("전송 실패: " + exception.getMessage());
    }
});

batch — 왜 모아서 보내는가

메시지를 하나씩 보내지 않고 여러 개를 batch로 모아 보낸다. linger.ms(대기)나 batch.size(크기)에 도달하면 전송. 네트워크 왕복 비용을 줄인다.

batch는 압축과 짝 — batch로 뭉쳐야 압축 효율이 올라간다. linger.ms=0 + 압축은 무의미(메시지 1개씩이라 압축 안 걸림).

linger.ms와 batch.size가 처리량·지연을 어떻게 가르는가

이 두 설정은 trade-off 축이다:

  • linger.ms=0: batch를 안 모으고 즉시 전송 → 지연 최소, 처리량 낮음. 메시지 1개마다 네트워크 왕복이므로 RTT 비용이 그대로 든다.
  • linger.ms 증가: batch를 모으는 시간을 줌 → 지연 증가, 처리량 증가. 한 batch에 더 많은 메시지가 들어가 네트워크 효율이 오른다.
  • batch.size(기본 16384=16KB)는 batch의 최대 바이트. 이 크기에 도달하면 linger.ms를 안 기다리고 바로 전송. 너무 작으면 batch가 자주 차서 linger 의미가 없고, 너무 크면 메모리 낭비(producer는 partition마다 batch.size만큼 버퍼를 잡음).

실측 감(수치는 환경 의존, 미검증): 단일 partition에 초당 1000메시지, 메시지당 100바이트. linger.ms=0이면 초당 1000번의 네트워크 왕복. linger.ms=10이면 batch당 약 10메시지 → 초당 100번 왕복. 10배 차이. 처리량이 병목이면 linger를 키우는 게 가장 큰 한 방이다.

처리량 튜닝의 첫 단계는 거의 항상 linger.ms와 compression.type을 키우는 것이다. 단, end-to-end 지연에 민감한(실시간 UX) 워크로드면 linger를 0에 가깝게 둬야 한다. "처리량 vs 지연"의 트레이드오프 축이 producer 설정의 핵심이다.

delivery.timeout.ms — 재시도 시간 모델

구형 Kafka 자료는 retries=MAX_INT를 권장했지만, 2.1+부터는 시간 기반 재시도 모델로 바뀌었다. 핵심 설정 두 개:

  • delivery.timeout.ms(기본 120000=2분): 메시지가 send 호출된 시점부터, 최종 성공/포기까지의 총 상한. 재시도를 포함한 전체 시간.
  • request.timeout.ms(기본 30000): 단일 요청의 응답 대기 시간.

retries는 여전히 있지만, delivery.timeout.ms가 먼저 도달하면 그만둔다. "무한 재시도"는 사라졌다 — 대신 "2분 안에 안 되면 포기하고 예외"가 기본 동작. 이게 왜 중요한가 — 무한 재시도는 producer가 영원히 블록되거나 메모리를 채울 수 있어서, 시간 상한이 producer의 건강을 지킨다.

# 재시도 시간 모델 (4.x 기본값)
delivery.timeout.ms=120000    # 총 2분
request.timeout.ms=30000      # 단일 요청 30초
retries=2147483647            # 상한은 delivery.timeout.ms가 결정

delivery.timeout.ms가 작으면 네트워크 일시 장애에 민감해진다(재시도할 시간이 부족). 크면 장애 시 producer가 오래 블록된다. SLA(지연 허용치)에 맞춰 설정하는 게 정석이다.

acks — "얼마나 확실히 썼다고 볼 것인가"

acks 의미 신뢰성 언제
0 확인 안 기다림 가장 낮음 손실 감내 로그
1 리더까지만 확인 중간 성능 우선
all(-1) 리더 + ISR 전원 확인 가장 높음 신뢰성 필수

acks=all은 min.insync.replicas(예: 2)와 세트여야 의미 — ISR이 1개면 사실상 신뢰성 없음. 프로덕션 표준: replication.factor=3, min.insync.replicas=2, acks=all(장 06).

"acks=all이면 안전하다"가 틀리는 순간

acks=all은 이름과 달리 "모든 복제본"이 아니라 "현재 ISR에 있는 모든 복제본"을 기다린다. 여기서 함정이 드러난다 — ISR이 1개(leader만)로 줄어들면, acks=all은 사실상 acks=1과 같아진다. leader 한 번의 디스크 쓰기로 성공을 반환하므로, 그 직후 leader가 죽으면 미복제 메시지가 유실된다.

이 함정을 막는 게 min.insync.replicas다. ISR 수가 이 값 미만이면 broker가 쓰기를 거부(NotEnoughReplicasException). producer는 타임아웃/재시도로 대응한다. 즉 "안전한 쓰기"는 acks=all 혼자 만드는 게 아니라, acks=all + min.insync.replicas≥2 + replication.factor=3의 셋트가 만든다. 하나라도 빠지면 특정 장애 시나리오에서 구멍이 열린다.

flowchart TD
    A["producer.send()"] --> B{"acks 설정"}
    B -->|"0"| C["즉시 다음 라인<br/>(broker 응답 무시)"]
    B -->|"1"| D["리더 append 대기<br/>(복제 안 기다림)"]
    B -->|"all"| E{"ISR ≥ min.insync.replicas?"}
    E -->|"O"| F["ISR 전원 fetch 대기<br/>→ HW 전진 → ack"]
    E -->|"X"| G["NotEnoughReplicas<br/>쓰기 거부"]

acks=all을 "모든 복제본"으로 읽으면 ISR 동적 변화를 놓친다. ISR은 고정이 아니라 liveness·lag에 따라 줄었다 늘었다 하므로(장 06), "all"의 대상도 계속 바뀐다.

idempotent producer — 재시도해도 중복이 안 생기는 비밀

재시도를 켜면 같은 메시지가 두 번 쓰일 수 있다. idempotent producer는 broker가 중복을 걸러낸다:

  • enable.idempotence=true → broker가 PID(producer ID) 발급 → 각 메시지에 sequence number → 중복 검출.

4.x 기본값은 true. 구형 자료(false 기본)와 혼동 주의.

idempotent 모드는 acks=all, retries>0, max.in.flight<=5를 자동 강제.

PID와 sequence number가 어떻게 중복을 잡는가

producer가 initTransactions() 없이 enable.idempotence=true로 처음 접속하면, broker는 이 producer에게 PID(producer ID) 와 epoch를 발급한다(InitProducerId 요청). 이후 producer가 보내는 모든 batch에는 (PID, partition, sequence number)가 붙는다. sequence number는 partition마다 0부터 시작해 단조증가한다.

broker는 partition별로 "마지막으로 commit된 sequence number"를 기억한다. 같은 (PID, partition, sequence)가 또 오면 broker가 중복으로 판단해 무시하고, 정상 ack는 반환한다. producer 입장에선 "재시도했지만 broker가 알아서 걸렀다"로 보인다.

sequenceDiagram
    participant P as producer (PID=42)
    participant B as broker
    P->>B: InitProducerId → PID=42, epoch=0
    P->>B: send batch(seq=0) → ack 타임아웃
    P->>B: retry send batch(seq=0) → broker: seq=0 이미 받음 → 무시, ack
    P->>B: send batch(seq=1) → 정상 commit
    Note over P,B: consumer는 seq=0, seq=1 각각 한 번씩만 봄

max.in.flight가 5 이하인 이유

idempotent producer가 순서를 보존하려면, 재시도 중에도 sequence number 순서대로 broker에 commit돼야 한다. max.in.flight.requests.per.connection이 크면 여러 batch가 병렬로 날아가는데, 하나가 재시도하면 뒤 batch가 먼저 commit되는(out-of-order) 상황이 생길 수 있다. Kafka는 이를 막기 위해 idempotent 모드에서 max.in.flight ≤ 5를 강제하고, 그 범위 내에서 재시도 순서를 보존한다(Out Of Order Sequence Exception 방지). 이 5라는 상한은 5개 batch까지는 순서 복구가 가능하지만 그 이상은 보장 못 한다는 설계 한계다.

producer.properties — 전체 설정 예제

신뢰성 우선 (프로덕션 권장)

# 연결
bootstrap.servers=localhost:9092

# 직렬화
key.serializer=org.apache.kafka.common.serialization.StringSerializer
value.serializer=org.apache.kafka.common.serialization.StringSerializer

# 신뢰성
acks=all
enable.idempotence=true
retries=2147483647
max.in.flight.requests.per.connection=5

# 배치/처리량
linger.ms=10
batch.size=16384
compression.type=zstd

# 재시도 상한 (총)
delivery.timeout.ms=120000
request.timeout.ms=30000

처리량 우선 (로그 수집)

acks=1
linger.ms=50
batch.size=131072
compression.type=zstd

→ 지연 수십 ms 희생, 처리량·네트워크 효율 최대.

지연 최소 (실시간 이벤트)

acks=1
linger.ms=0
compression.type=none

→ 처리량 희생, end-to-end 지연 최소.

Kafka 4.3 실습 — 직접 확인하기

console-producer

# Kafka 4.3, KRaft 단일 노드
bin/kafka-console-producer.sh --topic prod-demo --bootstrap-server localhost:9092
>Hello
>World

확인할 것: 각 줄이 topic에 메시지로 들어감. 다른 터미널에서 consumer로 읽어 확인.
추가 확인: producer 프로세스 종료 후에도 메시지는 broker에 남아 있다(로그 모델, 장 01). 다른 consumer를 --from-beginning으로 띄워 처음부터 다시 읽을 수 있다.
실행 팁: console-producer는 한 줄이 곧 한 메시지. 빈 줄을 입력하면 무시되고, Ctrl-D로 종료한다.

key와 함께 produce

bin/kafka-console-producer.sh --topic prod-demo \
  --property "parse.key=true" --property "key.separator=:" \
  --bootstrap-server localhost:9092
# userA:evt1 / userA:evt2 / userB:evt3

확인할 것: 같은 key는 같은 partition으로 감(장 02).

idempotence 기본값 확인

# Kafka 4.3, KRaft 단일 노드
# producer 설정 없이 console-producer를 띄워도 4.x는 idempotence=true가 기본.
# 다른 터미널에서 broker 로그를 보면 InitProducerId 요청이 찍히는지 확인(미검증: 로그 레벨 의존).
bin/kafka-console-producer.sh --topic prod-demo --bootstrap-server localhost:9092
# required:acks 컨피그가 -1(=all)로 강제됐는지 producer 로그에서 확인.

확인할 것: idempotence=true일 때 producer가 acks=all과 max.in.flight.requests.per.connection<=5를 자동 강제하는지. 구형 자료(false 기본)와 4.x(true 기본)의 차이를 눈으로 확인하는 게 함정 방지의 첫걸음이다.

흔히 묻는 것, 흔히 틀리는 것

오해 정정
"producer는 별도 서버다" 앱에 내장되는 클라이언트
"send() 반환됐으면 broker에 도달했다" 비동기. accumulator에 있을 수 있음
"acks=1이면 안전하다" 리더 장애 시 미복제 손실 위험
"재시도하면 무조건 중복" idempotent(4.x 기본)는 broker가 제거
"linger.ms=0이 항상 빠르다" 지연은 최소지만 처리량은 낮음
"4.x도 idempotence=false 기본" 아니다. 4.x는 기본 true
"압축은 producer만 손해" broker가 압축된 채 저장·전송

producer 메트릭 — 건강을 어떻게 읽는가

producer는 JMX(보통 포트 9999)로 메트릭을 노출한다. 운영에서 반드시 봐야 할 것들:

메트릭 의미 건강 신호
record-send-rate 초당 전송 record 수 처리량 기준선
record-error-rate 초당 실패 record 수 0이 정상. >0이면 broker/네트워크 문제
retry-rate 초당 재시도 batch 수 높으면 불안정(broker 과부하·네트워크 불안)
request-latency-avg 평균 요청 지연 길어지면 broker 병목
batch-size-avg 평균 batch 크기 batch.size 대비 너무 작으면 batch가 안 참(linger/batch.size 튜닝 필요)
records-per-request-avg 요청당 평균 record 수 linger 영향 확인

retry-rate가 계속 0보다 크면 producer가 빈번히 재시도하고 있다는 뜻이다. 흔한 원인: broker GC·디스크 포화·네트워크 지터. 처리량은 정상처럼 보여도 지연이 늘고, 결국 delivery.timeout.ms 도달 시 메시지 유실로 이어진다. 따라서 retry-rate는 producer 건강의 선행 지표다.

더 깊이

  • max.in.flight와 순서: 1보다 크면 재시도 시 순서 뒤바뀔 수 있음. idempotent 모드는 5 이하로 제한해 순서 보존.
  • delivery.timeout.ms: 총 재시도 상한(기본 2분). 시간 기반 모델(무한 retries 아님).
  • transactional producer: transactional.id로 여러 partition 원자 쓰기 → EOS(장 08). idempotent가 "단일 partition 중복 제거"라면, transactional은 "여러 partition 원자 쓰기 + offset commit까지 원자". 둘은 다른 층이다.
  • JMX metrics: record-send-rate, retry-rate(높으면 불안정), batch-size-avg.
  • compression.type: none/gzip/snappy/lz4/zstd. zstd가 압축율·속도 균형이 좋아 4.x 권장. 압축은 batch 단위로만 걸리므로, linger.ms=0이면 압축 효과가 거의 사라진다(batch에 메시지 1개).
  • partitioner 선택: 기본은 murmur2 해시(장 02). 사용자 정의 Partitioner를 끼워 key→partition 매핑을 직접 제어할 수 있다.
  • buffer.memory: producer가 사용하는 총 버퍼(기본 64MB). 버퍼가 가득 차면 send()가 블록되거나 예외. 처리량이 큰 앱에선 늘려야 할 수 있다.

요약 — 이 글의 결론

  • producer = 앱 내장 클라이언트. send()로 topic에 메시지.
  • 비동기 send: accumulator→sender 배경 전송. 콜백으로 성공/실패 확인.
  • batch: linger.ms/batch.size로 모아 효율↑. 압축과 세트.
  • acks: 0/1/all. all은 min.insync.replicas와 세트. ISR이 1개면 all도 무의미.
  • idempotent producer(4.x 기본): PID+sequence로 중복 제거 + 순서 보존. max.in.flight≤5 강제.
  • 설정 프로필: 신뢰성 우선(acks=all+idempotent) / 처리량 우선(linger 큼) / 지연 최소(linger=0).
  • 시간 기반 재시도: delivery.timeout.ms(기본 2분)가 총 상한. retries가 무한이어도 시간 도달 시 포기.
  • producer 메트릭: retry-rate(선행 지표), record-error-rate, batch-size-avg로 건강 모니터링.

생각해 볼 문제

  1. producer가 "앱 안의 클라이언트"라는 것이 운영상 어떤 의미인가?
  2. send()가 비동기인 것을 어떻게 확인하는가? (콜백)
  3. acks=0/1/all의 차이를 복제 관점에서 설명하라.
  4. idempotent producer가 중복을 어떻게 제거하는가? 4.x 기본값은?
  5. 신뢰성 우선 producer.properties를 작성해 보라.
  6. linger.ms=0 + compression이 무의미한 이유는?
  7. acks=all인데 ISR이 1개 남았을 때 신뢰성이 무너지는 이유를 설명하라. 이를 막는 설정은?
  8. delivery.timeout.ms가 2분인데 broker가 3분간 응답하지 않으면 producer는 어떻게 되는가?
  9. retry-rate JMX 메트릭이 지속적으로 0보다 크면 무엇을 의심해야 하는가?

참고

'Data & Platform > Event Streaming' 카테고리의 다른 글

Kafka - 06. replication  (0) 2026.07.09
Kafka - 05. consumer group  (0) 2026.07.09
Kafka - 04. consumer  (0) 2026.07.09
Kafka - 02. topic and partition  (0) 2026.07.09
Kafka - 01. architecture  (0) 2026.07.09