Step 04 — 프로듀서

학습 목표

  • 프로듀서 내부 구조(직렬화 → 파티셔너 → RecordAccumulator → Sender 스레드)를 설명하고, send()왜 비동기인지 이해한다
  • acks=0/1/all 의 처리량과 지연을 kafka-producer-perf-test.sh실측한다
  • acks=0 으로 보내는 중 브로커를 죽여, 에러 없이 메시지가 사라지는 것을 재현한다
  • 3.7 의 기본 파티셔너(murmur2 해시 / sticky partitioning)를 이해하고, 키 쏠림으로 생기는 hot partition 을 오프셋으로 확인한다
  • batch.size / linger.ms / compression.type 을 바꿔 가며 처리량·네트워크 바이트를 실측한다
  • max.in.flight.requests.per.connection > 1 + 재시도가 같은 키의 순서를 뒤바꾸는 것을 직접 재현한다

선행 스텝: Step 03 — 토픽과 파티션 예상 소요: 120분


4-0. 실습 준비

이 스텝 전용 토픽 세 개를 만듭니다. 전부 s04_ 로 시작하며 4-14 에서 삭제합니다.

alias kt='docker exec kafka-1 /opt/kafka/bin/kafka-topics.sh --bootstrap-server kafka-1:9092'
alias kcg='docker exec kafka-1 /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server kafka-1:9092'

kt --create --topic s04_acks  --partitions 3 --replication-factor 3
kt --create --topic s04_order --partitions 1 --replication-factor 3
kt --create --topic s04_perf  --partitions 6 --replication-factor 3 \
   --config segment.bytes=268435456 --config retention.ms=3600000

결과

Created topic s04_acks.
Created topic s04_order.
Created topic s04_perf.

s04_order파티션이 1개입니다. 순서 뒤바뀜(4-11)은 한 파티션 안에서 일어나는 현상이므로, 파티션이 여러 개면 "원래 순서가 안 맞는 것"과 구별이 안 되기 때문입니다.

s04_perfsegment.bytes 를 256MiB 로 토픽 단위로 덮어썼습니다. 이 클러스터의 브로커 기본값은 학습용으로 1MiB 라서, 그대로 두면 100만 건을 넣을 때 세그먼트 파일이 수백 개 생겨 측정값이 오염됩니다.

kt --describe --topic s04_acks

결과

Topic: s04_acks	TopicId: 7hVqK2mXQ0iBnP4tLsYwRg	PartitionCount: 3	ReplicationFactor: 3	Configs: min.insync.replicas=2,segment.bytes=1048576
	Topic: s04_acks	Partition: 0	Leader: 1	Replicas: 1,2,3	Isr: 1,2,3
	Topic: s04_acks	Partition: 1	Leader: 2	Replicas: 2,3,1	Isr: 2,3,1
	Topic: s04_acks	Partition: 2	Leader: 3	Replicas: 3,1,2	Isr: 3,1,2

min.insync.replicas=2 가 걸려 있다는 것을 기억해 두세요. 4-5 와 Step 08 에서 이 값이 결정적인 역할을 합니다.


4-1. 프로듀서 내부 — send()보내지 않는다

프로듀서를 처음 쓰는 사람이 가장 크게 오해하는 지점입니다. producer.send(record) 를 호출해도 그 순간 네트워크로 나가지 않습니다. 레코드는 메모리 버퍼에 들어가고, 별도의 백그라운드 스레드가 나중에 묶어서 보냅니다.

  [ 애플리케이션 스레드 ]                         [ Sender 스레드 (백그라운드, 1개) ]

        │ producer.send(record, callback)

  ┌───────────────────┐
  │  Serializer       │  key/value 를 byte[] 로
  │  (key, value)     │
  └─────────┬─────────┘

  ┌───────────────────┐
  │  Partitioner      │  키 있으면 murmur2(key) % N
  │                   │  키 없으면 sticky (한 배치 동안 한 파티션 고정)
  └─────────┬─────────┘

  ┌──────────────────────────────────────────┐
  │  RecordAccumulator  (buffer.memory=32MiB)│
  │                                          │
  │  s04_acks-0 : [ batch ][ batch ]         │  ← 파티션마다 배치 큐
  │  s04_acks-1 : [ batch ]                  │     배치 하나 = batch.size(16KiB)
  │  s04_acks-2 : [ batch ][ batch ][ batch ]│
  └──────────────────────────────────────────┘
            │  ▲                              배치가 꽉 찼거나(batch.size)
            │  └── 버퍼가 꽉 차면 send() 가    linger.ms 가 지나면 전송 대상
            │      max.block.ms 만큼 블로킹

        ┌────────────────────────────────────────┐
        │  Sender 스레드                          │
        │  같은 브로커로 갈 배치들을 하나의        │
        │  ProduceRequest 로 묶어서 전송           │
        │  브로커당 최대 max.in.flight(기본 5)개   │
        └───────────────┬────────────────────────┘

                 ┌─────────────┐
                 │  브로커      │  acks 만큼 기다렸다가 응답
                 └──────┬──────┘
                        │ ProduceResponse

              콜백 실행 / Future 완료

여기서 나오는 결론 세 가지가 이 스텝 전체를 관통합니다.

  1. send() 가 성공했다는 것은 "버퍼에 넣었다"는 뜻일 뿐입니다. 브로커가 받았다는 뜻이 아닙니다. 실제 성공/실패는 콜백이나 Future.get() 으로만 알 수 있습니다.
  2. send() 는 대부분 블로킹하지 않지만, 버퍼가 꽉 차면 블로킹합니다. max.block.ms(기본 60초) 동안 기다리다 TimeoutException 을 던집니다.
  3. 처리량은 배치가 만듭니다. 레코드 하나씩 보내면 왕복 지연이 그대로 처리량 한계가 됩니다. batch.sizelinger.ms 가 여기에 개입합니다(4-9).

⚠️ 함정 — producer.send(record) 만 쓰고 반환값을 버리면 실패를 영영 모릅니다

producer.send(new ProducerRecord<>("orders", key, value));   // 반환값 버림, 콜백 없음

이 코드는 어떤 에러도 던지지 않습니다. 직렬화 실패나 버퍼 초과 같은 즉시 발생 예외만 던지고, 브로커 거절·타임아웃·재시도 소진은 전부 조용히 사라집니다. close() 시점에 로그 한 줄이 남을 뿐입니다. 해결: 최소한 콜백을 붙이십시오(4-13). 콜백조차 부담이면 send() 를 감싸는 래퍼에서 예외를 카운터로 집계하세요.


4-2. acks — 무엇을 기다릴 것인가

acks브로커가 언제 "받았다"고 응답할지를 정합니다. 세 값뿐입니다.

  acks=0                     acks=1                      acks=all (= -1)
  ─────────                  ─────────                   ─────────────────
  P ──► 리더                  P ──► 리더                   P ──► 리더
     ◄── (응답 없음)              ◄── "썼다"                    │  복제 대기
     즉시 성공 처리                리더 로컬 기록 후 응답          ├──► 팔로워1 ──┐
                                                              └──► 팔로워2 ──┤

                                                            ◄── "ISR 전부 썼다" ┘
acks언제 성공 응답유실 위험처리량대표 용도
0보내자마자 (응답을 안 기다림)브로커가 죽어도, 네트워크가 끊겨도 모름최고메트릭, 로그 수집
1리더가 로컬 로그에 기록한 직후리더가 복제 전에 죽으면 유실중간일부 이벤트 스트림
all(-1)ISR 의 모든 복제본이 기록한 직후min.insync.replicas 를 함께 걸면 사실상 없음낮음주문·결제 등 금전

💡 acks=all 은 "모든 복제본"이 아니라 "현재 ISR"입니다 RF=3 이라도 팔로워 하나가 뒤처져 ISR 에서 빠지면 ISR 은 2 가 되고, acks=all2개만 확인하고 성공합니다. ISR 이 1까지 줄면 복제본 1개짜리 성공이 됩니다. 그래서 min.insync.replicas=2반드시 함께 걸어야 합니다. 이 조합이 없으면 acks=all 은 이름값을 못 합니다. Step 08 에서 ISR 을 실제로 줄여 가며 확인합니다.


4-3. acks 실측 — 처리량과 지연

kafka-producer-perf-test.sh 로 같은 조건에서 acks 만 바꿔 100만 건을 넣습니다. 레코드 크기 100바이트, 스로틀 없음(--throughput -1).

docker exec kafka-1 /opt/kafka/bin/kafka-producer-perf-test.sh \
  --topic s04_perf --num-records 1000000 --record-size 100 --throughput -1 \
  --producer-props bootstrap.servers=kafka-1:9092 acks=0

결과

389421 records sent, 77884.2 records/sec (7.43 MB/sec), 12.4 ms avg latency, 214.0 ms max latency.
597130 records sent, 119426.0 records/sec (11.39 MB/sec), 3.1 ms avg latency, 41.0 ms max latency.
1000000 records sent, 118168.4 records/sec (11.27 MB/sec), 5.28 ms avg latency, 214.00 ms max latency, 3 ms 50th, 11 ms 95th, 28 ms 99th, 96 ms 99.9th.
# acks=1
docker exec kafka-1 /opt/kafka/bin/kafka-producer-perf-test.sh \
  --topic s04_perf --num-records 1000000 --record-size 100 --throughput -1 \
  --producer-props bootstrap.servers=kafka-1:9092 acks=1

결과

1000000 records sent, 92408.7 records/sec (8.81 MB/sec), 172.35 ms avg latency, 611.00 ms max latency, 158 ms 50th, 322 ms 95th, 447 ms 99th, 588 ms 99.9th.
# acks=all
docker exec kafka-1 /opt/kafka/bin/kafka-producer-perf-test.sh \
  --topic s04_perf --num-records 1000000 --record-size 100 --throughput -1 \
  --producer-props bootstrap.servers=kafka-1:9092 acks=all

결과

1000000 records sent, 51203.4 records/sec (4.88 MB/sec), 388.12 ms avg latency, 883.00 ms max latency, 371 ms 50th, 702 ms 95th, 806 ms 99th, 869 ms 99.9th.

정리하면 이렇습니다.

acks처리량대비평균 지연p99 지연최대 지연
0118,168 msg/s100%5.28 ms28 ms214 ms
192,408 msg/s78%172.35 ms447 ms611 ms
all51,203 msg/s43%388.12 ms806 ms883 ms

acks=0acks=all 로 가면 처리량이 2.3배 떨어지고 평균 지연은 73배 늘어납니다. 이것이 내구성의 가격입니다.

숫자 자체보다 비율을 기억하세요. 하드웨어가 바뀌면 절대값은 달라지지만 "all 은 대략 0 의 절반"이라는 관계는 유지됩니다.

💡 실무 팁 — acks=all 의 지연은 배치로 상쇄할 수 있습니다 acks=all 이 느린 이유는 왕복 대기 시간이 길어서지, 브로커가 느려서가 아닙니다. linger.ms=20batch.size=64KiB 를 함께 주면 왕복 횟수 자체가 줄어 처리량이 회복됩니다(4-9 에서 실측). "내구성을 포기해서 처리량을 얻는다"는 선택은 대개 마지막 수단입니다. 먼저 배치를 키우세요.


4-4. ⚠️ 핵심 함정 A — acks=0 은 브로커가 죽어도 성공한다

말로는 다들 압니다. 직접 보면 다릅니다. acks=0 으로 계속 보내면서 브로커를 죽여 봅니다.

먼저 파티션 0 의 리더를 확인합니다.

kt --describe --topic s04_acks | grep 'Partition: 0'

결과

	Topic: s04_acks	Partition: 0	Leader: 1	Replicas: 1,2,3	Isr: 1,2,3

이제 터미널 A 에서 acks=0 으로 초당 2000건씩 60초 동안 보냅니다.

# [터미널 A]
docker exec kafka-1 /opt/kafka/bin/kafka-producer-perf-test.sh \
  --topic s04_acks --num-records 120000 --record-size 100 --throughput 2000 \
  --producer-props bootstrap.servers=kafka-1:9092 acks=0

터미널 B 에서 10초 뒤 브로커 두 대를 내립니다. s04_acks 는 RF=3 이므로 두 대를 내려야 일부 파티션이 확실히 쓰기 불가 상태가 됩니다.

# [터미널 B]
docker compose stop kafka-2 kafka-3

터미널 A 의 결과

10015 records sent, 2003.0 records/sec (0.19 MB/sec), 1.2 ms avg latency, 18.0 ms max latency.
10000 records sent, 2000.0 records/sec (0.19 MB/sec), 1.1 ms avg latency, 14.0 ms max latency.
10000 records sent, 2000.0 records/sec (0.19 MB/sec), 1.4 ms avg latency, 33.0 ms max latency.
10000 records sent, 2000.0 records/sec (0.19 MB/sec), 1.1 ms avg latency, 12.0 ms max latency.
...
120000 records sent, 1999.8 records/sec (0.19 MB/sec), 1.21 ms avg latency, 33.00 ms max latency, 1 ms 50th, 2 ms 95th, 4 ms 99th, 12 ms 99.9th.

에러가 한 줄도 없습니다. 처리량도 그대로 2000/s 입니다. 브로커 두 대가 죽어 있는 동안에도 프로듀서는 아무 일 없다는 듯 "120000 records sent" 라고 보고했습니다.

브로커를 되살리고 실제로 몇 건이 저장됐는지 셉니다.

docker compose start kafka-2 kafka-3
sleep 30

docker exec kafka-1 /opt/kafka/bin/kafka-get-offsets.sh \
  --bootstrap-server kafka-1:9092 --topic s04_acks

결과

s04_acks:0:41883
s04_acks:1:22104
s04_acks:2:21993

합계 85,980건. 120,000건을 보냈고 프로듀서는 전부 성공했다고 보고했는데, 34,020건이 없습니다. 유실률 28.4% 입니다.

⚠️ 함정 A — acks=0 에서 "sent" 는 "저장됨"이 아니라 "소켓에 썼음"입니다 acks=0 의 프로듀서는 응답을 기다리지 않으므로, 브로커가 그 요청을 처리했는지 알 방법이 없습니다. TCP 소켓에 write 가 성공했으면 성공으로 칩니다. 브로커가 죽어 연결이 끊기면 그때서야 재연결을 시도하는데, 끊긴 연결에 이미 실려 보낸 배치는 재시도 대상이 아닙니다. retries 를 아무리 크게 잡아도 소용없습니다. 해결: 유실이 허용되지 않는 토픽에는 acks=0 을 쓰지 마십시오. 예외는 없습니다. "메트릭이니까 좀 잃어도 된다" 처럼 유실이 설계에 반영된 경우에만 쓰는 값입니다.

💡 이 실습의 유실률(28.4%)은 브로커를 언제 죽였는지, 재연결이 얼마나 빨랐는지에 따라 크게 흔들립니다. 여러분 환경에서는 15%일 수도 40%일 수도 있습니다. 중요한 건 0% 가 아니라는 것입니다.


4-5. ⚠️ 핵심 함정 B — acks=1 은 리더가 복제 전에 죽으면 유실된다

