Producer — Kafka 3편, 안전하고 빠르게 보내는 법

2026-05-02AWS SAA-C03 스터디

Apache Kafka 입문 정리 3편. Producer를 편지 부치는 사람 비유로 풀어 — 메시지 직렬화, 파티셔너 종류, acks 설정(0/1/all), 멱등성 Producer, 배치(linger.ms·batch.size)와 압축(snappy/gzip/lz4/zstd), Kafka 3.0의 안전한 기본값, 고처리량 vs 저지연 설정 균형까지 친절하게 정리.

📚 Apache Kafka 입문 정리 · 3편 / 14편 — Kafka 3편, 안전하고 빠르게 보내는 법

이 글은 Apache Kafka 입문 정리 시리즈의 세 번째 편입니다. 1편에서는 카프카가 어떤 시스템인지 큰 그림을 잡았고, 2편에서는 Topic·Partition·Broker·Replication 같은 핵심 아키텍처를 우체국 비유로 풀어 왔어요. 이제 그 카프카 우체국에 편지를 부치는 쪽을 들여다볼 차례입니다.

카프카에 데이터를 넣는 주체를 Producer(프로듀서)라고 부릅니다. 이름은 거창하지만 하는 일은 단순해요 — 메시지를 만들어서 카프카 토픽에 보내는 클라이언트 애플리케이션입니다. 그런데 이 단순한 작업 뒤에 직렬화·파티션 선택·배치·압축·재시도·중복 제거 같은 의외로 많은 디테일이 깔려 있어요.

이 글에서는 Producer의 동작 원리를 우체국 비유 그대로 — 편지 부치는 사람의 입장에서 — 처음부터 풀어 갑니다. 한 번에 다 외우려 하지 마시고, "내 편지가 어떻게 분류되고, 안전하게 도착했는지 어떻게 확인하는가" 정도의 큰 질문에 답을 얻고 가시면 충분해요.

왜 Producer 단원이 처음엔 어렵게 느껴질까요

이유는 세 가지예요.

첫째, 설정 파라미터가 너무 많아요. acks·retries·linger.ms·batch.size·enable.idempotence·max.in.flight.requests.per.connection — 이름이 비슷비슷한 옵션이 한 페이지에 줄지어 나오면, "이걸 다 알아야 하나?" 싶어집니다. 게다가 옵션끼리 서로 영향을 주는 게 있어서, 하나 바꿨을 때 어떤 다른 게 같이 바뀌는지 잘 안 보여요.

둘째, "안전" vs "빠름"의 트레이드오프가 추상적으로 들려요. "데이터 손실 가능성을 줄이면 처리량이 떨어진다" 같은 문장은 머리로는 이해되지만, 실제로 어느 옵션이 어느 쪽으로 영향을 주는지 한 번에 안 잡힙니다.

셋째, "멱등성"·"정확히 한 번"·"ISR" 같은 개념어가 무섭게 들려요. 이름만 들어도 학술 논문 같은 단어들인데, 알고 보면 우체국에서 일어나는 흔한 일들이에요. 이름이 어렵게 붙어 있을 뿐.

해결법은 1·2편에서 썼던 방법 그대로예요. 카프카를 회사 안 중앙 우체국으로 비유하고, 그 우체국에 편지를 부치는 사람(Producer)의 입장에서 하나하나 풀어 봅시다. 편지를 표준 양식으로 옮겨 쓰고(직렬화), 어느 칸으로 갈지 분류하고(파티셔너), 답장을 받을지 말지 정하고(acks), 같은 편지를 두 번 부치지 않게 막고(멱등성), 편지 여러 장을 한 봉투에 모아 보내고(배치), 봉투를 작게 접어 보내는(압축) — 이 과정만 잡으면 모든 옵션이 자기 자리를 찾습니다.

Producer의 큰 그림 — 편지 부치는 사람의 7단계 작업

Producer가 메시지를 보낼 때 내부적으로 일어나는 일을 한 번 풀어 봅시다. 우체국 비유로 보면 자연스럽게 외워져요.

1. ProducerRecord 생성
   (topic, key, value, partition, timestamp, headers)
         │
2. Serializer (키/값을 바이트로 변환)         ← 편지를 표준 양식으로 옮겨 적기
         │
3. Partitioner (어느 파티션으로 보낼지 결정)   ← 어느 칸으로 갈지 분류
         │
4. Record Accumulator (배치 버퍼에 누적)      ← 봉투에 편지 모으기
         │
5. Sender Thread (백그라운드 전송 스레드)     ← 봉투를 우체국 트럭에 실음
         │
6. Kafka Broker (해당 파티션 리더 브로커)     ← 우체국 도착
         │
7. ACK 수신 → 완료 / 실패 → 재시도            ← 영수증 받기 (또는 다시 부치기)

