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.mscompression.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=allmin.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=allmax.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. allmin.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