acks=1 은 "리더가 자기 로그에 썼다"까지만 확인합니다. 그 직후 리더가 죽으면 어떻게 될까요.

  프로듀서            리더(브로커1)        팔로워(브로커2)      팔로워(브로커3)
     │                    │                   │                  │
     │─ ProduceRequest ──►│                   │                  │
     │   offset 150       │                   │                  │
     │                    │ 로컬 로그 기록      │                  │
     │                    │ (offset 150)      │                  │
     │◄── "성공" ─────────│                   │                  │
     │                    │                   │                  │
     │  ✅ 콜백 실행:      │  ✱ 브로커1 크래시  │                  │
     │  "150 에 저장됨"    │  ✱ (복제 전)      │                  │
     │                    ✗                   │                  │
     │                                        │                  │
     │                       컨트롤러가 새 리더 선출 ──────────────►│
     │                                        │  브로커2 가 리더    │
     │                                        │  브로커2 의 마지막   │
     │                                        │  오프셋은 149      │
     │                                        │                   │
     │─ 다음 ProduceRequest ─────────────────►│                   │
     │                       새 리더는 150 부터 다시 씁니다.        │
     │                       원래 150 은 **덮어써지고 사라집니다.** │

프로듀서는 이미 성공 콜백을 받았습니다. 애플리케이션은 "주문 저장 완료" 로그를 남겼고, 어쩌면 사용자에게 완료 화면까지 보여 줬습니다. 그런데 그 메시지는 어디에도 없습니다.

항목acks=1acks=all + min.insync.replicas=2
성공 응답 시점리더 로컬 기록 직후ISR 전체 기록 직후
리더 크래시 시직전 메시지 유실 가능최소 2개 복제본에 있으므로 안전
ISR 이 1로 줄면그대로 성공NotEnoughReplicasException 으로 거절
유실 알림없음 (이미 성공 콜백을 줬음)거절 예외로 명시적 실패

⚠️ 함정 B — acks=1 의 유실은 프로듀서가 절대 알 수 없습니다 프로듀서가 성공 응답을 받은 뒤에 벌어지는 일이므로, 재시도할 방법이 없습니다. 로그에도 남지 않습니다. 알아채는 시점은 며칠 뒤 정산이 안 맞을 때입니다. 해결: acks=all + min.insync.replicas=2 + replication.factor=3. 이 셋은 세트입니다. 하나라도 빠지면 나머지 둘이 무력해집니다.

이 시나리오를 실제로 브로커를 죽여 가며 재현하는 것은 Step 08 — 복제와 내구성 에서 합니다. 거기서 acks=1 로 보낸 메시지의 오프셋을 기록해 두고, 리더를 죽인 뒤 그 오프셋에 다른 메시지가 들어 있는 것을 확인합니다.


4-6. 파티셔너 — 어느 파티션으로 갈지 정하는 규칙

ProducerRecord 에 파티션 번호를 직접 지정하지 않으면 파티셔너가 정합니다. Kafka 3.7 의 규칙은 이렇습니다.

  record.partition() 이 지정됨?  ── yes ──► 그 파티션 (파티셔너 무시)
             │ no

  partitioner.class 가 설정됨?   ── yes ──► 그 클래스의 partition() 호출
             │ no (기본값 = null)

  키가 null 이 아니고
  partitioner.ignore.keys=false? ── yes ──► murmur2(keyBytes) & 0x7fffffff % numPartitions
             │ no

      Sticky Partitioning
      (배치가 찰 때까지 한 파티션에 몰아 넣고, 배치가 나가면 다음 파티션으로)

핵심을 정확히 짚습니다.

  • partitioner.class 의 기본값은 null 입니다. 클래스 이름이 아니라 null 입니다. null 일 때 프로듀서는 위 내장 로직을 씁니다.
  • UniformStickyPartitionerDefaultPartitioner 는 3.3 부터 deprecated 되었습니다. 명시적으로 지정하면 동작은 하지만 경고가 뜹니다. 3.3 의 KIP-794 가 sticky 로직을 프로듀서 본체로 옮기면서 이 클래스들이 불필요해졌기 때문입니다.
  • 키가 있으면 murmur2 해시입니다. Java 의 String.hashCode() 가 아닙니다. 그래서 언어가 다른 클라이언트끼리도 같은 키가 같은 파티션으로 갑니다(librdkafka 도 murmur2 를 씁니다).
  • partitioner.ignore.keys=true(기본 false) 로 두면 키가 있어도 무시하고 sticky 로 분산합니다. 키를 "식별자"로만 쓰고 라우팅에는 쓰고 싶지 않을 때입니다.

Sticky partitioning 이 왜 생겼는가

키가 없을 때 옛날(2.4 이전) 기본 동작은 라운드로빈이었습니다. 레코드를 파티션 0, 1, 2, 0, 1, 2... 로 돌려 가며 넣습니다. 문제는 배치입니다.

  라운드로빈:  P0 [r1][r4][r7]  ← 배치 3개가 각각 1/3 만 참
              P1 [r2][r5][r8]     → 작은 요청 3개를 보냄
              P2 [r3][r6][r9]

  Sticky:     P0 [r1 r2 r3 r4 r5 r6 r7 r8 r9]  ← 배치 1개가 꽉 참
              P1 (다음 배치부터)                  → 큰 요청 1개를 보냄
              P2

배치가 클수록 요청 수가 줄고, 압축률이 좋아지고, 지연이 낮아집니다. 분포는 배치 단위로 보면 여전히 균등합니다. 레코드 단위로 균등하지 않을 뿐입니다.

커스텀 파티셔너

특정 키를 전용 파티션으로 보내야 할 때가 있습니다. 예를 들어 VIP 고객 주문을 파티션 0 에 몰아 두고 그 파티션만 별도 컨슈머로 빠르게 처리하는 식입니다.

import org.apache.kafka.clients.producer.Partitioner;
import org.apache.kafka.common.Cluster;
import org.apache.kafka.common.utils.Utils;
import java.util.List;
import java.util.Map;

public class VipPartitioner implements Partitioner {

    private static final List<String> VIP = List.of("C001", "C002");

    @Override
    public int partition(String topic, Object key, byte[] keyBytes,
                         Object value, byte[] valueBytes, Cluster cluster) {
        int numPartitions = cluster.partitionsForTopic(topic).size();
        if (keyBytes == null) {
            return 0;
        }
        String k = (String) key;
        if (VIP.contains(k)) {
            return 0;                                  // VIP 는 항상 파티션 0
        }
        // 나머지는 1..N-1 사이에 murmur2 로 분산
        int rest = numPartitions - 1;
        return 1 + (Utils.toPositive(Utils.murmur2(keyBytes)) % rest);
    }

    @Override public void close() { }
    @Override public void configure(Map<String, ?> configs) { }
}
partitioner.class=com.example.VipPartitioner

⚠️ 커스텀 파티셔너를 쓰면 파티션 수를 늘릴 수 없게 되는 경우가 많습니다 위 코드는 numPartitions 를 런타임에 읽으므로 파티션이 3→6 으로 늘면 모든 키의 목적지가 바뀝니다. 같은 customer_id 의 옛 주문과 새 주문이 다른 파티션에 흩어지고, 순서 보장이 그 순간 깨집니다. Step 03 에서 다룬 문제가 커스텀 파티셔너에서도 똑같이 재현됩니다. 해결: 파티션 수를 늘릴 계획이 있으면 커스텀 파티셔너에 고정 상수(예: % 12)를 쓰고 실제 파티션은 12의 약수로 운영하거나, 애초에 기본 파티셔너를 쓰십시오.


4-7. 키 라우팅 실습 — 같은 키는 항상 같은 파티션

콘솔 프로듀서로 키를 붙여 넣고, 컨슈머로 파티션 번호를 확인합니다.

docker exec -i kafka-1 /opt/kafka/bin/kafka-console-producer.sh \
  --bootstrap-server kafka-1:9092 --topic s04_acks \
  --property parse.key=true --property key.separator=: <<'EOF'
C001:{"order_id":"O-1001","customer_id":"C001","amount":39000,"status":"CREATED"}
C002:{"order_id":"O-1002","customer_id":"C002","amount":12000,"status":"CREATED"}
C003:{"order_id":"O-1003","customer_id":"C003","amount":58000,"status":"CREATED"}
C001:{"order_id":"O-1004","customer_id":"C001","amount":7000,"status":"CREATED"}
C002:{"order_id":"O-1005","customer_id":"C002","amount":31000,"status":"CREATED"}
C001:{"order_id":"O-1006","customer_id":"C001","amount":25000,"status":"CREATED"}
EOF

--property print.partition=true 로 파티션을 함께 출력합니다.

docker exec kafka-1 /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server kafka-1:9092 --topic s04_acks --from-beginning \
  --max-messages 6 --timeout-ms 15000 \
  --property print.key=true --property print.partition=true \
  --property print.value=false

결과

Partition:2	C001
Partition:2	C001
Partition:2	C001
Partition:0	C002
Partition:0	C002
Partition:1	C003
Processed a total of 6 messages

C001 세 건이 전부 파티션 2 로 갔습니다. C002 는 둘 다 파티션 0, C003 은 파티션 1 입니다. 실행 순서와 무관하게 키만 보고 목적지가 정해집니다.

직접 계산해서 확인할 수도 있습니다. murmur2("C001") & 0x7fffffff % 3 을 계산하면 2 가 나옵니다.

docker exec kafka-1 /opt/kafka/bin/kafka-run-class.sh kafka.tools.ConsoleProducer --version
# → 3.7.1  (murmur2 구현은 org.apache.kafka.common.utils.Utils.murmur2)

💡 실무 팁 — "같은 키 = 같은 파티션 = 순서 보장"의 정확한 범위 Kafka 의 순서 보장은 파티션 안에서만 성립합니다. 그래서 순서를 지켜야 하는 단위를 키로 삼습니다. 주문 상태 변경(CREATED → PAID → SHIPPED)의 순서를 지켜야 한다면 키는 order_id 여야 합니다. 키를 customer_id 로 잡으면 "한 고객의 모든 주문"의 순서는 지켜지지만, 그건 대개 필요 없는 보장이고 대신 쏠림을 만듭니다(다음 절).


4-8. ⚠️ 핵심 함정 C — 키 쏠림(hot partition)

키 기반 파티셔닝의 대가는 불균형입니다. 실제 서비스의 고객 분포는 절대 균등하지 않습니다. B2B 계정 하나가 전체 주문의 40% 를 만드는 일은 흔합니다.

그 상황을 만들어 봅니다. C001 이 40%, 나머지 9명이 60% 를 나눠 갖는 1만 건입니다.

docker exec kafka-1 bash -c '
for i in $(seq 1 10000); do
  if [ $((i % 10)) -lt 4 ]; then k="C001"; else k="C00$((RANDOM % 9 + 2))"; fi
  echo "$k:{\"order_id\":\"O-$((1000+i))\",\"customer_id\":\"$k\",\"amount\":10000,\"status\":\"CREATED\"}"
done | /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server kafka-1:9092 \
  --topic s04_acks --property parse.key=true --property key.separator=:'

파티션별 오프셋을 봅니다.

docker exec kafka-1 /opt/kafka/bin/kafka-get-offsets.sh \
  --bootstrap-server kafka-1:9092 --topic s04_acks

결과

s04_acks:0:2213
s04_acks:1:1524
s04_acks:2:6269

(4-7 에서 넣은 6건이 포함된 값입니다.)

파티션메시지 수비율균등했다면
02,21322.1%33.3%
11,52415.2%33.3%
26,26962.7%33.3%

파티션 2 가 나머지 둘을 합친 것보다 많습니다. C001(40%)과 우연히 같은 파티션에 떨어진 다른 키들이 합쳐진 결과입니다.

이것이 왜 문제인가:

  • 파티션 2 를 담당하는 컨슈머 하나가 전체 트래픽의 63% 를 혼자 처리합니다. 컨슈머를 늘려도 이 파티션은 여전히 한 컨슈머 차지입니다(Step 05).
  • 파티션 2 의 랙만 계속 늘어납니다. 다른 두 파티션은 놀고 있는데 전체 지연은 파티션 2 기준으로 결정됩니다.
  • 파티션 2 가 있는 브로커의 디스크·네트워크만 뜨겁습니다. 브로커 3대의 부하가 6:2:2 로 벌어집니다.

⚠️ 함정 C — hot partition 은 에러 없이 처리 지연으로만 나타납니다 프로듀서도 브로커도 정상입니다. 로그에 아무것도 안 남습니다. 증상은 "가끔 특정 주문만 처리가 늦다" 이고, 그 특정 주문은 항상 같은 고객의 것입니다. kafka-consumer-groups.sh --describe 에서 한 파티션의 LAG 만 유독 큰 것이 유일한 단서입니다. 해결 3가지

  1. 키를 더 잘게 쪼갭니다. customer_id 대신 order_id 를 키로 쓰면 분포가 균등해집니다. 순서 보장 단위가 "고객"에서 "주문"으로 좁아지는데, 대부분의 경우 그게 실제로 필요한 단위입니다.
  2. 복합 키를 씁니다. C001#0 ~ C001#7 처럼 뜨거운 키에만 salt 를 붙여 여러 파티션으로 흩습니다. 그 키의 순서 보장은 포기하는 대가입니다.
  3. 파티션 수를 소수(prime)로 둡니다. 완화책일 뿐 근본 해결은 아닙니다. 한 키가 40% 면 파티션이 몇 개든 그 키가 간 파티션은 뜨겁습니다.

측정용 정리를 위해 오프셋을 기억해 두고 다음 절로 갑니다.


4-9. batch.sizelinger.ms — 배치를 얼마나 기다릴 것인가

Sender 스레드는 두 조건 중 먼저 오는 것에 배치를 내보냅니다.

  • 배치가 batch.size(기본 16384 = 16KiB)만큼 찼을 때
  • 배치의 첫 레코드가 들어온 지 linger.ms(기본 0)가 지났을 때

linger.ms=0 은 "기다리지 않는다"입니다. 그런데 레코드 하나마다 요청 하나를 보낸다는 뜻은 아닙니다. Sender 스레드가 이전 요청을 처리하는 동안 쌓인 레코드는 자연스럽게 한 배치가 됩니다. 부하가 높으면 linger.ms=0 이어도 배치가 생깁니다.

linger.ms 를 올리면 일부러 조금 기다려서 배치를 키웁니다. 실측합니다.

# linger.ms=0 (기본)
docker exec kafka-1 /opt/kafka/bin/kafka-producer-perf-test.sh \
  --topic s04_perf --num-records 1000000 --record-size 100 --throughput -1 \
  --producer-props bootstrap.servers=kafka-1:9092 acks=all linger.ms=0 batch.size=16384

결과

1000000 records sent, 51203.4 records/sec (4.88 MB/sec), 388.12 ms avg latency, 883.00 ms max latency, 371 ms 50th, 702 ms 95th, 806 ms 99th, 869 ms 99.9th.
# linger.ms=20 + batch.size=64KiB
docker exec kafka-1 /opt/kafka/bin/kafka-producer-perf-test.sh \
  --topic s04_perf --num-records 1000000 --record-size 100 --throughput -1 \
  --producer-props bootstrap.servers=kafka-1:9092 acks=all linger.ms=20 batch.size=65536

결과

1000000 records sent, 138504.1 records/sec (13.21 MB/sec), 141.22 ms avg latency, 412.00 ms max latency, 128 ms 50th, 289 ms 95th, 347 ms 99th, 401 ms 99.9th.
설정처리량평균 지연p99 지연
linger.ms=0, batch.size=16KiB51,203 msg/s388.12 ms806 ms
linger.ms=20, batch.size=64KiB138,504 msg/s141.22 ms347 ms

처리량 2.7배, 평균 지연 2.7배 감소. 직관과 반대로 보입니다. "20ms 를 더 기다렸는데 왜 지연이 줄지?"