여기서 시험 함정이 하나 있어요. producer.send()를 호출했다고 해서 메시지가 즉시 브로커로 날아가는 게 아닙니다. 일단 4단계 배치 버퍼에 들어가서, 5단계 Sender Thread가 비동기로 가져가 전송해요. 그래서 카프카 Producer는 기본적으로 비동기예요. 동기처럼 보이게 하려면 별도 옵션이 필요합니다(뒤에서 다룹니다).

가장 단순한 Producer 코드는 이런 모양이에요.

import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.serialization.StringSerializer;

import java.util.Properties;

public class ProducerDemo {
    public static void main(String[] args) {
        // 1. 프로퍼티 설정
        Properties properties = new Properties();
        properties.setProperty(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG,
            "localhost:9092");
        properties.setProperty(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
            StringSerializer.class.getName());
        properties.setProperty(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
            StringSerializer.class.getName());

        // 2. 프로듀서 생성
        KafkaProducer<String, String> producer = new KafkaProducer<>(properties);

        // 3. 레코드 생성
        ProducerRecord<String, String> record =
            new ProducerRecord<>("first_topic", "hello world");

        // 4. 전송 (비동기)
        producer.send(record);

        // 5. 플러시 및 종료 (동기화 보장)
        producer.flush();
        producer.close();
    }
}

여기서 producer.flush()producer.close()는 절대 빼면 안 돼요. send()만 호출하고 프로그램이 끝나 버리면, 배치 버퍼에 남아 있는 편지가 트럭에 실리기 전에 우체국 문이 닫히는 셈입니다. 메시지가 사라져요.

직렬화(Serializer) — 편지를 표준 양식으로 옮기기

카프카는 안에 들어가는 데이터가 무엇인지 신경 쓰지 않아요. 1편에서 강조했듯 카프카는 운반 메커니즘일 뿐이고, 자기 안에서는 모든 메시지를 그저 바이트 배열로 봅니다.

그래서 Producer는 우리가 만든 객체(문자열·정수·JSON·Avro 객체 등)를 바이트로 변환해서 카프카에 보내야 해요. 이 변환을 담당하는 게 Serializer입니다. 우체국 비유로 — 부서마다 자기들 양식으로 쓴 자료를 우체국이 읽을 수 있는 표준 봉투에 옮겨 담는 작업이에요.

카프카가 기본 제공하는 직렬화기에는 다음 같은 게 있어요.

Serializer용도
StringSerializer문자열 → UTF-8 바이트
IntegerSerializer정수 → 4바이트
LongSerializerLong → 8바이트
FloatSerializer / DoubleSerializer부동소수점
ByteArraySerializer이미 바이트 배열인 경우

JSON이나 Avro 같은 복잡한 포맷을 쓰려면 별도 라이브러리(Jackson·Confluent Avro Serializer 등)를 같이 씁니다. 1편에서 언급한 Schema Registry가 등장하는 자리가 바로 이쪽이에요 — Avro·Protobuf·JSON Schema 형식의 데이터를 안전하게 직렬화·역직렬화하도록 도와 주는 도구. 자세한 사용 가이드는 Confluent Schema Registry 공식 문서에 정리돼 있어요.

여기서 시험 함정이 하나 있어요. Producer의 Serializer와 Consumer의 Deserializer는 짝을 맞춰야 합니다. Producer가 StringSerializer로 보냈는데 Consumer가 IntegerDeserializer로 받으려 하면 깨져요. 카프카는 이걸 자동으로 변환해 주지 않습니다 — 그저 바이트만 운반할 뿐.

파티셔너(Partitioner) — 편지가 어느 칸으로 갈지 결정

2편에서 이야기했듯 한 토픽은 여러 파티션으로 나뉘어 있어요. Producer는 메시지를 어느 파티션에 넣을지 결정해야 하는데, 이걸 정하는 게 Partitioner(파티셔너)입니다.

우체국 비유로 — 우체국 안에 게시판(토픽)이 있고, 그 게시판이 여러 칸(파티션)으로 나뉘어 있어요. 편지를 부칠 때 어느 칸에 꽂을지 누군가 결정해 줘야 하는데, 그 분류 담당이 파티셔너예요.

분류 방식은 메시지에 키(key)가 있는지 없는지로 갈립니다.

키가 없는 경우 (key = null)

키가 없으면 카프카는 "이 편지는 그냥 아무 칸이나 넣어도 돼요"로 받아들여요. 분류 방식은 카프카 버전에 따라 달랐어요.

  • Kafka 2.4 이전 — 라운드 로빈(Round Robin) 방식. P0 → P1 → P2 → P0 → … 순서로 한 칸씩 돌아가며 넣음.
  • Kafka 2.4 이후Sticky Partitioner 방식. 한 파티션에 계속 넣다가 배치가 차거나 시간이 다 되면 다른 파티션으로 이동.

