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.idpersistent해야 함 (재시작해도 같은 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:

  1. output topic에 쓰기
  2. input topic의 offset commit
  3. 둘 중 하나 실패 시 전부 롤백

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 가 기본으로 동작하는 스트림 처리 프레임워크.