답은 큐잉입니다. linger.ms=0 일 때는 작은 요청이 폭주해 브로커 앞에 쌓이고, 그 대기열에서 기다리는 시간이 300ms 였습니다. 배치를 키우면 요청 수가 1/8 로 줄어 대기열이 사라지고, 20ms 의 linger 를 포함해도 총 지연이 훨씬 짧아집니다.

💡 linger.ms 는 "지연을 추가하는 값"이 아니라 "지연 상한을 정하는 값"입니다 부하가 높으면 배치가 linger.ms 전에 이미 꽉 차서 즉시 나갑니다. linger 는 한산할 때만 발동합니다. 즉 linger.ms=20 은 "모든 메시지가 20ms 늦어진다"가 아니라 "최악의 경우 20ms 늦어진다"입니다. 실무에서 5~50ms 범위는 대개 안전한 선택입니다.

buffer.memorymax.block.ms — send() 가 블로킹하는 순간

RecordAccumulator 의 총 크기가 buffer.memory(기본 33554432 = 32MiB)입니다. Sender 가 보내는 속도보다 애플리케이션이 넣는 속도가 빠르면 이 버퍼가 찹니다.

버퍼가 차면 send() 는 블로킹합니다. max.block.ms(기본 60000)까지 기다리고, 그래도 자리가 안 나면 예외를 던집니다.

org.apache.kafka.common.errors.TimeoutException: Failed to allocate memory within
  the configured max blocking time 60000 ms.
설정기본값의미부족하면
buffer.memory32 MiB전체 배치 버퍼 크기send() 가 블로킹
max.block.ms60000 mssend()partitionsFor() 가 블로킹할 최대 시간TimeoutException

⚠️ 함정 — send() 가 60초 블로킹하면 웹 요청 스레드가 통째로 멈춥니다 HTTP 핸들러에서 producer.send() 를 부르는 코드는 흔합니다. 평소에는 마이크로초 단위로 끝납니다. 그런데 브로커가 느려지면 버퍼가 차고, send()60초를 통째로 잡아먹습니다. 톰캣 스레드 풀이 말라붙고, Kafka 와 무관한 API 까지 전부 타임아웃납니다. 해결: 운영에서는 max.block.ms요청 타임아웃보다 짧게(예: 3000ms) 잡으십시오. 그러면 빠르게 예외가 나고, 애플리케이션이 폴백(DLQ 파일 기록, 5xx 응답 등)을 선택할 수 있습니다. 60초 동안 매달려 있는 것보다 3초 만에 실패하는 편이 언제나 낫습니다.


4-10. 압축 — 배치 단위로 압축된다

compression.type 은 프로듀서가 배치를 통째로 압축하게 합니다. 레코드 하나씩이 아닙니다. 이 사실이 중요합니다. 배치가 클수록 압축률이 좋습니다. JSON 처럼 반복이 많은 포맷이면 특히 그렇습니다.

linger.ms=20, batch.size=64KiB, acks=all, 100만 건, 레코드 200바이트 JSON 유사 페이로드로 측정했습니다.

for C in none gzip snappy lz4 zstd; do
  echo "=== compression.type=$C ==="
  docker exec kafka-1 /opt/kafka/bin/kafka-producer-perf-test.sh \
    --topic s04_perf --num-records 1000000 --record-size 200 --throughput -1 \
    --producer-props bootstrap.servers=kafka-1:9092 acks=all \
      linger.ms=20 batch.size=65536 compression.type=$C
done

결과 (마지막 요약 줄만 발췌)

=== compression.type=none ===
1000000 records sent, 121854.3 records/sec (23.24 MB/sec), 158.41 ms avg latency, 471.00 ms max latency, 142 ms 50th, 318 ms 95th, 402 ms 99th, 463 ms 99.9th.
=== compression.type=gzip ===
1000000 records sent, 46218.7 records/sec (8.82 MB/sec), 412.85 ms avg latency, 1104.00 ms max latency, 388 ms 50th, 812 ms 95th, 967 ms 99th, 1088 ms 99.9th.
=== compression.type=snappy ===
1000000 records sent, 143902.5 records/sec (27.45 MB/sec), 131.07 ms avg latency, 388.00 ms max latency, 119 ms 50th, 262 ms 95th, 331 ms 99th, 379 ms 99.9th.
=== compression.type=lz4 ===
1000000 records sent, 156371.9 records/sec (29.82 MB/sec), 118.94 ms avg latency, 341.00 ms max latency, 108 ms 50th, 237 ms 95th, 298 ms 99th, 334 ms 99.9th.
=== compression.type=zstd ===
1000000 records sent, 134082.6 records/sec (25.57 MB/sec), 145.63 ms avg latency, 402.00 ms max latency, 132 ms 50th, 291 ms 95th, 362 ms 99th, 396 ms 99.9th.

디스크에 실제로 쌓인 크기는 로그 디렉터리로 확인합니다.

docker exec kafka-1 sh -c 'du -sh /var/lib/kafka/data/s04_perf-* | head -1'
compression.type처리량원본 200MB → 저장압축률프로듀서 CPU언제 씁니까
none121,854 msg/s200 MB1.0x낮음이미 압축된 페이로드(이미지, Avro+snappy)
gzip46,219 msg/s38 MB5.3x매우 높음저장 비용이 압도적으로 비쌀 때만
snappy143,903 msg/s71 MB2.8x낮음무난한 기본값
lz4156,372 msg/s66 MB3.0x낮음대부분의 경우 최선
zstd134,083 msg/s44 MB4.5x중간네트워크·저장이 병목일 때

주목할 점은 압축을 켜면 처리량이 오히려 늘어난다는 것입니다(lz4none 대비 +28%). 네트워크로 보낼 바이트가 1/3 이 되니 왕복이 빨라지고, CPU 비용보다 절약된 I/O 가 큽니다. gzip 만 예외인데 CPU 비용이 너무 비쌉니다.

브로커의 compression.type=producer

브로커/토픽에도 compression.type 이 있고 기본값은 producer 입니다. "프로듀서가 압축한 그대로 저장한다"는 뜻이며, 브로커가 압축을 풀지도 다시 하지도 않습니다. 이것이 Kafka 가 빠른 이유 중 하나입니다(zero-copy 로 컨슈머에게 그대로 전달).

kt --alter --topic s04_perf --config compression.type=gzip

이렇게 토픽에 특정 코덱을 강제하면, 프로듀서가 lz4 로 보낸 배치를 브로커가 풀어서 gzip 으로 다시 압축합니다.

⚠️ 함정 — 브로커 compression.type 을 프로듀서와 다르게 설정하면 재압축 비용이 붙습니다 재압축은 브로커 CPU 를 크게 먹고, 무엇보다 zero-copy 전송이 깨집니다. 브로커가 배치를 메모리로 복사해 풀고, 다시 압축해서 쓰기 때문입니다. 벤치마크상 브로커 CPU 가 2~3배로 뜁니다. 게다가 재압축 과정에서 배치가 재구성되어 오프셋 할당 로직이 다시 돕니다. 해결: 토픽의 compression.typeproducer(기본값) 로 두고, 코덱은 프로듀서 쪽에서 정하십시오. 조직 차원에서 코덱을 강제하고 싶다면 브로커 설정이 아니라 공용 프로듀서 설정 라이브러리로 하는 것이 맞습니다.

# 되돌립니다
kt --alter --topic s04_perf --delete-config compression.type

4-11. ⚠️ 최대 함정 D — max.in.flight > 1 + retries = 순서 뒤바뀜

이 스텝에서 가장 중요한 절입니다.

max.in.flight.requests.per.connection(기본 5)은 응답을 받지 않은 채 브로커 하나에 동시에 보낼 수 있는 요청 수입니다. 5 라는 것은 배치 5개가 동시에 날아가 있을 수 있다는 뜻입니다.

여기에 retries 가 겹치면 이렇게 됩니다.

  시각   프로듀서                                브로커 로그 (s04_order-0)
  ────   ────────────────────────────────────   ─────────────────────────
   t0    배치1 [ msg-1 msg-2 msg-3 ] 전송 ────►
   t1    배치2 [ msg-4 msg-5 msg-6 ] 전송 ────►    (in-flight 2개)
   t2                                     ◄──── 배치1 실패 (NOT_LEADER / 일시적 네트워크)
   t3                                     ◄──── 배치2 **성공**
                                                 offset 0: msg-4
                                                 offset 1: msg-5
                                                 offset 2: msg-6
   t4    배치1 재시도 전송 ─────────────────►
   t5                                     ◄──── 배치1 성공
                                                 offset 3: msg-1
                                                 offset 4: msg-2
                                                 offset 5: msg-3

  → 로그 최종 순서: msg-4, msg-5, msg-6, msg-1, msg-2, msg-3
                   ^^^^^^^^^^^^^^^^^^^  나중에 보낸 것이 앞에 있습니다.

한 파티션 안에서 순서가 뒤집혔습니다. Kafka 가 보장한다는 "파티션 내 순서"는 브로커가 받은 순서에 대한 보장이지, 프로듀서가 send() 를 호출한 순서에 대한 보장이 아닙니다.

실제로 재현하기

Practice.javaorder-break 시나리오가 이것을 재현합니다. 핵심 설정은 다음과 같습니다.

props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "false");   // ★ 반드시 꺼야 재현됩니다
props.put(ProducerConfig.ACKS_CONFIG, "1");
props.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, "5");
props.put(ProducerConfig.RETRIES_CONFIG, "10");
props.put(ProducerConfig.BATCH_SIZE_CONFIG, "64");              // 배치를 아주 작게 → 배치가 잘게 쪼개짐
props.put(ProducerConfig.LINGER_MS_CONFIG, "0");
props.put(ProducerConfig.RETRY_BACKOFF_MS_CONFIG, "5");
props.put(ProducerConfig.REQUEST_TIMEOUT_MS_CONFIG, "300");     // ★ 짧게 → 타임아웃 후 재시도 유발
props.put(ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG, "30000");

실행합니다. 재시도를 유발하기 위해 전송 도중 브로커 하나를 잠깐 내렸다 올립니다.

docker cp Practice.java kafka-1:/tmp/
docker exec kafka-1 sh -c 'cd /tmp && java -cp "/opt/kafka/libs/*" Practice.java order-break'

결과

[order-break] enable.idempotence=false, max.in.flight=5, retries=10
[order-break] s04_order 에 seq-000 ~ seq-199 를 같은 키(K)로 전송합니다.
[order-break] 전송 중 브로커를 흔들어 재시도를 유발하십시오:
              docker compose restart kafka-2
[order-break] sent seq-000 .. seq-199 (200건), 재시도 발생: 3회
[order-break] 완료. 아래 명령으로 로그 순서를 확인하십시오.

이제 컨슈머로 저장된 순서를 봅니다.

docker exec kafka-1 /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server kafka-1:9092 --topic s04_order --from-beginning \
  --max-messages 200 --timeout-ms 20000 \
  --property print.offset=true --property print.key=false

결과 (일부 발췌)

Offset:118	seq-118
Offset:119	seq-119
Offset:120	seq-123
Offset:121	seq-124
Offset:122	seq-125
Offset:123	seq-126
Offset:124	seq-120
Offset:125	seq-121
Offset:126	seq-122
Offset:127	seq-127
Offset:128	seq-128

오프셋 120 에 seq-123 이 있습니다. seq-120, seq-121, seq-122 는 뒤로 밀려 오프셋 124~126 에 들어갔습니다. 배치 하나가 실패하고 재시도되는 동안 다음 배치가 먼저 통과한 것입니다.

순서가 깨진 지점만 뽑는 스크립트도 준비했습니다.

docker exec kafka-1 /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server kafka-1:9092 --topic s04_order --from-beginning \
  --max-messages 200 --timeout-ms 20000 --property print.value=true \
  2>/dev/null | awk -F'-' '{n=$2+0; if (n < prev) printf "역전: %s (직전 seq-%03d)\n", $0, prev; prev=n}'

결과

역전: seq-120 (직전 seq-126)
역전: seq-057 (직전 seq-062)
역전: seq-181 (직전 seq-185)

세 곳에서 순서가 뒤집혔습니다. 재시도 3회에 정확히 대응합니다.

해결책 3가지

방법설정순서 보장처리량평가
in-flight 1max.in.flight.requests.per.connection=1완전크게 하락 (왕복 직렬화)옛날 방식. 지금은 쓸 이유 없음
멱등 프로듀서enable.idempotence=true완전 (max.in.flight ≤ 5 까지)거의 그대로정답
재시도 없음retries=0완전 (재시도 자체가 없으므로)그대로대신 일시적 오류에 그냥 유실. 최악

max.in.flight=1 로 두면 왕복이 직렬화되어 처리량이 반토막 납니다. 실측해 보면 이렇습니다.

docker exec kafka-1 /opt/kafka/bin/kafka-producer-perf-test.sh \
  --topic s04_perf --num-records 500000 --record-size 100 --throughput -1 \
  --producer-props bootstrap.servers=kafka-1:9092 acks=all enable.idempotence=false \
    max.in.flight.requests.per.connection=1

결과

500000 records sent, 28741.6 records/sec (2.74 MB/sec), 692.44 ms avg latency, 1487.00 ms max latency, 661 ms 50th, 1204 ms 95th, 1381 ms 99th, 1462 ms 99.9th.
설정처리량순서
max.in.flight=5, 멱등 off51,203 msg/s깨짐
max.in.flight=1, 멱등 off28,742 msg/s안전
max.in.flight=5, 멱등 on50,878 msg/s안전

멱등 프로듀서는 순서를 지키면서 처리량을 거의 잃지 않습니다. 비교 자체가 성립하지 않을 만큼 우월합니다.

멱등 프로듀서가 순서를 지키는 원리

멱등 프로듀서를 켜면 각 배치에 PID(producer id) + 시퀀스 번호가 붙습니다. 브로커는 파티션마다 "이 PID 에서 마지막으로 받은 시퀀스"를 기억하고 있어서,

  • 기대보다 작은 시퀀스가 오면 → 중복이므로 버립니다(응답은 성공). → 중복 제거
  • 기대보다 시퀀스가 오면 → 사이에 빠진 게 있으므로 거절합니다(OutOfOrderSequenceException). → 순서 보장

즉 위 시나리오에서 배치2 가 배치1 보다 먼저 도착하면 브로커가 배치2 를 거절합니다. 프로듀서는 배치1 을 재전송하고 그다음 배치2 를 다시 보냅니다. 순서가 유지됩니다. 브로커가 기억하는 시퀀스 윈도우가 5개여서 max.in.flight ≤ 5 라는 제약이 붙습니다.

💡 Kafka 3.0 부터 enable.idempotence 의 기본값이 true 입니다 그리고 켜지는 순간 아래 세 값이 함께 강제됩니다.

설정강제되는 값
acksall
retriesInteger.MAX_VALUE
max.in.flight.requests.per.connection5 이하

이 값들과 충돌하는 설정을 명시하면 기동 시 예외가 납니다.

org.apache.kafka.common.config.ConfigException: Must set acks to all in order to use
  the idempotent producer. Otherwise we cannot guarantee idempotence.

그래서 3.0 이상에서 acks=1 을 명시하면 멱등이 자동으로 꺼집니다(3.0~3.7 은 예외를 던지지 않고 조용히 끕니다). 위 재현 코드에서 enable.idempotence=false 를 명시한 이유가 이것입니다. 명시하지 않으면 재현되지 않습니다.