여기서 시험 함정이 하나 있어요. 왜 Sticky로 바뀌었을까요? 라운드 로빈이 더 균등해 보이는데 말이죠. 답은 배치 효율 때문입니다.

라운드 로빈 방식:
메시지 1 → P0
메시지 2 → P1  ← 배치가 1개짜리 배치들로 분산
메시지 3 → P2

Sticky 방식:
메시지 1, 2, 3 → P0 (배치)  ← 큰 배치 하나로 전송
메시지 4, 5, 6 → P1 (배치)  ← 효율적

라운드 로빈은 메시지를 파티션마다 한 개씩 흩뿌리니, 배치 버퍼에 메시지 한 장씩만 모이고 전송 효율이 떨어져요. Sticky는 한 파티션에 몰아서 보내니 배치가 두툼해지고, 결과적으로 처리량이 올라가고 지연도 줄어듭니다. 균등함은 시간이 지나면 자연스레 평균에 수렴하니 손해도 거의 없어요.

키가 있는 경우

키가 있으면 카프카는 키의 해시값으로 파티션을 정합니다.

파티션 번호 = hash(key) % 파티션 수

예시 (파티션 3개):
key = "truck-123" → hash = X → X % 3 = 1 → 항상 Partition 1

여기 핵심 — 같은 키는 항상 같은 파티션으로 갑니다. 이게 왜 중요할까요? 2편에서 강조했듯 순서는 한 파티션 안에서만 보장돼요. 그래서 "특정 사용자/기기/주문에 대한 메시지는 순서대로 처리돼야 한다"는 요구사항이 있으면, 그 ID를 키로 써서 같은 파티션으로 모아야 합니다.

여기서 시험 함정이 하나 있어요. 파티션 수를 늘리면 키-파티션 매핑이 달라집니다. hash(key) % 파티션 수이니까 분모가 바뀌면 결과도 바뀌죠. 이미 운영 중인 토픽의 파티션 수를 늘리면 순서 보장이 깨질 수 있어요. 그래서 토픽 설계 시 파티션 수를 신중히 정해야 합니다.

커스텀 파티셔너

기본 분류로 부족하면 직접 분류 로직을 짤 수도 있어요. "VIP 고객 메시지는 마지막 파티션으로 분리하고 싶다" 같은 요구일 때.

public class CustomerPartitioner implements Partitioner {

    @Override
    public int partition(String topic, Object key, byte[] keyBytes,
                        Object value, byte[] valueBytes,
                        Cluster cluster) {
        List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
        int numPartitions = partitions.size();

        // VIP 고객은 마지막 파티션으로
        if (((String) key).startsWith("VIP_")) {
            return numPartitions - 1;
        }
        // 나머지는 기본 해시
        return Math.abs(Utils.murmur2(keyBytes)) % (numPartitions - 1);
    }

    @Override public void close() {}
    @Override public void configure(Map<String, ?> configs) {}
}

// 프로듀서에 적용
properties.setProperty(ProducerConfig.PARTITIONER_CLASS_CONFIG,
    CustomerPartitioner.class.getName());

대부분의 경우는 기본 파티셔너로 충분해요. 커스텀 파티셔너는 정말 특수한 비즈니스 요구가 있을 때만 씁니다.

acks — 답장 받을지 말지

여기가 Producer 단원의 가장 중요한 옵션이에요. acks는 Producer가 브로커로부터 어떤 수준의 확인(acknowledgment)을 받을지 결정합니다. 옵션 값별 정확한 의미와 기본값 변경 이력은 Kafka 공식 문서의 Producer 설정에 정리돼 있어요.

우체국 비유로 — 편지를 부치고 나서 "잘 받았다"는 답장을 받을지, 받는다면 누구한테 받을지 정하는 거예요. 옵션은 세 가지.

acks 값동작데이터 손실처리량
0답장 안 받음높음최대
1리더만 답장중간높음
all (-1)리더 + 모든 ISR 답장없음낮음

acks=0 — Fire and Forget

Producer ──→ Broker
         (응답 기다리지 않음)

편지를 부치고 영수증도 안 받고 그냥 가는 거예요. 가장 빠르지만, 편지가 도착했는지 알 수 없어요. 브로커가 죽었어도 모릅니다. 재시도도 안 해요.

properties.setProperty(ProducerConfig.ACKS_CONFIG, "0");

쓸 수 있는 자리는 데이터 일부 손실을 허용하는 곳이에요. 로그 수집·메트릭 수집 같이, 1초에 수백만 건 흘러가는 중에 몇 건 사라져도 큰 문제가 안 되는 자리. 본격 비즈니스 데이터에는 거의 쓰지 않습니다.

acks=1 — 리더만 답장

Producer ──→ Broker Leader ──→ ACK
         (리더만 저장하면 OK)

리더 브로커(2편 참고 — 한 파티션의 대표 브로커)가 데이터를 저장하면 영수증을 보냅니다. 빠르고 어느 정도 안전해요.

