[Kafka] Exactly-once 트랜잭션 설정: 멱등 프로듀서, transactional.id, read_committed와 한계
![[Kafka] Exactly-once 트랜잭션 설정: 멱등 프로듀서, transactional.id, read_committed와 한계](https://blog.kakaocdn.net/dna/bN1N5p/dJMcafmAOJi/AAAAAAAAAAAAAAAAAAAAAPOlg1ljOuljlVfPGVG9IMVSz726qwbVCUUR4NbY5bMY/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1782831599&allow_ip=&allow_referer=&signature=kvC9rCFF5swsLasKd4LHqcsEfIw%3D)
[Kafka] Exactly-once 트랜잭션 설정: 멱등 프로듀서, transactional.id, read_committed와 한계
카프카에서 기본 전달 보장은 "최소 한 번(at-least-once)"이라 중복이 생겨요. 그래서 보통은 멱등성으로 중복을 흡수하죠. 그런데 카프카 안에서 메시지를 받아 가공해 다시 내보내는 흐름이라면, 중복 자체를 없애는 "정확히 한 번(exactly-once)"이 가능해요.
exactly-once가 무엇을 보장하고 무엇은 보장하지 않는지에서 출발해, 멱등 프로듀서, 카프카 트랜잭션, read_committed, 스프링에서 쓰는 법, 비용과 한계, 자주 만나는 문제 순서로 풀어요. 중복을 멱등성으로 막는 방법은 수동 커밋과 멱등성 글에서 다뤘으니, 여기서는 그 상위 주제를 봐요.
01. exactly-once는 "카프카 안에서" 정확히 한 번이에요
먼저 오해를 하나 풀어야 해요. exactly-once(EOS, Exactly-Once Semantics)는 마법이 아니에요. 카프카 토픽에서 읽어 카프카 토픽으로 쓰는 흐름에서, 그 과정이 원자적으로 일어나도록 보장하는 거예요.

그래서 카프카에서 읽어 외부 DB에 쓰는 흐름까지 자동으로 정확히 한 번이 되는 건 아니에요. DB 쓰기는 카프카 트랜잭션 밖이거든요. 외부 시스템까지의 정확히 한 번은 여전히 멱등성이나 아웃박스 패턴 같은 별도 설계가 필요해요. 이 경계를 알고 시작하는 게 중요해요. EOS가 적용되는 곳은 "consume → process → produce" 패턴이에요.
02. 멱등 프로듀서, 프로듀서 쪽 중복부터 막기
중복은 컨슈머에서만 생기는 게 아니에요. 프로듀서가 메시지를 보내고 응답(ack)을 못 받아 재시도하면, 사실은 잘 전송됐는데 또 보내서 중복이 생길 수 있어요. 네트워크가 순단됐을 때 흔해요.
프로듀서 전송 코드 구현 자체는 KafkaTemplate을 이용한 Producer 구현(2021)에 있어요. 이 절은 그 위에 얹는 보장 설정이에요.

멱등 프로듀서를 켜면 이걸 막아요. 프로듀서마다 고유 ID(PID)를 받고, 보내는 메시지마다 시퀀스 번호를 붙여요. 브로커는 파티션별로 마지막 시퀀스 번호를 기억하다가, 같은 번호가 또 오면 중복으로 보고 버려요. 그래서 재시도로 인한 중복이 사라져요.
spring:
kafka:
producer:
acks: all
properties:
enable.idempotence: true
멱등 프로듀서는 acks=all(모든 in-sync 복제본이 받았는지 확인)과 함께 동작해요. 최신 카프카(3.0+)에서는 enable.idempotence가 기본으로 켜져 있는 경우가 많지만, 명시해두면 의도가 분명해요. 트랜잭션을 쓰려면 멱등 프로듀서가 전제예요.
03. 카프카 트랜잭션, 읽고-처리하고-쓰기를 한 묶음으로
멱등 프로듀서가 "한 토픽으로의 중복 전송"을 막는다면, 트랜잭션은 "여러 토픽에 대한 쓰기 + 입력 오프셋 커밋"을 하나의 원자적 단위로 묶어요. 전부 커밋되거나 전부 취소되거나, 둘 중 하나예요.

핵심은 transactional.id를 지정하는 거예요. 이걸 주면 프로듀서가 트랜잭션을 시작하고 커밋할 수 있게 돼요. raw API로 보면 흐름이 이래요.
producer.initTransactions();
try {
producer.beginTransaction();
// 가공한 메시지를 출력 토픽으로 send
producer.send(new ProducerRecord<>("orders.processed", processed));
// 방금 읽은 메시지의 컨슈머 오프셋도 같은 트랜잭션에 포함
producer.sendOffsetsToTransaction(offsets, consumerGroupMetadata);
producer.commitTransaction();
} catch (Exception e) {
producer.abortTransaction(); // 하나라도 실패하면 전부 취소
}
여기서 중요한 게, 출력 메시지 쓰기와 입력 오프셋 커밋이 같은 트랜잭션으로 묶인다는 거예요. 그래서 "처리는 했는데 오프셋만 커밋 안 됨" 같은 어긋남이 사라져요. 트랜잭션이 커밋되면 출력도 나가고 오프셋도 올라가고, 취소되면 둘 다 없던 일이 돼요. 이게 read-process-write 패턴이 정확히 한 번이 되는 원리예요.
transactional.id에는 함정이 있어요. 이 ID는 좀비 펜싱용이에요 — 같은 ID로 새 프로듀서가 뜨면 브로커가 epoch를 올려서, 같은 ID를 들고 있던 옛 인스턴스(좀비)의 커밋을 거부해요. 그래서 ID는 재시작 전후로는 같아야 하고, 동시에 떠 있는 인스턴스끼리는 달라야 해요. 인스턴스마다 무작위 ID를 쓰면 펜싱이 무력화되고, 전부 같은 ID를 쓰면 서로를 좀비로 몰아 펜싱 에러(ProducerFencedException)가 터져요.
04. read_committed, 커밋된 것만 읽기
프로듀서가 트랜잭션으로 써도, 그걸 읽는 컨슈머가 아무거나 읽으면 소용없어요. 그래서 컨슈머는 isolation.level을 read_committed로 둬요.
spring:
kafka:
consumer:
isolation-level: read_committed

read_committed면 커밋된 트랜잭션의 메시지만 읽어요. 아직 진행 중이거나 취소된(abort) 트랜잭션의 메시지는 컨슈머에게 안 보여요. 정확히는, 컨슈머가 LSO(Last Stable Offset)까지만 읽어요. LSO는 "그 앞의 모든 트랜잭션이 커밋 또는 취소로 확정된 지점"이에요. 그래서 아직 열려 있는 트랜잭션 뒤의 메시지는 그게 끝날 때까지 안 보여요.
기본값이 함정이에요. 컨슈머isolation.level의 기본은read_uncommitted라서, 프로듀서가 트랜잭션을 정성껏 써도 컨슈머가 이걸 안 바꾸면 취소된 메시지까지 다 읽어버려요. EOS는 프로듀서 설정만으로 완성되지 않아요 — 읽는 쪽이 안 맞추면 효과가 없어요.
05. 스프링에서 쓰는 법
스프링 카프카는 트랜잭션을 추상화해줘요. transactional.id(스프링에서는 transaction-id-prefix)를 설정하면 KafkaTransactionManager가 만들어지고, 우리가 raw API를 직접 안 불러도 돼요.
spring:
kafka:
producer:
transaction-id-prefix: tx-order-
그러면 리스너 컨테이너가 트랜잭션을 인식해서, 출력 send와 입력 오프셋 커밋을 자동으로 한 트랜잭션에 묶어줘요. sendOffsetsToTransaction을 직접 부를 필요가 없어요.
@KafkaListener(topics = "orders")
public void consume(Order order) {
var processed = process(order);
kafkaTemplate.send("orders.processed", processed);
// 메서드가 정상 종료되면 출력 send + 입력 오프셋 커밋이 한 트랜잭션으로 커밋됨
}
리스너 밖에서 트랜잭션을 직접 묶고 싶으면 executeInTransaction을 써요.
// transaction-id-prefix가 설정돼 있어야 동작해요
kafkaTemplate.executeInTransaction(ops -> {
ops.send("orders.processed", processed);
ops.send("orders.audit", audit);
return null; // 둘 다 한 트랜잭션 → 원자적
});
06. 공짜가 아니에요
EOS는 강력하지만 비용이 있어요. 트랜잭션 시작·커밋·중단 과정이 추가되니 처리량이 떨어지고, 지연도 늘어요. 컨슈머도 read_committed로 LSO까지만 읽으니, 열린 트랜잭션이 있으면 그만큼 읽기가 지연돼요. 행잉 트랜잭션 하나가 LSO를 막으면 그 파티션의 뒤 메시지 전체가 대기하고요.
트랜잭션 단위 크기도 트레이드오프예요. 너무 작으면(메시지 하나당 트랜잭션) 오버헤드가 커지고, 너무 크면 실패 시 되돌리는 양이 많아지고 LSO 지연도 길어져요. 보통은 한 poll 묶음 단위로 묶어요.
언제 쓰고 언제 안 쓰나요
그래서 모든 컨슈머에 EOS를 깔 필요는 없어요. "카프카에서 읽어 카프카로 쓰는데 중복이 절대 안 되는" 흐름(예: 정산, 집계 파이프라인)에만 쓰고, 그 외에는 at-least-once + 멱등성 조합이 더 단순하고 빨라요. 외부 DB까지 엮이면 EOS만으로는 부족하니 멱등성을 같이 챙겨야 하고요.
07. 자주 만나는 문제
트랜잭션을 켰는데 컨슈머가 중복을 봐요
컨슈머의 isolation.level이 기본값 read_uncommitted인 거예요. read_committed로 바꿔야 커밋된 것만 읽어요. 프로듀서만 트랜잭션을 써도 컨슈머가 안 맞추면 효과가 없어요.
처리량이 확 떨어졌어요
트랜잭션 오버헤드예요. 트랜잭션 단위가 너무 잘면 더 심해요. 한 poll 묶음 단위로 묶고, 정말 EOS가 필요한 흐름인지 다시 따져봐요. 단순 중복 흡수면 멱등성이 더 가벼워요.
외부 DB 저장이 두 번 돼요
EOS는 카프카 안에서만 정확히 한 번이에요. DB 쓰기는 트랜잭션 밖이라 보장 안 돼요. DB 쪽은 멱등성(upsert·unique)이나 아웃박스 패턴으로 따로 막아야 해요.
정리
exactly-once는 카프카 토픽 사이의 read-process-write를 원자적으로 만드는 보장이에요. 멱등 프로듀서로 프로듀서 중복을 막고, 트랜잭션으로 출력 쓰기와 입력 오프셋 커밋을 묶고, 컨슈머는 read_committed로 커밋된 것만 읽어요. 단 처리량 비용이 있으니 꼭 필요한 흐름에만 쓰고, 외부 시스템 연동은 여전히 멱등성으로 보완해야 해요.
도입 전에 스스로 물어보면 좋아요. 이 흐름이 정말 카프카 안에서 끝나는 read-process-write인가, 아니면 외부 DB까지 엮여 멱등성이 더 필요한가? 답이 후자라면 at-least-once + 멱등성 조합이 더 단순하고 빨라요. 시리즈의 @KafkaListener 설정, 수동 커밋과 멱등성, 리밸런싱, 에러 핸들링과 DLQ를 함께 보면 됩니다.
출처: Spring for Apache Kafka — Transactions · Kafka — Message Delivery Semantics · Kafka — enable.idempotence
'백엔드' 카테고리의 다른 글
📚 같이 보면 좋은
"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."