⚠️ 함정 — "우리는 3.0 이상이니까 안전하다"는 착각 기본값이 true 인 것은 맞지만, 다음 경우에 조용히 꺼집니다.

  • acks=0 또는 acks=1 을 명시했을 때
  • max.in.flight.requests.per.connection 을 6 이상으로 설정했을 때
  • retries=0 을 명시했을 때 "처리량 튜닝한다고 acks=1 로 바꿨더니 순서가 깨지기 시작했다"가 실제로 벌어지는 경로입니다. 해결: 프로듀서 기동 시 실제 적용된 설정을 로그로 남기십시오. 프로듀서는 시작할 때 전체 설정을 INFO 로 출력합니다.
INFO ProducerConfig values:
	acks = -1
	enable.idempotence = true
	max.in.flight.requests.per.connection = 5
	retries = 2147483647

acks = -1acks=all 입니다. 이 네 줄만 확인하면 됩니다.


4-12. 타임아웃 3형제 — delivery.timeout.ms / request.timeout.ms / retry.backoff.ms

재시도 동작을 이해하려면 세 값의 관계를 알아야 합니다.

  send() 호출

     ├──────────────────── delivery.timeout.ms (기본 120000) ─────────────────────┐
     │                     "send() 부터 최종 성공/실패까지의 총 상한"                 │
     │                                                                           │
     │  [ 배치 대기 ]  [ 요청 1 ]   backoff   [ 요청 2 ]   backoff   [ 요청 3 ] ... │
     │   linger.ms     ├──────┤     100ms    ├──────┤     100ms   ├──────┤       │
     │                  request.               retry.                             │
     │                  timeout.ms             backoff.ms                         │
     │                  (기본 30000)           (기본 100)                          │
     │                                                                           │
     └───────────────────────────────────────────────────────────────────────────┘
                             이 상한을 넘으면 → TimeoutException (재시도 중단)
설정기본값무엇을 재는가넘으면
delivery.timeout.ms120,000send() 부터 최종 결과까지의 전체 시간TimeoutException, 재시도 중단
request.timeout.ms30,000요청 하나의 응답 대기 시간그 요청만 실패 처리 → 재시도
retry.backoff.ms100재시도 사이의 대기
retry.backoff.max.ms1,000지수 백오프의 상한 (2.7+)
linger.ms0배치를 모으는 대기
retriesInteger.MAX_VALUE최대 재시도 횟수소진 시 실패

제약: delivery.timeout.ms >= linger.ms + request.timeout.ms 여야 합니다. 어기면 기동 시 예외가 납니다.

org.apache.kafka.common.config.ConfigException: delivery.timeout.ms should be equal to
  or larger than linger.ms + request.timeout.ms

💡 실무 팁 — retries 를 줄이지 말고 delivery.timeout.ms 를 조정하십시오 2.1 부터 재시도의 실질적 상한은 retries 가 아니라 delivery.timeout.ms 입니다. retries=3 으로 두면 "3번 만에 포기" 인데, 그 3번이 300ms 안에 끝나 버려서 일시적 리더 선출(보통 수 초)을 못 넘깁니다. retriesInteger.MAX_VALUE 로 두고 delivery.timeout.ms 로 "얼마나 매달릴지"를 정하는 것이 3.x 의 권장 방식입니다. 예: SLA 가 5초인 API 라면 delivery.timeout.ms=4000, request.timeout.ms=1500, max.block.ms=1000.


4-13. 전송 3패턴 — fire-and-forget / 동기 / 콜백

같은 send() 인데 결과를 어떻게 다루느냐에 따라 성능과 안전성이 완전히 달라집니다.

(1) Fire-and-forget

producer.send(new ProducerRecord<>("s04_acks", key, value));

가장 빠르고 가장 위험합니다. 4-1 의 함정 그대로, 브로커 거절을 영영 모릅니다.

(2) 동기 전송

RecordMetadata md = producer.send(new ProducerRecord<>("s04_acks", key, value)).get();
System.out.printf("partition=%d offset=%d%n", md.partition(), md.offset());

.get() 이 응답을 기다립니다. 가장 안전하지만 배치가 죽습니다. 한 건 보내고 응답을 기다리는 동안 다음 레코드가 버퍼에 안 들어가므로, 배치 크기가 항상 1 입니다.

(3) 콜백

producer.send(new ProducerRecord<>("s04_acks", key, value), (md, ex) -> {
    if (ex != null) {
        log.error("전송 실패 key={}", key, ex);
        deadLetter.write(key, value);
    } else {
        log.debug("p={} off={}", md.partition(), md.offset());
    }
});

실무의 기본값입니다. 배치를 그대로 활용하면서 실패를 놓치지 않습니다.

Practice.java sync-vs-async 로 세 패턴을 각각 10,000건씩 측정했습니다.

docker exec kafka-1 sh -c 'cd /tmp && java -cp "/opt/kafka/libs/*" Practice.java sync-vs-async'

결과

[sync-vs-async] topic=s04_acks, 각 패턴 10000건, acks=all, linger.ms=5

  fire-and-forget :   1103 ms   9066 msg/s   (실패를 감지할 수 없음)
  callback        :   1247 ms   8019 msg/s   (실패 0건 감지)
  sync (.get())   :  24518 ms    408 msg/s   (실패 0건 감지)

  → sync 는 callback 대비 19.6배 느립니다.
패턴처리량실패 감지순서권장
fire-and-forget9,066 msg/s불가배치 활용
콜백8,019 msg/s가능배치 활용기본값
동기 .get()408 msg/s가능완전 직렬초기화·마이그레이션 등 소량 전송에만

⚠️ 함정 — 콜백 안에서 무거운 일을 하면 프로듀서 전체가 멈춥니다 콜백은 Sender 스레드(1개)에서 실행됩니다. 콜백에서 DB 를 조회하거나 HTTP 를 호출하면 그동안 Sender 는 다음 배치를 못 보냅니다. 모든 파티션의 전송이 멈춥니다. 해결: 콜백은 카운터 증가, 로그, 큐에 넣기 정도만 하십시오. 무거운 처리는 별도 스레드 풀로 넘깁니다. 같은 이유로 콜백에서 producer.close() 를 부르면 데드락입니다(Sender 스레드가 자기 자신의 종료를 기다림).


4-14. 정리 (실습 마무리)

이 스텝에서 만든 토픽을 삭제합니다.

kt --delete --topic s04_acks
kt --delete --topic s04_order
kt --delete --topic s04_perf

kt --list | grep '^s04_' || echo "s04_ 토픽 없음 — 정리 완료"

결과

s04_ 토픽 없음 — 정리 완료

4-4 에서 브로커를 내렸으므로 세 대가 모두 살아 있는지 확인합니다.

docker compose ps --format 'table {{.Name}}\t{{.Status}}'

결과

NAME       STATUS
kafka-1    Up 41 minutes (healthy)
kafka-2    Up 12 minutes (healthy)
kafka-3    Up 12 minutes (healthy)
kafka-ui   Up 41 minutes

(healthy) 가 셋이 아니면 다음 스텝의 리밸런싱 실습이 이상하게 동작합니다. 반드시 확인하고 넘어가세요.


정리

개념핵심
send()비동기. 버퍼에 넣을 뿐. 성공 응답은 콜백/Future 로만 알 수 있음
RecordAccumulator파티션별 배치 큐. buffer.memory(32MiB)가 차면 send()max.block.ms 만큼 블로킹
acks=0응답을 안 기다림. 브로커가 죽어도 성공한다. 실측 유실률 28%
acks=1리더 로컬 기록까지. 리더가 복제 전 죽으면 성공 콜백을 받은 메시지가 사라짐
acks=allISR 전체 기록까지. min.insync.replicas=2 와 세트가 아니면 무의미
acks 실측118,168 / 92,408 / 51,203 msg/s — all 은 0 의 43%
파티셔너키 있으면 murmur2 % N, 없으면 sticky. partitioner.class 기본값은 null
deprecatedUniformStickyPartitioner·DefaultPartitioner3.3 부터 deprecated (KIP-794)
hot partition키 40% 쏠림 → 한 파티션이 63%. 에러 없이 그 파티션의 LAG 만 증가
linger.ms"지연 추가"가 아니라 "지연 상한". 0→20 + batch 64KiB 로 처리량 2.7배, 지연 2.7배 감소
압축배치 단위. lz4 가 대체로 최선(처리량 +28%, 3.0x). gzip 은 CPU 비용이 과함
브로커 압축토픽 compression.typeproducer(기본) 로 둘 것. 다르면 재압축 + zero-copy 파괴
순서 뒤바뀜max.in.flight>1 + 재시도 → 같은 파티션 안에서 순서 역전. 실측 3곳 역전
멱등 프로듀서PID+시퀀스로 순서·중복 해결. max.in.flight ≤ 5 까지 보장. 처리량 손실 거의 없음
3.0 기본값enable.idempotence=trueacks=all, retries=MAX, max.in.flight≤5 강제
조용한 비활성화acks=1 을 명시하면 멱등이 조용히 꺼집니다
타임아웃실질 상한은 retries 가 아니라 delivery.timeout.ms. >= linger.ms + request.timeout.ms
전송 패턴콜백이 기본. .get() 은 19.6배 느림. 콜백 안에서 무거운 일 금지(Sender 스레드)

연습문제

exercise.sh 에 7문제가 있습니다. 정답은 solution.sh.

  1. acks=0/1/all 을 각각 30만 건으로 측정하고, 이 스텝의 표와 같은 형태로 정리하기
  2. C001~C010 을 파티션 3개 토픽에 넣고, 각 키가 어느 파티션으로 가는지 표로 만들기
  3. linger.ms 를 0 / 5 / 20 / 100 으로 바꿔 가며 처리량과 p99 지연의 변곡점 찾기
  4. 압축 5종을 측정하고, "네트워크 바이트 대비 CPU" 관점에서 이 워크로드에 맞는 코덱 고르기
  5. enable.idempotence=false + max.in.flight=5 로 순서 역전을 재현하고, 역전 지점 수 세기
  6. 문제 5 를 enable.idempotence=true 로 바꿔 역전이 0건임을 확인하고, 처리량 차이 측정하기
  7. buffer.memory=1048576(1MiB) + max.block.ms=2000 으로 send() 가 블로킹하다 예외를 던지게 만들기

다음 단계

프로듀서가 메시지를 어떻게 넣는지 봤습니다. 이제 꺼내는 쪽입니다. 컨슈머는 혼자 동작하지 않고 그룹으로 묶여 파티션을 나눠 갖습니다. 그 나눠 갖기가 어떻게 일어나고, 왜 컨슈머를 늘려도 처리량이 안 늘어나는지, 리밸런싱이 왜 모든 컨슈머를 멈춰 세우는지를 봅니다.

Step 05 — 컨슈머와 컨슈머 그룹


실습 파일

이 스텝은 셸 스크립트 세 개와 Java 파일 하나로 진행합니다. practice.sh 는 4-0 ~ 4-14 의 CLI 명령을 순서대로 담고 있고, CLI 로는 재현할 수 없는 순서 역전전송 패턴 성능 비교Practice.java 가 담당합니다. 문제를 풀 때는 exercise.sh 를 먼저 열고, 막히면 solution.sh 를 여십시오.

practice.sh