여기서 시험 함정이 하나 있어요. acks=1이라도 데이터 손실 가능성이 0은 아닙니다. 시나리오 — 리더가 저장하고 영수증을 보낸 직후, 팔로워(복제본)에 복제가 끝나기 전에 리더 브로커가 죽으면? 그 데이터는 사라져요. 새 리더로 뽑힌 팔로워에는 그 데이터가 없으니까요.

properties.setProperty(ProducerConfig.ACKS_CONFIG, "1");

Kafka 3.0 이전의 기본값이었어요. 신뢰성과 성능의 절충선 — 일반적인 메시지 시스템 자리에 오래 쓰였습니다.

acks=all (혹은 -1) — 모든 ISR 답장

Producer ──→ Broker Leader ──→ ISR Replica 1 ──┐
                         ──→ ISR Replica 2 ──┤ → All ACK → Producer

리더가 저장하고, ISR(In-Sync Replicas)에 속한 모든 복제본이 저장한 후에 영수증을 보냅니다. 가장 안전해요. 한두 대 브로커가 죽어도 데이터가 살아남아요.

properties.setProperty(ProducerConfig.ACKS_CONFIG, "all");

Kafka 3.0 이후의 기본값이에요. 금융 거래·결제·중요 이벤트 같이 데이터 한 건도 잃으면 안 되는 자리에 씁니다.

min.insync.replicas — 안전망 한 단계 더

acks=all만으로는 부족해요. ISR이 1개로 줄어든 상황(브로커 여러 대가 죽어 사실상 복제본 없음)에서도 그 1개에 저장만 되면 답장을 보내 버리거든요. 그러면 그 한 대마저 죽으면 데이터가 사라집니다.

이걸 막는 게 min.insync.replicas 설정. "이 토픽은 ISR이 최소 N개 이상 살아 있을 때만 쓰기를 허용한다"는 뜻이에요.

acks=all + min.insync.replicas=2 (권장 설정):

상황 1: 브로커 3개, ISR = {Leader, Replica1, Replica2}
  → 3개 ≥ 2 → 정상 동작

상황 2: 브로커 1개 장애, ISR = {Leader, Replica1}
  → 2개 ≥ 2 → 정상 동작

상황 3: 브로커 2개 장애, ISR = {Leader만}
  → 1개 < min.insync.replicas(2) → NotEnoughReplicasException 발생
# 토픽에 min.insync.replicas 설정
kafka-topics.sh --bootstrap-server localhost:9092 \
  --alter --topic important-topic \
  --config min.insync.replicas=2

# 브로커 기본값 (server.properties)
min.insync.replicas=2

여기서 시험 함정이 하나 있어요. min.insync.replicas=2로 설정하고 브로커가 2대뿐이면, 1대만 죽어도 토픽 전체가 쓰기 불가가 됩니다. 그래서 복제 팩터 3, min.insync.replicas=2가 권장 조합이에요. 한 대 죽어도 쓰기가 가능하면서, 두 대까지 죽어도 데이터는 안전한 균형점.

재시도(retries)와 메시지 순서

브로커가 일시적으로 답장을 못 보내거나, 네트워크가 출렁거리면 Producer는 재시도합니다. 우체국 비유로 — 영수증을 못 받았으면 같은 편지를 다시 부쳐 보는 거예요.

// Kafka 2.1 이전 기본값: retries = 0
// Kafka 2.1 이후 기본값: retries = Integer.MAX_VALUE
properties.setProperty(ProducerConfig.RETRIES_CONFIG,
    String.valueOf(Integer.MAX_VALUE));

// 재시도 사이 대기 시간 (기본 100ms)
properties.setProperty(ProducerConfig.RETRY_BACKOFF_MS_CONFIG, "100");

// 전체 전달 타임아웃 (기본 120초)
properties.setProperty(ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG, "120000");

delivery.timeout.ms 안에서 가능한 한 재시도를 반복하고, 그 시간이 지나면 최종 실패로 처리해요.

재시도가 만드는 순서 역전 문제

여기서 진짜 함정이 하나 있어요. 재시도가 발생하면 메시지 순서가 뒤바뀔 수 있습니다.

전송:
배치 1: [msg1, msg2] → 브로커 전송 실패 (재시도 대기)
배치 2: [msg3, msg4] → 브로커 전송 성공

재시도 후:
배치 1: [msg1, msg2] → 브로커 전송 성공

결과적 순서: msg3, msg4, msg1, msg2  ← 순서 역전!

같은 파티션 안에서 순서를 보장한다고 했는데, 재시도 때문에 깨질 수 있는 거예요.

이걸 막는 옵션이 max.in.flight.requests.per.connection — 답장을 안 받은 채로 동시에 떠 있을 수 있는 요청의 최대 개수예요.

// 순서 보장을 위해 1로 설정 (성능 저하)
properties.setProperty(
    ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, "1");

