Kafka 멱등 프로듀서와 트랜잭션은 Exactly-Once를 어떻게 보장하나

seonwoo_jung·2026년 6월 2일

1. 도입

메시지 큐를 쓰다 보면 "한 번만 처리"라는 말이 생각보다 까다롭다는 걸 알게 된다. 컨슈머가 메시지를 처리하고 나서 오프셋을 커밋하기 직전에 죽으면, 재시작 후 같은 메시지를 다시 받는다. 반대로 처리 전에 오프셋부터 커밋하면 장애 시 메시지가 사라진다. 전자가 at-least-once, 후자가 at-most-once다. 그 사이 어딘가에 있는 "exactly-once"는 오랫동안 분산 시스템에서 어려운 문제로 알려져 있었다.

Kafka는 0.11 버전부터 exactly-once semantics(이하 EOS)를 지원한다고 발표했다. 처음 이 문구를 봤을 때 "네트워크 위에서 정확히 한 번 전달이 정말 가능한가?"라는 의심이 들었는데, 공식 docs를 따라가 보니 핵심은 전달(delivery)을 한 번으로 만드는 게 아니라 중복을 제거하고(dedup) 쓰기를 원자적으로(atomic) 묶는 것이었다. 이 글은 EOS를 떠받치는 두 축 — 멱등 프로듀서(idempotent producer)와 트랜잭션(transactions) — 이 각각 무엇을 보장하는지, 그리고 흔히 오해하는 경계가 어디인지를 정리한 노트다.

이 글은 Kafka 3.x 기준으로 정리했다. 버전에 따라 기본값(특히 멱등성 활성화 여부)이 달라서, 확신이 약한 부분은 공식 docs 인용으로 처리했다.

2. 핵심 개념: 두 가지 실패와 두 가지 해법

EOS가 막으려는 문제는 크게 두 종류다.

문제원인해법
프로듀서 재시도로 인한 중복ack 유실 → 같은 레코드 재전송멱등 프로듀서
여러 파티션/오프셋에 걸친 부분 쓰기일부만 쓰이고 장애트랜잭션

핵심은 이 둘이 다른 범위(scope)를 다룬다는 점이다. 멱등 프로듀서는 "한 프로듀서 세션 안에서, 파티션 단위로" 재전송 중복을 막는다. 트랜잭션은 "여러 파티션에 걸친 쓰기 + 컨슈머 오프셋 커밋"을 하나의 원자 단위로 묶는다. 그래서 진짜 end-to-end EOS(읽기-처리-쓰기 루프)를 만들려면 둘 다 필요하다.

3. 내부 동작

3.1 멱등 프로듀서

멱등 프로듀서를 켜면(enable.idempotence=true) 프로듀서는 브로커로부터 PID(Producer ID)를 발급받는다. 이후 보내는 모든 레코드에는 (PID, 파티션, 시퀀스 번호)가 붙는다. 시퀀스 번호는 파티션별로 0부터 단조 증가한다.

브로커는 파티션마다 "이 PID에서 마지막으로 받은 시퀀스 번호"를 기억한다. 그래서 ack가 유실되어 프로듀서가 같은 레코드를 재전송하면, 브로커는 시퀀스 번호가 이미 처리된 값임을 보고 중복으로 판단해 버린다(레코드는 한 번만 로그에 남는다). 공식 docs에 따르면 이때 정상 처리된 것처럼 ack를 돌려주므로 프로듀서 입장에서는 투명하게 동작한다.

여기엔 전제 조건이 있다. 멱등성을 켜면 다음이 강제된다고 알려져 있다.

  • acks=all
  • retries > 0
  • max.in.flight.requests.per.connection <= 5

마지막 조건이 중요한데, 인플라이트 요청이 5개를 넘으면 브로커가 시퀀스 번호의 순서를 보장하며 dedup하기 어려워지기 때문이다. Kafka 3.0부터는 enable.idempotence가 기본 true로 바뀌었다(이전엔 기본 비활성).

다만 멱등 프로듀서의 보장 범위는 단일 세션, 단일 파티션이다. 프로듀서가 재시작해 새 PID를 받으면 이전 세션과의 연속성은 없다. 또 여러 파티션에 나눠 쓰는 묶음은 "전부 또는 전무"를 보장하지 못한다. 이 한계를 메우는 게 트랜잭션이다.

3.2 트랜잭션

트랜잭션을 쓰려면 프로듀서에 transactional.id를 설정한다. 이 ID는 프로듀서 재시작을 넘어서 유지되는 논리적 식별자다. 동작의 중심에는 트랜잭션 코디네이터(transaction coordinator)와 내부 토픽 __transaction_state가 있다.