본문의 모든 명령을 절 번호 주석(# [4-3])과 함께 담은 실행 스크립트입니다.

  • 상단에 BS=kafka-1:9092K() { docker exec kafka-1 /opt/kafka/bin/"$@"; } 헬퍼를 정의해, 본문의 긴 docker execK kafka-topics.sh --bootstrap-server "$BS" ... 로 줄였습니다. 별칭(kt/kcg)은 대화형 셸 전용이라 스크립트에서는 쓰지 않습니다.
  • [4-3] 의 acks 비교는 100만 건 × 3회라 전체 실행에 5~8분이 걸립니다. 빠르게 훑고 싶으면 파일 상단의 NUM_RECORDS 를 30만으로 낮추십시오. 절대값은 달라지지만 비율은 유지됩니다.
  • [4-4] 의 브로커 죽이기 구간은 # [터미널 B] docker compose stop kafka-2 kafka-3 주석으로 표시해 두었습니다. 스크립트는 이 지점에서 read -p 로 멈춰 여러분이 다른 창에서 브로커를 내릴 시간을 줍니다. 그냥 Enter 를 치면 유실 없이 지나가므로 반드시 다른 창에서 브로커를 내리고 Enter 를 누르세요.
  • [4-8] 의 쏠림 데이터 생성은 컨테이너 안에서 for 루프로 1만 줄을 만들어 파이프로 넘깁니다. 호스트에서 만들어 docker exec -i 로 넘기면 파이프 버퍼 때문에 느려서, 생성과 소비를 같은 컨테이너 안에서 하도록 짰습니다.
  • [4-10] 의 압축 루프는 5개 코덱을 순회하며 각각 100만 건을 보냅니다. 마지막에 du -sh 로 파티션 디렉터리 크기를 찍어 압축률을 눈으로 확인합니다. 코덱을 바꿔도 같은 토픽에 계속 쌓이므로 각 코덱 측정 전에 토픽을 재생성합니다.
  • [4-14]s04_acks / s04_order / s04_perf 를 삭제하고, docker compose ps 로 브로커 3대가 healthy 인지 확인합니다. 4-4 에서 내린 브로커를 되살리지 않은 채 끝나면 Step 05 가 이상하게 동작하므로 이 확인이 중요합니다.
#!/usr/bin/env bash
#
# Step 04 — 프로듀서 : 본문(4-0 ~ 4-14)의 모든 명령
#
# 실행법:
#   bash practice.sh              # 통째로 실행 (10~15분)
#   bash -x practice.sh           # 한 줄씩 확인하며 실행 (권장)
#
# 주의:
#   - [4-4] 구간은 터미널 2개가 필요합니다. 스크립트가 read 로 멈춥니다.
#   - [4-3] [4-9] [4-10] 은 100만 건씩 보내므로 시간이 걸립니다.
#     빠르게 훑고 싶으면 아래 NUM_RECORDS 를 300000 으로 낮추십시오.
#     절대값은 달라지지만 비율은 유지됩니다.
#
set -euo pipefail

BS=kafka-1:9092
K() { docker exec kafka-1 /opt/kafka/bin/"$@"; }
Ki() { docker exec -i kafka-1 /opt/kafka/bin/"$@"; }

NUM_RECORDS=${NUM_RECORDS:-1000000}
REC_SIZE=100

hr() { echo; echo "=================== $* ==================="; }

# ---------------------------------------------------------------------------
# [4-0] 실습 준비 — s04_ 토픽 3개
# ---------------------------------------------------------------------------
hr "[4-0] 실습 준비"

# 이미 있으면 지우고 다시 만듭니다 (여러 번 돌려도 안전하게)
for T in s04_acks s04_order s04_perf; do
  K kafka-topics.sh --bootstrap-server "$BS" --delete --topic "$T" 2>/dev/null || true
done
sleep 3

K kafka-topics.sh --bootstrap-server "$BS" --create --topic s04_acks  \
  --partitions 3 --replication-factor 3
K kafka-topics.sh --bootstrap-server "$BS" --create --topic s04_order \
  --partitions 1 --replication-factor 3
K kafka-topics.sh --bootstrap-server "$BS" --create --topic s04_perf  \
  --partitions 6 --replication-factor 3 \
  --config segment.bytes=268435456 --config retention.ms=3600000

# s04_order 만 파티션 1개입니다. 순서 역전은 한 파티션 안의 현상이라,
# 파티션이 여러 개면 "원래 순서가 안 맞는 것"과 구별되지 않습니다.
K kafka-topics.sh --bootstrap-server "$BS" --describe --topic s04_acks

# ---------------------------------------------------------------------------
# [4-3] acks=0 / 1 / all 실측
# ---------------------------------------------------------------------------
hr "[4-3] acks 실측 — ${NUM_RECORDS} 건 x 3회"

run_perf() {
  local label="$1"; shift
  echo "--- $label ---"
  K kafka-producer-perf-test.sh \
    --topic s04_perf --num-records "$NUM_RECORDS" --record-size "$REC_SIZE" \
    --throughput -1 --producer-props bootstrap.servers="$BS" "$@" | tail -1
}

run_perf "acks=0"   acks=0
run_perf "acks=1"   acks=1
run_perf "acks=all" acks=all

# 마지막 줄의 "N records/sec" 가 처리량,
# "M ms 99th" 가 p99 지연입니다. 세 값을 표로 옮겨 적으십시오.

# ---------------------------------------------------------------------------
# [4-4] 핵심 함정 A — acks=0 은 브로커가 죽어도 성공한다
# ---------------------------------------------------------------------------
hr "[4-4] 함정 A — acks=0 유실 재현"

K kafka-topics.sh --bootstrap-server "$BS" --describe --topic s04_acks | grep 'Partition: 0'

echo
echo "  이제 [터미널 B] 를 열고 아래 명령을 준비해 두십시오."
echo "      docker compose stop kafka-2 kafka-3"
echo
echo "  Enter 를 누르면 120000건 전송(60초)이 시작됩니다."
echo "  전송이 시작되고 10초쯤 뒤에 터미널 B 에서 위 명령을 실행하십시오."
read -r -p "  준비되면 Enter: " _

K kafka-producer-perf-test.sh \
  --topic s04_acks --num-records 120000 --record-size 100 --throughput 2000 \
  --producer-props bootstrap.servers="$BS" acks=0

echo
echo "  프로듀서는 에러 한 줄 없이 '120000 records sent' 를 보고했습니다."
echo "  이제 브로커를 되살리고 실제 저장 건수를 셉니다."
read -r -p "  터미널 B 에서 'docker compose start kafka-2 kafka-3' 실행 후 Enter: " _
sleep 30

K kafka-get-offsets.sh --bootstrap-server "$BS" --topic s04_acks

# 세 파티션 오프셋의 합이 120000 보다 작으면 그 차이가 그대로 유실입니다.
# acks=0 은 응답을 기다리지 않으므로 끊긴 연결에 실린 배치를 재시도하지 않습니다.

# ---------------------------------------------------------------------------
# [4-7] 키 라우팅 — 같은 키는 항상 같은 파티션
# ---------------------------------------------------------------------------
hr "[4-7] 키 라우팅"

# 앞 절에서 데이터가 섞였으므로 토픽을 재생성합니다
K kafka-topics.sh --bootstrap-server "$BS" --delete --topic s04_acks
sleep 3
K kafka-topics.sh --bootstrap-server "$BS" --create --topic s04_acks \
  --partitions 3 --replication-factor 3

Ki kafka-console-producer.sh --bootstrap-server "$BS" --topic s04_acks \
  --property parse.key=true --property key.separator=: <<'EOF'
C001:{"order_id":"O-1001","customer_id":"C001","amount":39000,"status":"CREATED"}
C002:{"order_id":"O-1002","customer_id":"C002","amount":12000,"status":"CREATED"}
C003:{"order_id":"O-1003","customer_id":"C003","amount":58000,"status":"CREATED"}
C001:{"order_id":"O-1004","customer_id":"C001","amount":7000,"status":"CREATED"}
C002:{"order_id":"O-1005","customer_id":"C002","amount":31000,"status":"CREATED"}
C001:{"order_id":"O-1006","customer_id":"C001","amount":25000,"status":"CREATED"}
EOF

K kafka-console-consumer.sh --bootstrap-server "$BS" --topic s04_acks --from-beginning \
  --max-messages 6 --timeout-ms 15000 \
  --property print.key=true --property print.partition=true --property print.value=false

# C001 세 건이 전부 같은 파티션으로 갑니다. murmur2(key) % 3 이 결정적이기 때문입니다.

# ---------------------------------------------------------------------------
# [4-8] 핵심 함정 C — 키 쏠림 (hot partition)
# ---------------------------------------------------------------------------
hr "[4-8] 함정 C — hot partition"

# C001 이 40%, 나머지 9명이 60% 를 나눠 갖는 1만 건.
# 생성과 소비를 같은 컨테이너 안에서 합니다(호스트에서 파이프로 넘기면 훨씬 느립니다).
docker exec kafka-1 bash -c '
for i in $(seq 1 10000); do
  if [ $((i % 10)) -lt 4 ]; then k="C001"; else k="C00$((RANDOM % 9 + 2))"; fi
  echo "$k:{\"order_id\":\"O-$((1000+i))\",\"customer_id\":\"$k\",\"amount\":10000,\"status\":\"CREATED\"}"
done | /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server kafka-1:9092 \
  --topic s04_acks --property parse.key=true --property key.separator=:'

K kafka-get-offsets.sh --bootstrap-server "$BS" --topic s04_acks

# 한 파티션이 60% 이상을 차지합니다. 에러는 나지 않습니다.
# 증상은 "그 파티션의 LAG 만 계속 증가" 로만 나타납니다 (Step 05).

# ---------------------------------------------------------------------------
# [4-9] batch.size / linger.ms 트레이드오프
# ---------------------------------------------------------------------------
hr "[4-9] linger.ms 0 vs 20"

run_perf "linger.ms=0  batch.size=16KiB" acks=all linger.ms=0  batch.size=16384
run_perf "linger.ms=20 batch.size=64KiB" acks=all linger.ms=20 batch.size=65536

# 20ms 를 더 기다렸는데 평균 지연이 오히려 줄어듭니다.
# 요청 수가 줄어 브로커 앞의 큐잉이 사라지기 때문입니다.

# ---------------------------------------------------------------------------
# [4-9b] buffer.memory 가 차면 send() 가 블로킹한다
# ---------------------------------------------------------------------------
hr "[4-9b] buffer.memory + max.block.ms"

set +e
K kafka-producer-perf-test.sh \
  --topic s04_perf --num-records 500000 --record-size 1000 --throughput -1 \
  --producer-props bootstrap.servers="$BS" acks=all \
    buffer.memory=1048576 max.block.ms=2000 2>&1 | tail -5
set -e

# TimeoutException: Failed to allocate memory within the configured max blocking
# time 2000 ms  → 예외가 나는 것이 정상입니다.

# ---------------------------------------------------------------------------
# [4-10] 압축 5종 비교
# ---------------------------------------------------------------------------
hr "[4-10] 압축 비교"

for C in none gzip snappy lz4 zstd; do
  # 코덱마다 같은 조건에서 재려면 토픽을 비워야 합니다
  K kafka-topics.sh --bootstrap-server "$BS" --delete --topic s04_perf
  sleep 3
  K kafka-topics.sh --bootstrap-server "$BS" --create --topic s04_perf \
    --partitions 6 --replication-factor 3 \
    --config segment.bytes=268435456 --config retention.ms=3600000

  echo "--- compression.type=$C ---"
  K kafka-producer-perf-test.sh \
    --topic s04_perf --num-records "$NUM_RECORDS" --record-size 200 --throughput -1 \
    --producer-props bootstrap.servers="$BS" acks=all \
      linger.ms=20 batch.size=65536 compression.type="$C" | tail -1

  docker exec kafka-1 sh -c 'du -sc /var/lib/kafka/data/s04_perf-* | tail -1'
done

# 압축을 켜면 처리량이 오히려 늘어납니다(lz4 가 none 대비 +28%).
# 네트워크 바이트가 1/3 이 되어 절약된 I/O 가 CPU 비용보다 크기 때문입니다.
# gzip 만 예외입니다 — 압축률은 최고지만 CPU 비용이 과합니다.

# ---------------------------------------------------------------------------
# [4-10b] 브로커 compression.type 을 다르게 두면 재압축 비용
# ---------------------------------------------------------------------------
hr "[4-10b] 브로커 재압축"

K kafka-configs.sh --bootstrap-server "$BS" --alter \
  --entity-type topics --entity-name s04_perf --add-config compression.type=gzip
K kafka-configs.sh --bootstrap-server "$BS" --describe \
  --entity-type topics --entity-name s04_perf

# 이 상태에서 프로듀서가 lz4 로 보내면 브로커가 풀어서 gzip 으로 다시 압축합니다.
# zero-copy 가 깨지고 브로커 CPU 가 2~3배로 뜁니다.
K kafka-configs.sh --bootstrap-server "$BS" --alter \
  --entity-type topics --entity-name s04_perf --delete-config compression.type

# ---------------------------------------------------------------------------
# [4-11] 최대 함정 D — 순서 뒤바뀜 (Practice.java)
# ---------------------------------------------------------------------------
hr "[4-11] 함정 D — 순서 역전"

docker cp "$(dirname "$0")/Practice.java" kafka-1:/tmp/

echo
echo "  [터미널 B] 에서 아래 명령을 준비하십시오. 전송이 시작되면 실행합니다."
echo "      docker compose restart kafka-2"
echo "  재시도가 한 번도 안 일어나면 순서는 그대로이고 함정이 재현되지 않습니다."
read -r -p "  준비되면 Enter: " _

docker exec kafka-1 sh -c 'cd /tmp && java -cp "/opt/kafka/libs/*" Practice.java order-break'

echo "--- 저장된 순서에서 역전 지점 찾기 ---"
K kafka-console-consumer.sh --bootstrap-server "$BS" --topic s04_order --from-beginning \
  --max-messages 200 --timeout-ms 20000 --property print.value=true 2>/dev/null \
  | awk -F'-' '{n=$2+0; if (n < prev) printf "역전: %s (직전 seq-%03d)\n", $0, prev; prev=n}'

# 역전이 나왔다면 함정 재현 성공입니다.
# 같은 코드에서 enable.idempotence 만 켜면 역전이 사라집니다:
K kafka-topics.sh --bootstrap-server "$BS" --delete --topic s04_order
sleep 3
K kafka-topics.sh --bootstrap-server "$BS" --create --topic s04_order \
  --partitions 1 --replication-factor 3

read -r -p "  다시 [터미널 B] 에서 브로커를 흔들 준비 후 Enter: " _
docker exec kafka-1 sh -c 'cd /tmp && java -cp "/opt/kafka/libs/*" Practice.java order-safe'

K kafka-console-consumer.sh --bootstrap-server "$BS" --topic s04_order --from-beginning \
  --max-messages 200 --timeout-ms 20000 --property print.value=true 2>/dev/null \
  | awk -F'-' '{n=$2+0; if (n < prev) printf "역전: %s\n", $0; prev=n}' \
  | { grep . || echo "역전 0건 — 멱등 프로듀서가 순서를 지켰습니다."; }

# ---------------------------------------------------------------------------
# [4-11b] max.in.flight=1 의 대가
# ---------------------------------------------------------------------------
hr "[4-11b] max.in.flight=1 처리량"

K kafka-producer-perf-test.sh \
  --topic s04_perf --num-records 500000 --record-size 100 --throughput -1 \
  --producer-props bootstrap.servers="$BS" acks=all enable.idempotence=false \
    max.in.flight.requests.per.connection=1 | tail -1

K kafka-producer-perf-test.sh \
  --topic s04_perf --num-records 500000 --record-size 100 --throughput -1 \
  --producer-props bootstrap.servers="$BS" acks=all enable.idempotence=true | tail -1

# in-flight=1 은 왕복이 직렬화되어 처리량이 반토막 납니다.
# 멱등 프로듀서는 in-flight 5 를 유지하면서 순서를 지킵니다. 비교가 성립하지 않습니다.

# ---------------------------------------------------------------------------
# [4-13] 전송 3패턴 성능 비교
# ---------------------------------------------------------------------------
hr "[4-13] fire-and-forget / callback / sync"

docker exec kafka-1 sh -c 'cd /tmp && java -cp "/opt/kafka/libs/*" Practice.java sync-vs-async'
docker exec kafka-1 sh -c 'cd /tmp && java -cp "/opt/kafka/libs/*" Practice.java callback'
docker exec kafka-1 sh -c 'cd /tmp && java -cp "/opt/kafka/libs/*" Practice.java key-route'

# callback 시나리오는 콜백이 실행되는 스레드 이름을 찍습니다.
# kafka-producer-network-thread | producer-1  → Sender 스레드입니다.
# 여기서 무거운 일을 하면 모든 파티션의 전송이 멈춥니다.

# ---------------------------------------------------------------------------
# [4-14] 정리
# ---------------------------------------------------------------------------
hr "[4-14] 정리"

for T in s04_acks s04_order s04_perf; do
  K kafka-topics.sh --bootstrap-server "$BS" --delete --topic "$T" 2>/dev/null || true
done
sleep 3

K kafka-topics.sh --bootstrap-server "$BS" --list | grep '^s04_' \
  || echo "s04_ 토픽 없음 — 정리 완료"

# 4-4 에서 브로커를 내렸으므로 3대가 모두 살아 있는지 반드시 확인합니다.
docker compose ps --format 'table {{.Name}}\t{{.Status}}' 2>/dev/null \
  || docker ps --filter name=kafka --format 'table {{.Names}}\t{{.Status}}'

echo
echo "Step 04 완료. (healthy) 가 3개가 아니면 Step 05 로 넘어가지 마십시오."

exercise.sh

7문제의 문제지입니다. 각 문제는 # 여기에 작성: 자리를 비워 두었습니다.

  • 문제 1·3·4kafka-producer-perf-test.sh 의 옵션을 직접 조합하는 측정 문제입니다. 파일이 토픽(s04_ex_perf)을 미리 만들어 주므로 여러분은 --producer-props 뒤만 채우면 됩니다.
  • 문제 2 는 콘솔 프로듀서로 C001~C010 을 한 건씩 넣고, print.partition=true 컨슈머로 매핑 표를 만드는 문제입니다. 답이 고정되어 있으므로(murmur2 는 결정적) solution.sh 의 표와 정확히 일치해야 합니다.
  • 문제 5·6Practice.java 를 씁니다. 문제 5 는 order-break, 문제 6 은 order-safe 시나리오이며, 두 실행 사이에 반드시 s04_ex_order 토픽을 재생성해야 합니다. 안 그러면 문제 5 의 역전 기록이 문제 6 결과에 섞입니다.
  • 문제 7buffer.memory 를 1MiB 로 줄이고 max.block.ms=2000 을 걸어 send() 를 블로킹시키는 문제입니다. kafka-producer-perf-test.sh 로도 재현되며, 성공하면 TimeoutException: Failed to allocate memory within the configured max blocking time 2000 ms 가 뜹니다. 예외가 나는 것이 정답입니다.
  • 파일 끝의 cleanup_ex() 함수가 s04_ex_ 로 시작하는 토픽을 전부 지웁니다. 중간에 멈췄다면 이 함수만 따로 호출하십시오.
#!/usr/bin/env bash
#
# Step 04 — 프로듀서 : 연습문제 7문제
#
# 실행법:
#   bash exercise.sh            # 준비 구간만 돌고 각 문제에서 멈춥니다
#   (권장) 이 파일을 열어 놓고 "# 여기에 작성:" 자리를 채운 뒤 그 부분만 복사해 실행
#
# 정답은 solution.sh 에 있습니다. 먼저 직접 풀어 보십시오.
#
set -euo pipefail

BS=kafka-1:9092
K() { docker exec kafka-1 /opt/kafka/bin/"$@"; }
Ki() { docker exec -i kafka-1 /opt/kafka/bin/"$@"; }

hr() { echo; echo "-------- $* --------"; }

cleanup_ex() {
  for T in $(K kafka-topics.sh --bootstrap-server "$BS" --list | grep '^s04_ex_' || true); do
    K kafka-topics.sh --bootstrap-server "$BS" --delete --topic "$T" || true
  done
  echo "s04_ex_ 토픽 정리 완료"
}

# ---------------------------------------------------------------------------
# 준비 — 문제용 토픽
# ---------------------------------------------------------------------------
hr "준비"
cleanup_ex
sleep 3

K kafka-topics.sh --bootstrap-server "$BS" --create --topic s04_ex_perf \
  --partitions 6 --replication-factor 3 --config segment.bytes=268435456
K kafka-topics.sh --bootstrap-server "$BS" --create --topic s04_ex_key \
  --partitions 3 --replication-factor 3
K kafka-topics.sh --bootstrap-server "$BS" --create --topic s04_ex_order \
  --partitions 1 --replication-factor 3


# ===========================================================================
# 문제 1. acks 실측
# ===========================================================================
# s04_ex_perf 에 30만 건(레코드 100바이트, 스로틀 없음)을 acks=0 / 1 / all 로
# 각각 보내고, 처리량(records/sec)과 p99 지연을 표로 정리하십시오.
#
# 힌트: kafka-producer-perf-test.sh 의 마지막 요약 줄에 둘 다 있습니다.
hr "문제 1"

# 여기에 작성:
#   for A in 0 1 all; do
#     K kafka-producer-perf-test.sh --topic s04_ex_perf \
#        --num-records ______ --record-size ______ --throughput ______ \
#        --producer-props bootstrap.servers="$BS" acks=______ | tail -1
#   done


# ===========================================================================
# 문제 2. 키 → 파티션 매핑 표
# ===========================================================================
# C001 ~ C010 을 한 건씩 s04_ex_key (파티션 3개) 에 넣고,
# 각 키가 어느 파티션으로 갔는지 표를 만드십시오.
#
# 힌트: --property print.partition=true --property print.key=true
#       --property print.value=false
# 힌트: 파티셔너는 결정적입니다. 몇 번을 다시 돌려도 같은 답이 나와야 합니다.
hr "문제 2"

# 여기에 작성: (프로듀서)
#   for i in $(seq -w 1 10); do echo "C0$i:{\"customer_id\":\"C0$i\"}"; done \
#     | Ki kafka-console-producer.sh --bootstrap-server "$BS" --topic s04_ex_key \
#         --property ______ --property ______

# 여기에 작성: (컨슈머 — 파티션과 키를 함께 출력)
#


# ===========================================================================
# 문제 3. linger.ms 변곡점 찾기
# ===========================================================================
# acks=all, batch.size=65536 고정. linger.ms 를 0 / 5 / 20 / 100 으로 바꿔
# 30만 건씩 측정하고, "처리량이 더 이상 늘지 않는 지점"과
# "p99 지연이 나빠지기 시작하는 지점"을 각각 찾으십시오.
hr "문제 3"

# 여기에 작성:
#   for L in 0 5 20 100; do
#     ...
#   done


# ===========================================================================
# 문제 4. 압축 코덱 고르기
# ===========================================================================
# 레코드 200바이트, 30만 건, acks=all, linger.ms=20, batch.size=65536 고정.
# none / gzip / snappy / lz4 / zstd 를 각각 측정하고
#   (a) 처리량이 가장 높은 코덱
#   (b) 저장 용량이 가장 작은 코덱
#   (c) "네트워크 대역폭이 비싼 환경" 에서 고를 코덱
# 세 가지에 답하십시오.
#
# 힌트: 저장 용량은 docker exec kafka-1 du -sc /var/lib/kafka/data/s04_ex_perf-*
# 힌트: 코덱마다 공정하게 재려면 측정 전에 토픽을 재생성해야 합니다.
hr "문제 4"

# 여기에 작성:
#


# ===========================================================================
# 문제 5. 순서 역전 재현
# ===========================================================================
# Practice.java 의 order-break 시나리오로 s04_ex_order 에 200건을 보내면서
# 다른 창에서 브로커를 흔들어 재시도를 유발하고,
# 저장된 로그에서 "역전 지점이 몇 곳인지" 세십시오.
#
# 힌트: Practice.java 는 토픽 이름을 두 번째 인자로 받습니다.
#       java -cp "/opt/kafka/libs/*" Practice.java order-break s04_ex_order
# 힌트: 역전 세기
#       ... | awk -F'-' '{n=$2+0; if (n<prev) c++; prev=n} END{print c+0 " 곳 역전"}'
hr "문제 5"

# 여기에 작성: (전송)
#

# 여기에 작성: (역전 세기)
#


# ===========================================================================
# 문제 6. 멱등 프로듀서로 순서 지키기
# ===========================================================================
# 문제 5 와 완전히 같은 조건에서 enable.idempotence=true 로만 바꿔
# (order-safe 시나리오) 역전이 0곳임을 확인하십시오.
# 그리고 두 경우의 처리량 차이를 kafka-producer-perf-test.sh 로 측정하십시오.
#
# ⚠️ 반드시 s04_ex_order 를 재생성한 뒤에 실행하십시오.
#    안 그러면 문제 5 의 기록이 섞여 결과를 못 읽습니다.
hr "문제 6"

# 여기에 작성: (토픽 재생성)
#

# 여기에 작성: (order-safe 실행 + 역전 세기)
#

# 여기에 작성: (처리량 비교 — 멱등 off/on)
#


# ===========================================================================
# 문제 7. send() 를 블로킹시켜 TimeoutException 내기
# ===========================================================================
# buffer.memory 를 1MiB 로, max.block.ms 를 2000 으로 줄여
# 프로듀서 버퍼가 차서 send() 가 블로킹하다 예외를 던지게 만드십시오.
#
# 힌트: 레코드를 크게(1000바이트) 하고 스로틀을 풀어야 버퍼가 빨리 찹니다.
# 힌트: 예외가 나는 것이 정답입니다. set -e 때문에 스크립트가 죽지 않도록
#       set +e / set -e 로 감싸십시오.
#
# 나온 예외 메시지를 그대로 적어 보십시오:
#   org.apache.kafka.common.errors.____________: ______________________________
hr "문제 7"

# 여기에 작성:
#


# ===========================================================================
# 정리
# ===========================================================================
hr "정리"
# 중간에 멈췄다면 이 함수만 따로 호출하십시오.
cleanup_ex

K kafka-topics.sh --bootstrap-server "$BS" --list | grep '^s04_' \
  || echo "s04_ 토픽 없음 — 정리 완료"

solution.sh

정답 명령과 "왜 그 답인가"를 설명하는 긴 주석이 함께 들어 있습니다. 풀어 본 뒤에 여십시오.

  • 정답 1 은 세 measurement 를 run_acks() 함수로 묶고 결과를 awk 로 파싱해 표로 찍습니다. 해설의 핵심은 "절대값이 아니라 비율을 보라" 입니다. 노트북 사양에 따라 처리량은 3배까지 차이 나지만 all/0 ≈ 0.43 이라는 비율은 유지됩니다.
  • 정답 2 의 매핑 표는 C001→2, C002→0, C003→1, C004→0, C005→2, C006→1, C007→1, C008→2, C009→0, C010→2 입니다. 파티션 2 에 4개, 0 과 1 에 각각 3개로 완전히 균등하지 않다는 점을 해설이 짚습니다. 키가 10개뿐이면 해시가 아무리 좋아도 균등할 수 없으며, 이것이 4-8 의 쏠림 문제의 축소판입니다.
  • 정답 3linger.ms 를 0→5 에서 처리량이 가장 크게 뛰고(51K→92K), 20 이후로는 거의 평평해지며(138K→141K), 100 에서는 p99 지연만 늘어난다(347ms→612ms)는 것을 보여줍니다. 변곡점은 대개 5~20ms 라는 결론입니다.
  • 정답 5·6 이 이 문제지의 핵심입니다. 같은 코드에서 enable.idempotence 만 바꿨을 때 역전이 3건 → 0건이 되고, 처리량은 51,203 → 50,878 msg/s 로 0.6% 밖에 안 떨어진다는 것을 대조합니다. 해설은 "in-flight 를 1로 낮추는 것(28,742 msg/s)과 비교하면 멱등이 압도적"이라는 결론으로 이어집니다.
  • 정답 7 의 해설은 왜 buffer.memory 를 줄여야만 재현되는지를 설명합니다. 기본 32MiB 는 웬만한 부하로는 안 차서, 운영에서 이 문제를 만나는 시점은 브로커가 이미 느려진 뒤입니다. 그래서 max.block.ms 를 짧게 잡는 것이 "장애를 만드는" 설정이 아니라 "장애를 빨리 드러내는" 설정이라는 점을 강조합니다.
#!/usr/bin/env bash
#
# Step 04 — 프로듀서 : 연습문제 정답과 해설
#
# 실행법:
#   bash solution.sh
#
# exercise.sh 를 먼저 풀어 본 뒤에 여십시오.
#
set -euo pipefail

BS=kafka-1:9092
K() { docker exec kafka-1 /opt/kafka/bin/"$@"; }
Ki() { docker exec -i kafka-1 /opt/kafka/bin/"$@"; }

N=300000
hr() { echo; echo "======== $* ========"; }

recreate() {  # $1=토픽 $2=파티션 [$3=추가설정...]
  local t="$1" p="$2"; shift 2
  K kafka-topics.sh --bootstrap-server "$BS" --delete --topic "$t" 2>/dev/null || true
  sleep 3
  K kafka-topics.sh --bootstrap-server "$BS" --create --topic "$t" \
    --partitions "$p" --replication-factor 3 "$@"
}

recreate s04_ex_perf  6 --config segment.bytes=268435456
recreate s04_ex_key   3
recreate s04_ex_order 1


# ===========================================================================
# 정답 1. acks 실측
# ===========================================================================
hr "정답 1 — acks=0/1/all"

run_acks() {
  local a="$1"
  local out
  out=$(K kafka-producer-perf-test.sh --topic s04_ex_perf \
        --num-records "$N" --record-size 100 --throughput -1 \
        --producer-props bootstrap.servers="$BS" acks="$a" | tail -1)
  echo "$out" | awk -v A="$a" '{
      for (i=1;i<=NF;i++) {
        if ($i=="records/sec") tp=$(i-1);
        if ($i=="99th,")       p99=$(i-2);
      }
      printf "  acks=%-4s  %10s msg/s   p99 %s ms\n", A, tp, p99;
  }'
}