// 멱등성 프로듀서 사용 시 5까지 가능 (권장)
properties.setProperty(
    ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, "5");

1로 설정하면 한 번에 한 요청만 떠 있어서 순서가 무조건 보장돼요. 다만 처리량이 크게 떨어집니다.

다행히 다음에 다룰 멱등성 Producer를 켜면 5까지 올려도 순서가 자동으로 지켜져요. 그래서 실무에서는 거의 다 멱등성 + max.in.flight=5 조합을 씁니다.

멱등성 Producer — 같은 편지가 두 번 도착하지 않게

재시도가 만드는 또 하나의 문제가 중복 전송이에요.

문제 상황:
1. Producer → Broker: 메시지 전송
2. Broker: 메시지 저장 완료
3. ACK 전송 중 네트워크 오류
4. Producer: ACK 못 받음 → 재시도
5. Broker: 같은 메시지를 또 저장 (중복!)

브로커는 멀쩡히 저장했는데, 영수증이 돌아오는 길에 사고가 나서 Producer가 재시도해 버린 거예요. 결과적으로 같은 편지가 두 번 도착합니다.

이걸 막는 게 멱등성(Idempotent) Producer입니다. 우체국 비유로 — 편지마다 일련번호를 찍어 보내고, 우체국이 "이 일련번호는 이미 봤어요"라고 인식해서 한 번만 저장하게 만드는 거예요.

// 명시적 활성화
properties.setProperty(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true");

// Kafka 3.0 이후: 기본값이 true
// 단, acks=all, retries > 0, max.in.flight <= 5 가 전제

멱등성 동작 원리

카프카는 각 메시지에 Producer ID(PID)Sequence Number를 부여합니다.

Producer ID (PID): 12345 (브로커가 프로듀서에게 할당)

전송 1:        PID=12345, Seq=0, value="hello" → 저장 (Seq 0)
전송 2 (재시도): PID=12345, Seq=0, value="hello" → 이미 Seq 0 존재 → 무시 (중복 제거)
전송 3:        PID=12345, Seq=1, value="world" → 저장 (Seq 1)

브로커는 (PID, Seq)의 조합을 기억하고 있다가, 같은 조합이 또 오면 무시해요. 결과적으로 재시도해도 한 번만 저장됩니다.

여기서 시험 함정이 하나 있어요. 멱등성 Producer는 같은 Producer 인스턴스 안에서만 동작합니다. Producer가 재시작되면 새 PID가 발급돼서, 재시작 전 메시지의 중복이 인식되지 않아요. 또 여러 토픽에 걸친 일관성이나 외부 시스템과의 트랜잭션 같은 건 멱등성만으로 못 풀어요. 그건 다음 단계인 Kafka Transactions 영역이에요.

안전한 Producer 설정 — Kafka 3.0의 기본값

위에서 다룬 옵션들을 한 번에 묶어 놓은 게 안전한 Producer 설정(Safe Producer)이에요. Kafka 3.0부터는 이게 기본값이지만, 어느 버전에서든 명시적으로 적어 두면 안심이에요.

// 멱등성 활성화
properties.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true");

// acks=all (리더 + ISR 모두 확인)
properties.put(ProducerConfig.ACKS_CONFIG, "all");

// 재시도 무제한
properties.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);

// 순서 보장 + 병렬성 (멱등성 활성화 시 5까지 가능)
properties.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5);

여기서 시험 함정이 하나 있어요. 이 설정은 데이터 손실·중복 가능성을 거의 0으로 줄이지만, 처리량은 가장 빠른 설정 대비 다소 낮습니다. 그래도 대부분의 자리에서는 이 안전 설정이 정답이에요. 처리량이 정말 부족할 때만 다음 단계 — 배치·압축으로 끌어올리는 게 순서입니다.

배치(Batching) — 편지 여러 장을 한 봉투에

카프카 Producer는 메시지를 하나씩 보내지 않아요. 한꺼번에 모아서 배치로 묶어 전송합니다. 우체국 비유로 — 편지 한 장씩 우체국에 직접 들고 가는 게 아니라, 편지통에 모았다가 한 봉투에 담아 한 번에 보내는 거예요.

배치를 하면 좋은 게 세 가지.

  • 네트워크 요청 횟수가 줄어요 — 편지 100장을 100번 부치는 것보다 1번에 보내는 게 빠르죠.
  • 압축 효율이 높아져요 — 한 봉투에 든 편지가 많을수록 압축하기 좋아요.
  • 처리량이 올라갑니다 — 두 가지가 합쳐진 결과.

배치를 제어하는 핵심 옵션이 두 개 있어요.

linger.ms — 편지 모으는 대기 시간

// linger.ms: 배치를 전송하기 전 기다리는 시간 (기본값: 0ms)
properties.put(ProducerConfig.LINGER_MS_CONFIG, 20);

