학습 목표

세 가지 전달 보장

공식 설계 문서는 세 가지를 다음과 같이 정의합니다.

전달 보장의 정의 (공식 설계 문서 · Message Delivery Semantics)
보장정의감당해야 하는 것
at-most-once 메시지가 유실될 수 있지만 재전달되지는 않습니다 데이터 누락
at-least-once 메시지가 유실되지 않지만 재전달될 수 있습니다 중복 처리
exactly-once 각 메시지가 정확히 한 번 처리됩니다 복잡도와 지연 — 그리고 경계 제약

공식 문서는 곧바로 중요한 단서를 붙입니다. "이것은 두 개의 문제로 갈라진다" — 메시지를 발행할 때의 내구성 보장과, 메시지를 소비할 때의 보장입니다. 그리고 exactly-once를 주장하는 많은 시스템에 대해 "작은 글씨를 읽어야 한다"고 경고합니다. 컨슈머나 프로듀서가 실패하는 경우, 컨슈머 프로세스가 여러 개인 경우, 디스크에 쓴 데이터가 유실될 수 있는 경우로 번역되지 않는 주장이 흔하다는 것입니다.

3가지 전달 보장 비교 — at-most-once · at-least-once · exactly-once 세 가지 전달 보장을 설정 조합과 결과로 비교하는 표입니다. at-most-once 는 프로듀서에서 acks=0, retries=0, enable.idempotence=false 로 재시도를 끄고 컨슈머가 처리 전에 오프셋을 커밋합니다. 결과는 유실 있음, 중복 없음입니다. at-least-once 는 Kafka 의 기본 동작으로 acks=all, retries 는 기본값 2147483647, enable.idempotence=true 이며 컨슈머가 처리 후에 커밋합니다. 결과는 유실 없음, 중복 있음입니다. exactly-once 는 여기에 transactional.id 를 설정한 트랜잭션 프로듀서를 더하고, 컨슈머는 isolation.level=read_committed, enable.auto.commit=false 로 두고 오프셋 커밋을 트랜잭션 안에 포함시킵니다. 결과는 유실도 중복도 없지만 그 보장은 Kafka 토픽에서 Kafka 토픽으로 처리하는 범위 안에서만 성립합니다. retries 를 0 으로 두려면 enable.idempotence 를 false 로 함께 내려야 합니다. enable.idempotence 를 true 로 명시한 상태에서 retries 가 0 이면 ConfigException 이 발생하고, 명시하지 않았다면 예외 없이 멱등성이 조용히 꺼집니다. 3가지 전달 보장 — 설정 조합이 결과를 결정합니다 구분 at-most-once at-least-once (기본) exactly-once 프로듀서 acks=0 retries=0 enable.idempotence = false acks=all retries=2147483647 enable.idempotence = true at-least-once 설정 + transactional.id 트랜잭션 프로듀서를 컨슈머와 1:1 로 둡니다 컨슈머 처리 오프셋 커밋 커밋 후 죽으면 그 구간은 처리 안 됨 처리 오프셋 커밋 커밋 전에 죽으면 같은 구간을 다시 처리 read_committed auto.commit=false 오프셋을 트랜잭션에 유실 있음 없음 없음 중복 없음 있음 없음 성립 범위 재시도를 포기한 대가 Kafka 의 기본 보장 Kafka 토픽 → 토픽 안에서만 retries=0enable.idempotence=false 와 함께여야 합니다. enable.idempotence=true명시했을 때만 ConfigException · 안 썼으면 조용히 꺼집니다. exactly-once 는 외부 DB·HTTP 호출까지 보장하지 않습니다 (D-054). 기준: Apache Kafka 4.3.
3가지 전달 보장 비교 — 프로듀서 설정과 컨슈머 커밋 시점의 조합이 어떻게 at-most-once / at-least-once / exactly-once로 갈라지는지, 각 경우의 유실·중복 지점을 함께 표시합니다.

중복은 재시도에서, 유실은 커밋 순서에서

두 문제는 발생 지점이 다릅니다. 섞어서 생각하면 처방이 엉킵니다.

중복 — 프로듀서 쪽 재시도. 프로듀서가 메시지를 보낸 뒤 네트워크 오류를 만나면, 그 오류가 커밋 전에 발생한 것인지 커밋 후에 발생한 것인지 알 수 없습니다. 공식 문서는 이를 "자동 생성 키가 있는 데이터베이스 테이블에 insert하는 것과 비슷한 상황"으로 설명합니다. 재전송하면 중복이 생기고, 재전송하지 않으면 유실이 생깁니다. 0.11.0.0부터는 멱등 프로듀서가 이 딜레마를 해소합니다 — 브로커가 프로듀서마다 ID를 부여하고 시퀀스 번호로 중복을 제거합니다.

