Kafka Shallow Dive - Exactly-once와 Transactions
Published:
“Kafka에서 exactly-once가 가능하냐”는 말이 오랫동안 논쟁거리였다. 결론부터: 특정 조건에서는 가능하다. 그 조건이 뭔지, 왜 어려운지, 트랜잭션이 어떻게 해결하는지 정리해봄.
전달 보장 3가지
| 보장 | 의미 | 문제 |
|---|---|---|
| At-most-once | 최대 한 번 | 유실 가능 |
| At-least-once | 최소 한 번 | 중복 가능 |
| Exactly-once | 정확히 한 번 | 구현 복잡 |
실전에서 가장 흔한 조합은 at-least-once + 멱등 처리. consumer 쪽에서 이벤트 ID로 중복을 거르면 “결과적 exactly-once”가 됨. 다만 멱등 설계가 어려운 경우(예: 여러 topic에 동시에 쓰는 파이프라인)엔 진짜 Kafka 트랜잭션이 필요하다.
왜 어려운가
“한 번만 처리”가 어려운 이유:
1) Producer 쪽 중복:
Producer → broker 전송 → 네트워크 타임아웃 → 재시도
(실제론 처음 것도 기록됨 → 2번 기록)
2) Consumer 쪽 중복:
Consumer가 메시지 처리 → commit 직전 크래시 → 재시작 시 같은 메시지 다시 소비
3) 파이프라인 중복 (read-process-write):
Consumer가 A에서 읽음 → 처리 → B로 쓰기 → A의 offset commit
세 동작 중 하나라도 실패하면 불일치
Kafka의 해결책은 두 겹:
- Idempotent producer (Producer 편) — producer 재시도 중복 제거
- Transactions — 여러 topic에 걸친 원자성 + offset commit까지 묶기
Idempotent Producer 복습
enable.idempotence=true. Producer ID + 시퀀스 번호로 단일 파티션 내 중복 제거.
한계:
- 단일 producer 세션에서만 (재시작 시 PID 새로 부여)
- 단일 파티션 기준
- 여러 topic에 걸친 원자성은 못 함
→ 이 한계를 넘기 위해 트랜잭션.
Transactions
여러 파티션/토픽에 걸친 쓰기를 원자적으로 커밋하거나 abort할 수 있음.
props.put("enable.idempotence", "true");
props.put("transactional.id", "payment-tx-1"); // 핵심: stable한 id
Producer<String,String> p = new KafkaProducer<>(props);
p.initTransactions(); // 처음 한 번
try {
p.beginTransaction();
p.send(new ProducerRecord<>("debit", ...));
p.send(new ProducerRecord<>("credit", ...));
p.sendOffsetsToTransaction(offsets, "my-consumer-group"); // 소비 offset도 tx에
p.commitTransaction();
} catch (Exception e) {
p.abortTransaction();
}
핵심 포인트:
transactional.id는 persistent해야 함 (재시작해도 같은 id 유지). producer 장애 복구의 키- 내부적으로
__transaction_state라는 내부 topic에 상태 저장 - Transaction coordinator 브로커가 2PC 비슷한 프로토콜로 원자성 보장
read_committed
Consumer에서 isolation.level=read_committed 를 설정하면 aborted transaction의 메시지를 걸러내고 committed만 전달.
isolation.level=read_committed # 기본은 read_uncommitted
read_uncommitted— 모든 메시지 보임 (기본)read_committed— commit 완료된 트랜잭션 메시지만
진짜 exactly-once를 원하면 consumer도 read_committed 로 맞춰야 함.
Read-Process-Write 패턴
가장 흔한 EOS 유스케이스. 한 topic에서 읽어서 처리하고 다른 topic에 쓰는 파이프라인.
[input topic] → consumer → process → producer → [output topic]
│ │
└──── offset commit ────────────┘
(같은 트랜잭션 안에)
여기서 3개가 한 트랜잭션으로 묶여야 EOS:
- output topic에 쓰기
- input topic의 offset commit
- 둘 중 하나 실패 시 전부 롤백
producer.sendOffsetsToTransaction() 이 2번을 담당. consumer는 enable.auto.commit=false 필수.
Kafka Streams는 이 패턴을 내부적으로 다 처리해줌. processing.guarantee=exactly_once_v2 한 줄로 끝.
EOS가 안 되는 경계
Kafka 트랜잭션의 보호 범위:
- ✅ Kafka topic 간 (read-process-write)
- ✅ Kafka 내부에서 완결되는 흐름
보호되지 않는 곳:
- ❌ Kafka → 외부 DB/HTTP 호출 (그건 2PC나 outbox 패턴 필요)
- ❌ 외부 DB → Kafka (Debezium + outbox)
- ❌ 사이드 이펙트 (이메일 발송, 결제 호출 등)
실무에서 “end-to-end exactly-once”가 필요하면:
- Outbox 패턴 — DB 트랜잭션 안에서 outbox 테이블에 이벤트 쓰고, CDC로 Kafka에 전파
- 멱등키 — 외부 시스템 호출 시 request id 붙여 중복 호출에 방어
성능 비용
트랜잭션이 공짜는 아님.
| 항목 | 오버헤드 |
|---|---|
| commit 빈도 | 매번 2PC 성격의 라운드트립 (보통 수 ms) |
| 처리량 | 비트랜잭션 대비 10~30% 저하 |
| 복잡도 | 실패 처리, id 관리 |
그래서 “전부 트랜잭션으로”가 아니라 진짜 필요한 파이프라인에만 적용. 로그 수집/메트릭처럼 at-least-once + 멱등 처리가 가능한 쪽엔 낭비.
v2 개선
Kafka 3.0의 exactly_once_v2 는 이전 구현의 성능/안정성을 개선:
sendOffsetsToTransaction호출 수 감소- consumer group 메타데이터 직접 전달
- producer 개수에 비례하던 오버헤드 감소
Kafka Streams는 3.0+에서 기본으로 exactly_once_v2 사용 권장.
디버깅 팁
트랜잭션 문제 의심될 때:
# 트랜잭션 상태 토픽
kafka-console-consumer.sh --bootstrap-server localhost:9092 \
--topic __transaction_state \
--formatter "kafka.coordinator.transaction.TransactionLog\$TransactionLogMessageFormatter"
# hanging transaction 확인
kafka-transactions.sh --bootstrap-server localhost:9092 list
Hanging transaction — coordinator가 producer로부터 응답 못 받고 걸린 상태. producer 재시작 시 transactional.id 유지하면 자동 복구. 다른 id로 재시작하면 이전 tx가 timeout까지 공간 잡고 있어서 consumer가 lag 걸린 것처럼 보일 수 있음.
정리
Exactly-once는 “이벤트가 정확히 한 번 처리된다”보다는 “Kafka 안에서 여러 쓰기가 원자적이다”로 이해하는 게 맞다. Idempotent producer가 파티션 단위 중복을 막고, 트랜잭션이 여러 파티션에 걸친 원자성을 추가하고, consumer의 read_committed가 마지막 퍼즐. Kafka 바깥 경계(외부 DB, HTTP)까지 보장되진 않는다는 걸 놓치면 안 됨.
다음 주는 Kafka Streams — 이 EOS v2 가 기본으로 동작하는 스트림 처리 프레임워크.