기본값은 0ms — 편지가 한 장이라도 들어오면 곧장 보내요. 그래서 처음 카프카 Producer를 쓰면 배치 효과를 거의 못 봅니다.

linger.ms20ms로 올리면 — 편지 한 장이 와도 곧장 보내지 않고, "20ms 동안 기다려 봐서 더 올 편지가 있으면 같이 부치자"가 되는 거예요. 그래서 20ms 만큼 약간의 지연이 생기지만, 배치가 두툼해져서 처리량이 크게 올라갑니다.

batch.size — 한 봉투에 담을 최대 크기

// batch.size: 한 파티션에 대한 배치 최대 크기 (기본: 16KB)
properties.put(ProducerConfig.BATCH_SIZE_CONFIG, 32 * 1024); // 32KB

기본값은 16KB. 한 봉투에 16KB까지 담을 수 있어요. 32KB·64KB로 올리면 더 두툼한 봉투가 되고, 압축 효율도 같이 올라갑니다.

여기서 시험 함정이 하나 있어요. batch.size를 너무 키우면 메모리 사용량이 늘어요. 파티션 수 × batch.size 만큼 버퍼가 잡힐 수 있거든요. 그리고 어차피 linger.ms 안에 그만큼 안 모이면 그냥 그 시점에 전송돼요. 무작정 크게 잡는다고 다 좋은 건 아닙니다.

두 옵션이 같이 작동하는 방식

규칙:
- batch.size 까지 차면 → 곧장 전송
- linger.ms 시간이 지나면 → 차지 않았어도 전송
- 둘 중 먼저 도달하는 조건으로 보냄

linger.ms=20, batch.size=32KB 조합이 흔한 출발점이에요. 메시지가 빨리 들어오는 자리면 batch.size로 끊겨 전송되고, 천천히 들어오는 자리면 linger.ms로 끊겨 전송됩니다.

압축(Compression) — 편지를 작게 접어 보내기

배치를 두툼하게 만들었으면, 그걸 한 번 더 작게 접어 보내는 게 압축이에요. 네트워크 비용·디스크 공간 모두 절약됩니다. 게다가 카프카에서 압축은 배치 단위로 적용되니, 위에서 만든 두툼한 배치가 압축 효율을 더 끌어올려 줘요.

지원 알고리즘은 다섯 가지.

알고리즘압축 속도압축률CPU 사용추천 용도
none-없음없음소규모 테스트
gzip느림높음높음디스크 공간 최우선
snappy빠름중간낮음대부분의 사용 사례
lz4매우 빠름중간낮음낮은 지연 필요
zstd빠름높음중간Kafka 2.1+, 균형 잡힌 선택
// 프로듀서 레벨에서 압축 설정
properties.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "snappy");

가장 많이 쓰는 게 snappy — 압축 속도가 빠르고 CPU 부담이 적어 일반 자리에 잘 맞아요. 디스크 공간이 정말 아깝다면 gzip, 최신 환경이고 균형 잡힌 선택이 필요하면 zstd도 좋습니다.

압축이 적용되는 위치

프로듀서 압축 설정이 있는 경우:
Producer → [배치 압축] → Broker → [압축 유지 저장] → Consumer → [압축 해제]

Producer에서 압축한 채로 브로커에 도착하고, 브로커는 그 압축된 채로 디스크에 저장합니다. Consumer가 가져갈 때만 압축이 풀려요. 그래서 네트워크와 디스크 양쪽에서 다 이득을 봐요.

브로커에도 compression.type 설정이 있는데, 권장값은 producer — Producer가 보낸 형식 그대로 저장하라는 뜻이에요. 기본값이고 가장 효율적입니다. 다른 값으로 두면 브로커가 풀고 다시 압축하느라 CPU를 낭비해요.

콜백과 비동기·동기 전송

producer.send()는 기본적으로 비동기예요. 호출하면 즉시 리턴되고, 실제 전송은 백그라운드 Sender Thread가 처리합니다. 그래서 결과를 알고 싶으면 별도 방법을 써야 해요.

비동기 + 콜백

가장 흔하게 쓰는 패턴이에요. 결과를 알면서도 호출 측은 블로킹되지 않아요.

producer.send(record, (metadata, exception) -> {
    if (exception == null) {
        log.info("Topic={}, Partition={}, Offset={}",
            metadata.topic(), metadata.partition(), metadata.offset());
    } else {
        log.error("Error producing", exception);
    }
});

전송이 끝나면(성공이든 실패든) 람다가 호출돼요. 메타데이터로 들어오는 partition·offset2편의 그 좌표값이에요. "어느 칸 몇 번째 자리에 꽂혔다"가 그대로 찍힙니다.

동기 전송

send()가 돌려주는 Future.get()을 호출하면 ACK가 올 때까지 블로킹돼요.

try {
    RecordMetadata metadata = producer.send(record).get();
    log.info("Sent to partition {}, offset {}",
        metadata.partition(), metadata.offset());
} catch (ExecutionException | InterruptedException e) {
    log.error("Error sending message", e);
}