유실과 중복 — 컨슈머 쪽 커밋 순서. 공식 문서가 드는 두 가지 선택입니다.

  1. 읽고 → 위치를 저장하고 → 처리한다. 위치 저장 후 처리 결과 저장 전에 크래시하면, 인수한 프로세스가 저장된 위치부터 시작하므로 그 앞의 몇 건은 처리되지 않은 채 건너뛰어집니다. → at-most-once
  2. 읽고 → 처리하고 → 위치를 저장한다. 처리 후 위치 저장 전에 크래시하면, 인수한 프로세스가 이미 처리한 몇 건을 다시 받습니다. → at-least-once

공식 문서는 2번에 대해 "많은 경우 메시지에 기본 키가 있어 갱신이 멱등적이다" (같은 메시지를 두 번 받아도 같은 레코드를 덮어쓰는 것뿐)라고 덧붙입니다. 이것이 실무에서 at-least-once + 컨슈머 멱등성이 가장 널리 쓰이는 조합인 이유입니다.

보장별 설정 조합

전달 보장을 만드는 설정 조합 (Apache Kafka 4.3 기준)
보장 프로듀서 컨슈머 토픽·브로커
at-most-once acks=0 또는 acks=1
재시도 비활성
enable.idempotence=false
처리 전에 커밋
(또는 자동 커밋에 맡김)
at-least-once
기본
acks=all기본값
enable.idempotence=true기본값
delivery.timeout.ms=120000
enable.auto.commit=false
처리 후에 commitSync()/commitAsync()
replication.factor=3
min.insync.replicas=2
exactly-once
(Kafka 내부)
transactional.id 지정
enable.idempotence 자동 활성
transaction.timeout.ms
isolation.level=read_committed
enable.auto.commit=false
오프셋은 sendOffsetsToTransaction()으로
replication.factor 최소 3
min.insync.replicas=2
브로커 최소 3대(기본 요구)

멱등 프로듀서와 트랜잭션은 다른 것입니다

둘 다 "중복을 막는다"고 설명되지만 해결하는 문제가 다릅니다. 멱등성은 재시도로 인한 중복을 없애고, 트랜잭션은 여러 쓰기를 원자적으로 묶습니다.

멱등 프로듀서 vs 트랜잭션
관점 멱등 프로듀서 트랜잭션
켜는 방법enable.idempotence=true (4.x 기본값)transactional.id 지정 → 멱등성 자동 활성
해결하는 문제프로듀서 재시도로 인한 중복 기록여러 파티션·토픽에 대한 원자적 쓰기 + 오프셋 커밋의 원자적 포함
중복 제거 범위파티션 단위, 그리고 단일 프로듀서 세션 안에서만트랜잭션 경계 안의 모든 쓰기 (다중 파티션)
메커니즘브로커가 프로듀서 ID(PID) + 파티션별 시퀀스 번호로 중복 판별트랜잭션 코디네이터 + __transaction_state + 트랜잭션 마커 레코드
컨슈머 쪽 요구없음isolation.level=read_committed가 있어야 실질적 의미
세션 경계를 넘는가아니요. 프로세스가 재시작하면 새 PID예. transactional.id가 세션을 가로질러 이전 트랜잭션을 정리합니다
비용거의 없음 (4.x 기본값)커밋 지연 + 코디네이터 왕복 + read_committed의 가시성 지연

멱등성이 요구하는 것

공식 문서는 enable.idempotence=true가 세 가지를 요구한다고 명시합니다.

충돌하는 설정이 있고 멱등성을 명시적으로 켜지 않았다면 멱등성이 비활성화됩니다. 명시적으로 켰는데 충돌 설정이 있으면 설정 오류가 됩니다 — 조용히 꺼지지 않습니다.