for A in 0 1 all; do run_acks "$A"; done

# 해설:
#   기준 환경(노트북 3브로커)에서의 참고값입니다.
#     acks=0    118,168 msg/s   p99  28 ms
#     acks=1     92,408 msg/s   p99 447 ms
#     acks=all   51,203 msg/s   p99 806 ms
#
#   ★ 이 문제의 핵심은 절대값이 아니라 "비율" 입니다.
#     노트북 사양·Docker 메모리 할당에 따라 처리량은 3배까지 차이 납니다.
#     그러나 all/0 ≈ 0.43, 1/0 ≈ 0.78 이라는 비율은 어디서 재도 비슷하게 나옵니다.
#     면접에서든 용량 산정에서든 외워야 하는 것은 이 비율입니다.
#
#   ★ 왜 acks=0 의 지연이 이렇게 짧습니까?
#     acks=0 의 "지연" 은 브로커 왕복이 아니라 "버퍼에 넣고 소켓에 쓰기까지" 만 잽니다.
#     즉 acks=0 의 지연 숫자는 다른 두 값과 같은 의미가 아닙니다.
#     acks=0 이 "빠른" 게 아니라 "재는 대상이 다른" 것입니다.
#     이 사실이 4-4 의 함정과 정확히 같은 뿌리입니다.


# ===========================================================================
# 정답 2. 키 → 파티션 매핑
# ===========================================================================
hr "정답 2 — 키 라우팅"

for i in $(seq -w 1 10); do echo "C0$i:{\"customer_id\":\"C0$i\"}"; done \
  | Ki kafka-console-producer.sh --bootstrap-server "$BS" --topic s04_ex_key \
      --property parse.key=true --property key.separator=:

K kafka-console-consumer.sh --bootstrap-server "$BS" --topic s04_ex_key \
  --from-beginning --max-messages 10 --timeout-ms 15000 \
  --property print.key=true --property print.partition=true \
  --property print.value=false | sort -k2

# 정답 표:
#   | 키    | 파티션 |
#   |-------|--------|
#   | C001  |   2    |
#   | C002  |   0    |
#   | C003  |   1    |
#   | C004  |   0    |
#   | C005  |   2    |
#   | C006  |   1    |
#   | C007  |   1    |
#   | C008  |   2    |
#   | C009  |   0    |
#   | C010  |   2    |
#
# 해설:
#   파티션 2 에 4개, 파티션 0 에 3개, 파티션 1 에 3개입니다.
#   ★ 완전히 균등하지 않다는 점이 이 문제의 포인트입니다.
#     murmur2 는 훌륭한 해시지만, 입력이 10개뿐이면 균등할 수가 없습니다.
#     (10을 3으로 나누면 나누어떨어지지 않습니다.)
#     키 종류가 수천 개면 대수의 법칙으로 균등해집니다.
#     그러나 "키의 개수" 가 아니라 "키별 트래픽 비중" 이 치우쳐 있으면
#     키가 아무리 많아도 쏠립니다 — 그것이 4-8 의 hot partition 입니다.
#
#   ★ 이 매핑은 파티션 수에 종속됩니다.
#     파티션을 3 → 6 으로 늘리면 위 표가 전부 바뀝니다.
#     C001 의 옛 주문은 파티션 2, 새 주문은 다른 파티션으로 갈라지고
#     그 순간 순서 보장이 깨집니다 (Step 03 의 함정).


# ===========================================================================
# 정답 3. linger.ms 변곡점
# ===========================================================================
hr "정답 3 — linger.ms 0 / 5 / 20 / 100"

for L in 0 5 20 100; do
  echo -n "  linger.ms=$L  "
  K kafka-producer-perf-test.sh --topic s04_ex_perf \
    --num-records "$N" --record-size 100 --throughput -1 \
    --producer-props bootstrap.servers="$BS" acks=all \
      batch.size=65536 linger.ms="$L" | tail -1
done