여기서 시험 함정이 하나 있어요. 동기 전송은 처리량이 크게 떨어집니다. 한 메시지가 끝나야 다음 메시지를 보내니, 배치 효과가 거의 사라져요. 정말 한 건 한 건이 결정적인 자리(예: 세팅 메시지·헬스체크)가 아니면 비동기 + 콜백 패턴이 정답이에요.

고처리량 vs 저지연 — 두 갈래의 균형

지금까지 본 옵션을 두 갈래로 나눠 정리해 봅시다. 고처리량(많이 보내기)과 저지연(빨리 도착)은 어느 정도 트레이드오프 관계예요.

고처리량 우선 설정

props.put(ProducerConfig.LINGER_MS_CONFIG, 20);              // 더 모음
props.put(ProducerConfig.BATCH_SIZE_CONFIG, 32 * 1024);      // 32KB 배치
props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "snappy"); // 압축으로 더 보냄
props.put(ProducerConfig.ACKS_CONFIG, "all");                // 안전성 유지
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true"); // 중복 방지

배치를 두툼하게 만들고 압축을 켜서 한 번에 많이 보내는 전략. 약간의 지연(linger.ms)은 감수하고, 대신 시간당 처리량을 끌어올려요. 로그 수집·이벤트 스트림 같이 처리량이 핵심인 자리에 적합.

저지연 우선 설정

props.put(ProducerConfig.LINGER_MS_CONFIG, 0);               // 곧장 보냄
props.put(ProducerConfig.BATCH_SIZE_CONFIG, 16 * 1024);      // 기본 크기
props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4");    // 빠른 압축
props.put(ProducerConfig.ACKS_CONFIG, "1");                  // 빠른 확인

지연을 최소화하는 전략. linger.ms=0으로 즉시 전송하고, acks=1로 답장 대기를 짧게. 알림·실시간 트랜잭션 같이 한 건 한 건이 빨리 도착해야 하는 자리에 맞아요.

균형 잡힌 안전 + 고처리량 통합 설정

대부분의 자리는 다음 조합이 출발점이에요. 안전성을 잃지 않으면서 처리량도 충분히 끌어올린 설정.

Properties props = new Properties();

props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
    StringSerializer.class.getName());
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
    StringSerializer.class.getName());

// === 안전 설정 ===
props.put(ProducerConfig.ACKS_CONFIG, "all");                 // 완전 확인
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true");  // 중복 방지
props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);  // 무제한 재시도
props.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5); // 순서 + 병렬성
props.put(ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG, 120000); // 2분 타임아웃

// === 성능 최적화 ===
props.put(ProducerConfig.LINGER_MS_CONFIG, 20);               // 20ms 모음
props.put(ProducerConfig.BATCH_SIZE_CONFIG, 32 * 1024);       // 32KB 배치
props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "snappy");  // 빠른 압축

메시지 전달 보장(Delivery Semantics) — 세 가지 등급

분산 메시지 시스템에는 메시지가 어떻게 전달되는지에 따라 세 가지 보장 수준이 있어요.

보장 수준설명중복 가능손실 가능
At Most Once최대 한 번없음있음
At Least Once최소 한 번있음없음
Exactly Once정확히 한 번없음없음

At Most Onceacks=0, retries=0. 한 번 보내고 끝. 손실 가능. At Least Onceacks=all, retries>0. 안전하지만 재시도 때문에 중복 가능. Consumer 쪽에서 중복 처리 로직 필요. Exactly Once — 멱등성 Producer + Kafka Transactions(다중 토픽 시) + Kafka Streams 같은 도구의 조합으로 달성.

여기서 시험 함정이 하나 있어요. "멱등성 Producer = Exactly Once"가 아닙니다. 멱등성은 같은 Producer 인스턴스, 같은 토픽 안에서의 중복만 막아요. 여러 토픽에 걸친 원자적 쓰기, 외부 시스템과의 일관성 같은 진짜 Exactly Once는 Kafka Transactions라는 별도 메커니즘이 필요해요. 면접·실무 모두에서 자주 헷갈리는 자리예요.

// Kafka Transactions 예시
producer.initTransactions();
try {
    producer.beginTransaction();
    producer.send(record1);
    producer.send(record2);
    producer.commitTransaction();
} catch (ProducerFencedException e) {
    producer.close();
} catch (KafkaException e) {
    producer.abortTransaction();
}

CLI로 Producer 써 보기

Java 코드를 짜기 전에 CLI(kafka-console-producer.sh)로 빠르게 테스트할 수 있어요.

# 기본 (키 없음)
kafka-console-producer.sh \
  --bootstrap-server localhost:9092 \
  --topic first_topic

# 입력:
> Hello World
> ^C (종료)