트랜잭션 흐름 — initTransactions 부터 commit 또는 abort 까지 트랜잭션 프로듀서의 API 호출 순서와 각 호출이 트랜잭션 코디네이터에서 무엇을 하는지, 그 결과가 어디에 기록되는지를 6단계로 보여줍니다. 1단계 initTransactions 는 코디네이터를 찾아 PID 와 epoch 를 발급받고 이전 인스턴스의 미완 트랜잭션을 정리합니다. 2단계 beginTransaction 은 클라이언트 로컬 상태 전환이라 브로커와 통신하지 않습니다. 3단계 첫 send 전에 AddPartitionsToTxn 으로 대상 파티션이 __transaction_state 에 등록되고 레코드가 데이터 파티션에 기록됩니다. 4단계 sendOffsetsToTransaction 은 AddOffsetsToTxn 과 TxnOffsetCommit 으로 컨슈머 오프셋을 같은 트랜잭션 안에서 __consumer_offsets 에 씁니다. 5단계 commitTransaction 은 EndTxn 요청으로 코디네이터가 PREPARE_COMMIT 을 기록하고 관련된 모든 파티션에 커밋 마커를 쓴 뒤 COMPLETE_COMMIT 으로 끝냅니다. 6단계 abortTransaction 은 같은 경로로 abort 마커를 쓰며, read_committed 컨슈머가 그 레코드를 건너뜁니다. 코디네이터의 상태는 내부 토픽 __transaction_state 에 저장됩니다. 트랜잭션 흐름 — 프로듀서 호출 · 코디네이터 동작 · 기록 위치 프로듀서 API 호출 트랜잭션 코디네이터 기록 위치 1 initTransactions() 앱 시작 시 한 번만 코디네이터 찾기 → PID · epoch 발급, 미완 정리 __transaction_state Empty 상태로 초기화 2 beginTransaction() 트랜잭션마다 호출 브로커와 통신하지 않습니다 — 클라이언트 로컬 상태만 바뀝니다 코디네이터에는 첫 send() 시점에 처음 기록됩니다 3 send(record) 여러 토픽·파티션 가능 AddPartitionsToTxn 참여 파티션 목록을 기록 대상 토픽 파티션 이미 로그에 있지만 미확정 4 sendOffsets ToTransaction() AddOffsetsToTxn TxnOffsetCommit __consumer_offsets 같은 트랜잭션 안에서 5 commitTransaction() 여기서 원자성이 확정 EndTxn PREPARE → 마커 → 완료 모든 참여 파티션에 COMMIT 마커 기록 6 abortTransaction() 예외 처리 경로 EndTxn (ABORT) 오프셋 커밋도 함께 무효 ABORT 마커 기록 read_committed 가 건너뜀 레코드는 기록된 뒤에 마커로 확정됩니다. 그래서 abort 된 레코드도 로그에는 남아 있고, 컨슈머가 걸러냅니다. transaction.timeout.ms 기본 60000 · transactional.id.expiration.ms 기본 604800000 transaction.state.log.replication.factor 기본 3 · transaction.state.log.min.isr 기본 2
트랜잭션 흐름 — initTransactions()beginTransaction()send()/sendOffsetsToTransaction()commitTransaction()/abortTransaction(). 트랜잭션 코디네이터와 __transaction_state 내부 토픽, 그리고 파티션마다 기록되는 트랜잭션 마커를 포함합니다.

트랜잭션 — API와 코디네이터

공식 문서는 Kafka 트랜잭션이 다른 메시징 시스템의 트랜잭션과 다르다는 점을 먼저 짚습니다. Kafka에서는 컨슈머와 프로듀서가 분리되어 있고, 트랜잭션은 프로듀서만 갖습니다. 다만 프로듀서가 컨슈머의 위치(committed offset)를 트랜잭션 안에서 갱신할 수 있고, 바로 이것이 전체적인 exactly-once 동작을 만들어 냅니다.

transactional.id 의 역할

공식 문서의 정의입니다. transactional.id의 목적은 "단일 프로듀서 인스턴스의 여러 세션에 걸친 트랜잭션 복구를 가능하게 하는 것"입니다. 같은 transactional.id를 쓰는 트랜잭션이 새 트랜잭션 시작 전에 완료되었음을 보장하기 때문입니다. 재시작한 애플리케이션이 이전 인스턴스의 진행 중 트랜잭션을 중단(abort)시키는 효과도 여기서 나옵니다.

따라서 이 값은 파티션된 상태 저장 애플리케이션의 샤드 식별자에서 유도하는 것이 전형적이고, 애플리케이션 안에서 실행되는 프로듀서 인스턴스마다 유일해야 합니다.

API 호출 순서

트랜잭션 API (Apache Kafka 4.3 Producer 인터페이스)
호출언제비고
initTransactions() 프로듀서 생성 직후 한 번만 PID를 확보하고 같은 transactional.id의 진행 중 트랜잭션을 정리합니다
beginTransaction() 트랜잭션 시작 프로듀서당 열린 트랜잭션은 하나뿐입니다
send(record) 트랜잭션 안의 모든 쓰기 transactional.id가 설정되면 프로듀서의 모든 전송이 트랜잭션에 속해야 합니다
sendOffsetsToTransaction(offsets, groupMetadata) consume-transform-produce에서 입력 오프셋을 트랜잭션에 포함 4.x에는 ConsumerGroupMetadata를 받는 시그니처만 있습니다
commitTransaction() 커밋 파티션마다 커밋 마커가 기록됩니다
abortTransaction() 중단 이미 성공한 쓰기도 abort로 표시되어 read_committed 컨슈머에게 보이지 않습니다

공식 문서는 "모든 트랜잭션 API는 블로킹이며 실패 시 예외를 던진다"고 명시하고, send()에 콜백을 지정하거나 Future.get()을 호출할 필요가 없다고 합니다. 트랜잭션 중에 복구 불가한 오류가 발생하면 KafkaException이 던져지기 때문입니다.

공식 문서 기준 트랜잭션 프로듀서 패턴
Properties props = new Properties();
props.put("bootstrap.servers", "kafka-1:9092,kafka-2:9092,kafka-3:9092");
// 이 값이 세션을 가로지르는 복구의 열쇠입니다. 샤드 식별자에서 결정적으로 유도하세요.
props.put("transactional.id", "order-enricher-shard-3");
Producer<String, String> producer =
        new KafkaProducer<>(props, new StringSerializer(), new StringSerializer());