# 참고 결과:
#   linger.ms=0     51,203 msg/s   avg 388 ms   p99 806 ms
#   linger.ms=5     92,140 msg/s   avg 214 ms   p99 498 ms
#   linger.ms=20   138,504 msg/s   avg 141 ms   p99 347 ms
#   linger.ms=100  141,027 msg/s   avg 168 ms   p99 612 ms
#
# 정답:
#   - 처리량이 더 이상 늘지 않는 지점: 20ms  (20 → 100 은 +1.8% 뿐)
#   - p99 지연이 나빠지기 시작하는 지점: 100ms (347 → 612 ms)
#   → 실무 권장 구간은 5~20ms 입니다.
#
# 해설:
#   ★ 0 → 20 구간에서 지연이 "줄어드는" 것이 직관에 반합니다.
#     20ms 를 더 기다렸는데 왜 총 지연이 짧아집니까?
#     linger.ms=0 일 때는 작은 요청이 폭주해 브로커 앞에 큐가 쌓이고,
#     그 큐잉 대기가 300ms 였습니다. 배치를 키우면 요청 수가 1/8 이 되어
#     큐가 사라지고, 20ms 의 linger 를 포함해도 총 지연이 훨씬 짧아집니다.
#
#   ★ 100ms 에서 지연만 나빠지는 이유
#     이미 20ms 에 배치가 batch.size(64KiB)로 꽉 찹니다.
#     그래서 처리량은 더 안 늘고, 한산할 때만 100ms 를 꼬박 기다려
#     tail latency 만 늘어납니다.
#     ★ linger.ms 는 "지연을 추가하는 값" 이 아니라 "지연 상한을 정하는 값" 입니다.


# ===========================================================================
# 정답 4. 압축 코덱
# ===========================================================================
hr "정답 4 — 압축 5종"

for C in none gzip snappy lz4 zstd; do
  recreate s04_ex_perf 6 --config segment.bytes=268435456 >/dev/null
  echo -n "  $C  "
  K kafka-producer-perf-test.sh --topic s04_ex_perf \
    --num-records "$N" --record-size 200 --throughput -1 \
    --producer-props bootstrap.servers="$BS" acks=all \
      linger.ms=20 batch.size=65536 compression.type="$C" | tail -1
  docker exec kafka-1 sh -c 'du -sc /var/lib/kafka/data/s04_ex_perf-* | tail -1'
done

# 참고 결과 (100만 건 기준, 원본 200MB):
#   none    121,854 msg/s   200 MB   1.0x
#   gzip     46,219 msg/s    38 MB   5.3x
#   snappy  143,903 msg/s    71 MB   2.8x
#   lz4     156,372 msg/s    66 MB   3.0x
#   zstd    134,083 msg/s    44 MB   4.5x
#
# 정답:
#   (a) 처리량 최고        → lz4
#   (b) 저장 용량 최소      → gzip (38 MB)
#   (c) 네트워크가 비싼 환경 → zstd
#
# 해설:
#   ★ 압축을 켜면 처리량이 "오히려 늘어난다" 는 것이 핵심입니다.
#     lz4 는 none 대비 +28% 입니다. 네트워크로 보낼 바이트가 1/3 이 되어
#     절약된 I/O 가 압축 CPU 비용보다 크기 때문입니다.
#     gzip 만 예외인데, 압축률은 최고지만 CPU 를 너무 먹어
#     처리량이 none 의 38% 로 떨어집니다.
#
#   ★ (c)에서 gzip 이 아니라 zstd 인 이유
#     gzip 과 zstd 의 압축률 차이(5.3x vs 4.5x)는 15% 인데,
#     처리량 차이는 2.9배입니다. 대역폭 15% 를 아끼려고
#     처리량을 1/3 로 줄이는 거래는 거의 항상 손해입니다.
#     zstd 는 "압축률과 속도의 균형점" 이라 이런 상황의 정답이 됩니다.
#
#   ★ 압축은 배치 단위입니다.
#     batch.size 를 16KiB → 64KiB 로 키우면 압축률이 20~30% 더 좋아집니다.
#     "압축이 잘 안 된다" 싶으면 코덱을 바꾸기 전에 배치부터 키우십시오.
#
#   ★ 토픽의 compression.type 은 producer(기본) 로 두십시오.
#     다른 값을 주면 브로커가 풀었다 다시 압축하며 zero-copy 가 깨집니다.


# ===========================================================================
# 정답 5. 순서 역전 재현
# ===========================================================================
hr "정답 5 — 순서 역전"

recreate s04_ex_order 1 >/dev/null
docker cp "$(dirname "$0")/Practice.java" kafka-1:/tmp/

echo "  [터미널 B] 에서 전송 중에 'docker compose restart kafka-2' 를 실행하십시오."
read -r -p "  준비되면 Enter: " _

docker exec kafka-1 sh -c \
  'cd /tmp && java -cp "/opt/kafka/libs/*" Practice.java order-break s04_ex_order'

K kafka-console-consumer.sh --bootstrap-server "$BS" --topic s04_ex_order \
  --from-beginning --max-messages 200 --timeout-ms 20000 \
  --property print.value=true 2>/dev/null \
  | awk -F'-' '{n=$2+0; if (n<prev) c++; prev=n} END{print "  " c+0 " 곳 역전"}'

# 참고 결과: 3 곳 역전 (재시도 횟수와 대체로 일치)
#
# 해설:
#   ★ 왜 재시도 횟수만큼 역전이 생깁니까?
#     in-flight 배치 5개 중 하나가 실패하면, 그 배치가 재시도되는 동안
#     뒤의 4개가 먼저 브로커에 기록됩니다. 재시도 배치는 그 뒤에 붙습니다.
#     로그 순서는 [배치2..5][배치1] 이 됩니다.
#     재시도 1회 = 역전 1곳입니다.
#
#   ★ 왜 batch.size=64, request.timeout.ms=300 같은 극단적 값을 씁니까?
#     기본값(16KiB, 30초)이면 200건이 배치 1~2개에 다 들어가고
#     타임아웃도 안 나서 재시도가 발생하지 않습니다.
#     즉 함정을 "만들기 위해" 조건을 극단화한 것입니다.
#     운영에서는 이 조합이 저절로 만들어집니다 —
#     트래픽이 많으면 배치가 여러 개가 되고, 브로커가 흔들리면 타임아웃이 납니다.
#     ★ "우리는 이런 설정 안 써" 가 아니라 "부하가 걸리면 저절로 이 상황이 된다" 입니다.
#
#   ★ 에러가 하나도 안 났다는 점에 주목하십시오.
#     프로듀서는 200건 전부 성공 콜백을 받았습니다.
#     Kafka 가 보장한다는 "파티션 내 순서" 는
#     브로커가 받은 순서에 대한 보장이지, send() 호출 순서에 대한 보장이 아닙니다.


# ===========================================================================
# 정답 6. 멱등 프로듀서
# ===========================================================================
hr "정답 6 — enable.idempotence=true"

recreate s04_ex_order 1 >/dev/null

echo "  다시 [터미널 B] 에서 브로커를 흔들 준비를 하십시오."
read -r -p "  준비되면 Enter: " _

docker exec kafka-1 sh -c \
  'cd /tmp && java -cp "/opt/kafka/libs/*" Practice.java order-safe s04_ex_order'

K kafka-console-consumer.sh --bootstrap-server "$BS" --topic s04_ex_order \
  --from-beginning --max-messages 200 --timeout-ms 20000 \
  --property print.value=true 2>/dev/null \
  | awk -F'-' '{n=$2+0; if (n<prev) c++; prev=n} END{print "  " c+0 " 곳 역전"}'

echo "  --- 처리량 비교 ---"
echo -n "  멱등 off, in-flight 5 : "
K kafka-producer-perf-test.sh --topic s04_ex_perf --num-records "$N" \
  --record-size 100 --throughput -1 --producer-props bootstrap.servers="$BS" \
  acks=all enable.idempotence=false | tail -1

echo -n "  멱등 off, in-flight 1 : "
K kafka-producer-perf-test.sh --topic s04_ex_perf --num-records "$N" \
  --record-size 100 --throughput -1 --producer-props bootstrap.servers="$BS" \
  acks=all enable.idempotence=false max.in.flight.requests.per.connection=1 | tail -1

echo -n "  멱등 on,  in-flight 5 : "
K kafka-producer-perf-test.sh --topic s04_ex_perf --num-records "$N" \
  --record-size 100 --throughput -1 --producer-props bootstrap.servers="$BS" \
  acks=all enable.idempotence=true | tail -1

# 참고 결과:
#   역전 0 곳
#   멱등 off, in-flight 5 :  51,203 msg/s   (순서 깨짐)
#   멱등 off, in-flight 1 :  28,742 msg/s   (순서 안전)
#   멱등 on,  in-flight 5 :  50,878 msg/s   (순서 안전)
#
# 해설:
#   ★ 멱등을 켜도 처리량은 0.6% 밖에 안 떨어집니다 (51,203 → 50,878).
#     반면 in-flight 를 1로 낮추면 44% 가 날아갑니다 (51,203 → 28,742).
#     "순서를 지키려면 in-flight 를 1로" 는 2017년의 조언입니다.
#     지금의 답은 언제나 enable.idempotence=true 입니다.
#
#   ★ 원리:
#     멱등 프로듀서는 배치마다 PID + 시퀀스 번호를 붙입니다.
#     브로커는 파티션마다 "이 PID 의 마지막 시퀀스" 를 기억하고 있어서
#       - 더 작은 시퀀스가 오면 → 중복. 버리고 성공 응답. (중복 제거)
#       - 더 큰 시퀀스가 오면   → 중간에 빠진 게 있음. 거절. (순서 보장)
#     즉 배치2가 배치1보다 먼저 도착하면 브로커가 배치2를 거절하고,
#     프로듀서가 배치1 → 배치2 순으로 다시 보냅니다.
#     브로커가 기억하는 시퀀스 윈도우가 5개여서 max.in.flight <= 5 제약이 붙습니다.
#
#   ★ Kafka 3.0 부터 enable.idempotence 의 기본값은 true 입니다.
#     켜지면 acks=all, retries=Integer.MAX_VALUE, max.in.flight<=5 가 함께 강제됩니다.
#     그런데 다음 경우 조용히 꺼집니다:
#       - acks=0 또는 acks=1 을 명시
#       - max.in.flight 를 6 이상으로 설정
#       - retries=0 을 명시
#     "처리량 튜닝한다고 acks=1 로 바꿨더니 순서가 깨지기 시작했다" 의 정체입니다.
#     프로듀서 기동 로그의 ProducerConfig values 에서 네 줄만 확인하면 됩니다:
#       acks = -1 / enable.idempotence = true /
#       max.in.flight.requests.per.connection = 5 / retries = 2147483647


# ===========================================================================
# 정답 7. send() 블로킹 → TimeoutException
# ===========================================================================
hr "정답 7 — buffer.memory 고갈"

set +e
K kafka-producer-perf-test.sh --topic s04_ex_perf \
  --num-records 500000 --record-size 1000 --throughput -1 \
  --producer-props bootstrap.servers="$BS" acks=all \
    buffer.memory=1048576 max.block.ms=2000 2>&1 | tail -6
set -e

# 나오는 예외:
#   org.apache.kafka.common.errors.TimeoutException: Failed to allocate memory
#     within the configured max blocking time 2000 ms.
#
# 해설:
#   ★ 왜 buffer.memory 를 줄여야만 재현됩니까?
#     기본 32MiB 는 어지간한 부하로는 안 찹니다.
#     운영에서 이 예외를 만나는 시점은 "브로커가 이미 느려진 뒤" 입니다.
#     즉 이 예외는 원인이 아니라 증상입니다.
#
#   ★ 그런데 이 예외가 나는 것이 "좋은 일" 입니다.
#     max.block.ms 의 기본값은 60초입니다.
#     HTTP 핸들러에서 producer.send() 를 부르는 흔한 코드가
#     브로커 장애 시 요청 스레드를 60초 동안 통째로 잡아먹습니다.
#     톰캣 스레드 풀이 말라붙고, Kafka 와 무관한 API 까지 전부 타임아웃납니다.
#     ★ max.block.ms 를 짧게 잡는 것은 "장애를 만드는" 설정이 아니라
#       "장애를 빨리 드러내는" 설정입니다.
#     운영 권장: max.block.ms 를 상위 요청 타임아웃보다 짧게 (예: 3000ms).
#     그러면 빠르게 실패하고, 애플리케이션이 폴백(로컬 파일 기록, 5xx 응답)을
#     선택할 수 있습니다. 60초 매달려 있는 것보다 3초 만에 실패하는 편이 낫습니다.
#
#   ★ 관련 설정 정리
#     buffer.memory      32 MiB   전체 배치 버퍼
#     max.block.ms       60000    send()/partitionsFor() 블로킹 상한
#     delivery.timeout.ms 120000  send() 부터 최종 결과까지의 총 상한
#     request.timeout.ms  30000   요청 하나의 응답 대기
#     제약: delivery.timeout.ms >= linger.ms + request.timeout.ms


# ===========================================================================
# 정리
# ===========================================================================
hr "정리"
for T in $(K kafka-topics.sh --bootstrap-server "$BS" --list | grep '^s04_ex_' || true); do
  K kafka-topics.sh --bootstrap-server "$BS" --delete --topic "$T" || true
done
sleep 3
K kafka-topics.sh --bootstrap-server "$BS" --list | grep '^s04_' \
  || echo "s04_ 토픽 없음 — 정리 완료"

docker compose ps --format 'table {{.Name}}\t{{.Status}}' 2>/dev/null \
  || docker ps --filter name=kafka --format 'table {{.Names}}\t{{.Status}}'

Practice.java

CLI 로는 재현할 수 없는 두 가지 — 순서 역전전송 패턴별 성능 — 를 담은 Java 21 단일 파일 프로그램입니다. 별도 빌드 도구 없이 Kafka 배포판의 /opt/kafka/libs/* 만으로 실행합니다.

docker cp Practice.java kafka-1:/tmp/
docker exec kafka-1 sh -c 'cd /tmp && java -cp "/opt/kafka/libs/*" Practice.java <시나리오>'
  • 시나리오는 acks-compare / order-break / order-safe / key-route / callback / sync-vs-async 여섯 개입니다. 인자 없이 실행하면 사용법이 출력됩니다.
  • order-breakenable.idempotence=false, max.in.flight=5, batch.size=64, request.timeout.ms=300 을 조합해 재시도가 잘 일어나는 조건을 인위적으로 만듭니다. batch.size 를 64바이트로 극단적으로 줄인 이유는 배치를 잘게 쪼개 in-flight 요청 수를 늘리기 위해서입니다.
  • order-break 실행 중에 다른 창에서 docker compose restart kafka-2 를 하십시오. 재시도가 한 번도 안 일어나면 순서는 그대로 유지되고 함정이 재현되지 않습니다. 프로그램은 끝에 감지한 재시도 횟수를 출력하므로, 0 이면 다시 하십시오.
  • order-safeorder-break완전히 같은 코드enable.idempotence=true 만 다릅니다(같은 runOrder(boolean idempotent) 메서드를 씁니다). 같은 방식으로 브로커를 흔들어도 역전이 나오지 않는 것이 이 시나리오의 목적입니다.
  • key-routeUtils.murmur2() 를 직접 호출해 각 키의 파티션을 계산으로 구하고, 실제로 전송한 뒤 RecordMetadata.partition() 과 대조합니다. 둘이 항상 일치하는 것을 보여 주어 "파티셔너는 결정적이다"를 확인시킵니다.
  • sync-vs-async 는 세 패턴을 각각 10,000건 보내며 System.nanoTime() 으로 잽니다. fire-and-forget 과 콜백은 producer.flush() 이후를 종료 시각으로 잡아야 공정한 비교가 되므로, 세 패턴 모두 flush() 를 포함해 측정합니다.
  • callback 시나리오는 콜백이 Sender 스레드에서 실행된다는 것을 Thread.currentThread().getName() 으로 출력해 보여 줍니다. kafka-producer-network-thread | producer-1 이 찍힙니다. 4-13 의 마지막 함정을 눈으로 확인하는 용도입니다.
/*
 * Step 04 — 프로듀서 : CLI 로는 재현할 수 없는 실습
 *
 * Java 21 단일 파일 소스 실행. 별도 빌드 도구 없이 Kafka 배포판의 라이브러리만 씁니다.
 *
 * 실행법:
 *   docker cp Practice.java kafka-1:/tmp/
 *   docker exec kafka-1 sh -c 'cd /tmp && java -cp "/opt/kafka/libs/*" Practice.java <시나리오> [토픽]'
 *
 * 시나리오:
 *   acks-compare   acks=0/1/all 을 같은 코드로 각각 20000건 보내 처리량 비교
 *   order-break    enable.idempotence=false + max.in.flight=5 → 순서 역전 재현
 *   order-safe     order-break 와 같은 코드에 enable.idempotence=true 만 다름
 *   key-route      murmur2 계산값과 실제 파티션이 일치하는지 대조
 *   callback       콜백이 어느 스레드에서 실행되는지 확인
 *   sync-vs-async  fire-and-forget / callback / sync(.get()) 처리량 비교
 *
 * order-break / order-safe 는 전송 중에 다른 창에서 브로커를 흔들어야 합니다:
 *   docker compose restart kafka-2
 */
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.clients.producer.RecordMetadata;
import org.apache.kafka.common.serialization.StringSerializer;
import org.apache.kafka.common.utils.Utils;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicInteger;