흐름을 단계로 따라가 보면:

  1. initTransactions() — 코디네이터에 transactional.id를 등록하고, 에포크(epoch)를 발급/증가시킨다. 이때 같은 ID의 이전 프로듀서는 더 낮은 에포크를 갖게 되어 펜싱(fencing)된다. 즉 좀비 프로듀서가 옛 트랜잭션을 커밋하려 하면 ProducerFenced 류의 예외로 거부된다.
  2. beginTransaction() — 클라이언트 측에서 트랜잭션 시작을 표시.
  3. send() — 여러 파티션에 레코드를 보낸다. 코디네이터는 어떤 파티션이 이 트랜잭션에 참여했는지 기록한다.
  4. sendOffsetsToTransaction() — 읽기-처리-쓰기 루프라면 컨슈머 오프셋도 트랜잭션의 일부로 __consumer_offsets에 함께 커밋되도록 넘긴다.
  5. commitTransaction() / abortTransaction() — 코디네이터가 2단계 커밋과 유사한 절차로, 참여한 모든 파티션에 트랜잭션 마커(commit/abort marker)를 기록한다.

소비 측에서는 isolation.level=read_committed를 설정한 컨슈머만 커밋된 메시지를 본다. read_committed 컨슈머는 LSO(Last Stable Offset)까지만 읽는다. 즉 아직 커밋/어보트가 결정되지 않은 트랜잭션 메시지는 마커가 찍힐 때까지 보류된다. 반면 read_uncommitted(기본값)는 아직 진행 중인 트랜잭션 메시지도 그냥 읽는다.

EOS의 원자성은 "쓰기 + 컨슈머 오프셋 커밋"을 한 트랜잭션으로 묶는 데서 나온다. 출력이 커밋되면 입력 오프셋도 커밋되고, 어보트되면 둘 다 무효가 된다.

4. 예시 / 코드

가장 작은 형태의 consume-process-produce 트랜잭션 루프를 의사 코드에 가깝게 정리하면 이렇다.

// 프로듀서: transactional.id 필수, 멱등성은 자동으로 켜진다
Properties p = new Properties();
p.put("transactional.id", "order-processor-1");
p.put("enable.idempotence", "true");
KafkaProducer<String, String> producer = new KafkaProducer<>(p);

// 컨슈머: 커밋된 것만 읽도록
consumer.subscribe(List.of("orders"));
// isolation.level=read_committed, enable.auto.commit=false 로 설정해 둔다

producer.initTransactions();            // 등록 + epoch 발급(좀비 펜싱)

while (true) {
    var records = consumer.poll(Duration.ofMillis(200));
    if (records.isEmpty()) continue;

    producer.beginTransaction();
    try {
        for (var rec : records) {
            // 처리 결과를 출력 토픽으로 (여러 파티션 가능)
            producer.send(new ProducerRecord<>("orders-processed", transform(rec.value())));
        }
        // 입력 오프셋을 "트랜잭션의 일부로" 함께 커밋
        producer.sendOffsetsToTransaction(currentOffsets(records), consumer.groupMetadata());
        producer.commitTransaction();   // 출력 + 오프셋이 원자적으로 확정
    } catch (KafkaException e) {
        producer.abortTransaction();    // 전부 무효화 → 다음 루프에서 재처리
    }
}

엣지 케이스 하나. commitTransaction() 직후, ack가 돌아오기 전에 프로세스가 죽으면? 재시작한 새 프로듀서가 initTransactions()로 같은 transactional.id를 등록하면 코디네이터가 미완 트랜잭션의 상태를 복구한다. 그리고 read_committed 컨슈머는 어차피 마커가 확정된 트랜잭션만 읽으므로, 다운스트림에 중복이 새어 나가지 않는다.

5. 정리

한 줄로 줄이면: 멱등 프로듀서는 재전송 중복을 막고, 트랜잭션은 여러 파티션 쓰기와 오프셋 커밋을 원자로 묶으며, read_committed 컨슈머가 그 경계를 존중한다. 셋이 맞물려야 Kafka 내부에서의 end-to-end exactly-once가 성립한다.

가장 흔한 오해는 "EOS면 외부 DB 쓰기까지 정확히 한 번"이라고 믿는 것이다. EOS의 원자성은 어디까지나 Kafka-to-Kafka 범위다. 외부 시스템(DB, 외부 API)에 대한 부수 효과는 그 싱크가 멱등하거나 자체 트랜잭션을 지원하지 않는 한 자동으로 보장되지 않는다. Kafka Connect나 외부 싱크를 끼우는 순간 이 경계를 다시 따져봐야 한다.

다음에 더 파고들 만한 주제:

  • Kafka Streams의 processing.guarantee=exactly_once_v2가 KIP-447로 어떻게 프로듀서 펜싱을 효율화했는지
  • transaction.timeout.ms와 트랜잭션 코디네이터의 미완 트랜잭션 처리(abort) 타이밍

참고 자료

  • Apache Kafka 공식 docs — Design / Producer Configs (enable.idempotence, transactional.id, isolation.level)
  • KIP-98: Exactly Once Delivery and Transactional Messaging
  • KIP-447: Producer scalability for exactly once semantics

0개의 댓글