// 한 번만 호출합니다. 같은 transactional.id 의 진행 중 트랜잭션을 정리합니다.
producer.initTransactions();

try {
    producer.beginTransaction();
    for (int i = 0; i < 100; i++) {
        producer.send(new ProducerRecord<>("orders-enriched",
                Integer.toString(i), Integer.toString(i)));
    }
    producer.commitTransaction();
} catch (ProducerFencedException | OutOfOrderSequenceException | AuthorizationException e) {
    // 복구 불가 — 프로듀서를 닫고 종료하는 것이 유일한 선택입니다.
    producer.close();
} catch (KafkaException e) {
    // 그 밖의 예외는 트랜잭션을 중단하고 다시 시도합니다.
    // abort 를 호출해야 이미 성공한 쓰기가 abort 로 표시됩니다.
    producer.abortTransaction();
}
producer.close();

4.x의 표준화된 예외 분류

Apache Kafka 4.x에서 트랜잭션 프로듀서의 오류 처리가 표준화되었습니다. 공식 문서가 정의하는 여섯 개 범주입니다. 어느 것이 애플리케이션까지 올라오는지가 핵심입니다.

트랜잭션 프로듀서 예외 범주 (공식 문서 · Using Transactions)
범주 애플리케이션까지 올라오는가 필요한 조치
RetriableException아니요클라이언트가 자동 재시도합니다
RefreshRetriableException아니요클라이언트가 메타데이터를 갱신한 뒤 재시도합니다
AbortableException트랜잭션을 abort하고 컨슈머 위치를 되돌려 재처리합니다
ApplicationRecoverableException애플리케이션이 복구 전략을 구현해야 하며, 프로듀서 재시작을 포함해야 합니다
InvalidConfigurationException설정 문제. 프로듀서 재시작은 필요하지 않습니다
KafkaException위에 속하지 않는 일반 예외

트랜잭션 관련 설정

트랜잭션 설정 (Apache Kafka 4.3 기본값)
설정 위치 기본값 설명
transactional.id producer null 미설정이면 트랜잭션을 쓸 수 없습니다(멱등성만). 설정하면 멱등성이 자동 활성됩니다
transaction.timeout.ms producer 60000
(1분)
코디네이터가 선제적으로 abort하기까지 트랜잭션이 열려 있을 수 있는 최대 시간. 첫 파티션이 추가된 시점부터 셉니다
transaction.max.timeout.ms broker 900000
(15분)
클라이언트 요청이 이보다 크면 InitProducerIdRequest가 오류를 반환합니다(InvalidTxnTimeoutException)
transaction.state.log.replication.factor broker 3 __transaction_state의 복제 계수. 클러스터가 이를 충족하지 못하면 내부 토픽 생성이 실패합니다
transaction.state.log.min.isr broker 2 __transaction_state 쓰기에 필요한 최소 ISR
transactional.id.expiration.ms broker 604800000
(7일)
상태 갱신 없이 이 시간이 지나면 코디네이터가 transactional.id를 만료시킵니다. 진행 중 트랜잭션이 있으면 만료되지 않습니다
isolation.level consumer read_uncommitted 아래 별도 절에서 다룹니다

consume-transform-produce 패턴

Kafka 토픽을 읽어 처리하고 다른 Kafka 토픽에 쓰는 파이프라인이 EOS가 성립하는 정확한 범위입니다. 공식 문서는 그 원리를 이렇게 설명합니다 — 컨슈머의 위치가 내부 토픽의 메시지로 저장되므로, 출력 토픽에 쓰는 것과 같은 트랜잭션 안에서 오프셋을 Kafka에 쓸 수 있습니다. 트랜잭션이 중단되면 컨슈머의 저장된 위치는 이전 값으로 되돌아가고, 출력 토픽에 쓰인 데이터는 read_committed 컨슈머에게 보이지 않습니다.

consume-transform-produce — 오프셋 커밋이 트랜잭션 안에 들어갑니다 입력 토픽에서 읽어 변환한 뒤 출력 토픽으로 쓰는 패턴을 그린 그림입니다. KafkaConsumer 는 isolation.level=read_committed, enable.auto.commit=false 로 설정합니다. 변환 결과를 트랜잭션 프로듀서로 출력 토픽에 쓰고, 같은 트랜잭션 안에서 sendOffsetsToTransaction 으로 컨슈머의 오프셋을 __consumer_offsets 에 씁니다. 점선으로 표시한 트랜잭션 경계 안에 출력 레코드와 오프셋 커밋이 함께 들어 있는 것이 핵심입니다. 그래서 커밋하면 둘 다 반영되고, abort 하면 둘 다 무효가 되어 같은 레코드를 다시 처리합니다. 컨슈머의 commitSync 나 commitAsync 를 따로 호출하면 원자성이 깨지므로 호출하지 않습니다. consume-transform-produce — 출력과 오프셋을 한 트랜잭션으로 묶습니다 하나의 트랜잭션 (원자적) 입력 토픽 orders KafkaConsumer read_committed auto.commit=false 변환 처리 transform 트랜잭션 프로듀서 transactional.id 컨슈머 1:1 로 두기 출력 토픽 orders-enriched sendOffsets ToTransaction() __consumer _offsets 커밋하면 출력 레코드와 오프셋이 동시에 확정됩니다. abort 하면 둘 다 무효 → 같은 구간을 재처리. 하지 말 것 consumer.commitSync() 따로 커밋하면 원자성이 깨집니다. 오프셋은 프로듀서가 컨슈머 그룹을 대신해 씁니다 — 그래서 consumer.groupMetadata() 를 함께 넘깁니다. abort 후에는 오프셋을 자동으로 되감지 않습니다. 애플리케이션이 커밋된 오프셋을 다시 읽어 위치를 되돌려야 합니다.
consume-transform-produce — 입력 토픽의 오프셋 커밋이 출력 토픽 쓰기와 같은 트랜잭션에 포함되는 구조. 이 원자성이 Kafka 내부 EOS의 근거입니다.