public class Practice {

    static final String BOOTSTRAP = System.getenv().getOrDefault("BOOTSTRAP", "kafka-1:9092");

    public static void main(String[] args) throws Exception {
        if (args.length == 0) {
            usage();
            return;
        }
        String scenario = args[0];
        String topic = args.length > 1 ? args[1] : null;

        switch (scenario) {
            case "acks-compare"  -> AcksCompare.run(topic == null ? "s04_acks" : topic);
            case "order-break"   -> OrderBreak.run(topic == null ? "s04_order" : topic, false);
            case "order-safe"    -> OrderBreak.run(topic == null ? "s04_order" : topic, true);
            case "key-route"     -> KeyRoute.run(topic == null ? "s04_acks" : topic);
            case "callback"      -> CallbackDemo.run(topic == null ? "s04_acks" : topic);
            case "sync-vs-async" -> SyncVsAsync.run(topic == null ? "s04_acks" : topic);
            default -> {
                System.out.println("알 수 없는 시나리오: " + scenario);
                usage();
            }
        }
    }

    static void usage() {
        System.out.println("""
            사용법: java -cp "/opt/kafka/libs/*" Practice.java <시나리오> [토픽]

              acks-compare    acks=0/1/all 처리량 비교
              order-break     순서 역전 재현 (enable.idempotence=false)
              order-safe      멱등 프로듀서로 순서 유지
              key-route       murmur2 계산 vs 실제 파티션
              callback        콜백 실행 스레드 확인
              sync-vs-async   전송 3패턴 처리량 비교

            전송 중 브로커 흔들기: docker compose restart kafka-2
            """);
    }

    /** 모든 시나리오가 공유하는 기본 설정. */
    static Properties baseProps() {
        Properties p = new Properties();
        p.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, BOOTSTRAP);
        p.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        p.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        return p;
    }

    static String payload(String orderId, String customerId, int amount) {
        return "{\"order_id\":\"" + orderId + "\",\"customer_id\":\"" + customerId
             + "\",\"amount\":" + amount + ",\"status\":\"CREATED\"}";
    }

    // =======================================================================
    // [4-3] acks 비교 — perf-test 와 달리 "같은 애플리케이션 코드" 에서 잽니다
    // =======================================================================
    static final class AcksCompare {
        static final int N = 20_000;

        static void run(String topic) {
            System.out.printf("[acks-compare] topic=%s, 각 acks 마다 %,d건%n%n", topic, N);
            Map<String, Long> result = new LinkedHashMap<>();

            for (String acks : List.of("0", "1", "all")) {
                Properties p = baseProps();
                p.put(ProducerConfig.ACKS_CONFIG, acks);
                // acks=0/1 을 명시하면 멱등이 조용히 꺼지므로, 공정한 비교를 위해
                // 세 경우 모두 명시적으로 끕니다.
                p.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "false");
                p.put(ProducerConfig.LINGER_MS_CONFIG, "5");

                long start = System.nanoTime();
                try (Producer<String, String> producer = new KafkaProducer<>(p)) {
                    for (int i = 0; i < N; i++) {
                        String key = "C%03d".formatted(i % 10 + 1);
                        producer.send(new ProducerRecord<>(topic, key,
                                payload("O-%d".formatted(2000 + i), key, 10_000)));
                    }
                    producer.flush();   // ★ flush 까지 포함해야 공정한 측정입니다
                }
                long ms = (System.nanoTime() - start) / 1_000_000;
                result.put(acks, ms);
                System.out.printf("  acks=%-4s  %,6d ms   %,8d msg/s%n",
                        acks, ms, ms == 0 ? 0 : N * 1000L / ms);
            }

            long base = result.get("0");
            System.out.printf("%n  acks=all 은 acks=0 의 %.0f%% 처리량입니다.%n",
                    base * 100.0 / result.get("all"));
            System.out.println("  ※ acks=0 의 '지연' 은 브로커 왕복이 아니라 소켓 쓰기까지만 잽니다.");
            System.out.println("     빠른 게 아니라 재는 대상이 다릅니다. (4-4 의 함정과 같은 뿌리)");
        }
    }

    // =======================================================================
    // [4-11] 최대 함정 D — max.in.flight > 1 + retries = 순서 뒤바뀜
    // =======================================================================
    static final class OrderBreak {
        static final int N = 200;

        static void run(String topic, boolean idempotent) {
            String label = idempotent ? "order-safe" : "order-break";

            Properties p = baseProps();
            p.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, String.valueOf(idempotent));
            if (idempotent) {
                // 멱등을 켜면 acks=all, retries=MAX, max.in.flight<=5 가 강제됩니다.
                // 여기서 acks=1 을 명시하면 ConfigException 이 납니다.
                p.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, "5");
            } else {
                p.put(ProducerConfig.ACKS_CONFIG, "1");
                p.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, "5");
                p.put(ProducerConfig.RETRIES_CONFIG, "10");
            }
            // 배치를 극단적으로 잘게 쪼개 in-flight 요청 수를 늘립니다.
            p.put(ProducerConfig.BATCH_SIZE_CONFIG, "64");
            p.put(ProducerConfig.LINGER_MS_CONFIG, "0");
            p.put(ProducerConfig.RETRY_BACKOFF_MS_CONFIG, "5");
            // 짧은 request.timeout.ms 로 타임아웃 → 재시도를 유발합니다.
            p.put(ProducerConfig.REQUEST_TIMEOUT_MS_CONFIG, "300");
            p.put(ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG, "30000");

            System.out.printf("[%s] enable.idempotence=%s, max.in.flight=5, batch.size=64%n",
                    label, idempotent);
            System.out.printf("[%s] %s 에 seq-000 ~ seq-%03d 를 같은 키(K)로 전송합니다.%n",
                    label, topic, N - 1);
            System.out.printf("[%s] 전송 중 브로커를 흔들어 재시도를 유발하십시오:%n", label);
            System.out.printf("              docker compose restart kafka-2%n");

            AtomicInteger errors = new AtomicInteger();
            AtomicInteger retries = new AtomicInteger();

            try (Producer<String, String> producer = new KafkaProducer<>(p)) {
                for (int i = 0; i < N; i++) {
                    String v = "seq-%03d".formatted(i);
                    producer.send(new ProducerRecord<>(topic, "K", v), (md, ex) -> {
                        if (ex != null) errors.incrementAndGet();
                    });
                    // 전송을 60초 정도로 늘려 브로커를 흔들 시간을 줍니다.
                    sleep(300);
                }
                producer.flush();
                // 재시도 횟수는 프로듀서 메트릭에서 읽습니다.
                producer.metrics().forEach((name, metric) -> {
                    if ("record-retry-total".equals(name.name())) {
                        Object v = metric.metricValue();
                        if (v instanceof Double d) retries.set((int) (double) d);
                    }
                });
            }

            System.out.printf("[%s] sent seq-000 .. seq-%03d (%d건), 재시도 발생: %d회, 실패: %d건%n",
                    label, N - 1, N, retries.get(), errors.get());
            if (retries.get() == 0) {
                System.out.printf("[%s] ⚠️ 재시도가 0회였습니다. 함정이 재현되지 않습니다.%n", label);
                System.out.printf("[%s]    토픽을 재생성하고, 전송 중에 브로커를 재시작한 뒤 다시 실행하십시오.%n", label);
            }
            System.out.printf("[%s] 완료. 아래 명령으로 로그 순서를 확인하십시오.%n", label);
            System.out.printf("""
                  docker exec kafka-1 /opt/kafka/bin/kafka-console-consumer.sh \\
                    --bootstrap-server %s --topic %s --from-beginning \\
                    --max-messages %d --timeout-ms 20000 --property print.offset=true
                %n""", BOOTSTRAP, topic, N);
        }
    }

    // =======================================================================
    // [4-7] 키 라우팅 — murmur2 계산값과 실제 파티션 대조
    // =======================================================================
    static final class KeyRoute {
        static void run(String topic) throws Exception {
            Properties p = baseProps();
            p.put(ProducerConfig.ACKS_CONFIG, "all");

            try (Producer<String, String> producer = new KafkaProducer<>(p)) {
                int numPartitions = producer.partitionsFor(topic).size();
                System.out.printf("[key-route] topic=%s, partitions=%d%n%n", topic, numPartitions);
                System.out.println("  키      murmur2 계산   실제    일치");
                System.out.println("  ------  ------------   ----    ----");

                boolean allMatch = true;
                for (int i = 1; i <= 10; i++) {
                    String key = "C%03d".formatted(i);
                    byte[] kb = key.getBytes("UTF-8");
                    // 프로듀서 내부와 동일한 계산식입니다.
                    int expected = Utils.toPositive(Utils.murmur2(kb)) % numPartitions;

                    RecordMetadata md = producer
                            .send(new ProducerRecord<>(topic, key, payload("O-9%03d".formatted(i), key, 1000)))
                            .get();

                    boolean match = expected == md.partition();
                    allMatch &= match;
                    System.out.printf("  %-6s  %12d   %4d    %s%n",
                            key, expected, md.partition(), match ? "O" : "X");
                }
                System.out.printf("%n  전부 일치: %s%n", allMatch ? "예" : "아니오");
                System.out.println("  → 파티셔너는 결정적입니다. 같은 키는 언제나 같은 파티션으로 갑니다.");
                System.out.println("  → 단, 파티션 수가 바뀌면 이 표 전체가 바뀝니다 (Step 03 의 함정).");
            }
        }
    }

    // =======================================================================
    // [4-13] 콜백이 실행되는 스레드
    // =======================================================================
    static final class CallbackDemo {
        static void run(String topic) {
            Properties p = baseProps();
            p.put(ProducerConfig.ACKS_CONFIG, "all");
            p.put(ProducerConfig.LINGER_MS_CONFIG, "5");

            System.out.printf("[callback] main 스레드 = %s%n", Thread.currentThread().getName());

            try (Producer<String, String> producer = new KafkaProducer<>(p)) {
                for (int i = 0; i < 3; i++) {
                    String key = "C%03d".formatted(i + 1);
                    producer.send(new ProducerRecord<>(topic, key, payload("O-8%03d".formatted(i), key, 5000)),
                            (md, ex) -> {
                                if (ex != null) {
                                    System.out.println("  전송 실패: " + ex);
                                    return;
                                }
                                System.out.printf("  콜백 스레드 = %-38s  p=%d off=%d%n",
                                        Thread.currentThread().getName(), md.partition(), md.offset());
                            });
                }
                producer.flush();
            }
            System.out.println();
            System.out.println("  콜백은 Sender 스레드(kafka-producer-network-thread) 하나에서 실행됩니다.");
            System.out.println("  ⚠️ 여기서 DB 조회나 HTTP 호출을 하면 모든 파티션의 전송이 멈춥니다.");
            System.out.println("  ⚠️ 콜백 안에서 producer.close() 를 부르면 데드락입니다.");
            System.out.println("     (Sender 스레드가 자기 자신의 종료를 기다리게 됩니다)");
        }
    }

    // =======================================================================
    // [4-13] 전송 3패턴 처리량 비교
    // =======================================================================
    static final class SyncVsAsync {
        static final int N = 10_000;

        static void run(String topic) throws Exception {
            System.out.printf("[sync-vs-async] topic=%s, 각 패턴 %,d건, acks=all, linger.ms=5%n%n", topic, N);

            long ff = fireAndForget(topic);
            long cb = withCallback(topic);
            long sy = synchronous(topic);

            System.out.printf("  fire-and-forget : %6d ms  %6d msg/s   (실패를 감지할 수 없음)%n",
                    ff, N * 1000L / Math.max(ff, 1));
            System.out.printf("  callback        : %6d ms  %6d msg/s   (실패 감지 가능)%n",
                    cb, N * 1000L / Math.max(cb, 1));
            System.out.printf("  sync (.get())   : %6d ms  %6d msg/s   (실패 감지 가능)%n",
                    sy, N * 1000L / Math.max(sy, 1));
            System.out.printf("%n  → sync 는 callback 대비 %.1f배 느립니다.%n", (double) sy / cb);
            System.out.println("  → 실무 기본값은 callback 입니다. .get() 은 초기화·마이그레이션 등 소량 전송에만.");
        }

        static Properties props() {
            Properties p = baseProps();
            p.put(ProducerConfig.ACKS_CONFIG, "all");
            p.put(ProducerConfig.LINGER_MS_CONFIG, "5");
            return p;
        }

        static long fireAndForget(String topic) {
            long start = System.nanoTime();
            try (Producer<String, String> producer = new KafkaProducer<>(props())) {
                for (int i = 0; i < N; i++) {
                    String key = "C%03d".formatted(i % 10 + 1);
                    // 반환값을 버립니다 — 브로커 거절을 영영 모릅니다.
                    producer.send(new ProducerRecord<>(topic, key, payload("O-A%05d".formatted(i), key, 1000)));
                }
                producer.flush();
            }
            return (System.nanoTime() - start) / 1_000_000;
        }

        static long withCallback(String topic) {
            AtomicInteger failed = new AtomicInteger();
            long start = System.nanoTime();
            try (Producer<String, String> producer = new KafkaProducer<>(props())) {
                for (int i = 0; i < N; i++) {
                    String key = "C%03d".formatted(i % 10 + 1);
                    producer.send(new ProducerRecord<>(topic, key, payload("O-B%05d".formatted(i), key, 1000)),
                            (md, ex) -> { if (ex != null) failed.incrementAndGet(); });
                }
                producer.flush();
            }
            long ms = (System.nanoTime() - start) / 1_000_000;
            System.out.printf("    (callback 실패 감지: %d건)%n", failed.get());
            return ms;
        }

        static long synchronous(String topic) throws Exception {
            long start = System.nanoTime();
            try (Producer<String, String> producer = new KafkaProducer<>(props())) {
                for (int i = 0; i < N; i++) {
                    String key = "C%03d".formatted(i % 10 + 1);
                    // .get() 이 응답을 기다립니다 → 배치가 항상 1건짜리가 됩니다.
                    producer.send(new ProducerRecord<>(topic, key, payload("O-C%05d".formatted(i), key, 1000)))
                            .get();
                }
            }
            return (System.nanoTime() - start) / 1_000_000;
        }
    }

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}