Kafka 트랜잭션으로 Exactly-Once — 멱등 프로듀서·트랜잭션·read_committed
Kafka에서 "메시지를 정확히 한 번" 처리하기는 쉽지 않습니다. 재시도하면 중복(at-least-once), 안 하면 유실(at-most-once) 위험이 있죠(전달 보장 정리 참고). Kafka는 이 사이를 메우려고 exactly-once(EOS) 를 제공하는데, 그 핵심은 세 가지 조합입니다 — 멱등 프로듀서 · 트랜잭션 · read_committed 컨슈머. 특히 "읽고 → 처리하고 → 결과를 쓰고 → 어디까지 읽었는지 기록"을 하나의 원자적 트랜잭션으로 묶는 read-process-write 패턴이 EOS의 중심입니다. 이 글은 Kafka 네이티브 트랜잭션으로 그걸 어떻게 달성하는지 정리합니다.
두 개의 기둥 — 멱등 프로듀서 + 트랜잭션
EOS는 두 기능이 함께 받쳐줍니다.
① 멱등 프로듀서(idempotent producer) — 프로듀서가 재시도하다 같은 메시지를 중복 전송하는 걸 막습니다. 브로커가 프로듀서마다 ID(PID)와 파티션별 시퀀스 번호를 보고, 이미 받은 번호면 버립니다. enable.idempotence=true(트랜잭션을 활성화하면 자동), acks=all이 전제입니다. 다만 이건 "한 프로듀서의 전송 재시도 중복"만 막습니다 — 여러 파티션·오프셋을 아우르는 원자성은 트랜잭션의 몫입니다.
② 트랜잭션(transactions) — 여러 파티션에 대한 쓰기 + 컨슈머 오프셋 커밋을 전부 성공 아니면 전부 취소로 묶습니다. 이걸 활성화하는 스위치가 transactional.id입니다(뒤의 좀비 펜싱에서 다시 봅니다).
read-process-write — EOS의 핵심 패턴
가장 흔한 EOS 시나리오는 "토픽 A를 읽어 가공해 토픽 B로 보내는" 처리입니다. 여기서 문제는 두 가지 상태가 따로 관리된다는 것입니다 — (1) 결과를 B에 썼는가, (2) A를 어디까지 읽었다고 커밋했는가. 이 둘이 맞지 않으면 중복 또는 유실이 납니다.
해법은 "결과 쓰기"와 "읽은 위치(오프셋) 커밋"을 같은 트랜잭션 안에 넣는 것입니다. 오프셋 커밋을 컨슈머가 따로 하지 않고, 프로듀서의 sendOffsetsToTransaction으로 트랜잭션에 포함시킵니다.
PlantUML 코드
@startuml
start
:토픽 A consume;
:처리 (process);
fork
:토픽 B로 결과 전송;
fork again
:읽은 오프셋을 트랜잭션에 추가;
end fork
if (commit / abort) then (commit)
:결과·오프셋 함께 확정;
else (abort)
:둘 다 취소 → 재처리;
endif
stop
@enduml
commit되면 결과와 오프셋이 함께 확정되고, 중간에 실패해 abort되면 둘 다 없던 일이 되어 그 배치를 다시 처리합니다. 그래서 "한 번 처리한 효과"가 정확히 한 번만 남습니다.
코드 — Raw Producer API
트랜잭션 프로듀서의 흐름은 정형화돼 있습니다.
// 설정: transactional.id 를 주면 트랜잭션 활성화(+ 멱등성 자동)
props.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "order-processor-1");
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
props.put(ProducerConfig.ACKS_CONFIG, "all");
producer.initTransactions(); // 앱 시작 시 1회
while (true) {
var records = consumer.poll(Duration.ofMillis(200));
producer.beginTransaction();
try {
for (var rec : records) {
producer.send(new ProducerRecord<>("B", transform(rec.value())));
}
// 읽은 위치(오프셋)를 '이 트랜잭션'에 포함 — 컨슈머가 따로 커밋하지 않는다
producer.sendOffsetsToTransaction(offsetsOf(records), consumer.groupMetadata());
producer.commitTransaction(); // 결과 + 오프셋 원자적 확정
} catch (Exception e) {
producer.abortTransaction(); // 실패 시 전부 취소 → 다음 poll에서 재처리
}
}
포인트는 sendOffsetsToTransaction — 오프셋 커밋을 트랜잭션에 넣어야 "처리 결과"와 "읽은 위치"가 하나로 묶입니다. 그래서 컨슈머는 자동 커밋을 꺼야 합니다(enable.auto.commit=false).
코드 — Spring Kafka
Spring에선 설정 몇 줄과 애노테이션으로 상당 부분이 자동화됩니다.
spring:
kafka:
producer:
transaction-id-prefix: tx- # 이걸 주면 트랜잭션 + KafkaTransactionManager 자동 구성
acks: all
consumer:
isolation-level: read_committed # 커밋된 메시지만 읽기(아래 설명)
enable-auto-commit: false
리스너 컨테이너에 KafkaTransactionManager가 물리면, 리스너 안에서 보낸 메시지와 그 레코드의 오프셋이 한 트랜잭션으로 묶여 자동 커밋됩니다.
@KafkaListener(topics = "A")
public void onMessage(ConsumerRecord<String, String> rec) {
// 이 안의 send 와 이 레코드의 오프셋이 하나의 트랜잭션으로 처리된다
kafkaTemplate.send("B", transform(rec.value()));
// 예외가 나면 트랜잭션 abort → 오프셋 미커밋 → 재처리
}
컨슈머가 read_committed가 아니면 소용없다
트랜잭션으로 아무리 잘 써도, 다운스트림 컨슈머가 그걸 존중하지 않으면 의미가 없습니다. 컨슈머의 isolation.level이 기본값 read_uncommitted이면 아직 커밋 안 됐거나 abort된 메시지까지 읽어버립니다.
EOS 파이프라인의 모든 컨슈머는 isolation.level=read_committed여야 합니다. 이러면 커밋된 메시지만 보이고, abort된 것은 건너뜁니다. 브로커는 트랜잭션 경계에 커밋/취소 마커를 남기고, 컨슈머는 아직 확정 안 된 지점(LSO, last stable offset) 이후는 읽지 않습니다.
좀비 펜싱 — transactional.id의 진짜 역할
분산 환경에선 "종료된 줄 알았던 옛 프로듀서(좀비)"가 되살아나 중복을 쓸 수 있습니다. transactional.id는 트랜잭션을 활성화하는 것 외에 이 문제를 막는 핵심입니다.
같은 transactional.id로 새 프로듀서가 트랜잭션을 시작하면 브로커가 epoch를 올리고, 이전 epoch(좀비)의 쓰기·커밋을 거절합니다. 그래서 transactional.id는 논리적 프로듀서마다 고정·유일해야 합니다(재시작해도 같은 값). read-process-write에서는 보통 입력 파티션 기준으로 안정적으로 배정합니다.
경계 — EOS는 "Kafka 안에서"만이다
가장 자주 오해하는 지점입니다. Kafka 트랜잭션의 exactly-once는 Kafka 토픽 읽기·쓰기와 오프셋 커밋 사이에서 성립합니다. 처리 중간에 외부 DB에 쓰거나 외부 API를 호출하면, 그건 Kafka 트랜잭션이 되돌려 주지 않습니다. "Kafka로 보내면서 DB에도 확실히 한 번"이 필요하면 트랜잭션만으로는 안 되고, Outbox 패턴 같은 방식으로 DB 트랜잭션과 발행을 엮어야 합니다.
Kafka Streams는 자동으로 해준다
직접 프로듀서/컨슈머를 다루지 않고 Kafka Streams를 쓴다면, 설정 하나면 됩니다.
processing.guarantee=exactly_once_v2
Streams가 트랜잭션·오프셋·read_committed를 알아서 처리합니다. exactly_once_v2(KIP-447)는 인스턴스당 프로듀서 하나로 여러 파티션을 처리할 수 있어, 예전 방식보다 확장성이 좋습니다(브로커 2.5+).
정리
- Kafka EOS = 멱등 프로듀서(전송 중복 방지) + 트랜잭션(원자적 쓰기·오프셋) +
read_committed(커밋된 것만 읽기). - 핵심 패턴은 read-process-write: 결과 쓰기와 오프셋 커밋을
sendOffsetsToTransaction으로 한 트랜잭션에. transactional.id는 트랜잭션 활성화 + 좀비 펜싱(epoch로 옛 프로듀서 차단). 논리 프로듀서마다 고정·유일하게.- 경계: Kafka 내부에서만 EOS. 외부 DB까지 필요하면 Outbox.
- Kafka Streams면
processing.guarantee=exactly_once_v2한 줄.
정확히 한 번의 진짜 의미는 "메시지가 딱 한 번 전달된다"가 아니라 "처리 결과가 정확히 한 번 반영된다"입니다. Kafka 트랜잭션은 그 결과 반영과 읽은 위치를 하나로 묶어 그 보장을 만들어 냅니다.
용어 한 줄 정리
| 용어 | 쉬운 뜻 |
|---|---|
| 멱등 프로듀서 | 재시도로 인한 전송 중복을 브로커가 걸러 줌 |
| transactional.id | 트랜잭션 활성화 + 좀비(옛 프로듀서) 차단 키 |
| sendOffsetsToTransaction | 읽은 오프셋 커밋을 트랜잭션에 포함 |
| read_committed | 커밋된 메시지만 읽는 컨슈머 설정 |
| 트랜잭션 마커 | 파티션에 남는 커밋/취소 경계 표시 |
| 좀비 펜싱 | epoch로 되살아난 옛 프로듀서의 쓰기를 거절 |