공식 문서가 드는 세 가지 요건

프로듀서와 컨슈머를 직접 써서 exactly-once 처리를 만들 때 필요한 것입니다.

  1. 컨슈머는 파티션 할당을 통해 자신이 그 파티션을 처리하는 그룹 내 유일한 컨슈머임을 보장받습니다.
  2. 프로듀서는 트랜잭션을 사용해 자신이 생산하는 모든 레코드와 컨슈머를 대신해 갱신하는 오프셋을 원자적으로 처리합니다.
  3. 리밸런싱과의 조합을 올바르게 다루기 위해, 컨슈머 인스턴스마다 프로듀서 인스턴스를 하나씩 쓰는 것이 권장됩니다. 더 복잡하고 효율적인 방식도 가능하지만 복잡도가 올라갑니다.
consume-transform-produce — 완전한 루프
// ---- 컨슈머: 공식 문서가 요구하는 두 설정 ----
Properties cp = new Properties();
cp.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka-1:9092");
cp.put(ConsumerConfig.GROUP_ID_CONFIG, "order-enricher");
cp.put(ConsumerConfig.ISOLATION_LEVEL_CONFIG, "read_committed");   // 필수
cp.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");         // 필수
cp.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
cp.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());

// ---- 프로듀서: 컨슈머 인스턴스마다 하나 ----
Properties pp = new Properties();
pp.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka-1:9092");
pp.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "order-enricher-shard-3");
pp.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
pp.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());

KafkaConsumer<String, String> consumer = new KafkaConsumer<>(cp);
KafkaProducer<String, String> producer = new KafkaProducer<>(pp);

producer.initTransactions();
consumer.subscribe(List.of("orders"));

while (running) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
    if (records.isEmpty()) continue;

    try {
        producer.beginTransaction();

        Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
        for (ConsumerRecord<String, String> record : records) {
            producer.send(new ProducerRecord<>("orders-enriched",
                    record.key(), enrich(record.value())));
            // 커밋할 오프셋은 "다음에 읽을 위치" = 현재 오프셋 + 1
            offsets.put(new TopicPartition(record.topic(), record.partition()),
                    new OffsetAndMetadata(record.offset() + 1));
        }

        // 오프셋 커밋을 트랜잭션에 포함시킵니다.
        // groupMetadata() 를 넘겨야 브로커가 제너레이션까지 검증해 좀비를 펜싱합니다.
        producer.sendOffsetsToTransaction(offsets, consumer.groupMetadata());

        producer.commitTransaction();

    } catch (ProducerFencedException | OutOfOrderSequenceException | AuthorizationException e) {
        // 복구 불가 — 종료합니다.
        producer.close();
        consumer.close();
        throw e;
    } catch (KafkaException e) {
        producer.abortTransaction();
        // ★ 중요: abort 후 컨슈머 위치를 커밋된 오프셋으로 되돌려야 재처리됩니다.
        //   자동으로 되감기지 않습니다.
        resetToLastCommitted(consumer, records.partitions());
    }
}

// 커밋된 오프셋을 다시 읽어 컨슈머 위치를 되돌립니다.
static void resetToLastCommitted(KafkaConsumer<?, ?> consumer,
                                 Set<TopicPartition> partitions) {
    Map<TopicPartition, OffsetAndMetadata> committed = consumer.committed(partitions);
    for (TopicPartition tp : partitions) {
        OffsetAndMetadata om = committed.get(tp);
        if (om != null) {
            consumer.seek(tp, om.offset());
        } else {
            consumer.seekToBeginning(List.of(tp));
        }
    }
}

Kafka Streams — processing.guarantee

공식 문서는 "Kafka에서 exactly-once 의미론을 얻는 가장 간단한 방법은 Kafka Streams를 쓰는 것"이라고 명시합니다. 위의 루프를 직접 쓰는 대신 설정 하나로 같은 결과를 얻습니다.

processing.guarantee (Kafka Streams)
상태요구사항
at_least_once기본값
exactly_once_v2현행 EOS브로커 2.5 이상
exactly_oncedeprecated (EOS alpha)브로커 0.11.0 이상
exactly_once_betadeprecated (v2와 동일)브로커 2.5 이상