# acks=all 설정
kafka-console-producer.sh \
  --bootstrap-server localhost:9092 \
  --topic first_topic \
  --producer-property acks=all

# 키:값 형태로 전송
kafka-console-producer.sh \
  --bootstrap-server localhost:9092 \
  --topic first_topic \
  --property parse.key=true \
  --property key.separator=:

# 입력:
> key1:value1
> key2:value2

--property parse.key=true로 키를 같이 넣어 보면, 같은 키가 같은 파티션으로 가는지 직접 확인할 수 있어요. 카프카 학습 초반에 파티셔너 동작을 눈으로 보기에 가장 좋은 방법.

모니터링 포인트 — Producer가 잘 돌고 있는지 확인하기

운영에서는 다음 같은 메트릭을 챙겨 봅니다.

record-send-rate          # 초당 전송 레코드 수
byte-rate                 # 초당 전송 바이트
record-error-rate         # 오류 비율 (0에 가까울수록 좋음)
request-latency-avg       # 평균 요청 지연 시간
batch-size-avg            # 평균 배치 크기 (클수록 효율적)
compression-rate-avg      # 평균 압축률
records-per-request-avg   # 요청당 평균 레코드 수

문제가 생겼을 때 어디를 보면 좋은지 한 줄씩.

처리량이 낮다 → linger.ms 증가, batch.size 증가, 압축 설정 확인
메시지 중복 발생 → enable.idempotence=true 확인
메시지 순서 역전 → max.in.flight.requests.per.connection 확인
전송 실패 많음 → acks 설정, retries, delivery.timeout.ms 확인
지연이 높다 → linger.ms 낮추기, 브로커 상태 확인

여기서 시험 함정이 하나 있어요. batch-size-avgbatch.size 설정값에 가깝게 나오면 정말 배치가 잘 모이고 있다는 뜻이에요. 반대로 한참 작으면 — 메시지가 너무 띄엄띄엄 들어와서 linger.ms 시간이 매번 다 차고 전송되는 상태예요. 처리량이 부족하면 linger.ms를 더 키워서 더 모으게 하든지, 아니면 메시지 발생 빈도를 따라 옵션을 조정해야 합니다.

시험 직전 한 번 더 — Producer 압축 노트

여기까지가 카프카 3편의 핵심입니다. 헷갈릴 때 다시 펼칠 수 있게 압축 노트로 마무리할게요.

  • Producer = 편지 부치는 사람 — 카프카 토픽에 메시지를 쓰는 클라이언트
  • 전송 7단계 — Record 생성 → Serializer → Partitioner → Accumulator → Sender → Broker → ACK
  • producer.send()비동기flush()·close() 빼먹으면 메시지 사라질 수 있음
  • Serializer = 편지를 표준 양식으로 옮기기 — Producer/Consumer 짝 맞춰야
  • 파티셔너 키 없음 → Sticky Partitioner(2.4+, 배치 효율 ↑) / 키 있음 → hash(key) % 파티션수
  • 같은 키 = 같은 파티션 — 순서 필요한 단위는 키로 묶기
  • 파티션 수 늘리면 키-파티션 매핑 깨짐 — 토픽 설계 시 신중
  • acks=0 답장 안 받음(빠름·손실), acks=1 리더만(중간), acks=all 모든 ISR(안전·느림)
  • Kafka 3.0 이전 기본 acks=1, 3.0 이후 acks=all
  • acks=all + min.insync.replicas=2 + 복제 팩터 3 = 권장 안전 조합
  • retries는 2.1 이후 기본 Integer.MAX_VALUE, delivery.timeout.ms(기본 2분) 안에서 반복
  • 재시도 → 순서 역전 가능 → max.in.flight.requests.per.connection로 제어
  • 멱등성 Producer = 같은 편지 두 번 도착 안 하게 — PID + Sequence Number로 중복 제거
  • Kafka 3.0 이후 enable.idempotence=true가 기본, max.in.flight=5까지 순서 보장
  • 안전한 Producer = acks=all + idempotence=true + retries=MAX + max.in.flight=5
  • 배치 = 편지 여러 장 한 봉투에 — 네트워크 ↓·압축 효율 ↑·처리량 ↑
  • linger.ms(기본 0) 늘리면 더 모음, batch.size(기본 16KB) 키우면 봉투 더 커짐
  • 압축 — snappy(범용 추천), lz4(저지연), gzip(공간 최우선), zstd(균형)
  • 브로커 compression.type=producer(기본) — 받은 그대로 저장이 효율적
  • 콜백 비동기가 표준 — 동기(send().get())는 처리량 크게 떨어짐
  • 고처리량 ↔ 저지연 트레이드오프 — linger.ms·acks가 대표 레버
  • 전달 보장 — At Most Once / At Least Once / Exactly Once(멱등성 + Transactions)
  • 멱등성 ≠ Exactly Once — 멱등성은 한 Producer·한 토픽 한정

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!