exactly_once_v2를 켜면 공식 문서 기준으로 다음이 자동으로 바뀝니다.

그리고 기본적으로 최소 3대의 브로커가 필요합니다. 개발용으로는 transaction.state.log.replication.factortransaction.state.log.min.isr를 브로커 수에 맞춰 낮춰야 합니다. Streams 상세는 10장에서 다룹니다.

isolation.level 과 LSO

isolation.level의 두 값 (공식 문서 Consumer Configs)
poll()이 반환하는 것읽기 상한
read_uncommitted 기본값 모든 메시지. 중단된 트랜잭션의 메시지도 포함됩니다 high watermark
read_committed 커밋된 트랜잭션 메시지와 비트랜잭션 메시지만 LSO (Last Stable Offset)

비트랜잭션 메시지는 두 모드 모두에서 무조건 반환됩니다. 그리고 메시지는 항상 오프셋 순서로 반환됩니다 — 이 두 사실이 다음 절의 근거입니다.

LSO — Last Stable Offset

공식 문서의 정의입니다. LSO는 "가장 처음 열려 있는 트랜잭션의 오프셋보다 1 작은 값"입니다. read_committed 모드의 poll()LSO까지만 반환합니다.

여기서 실무적으로 가장 중요한 결론이 나옵니다. 진행 중인 트랜잭션에 속한 메시지 뒤에 오는 메시지는, 그 자체가 이미 커밋되었더라도 해당 트랜잭션이 완료될 때까지 보류됩니다. 메시지가 오프셋 순서로만 반환되므로 중간을 뛰어넘을 수 없기 때문입니다. 그 결과 공식 문서 표현대로 "진행 중인 트랜잭션이 있으면 read_committed 컨슈머는 high watermark까지 읽을 수 없습니다."

read_committed 와 LSO — 진행 중 트랜잭션이 뒤쪽 커밋 메시지까지 가립니다 파티션 로그의 오프셋 0부터 11까지를 12칸으로 그린 그림입니다. 오프셋 0과 1은 일반 메시지, 2와 3은 트랜잭션 T1 의 레코드, 4는 T1 의 커밋 마커입니다. 오프셋 5는 아직 진행 중인 트랜잭션 T2 의 첫 레코드이고, 6과 7은 트랜잭션 T3 의 레코드, 8은 T3 의 커밋 마커, 9와 11은 트랜잭션과 무관한 일반 메시지, 10은 T2 의 두 번째 레코드입니다. LSO 즉 last stable offset 은 진행 중인 가장 앞선 트랜잭션의 첫 오프셋인 5 입니다. isolation.level 이 기본값 read_uncommitted 인 컨슈머는 high watermark 까지 전부 읽습니다. read_committed 컨슈머는 LSO 보다 작은 오프셋만 반환하므로 0부터 4까지만 읽습니다. 그 결과 T3 가 이미 커밋을 끝낸 6, 7, 8 과 트랜잭션과 아무 상관 없는 9, 11 까지 보이지 않습니다. 이것이 read_committed 가 지연을 만드는 이유이며, 진행 중 트랜잭션이 길수록 지연이 커집니다. read_committed 와 LSO — 진행 중 트랜잭션 하나가 그 뒤 전부를 막습니다 리더 로그 orders-0 0 일반 보임 1 일반 보임 2 T1 커밋 3 T1 커밋 4 마커 T1 끝 5 T2 진행중 6 T3 커밋 7 T3 커밋 8 마커 T3 끝 9 일반 막힘 10 T2 진행중 11 일반 막힘 HW 12 LSO = 5 진행 중 T2 의 첫 오프셋 read_uncommitted (기본값) 0 ~ 11 전부 반환 — abort 된 레코드까지 보입니다 read_committed 0 ~ 4 만 반환 LSO 뒤 — 7건 전부 보류 오프셋 6·7·8 은 T3 가 이미 커밋을 끝낸 레코드이고, 9·11 은 트랜잭션과 무관한 일반 메시지입니다. 그런데도 오프셋 5의 T2 가 끝나지 않아 전부 보류됩니다. 긴 트랜잭션 = 컨슈머 지연이 되는 이유입니다. read_committed 로도 보임 진행 중 트랜잭션 T2 의 레코드 LSO 뒤라서 보이지 않음 isolation.level 기본값은 read_uncommitted 입니다. read_committed 에서 seekToEnd() 는 HW 가 아니라 LSO 를 돌려줍니다.
read_committed와 LSO — 진행 중인 트랜잭션 하나 때문에 그 뒤에 있는 이미 커밋된 메시지까지 보이지 않는 상황. LSO와 high watermark의 간격이 곧 가시성 지연입니다.

컨트롤 레코드와 seekToEnd

트랜잭션이 커밋 또는 중단되면 트랜잭션 마커 레코드가 각 파티션에 기록됩니다. read_committed 컨슈머는 이 마커를 보고 중단된 트랜잭션의 레코드를 걸러 냅니다. 이 마커는 애플리케이션에 반환되지 않습니다 — 하지만 오프셋 공간은 차지합니다.

공식 문서가 명시하는 또 하나의 차이입니다. read_committed 모드에서 seekToEnd()는 high watermark가 아니라 LSO를 반환합니다. "끝으로 이동"의 의미가 모드에 따라 달라집니다.

EOS의 경계 — 이 장에서 가장 중요한 절

여기까지의 모든 장치는 Kafka 토픽을 읽어 Kafka 토픽에 쓰는 경계 안에서만 성립합니다. 공식 문서의 결론 문장을 그대로 인용하면 이렇습니다.

EOS 의 경계 — Kafka 내부는 exactly-once, 외부 시스템은 아닙니다 exactly-once semantics 가 성립하는 범위와 성립하지 않는 범위를 굵은 경계선으로 나눈 그림입니다. 경계선 위쪽은 Kafka 내부입니다. 입력 토픽에서 읽어 처리한 뒤 출력 토픽에 쓰고 컨슈머 오프셋을 __consumer_offsets 에 쓰는 것까지가 하나의 트랜잭션이므로 원자적으로 확정됩니다. 경계선 아래쪽은 외부 시스템입니다. 애플리케이션이 처리 중에 호출하는 외부 데이터베이스 INSERT 나 UPDATE, 결제 API 의 HTTP POST, 이메일이나 SMS 발송은 Kafka 트랜잭션에 참여할 수 없습니다. 경계를 통과하는 화살표에 X 표시를 한 것은 그 지점에서 보장이 끊긴다는 뜻입니다. 따라서 트랜잭션이 abort 되거나 재처리가 일어나면 Kafka 쪽 결과는 되돌아가지만 이미 실행된 외부 작업은 되돌아가지 않아 중복이 남습니다. EOS 를 켜면 데이터베이스 중복까지 막힌다는 것은 오해입니다. 해결책은 컨슈머 또는 싱크 쪽의 멱등성입니다. 업서트, 유니크 제약, 처리 기록 테이블로 이미 처리한 레코드를 걸러내야 합니다. EOS 의 경계 — 어디까지 Kafka 가 보장하는가 EOS 성립 — Kafka 토픽 → Kafka 토픽 (트랜잭션 원자성) 입력 토픽 orders 애플리케이션 (Streams 또는 직접 구현) read_committed · auto.commit=false transactional.id 설정 Streams: exactly_once_v2 출력 토픽 — 커밋 마커로 확정 __consumer_offsets 같은 트랜잭션 안에서 커밋 EOS 경계 — Kafka 의 보장은 여기서 끊깁니다 처리 중 외부 호출 — 트랜잭션에 참여하지 못합니다 EOS 성립 안 함 — 외부 시스템 (커밋도 롤백도 Kafka 와 무관) 외부 DB — INSERT / UPDATE 결제 API — HTTP POST 이메일 · SMS 발송 “EOS 를 켰으니 DB 중복도 막힌다”는 오해입니다. abort 나 재처리가 일어나면 Kafka 쪽은 되돌아가지만, 이미 실행된 외부 작업은 그대로 남습니다. 해결: 경계 밖에서는 컨슈머(싱크) 스스로 멱등성을 확보해야 합니다 업서트(UPSERT) · 유니크 제약 · 처리 기록 테이블(메시지 키 + 오프셋)로 재처리를 걸러냅니다. 공식 문서: 외부 시스템에 쓸 때의 한계는 컨슈머의 위치와 실제 저장된 출력을 함께 맞춰야 한다는 점입니다. 다른 목적지 시스템의 exactly-once 는 그 시스템의 협조가 필요합니다.
EOS의 경계 — Kafka 토픽 사이에서는 트랜잭션이 원자성을 보장하지만, 외부 DB나 결제 API 호출은 그 트랜잭션에 포함되지 않습니다. 경계 밖에서는 컨슈머 쪽 멱등성을 직접 구현해야 합니다.

왜 외부 시스템에는 성립하지 않는가

공식 문서가 설명하는 이유는 명확합니다. 외부 시스템에 쓸 때의 제약은 "컨슈머의 위치와 실제로 저장된 출력을 조율해야 하는 필요"에 있습니다. 고전적 해법은 컨슈머 위치 저장과 출력 저장 사이의 2단계 커밋(two-phase commit)인데, 공식 문서는 "컨슈머가 쓰려는 많은 출력 시스템이 2단계 커밋을 지원하지 않는다"고 지적합니다.

결국 Kafka 트랜잭션은 Kafka 안의 파티션들과 __consumer_offsets에 대해서만 원자성을 만듭니다. HTTP 요청 하나, SQL INSERT 하나는 그 원자성 안에 들어갈 방법이 없습니다. 트랜잭션이 abort되어 Kafka 쪽이 전부 되돌아가도 이미 나간 HTTP 요청은 되돌아오지 않습니다.

EOS가 성립하는 범위와 성립하지 않는 범위
시나리오EOS필요한 조치
Kafka 토픽 → 처리 → Kafka 토픽 성립 트랜잭션 프로듀서 + sendOffsetsToTransaction + read_committed. 또는 Kafka Streams exactly_once_v2
Kafka 토픽 → 처리 → 외부 RDBMS 성립하지 않음 DB 쪽 멱등 upsert(유니크 키) 또는 오프셋을 DB에 함께 저장해 트랜잭션으로 묶기
Kafka 토픽 → 처리 → 외부 HTTP/결제 API 성립하지 않음 API의 idempotency key 지원에 의존. 없으면 처리 이력 테이블로 직접 중복 차단
Kafka 토픽 → 이메일·SMS 발송 성립하지 않음 되돌릴 수 없는 부수효과입니다. 발송 이력을 먼저 확정한 뒤 발송하는 순서로 설계
Kafka Connect sink → 외부 시스템 커넥터에 따라 다름 공식 문서 예: HDFS 커넥터처럼 데이터와 오프셋을 같은 곳에 함께 저장하는 커넥터는 원자성을 얻습니다(9장)

그래서 컨슈머 멱등성을 직접 만듭니다

공식 문서가 제시하는 일반적 해법은 "컨슈머가 자신의 오프셋을 출력과 같은 곳에 저장하게 하는 것"입니다. 2단계 커밋보다 단순하고 범용적이기 때문입니다. 실무에서는 다음 세 가지 형태로 나타납니다.

외부 시스템에 대한 멱등성 구현 패턴
패턴방법쓰는 곳
① 멱등 upsert 메시지의 비즈니스 키를 유니크 제약으로 걸고 UPSERT/MERGE로 씁니다. 같은 메시지를 두 번 받아도 결과가 같습니다 상태 갱신형(재고, 프로필, 잔액). 공식 문서가 언급하는 "기본 키가 있어 갱신이 멱등적인" 경우
② 오프셋 동거 처리 결과와 오프셋을 같은 DB 트랜잭션에 저장합니다. 컨슈머는 assign() + seek()로 DB의 오프셋에서 시작합니다 진짜 EOS가 필요하고 DB 트랜잭션을 쓸 수 있는 경우. Kafka에 오프셋을 커밋하지 않습니다
③ 처리 이력 테이블 (topic, partition, offset) 또는 메시지 ID를 유니크 키로 기록하고, 이미 있으면 건너뜁니다. 이력 기록과 부수효과를 한 트랜잭션에 묶습니다 부수효과가 멱등적이지 않은 경우(발송, 외부 API 호출)
패턴 ② 오프셋 동거 — 결과와 오프셋을 같은 DB 트랜잭션에
// Kafka 에 오프셋을 커밋하지 않습니다. DB 가 진실의 원천입니다.
consumer.subscribe(List.of("orders"), new ConsumerRebalanceListener() {
    @Override public void onPartitionsRevoked(Collection<TopicPartition> partitions) { }
    @Override public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
        for (TopicPartition tp : partitions) {
            // DB 에 저장된 오프셋에서 재개합니다.
            Long offset = dao.loadOffset(tp.topic(), tp.partition());
            if (offset != null) consumer.seek(tp, offset);
            else consumer.seekToBeginning(List.of(tp));
        }
    }
});

while (running) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));

    try (Connection conn = dataSource.getConnection()) {
        conn.setAutoCommit(false);
        for (ConsumerRecord<String, String> record : records) {
            dao.upsertOrder(conn, record.value());
            // 결과와 오프셋이 같은 트랜잭션에 들어갑니다 → 둘 다 저장되거나 둘 다 안 됩니다.
            dao.saveOffset(conn, record.topic(), record.partition(), record.offset() + 1);
        }
        conn.commit();
    } catch (SQLException e) {
        // 롤백되었으므로 다음 루프에서 같은 오프셋을 다시 읽습니다.
        log.error("DB 트랜잭션 실패 — 재처리됩니다", e);
    }
}
패턴 ③ 처리 이력 테이블 — 되돌릴 수 없는 부수효과 앞에서
-- 유니크 제약이 중복 처리를 물리적으로 차단합니다.
CREATE TABLE processed_records (
    topic       VARCHAR(255) NOT NULL,
    partition_id INT         NOT NULL,
    record_offset BIGINT     NOT NULL,
    processed_at TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (topic, partition_id, record_offset)
);

-- 부수효과보다 먼저 이력을 확정합니다.
-- INSERT 가 유니크 위반으로 실패하면 이미 처리한 것이므로 건너뜁니다.
INSERT INTO processed_records (topic, partition_id, record_offset)
VALUES ('payments', 3, 981274);

흔한 오해

시험 포인트 요약

확인 문제

EOS의 경계, isolation.level이 어느 쪽 설정인지, 멱등성과 트랜잭션의 구분이 집중적으로 나옵니다.

공식 문서 출처

이 장의 정의·설정 기본값·API 시그니처는 모두 아래에서 확인했습니다 (Apache Kafka 4.3 문서 기준).