Exactly-once의 실제 경계: producer idempotence, transaction, sink semantics
"Exactly-once 설정했으니 괜찮다"는 착각
Kafka를 도입한 팀에서 종종 나오는 말이 있다. "우리는 enable.idempotence=true를 켰으니 중복 처리 걱정은 없어." 혹은 "Kafka Streams의 exactly_once_v2 설정을 했으니 데이터가 정확히 한 번 처리된다."
반은 맞고 반은 틀렸다. Kafka의 exactly-once는 강력하지만 Kafka 경계 안에서만 동작한다. 데이터가 Kafka를 벗어나 외부 데이터베이스나 API로 나가는 순간, 그 보장은 끝난다. 이 경계를 정확히 이해하지 않으면 중복을 막았다고 믿으면서 실제로는 중복이 발생하는 시스템을 운영하게 된다.
이 장은 Kafka의 exactly-once가 무엇을 보장하고 어디서 멈추는지를 메커니즘 수준에서 설명한다.
1. 메시지 전달 보장의 세 가지 수준
중복은 없음
구현: acks=0, 재시도 없음
용도: 로그 수집 (손실 허용)
중복이 발생할 수 있음
구현: acks=all + 재시도
용도: 대부분의 파이프라인 (기본값)
Kafka 경계 안에서만
구현: idempotence + transactions
용도: 금융, 정산, 재고 처리
대부분의 Kafka 파이프라인은 기본적으로 at-least-once다. acks=all과 재시도 설정만으로는 중복을 막을 수 없다. 네트워크 오류로 브로커가 메시지를 이미 받았지만 ack를 보내지 못한 경우, 프로듀서는 재시도하고 같은 메시지가 두 번 저장된다.
2. Producer Idempotence: 단일 세션 내 중복 제거
enable.idempotence=true를 설정하면 Kafka 브로커가 재시도로 인한 중복을 감지해 버린다. 활성화 시 다음 설정이 자동으로 적용된다.
enable.idempotence=true
acks=all # 모든 레플리카 확인
retries=2147483647 # 사실상 무제한 재시도
max.in.flight.requests.per.connection=5 # 최대 5개 동시 요청작동 원리: PID와 시퀀스 번호
브로커는 파티션별로 (PID, 최신 seq) 조합을 추적한다. 같거나 낮은 시퀀스 번호가 오면 중복으로 판단하고 버린다. 이 정보는 레플리케이션 로그에 저장되므로 리더 페일오버 후에도 유지된다.
idempotence가 보장하지 않는 것:
- 프로듀서 프로세스 재시작 후 중복 (PID가 새로 할당됨)
- 여러 파티션에 걸친 원자적 쓰기
- 외부 시스템과의 원자성
3. Kafka Transactions: 여러 파티션에 걸친 원자성
트랜잭션은 여러 파티션(또는 여러 토픽)에 쓰기를 하나의 원자적 단위로 묶는다. 일부만 커밋되거나 일부만 롤백되는 상황이 생기지 않는다.
설정
transactional.id=my-app-instance-0 # 인스턴스마다 고유한 IDtransactional.id는 프로듀서 인스턴스를 식별한다. 같은 ID로 새 프로듀서가 시작되면 이전 인스턴스를 펜싱(fencing)해서 좀비 쓰기를 막는다.
트랜잭션 코디네이터와 2단계 커밋
2. producer.send() × N
3. sendOffsetsTo
Transaction()
4. commitTransaction()
상태 기록 (durable)
• 참여 파티션 목록 관리
• Phase 1: PrepareCommit 기록
• Phase 2: 각 파티션에 COMMIT 마커 전송
• CompleteCommit 기록
• COMMIT 마커 수신 후
read_committed 컨슈머에게 노출
• ABORT 마커 수신 후 필터링
핵심 API 패턴
producer.initTransactions(); // 한 번만 호출
try {
producer.beginTransaction();
// 처리 결과를 출력 토픽에 쓰기
producer.send(new ProducerRecord<>("output-topic", key, value));
// 컨슈머 오프셋도 같은 트랜잭션에 포함
// → 읽기와 쓰기가 원자적으로 커밋
producer.sendOffsetsToTransaction(offsets, groupMetadata);
producer.commitTransaction();
} catch (ProducerFencedException e) {
// 같은 transactional.id를 가진 새 인스턴스가 시작됨
// 이 인스턴스는 종료해야 함
producer.close();
} catch (KafkaException e) {
producer.abortTransaction();
// 재시도 또는 에러 처리
}read_committed와 LSO
컨슈머가 트랜잭션 데이터를 읽으려면 isolation.level=read_committed를 설정해야 한다. 이 모드에서는 LSO(Last Stable Offset) 이하의 데이터만 반환된다.
LSO = 진행 중인(open) 트랜잭션의 최소 시작 오프셋LSO 이상에는 아직 커밋되지 않은 트랜잭션의 데이터가 있을 수 있다. read_committed 컨슈머는 LSO까지만 읽고 대기한다. 트랜잭션이 너무 오래 열려 있으면 LSO가 진전되지 않아 하류 컨슈머 전체가 멈추는 상황이 된다.
4. Exactly-Once의 실제 경계
Kafka 경계가 끝나는 순간: 외부 데이터베이스에 쓰거나, HTTP API를 호출하거나, S3에 파일을 저장할 때. 이 모든 작업은 Kafka 트랜잭션의 범위 밖이다.
5. Kafka Streams Exactly-Once: EOS-v2
Kafka Streams는 read-process-write 루프 전체를 하나의 트랜잭션으로 묶어 Kafka 내부에서 완전한 exactly-once를 달성한다.
processing.guarantee=exactly_once_v2 # Kafka 3.0+, 브로커 2.5+ 필요EOS-alpha vs EOS-v2 차이
• Task 100개 → Producer 100개
• 브로커 연결 100개, 트랜잭션 코디네이터 부하 폭발
• 처리량 낮음, 레이턴시 높음
• Thread 8개 → Producer 8개
• 브로커 연결 대폭 감소, 코디네이터 부하 최소화
• 처리량 10~50% 향상, 레이턴시 감소
EOS-v2에서 Kafka Streams가 하나의 트랜잭션으로 묶는 것:
- 입력 토픽의 오프셋 커밋
- 내부 state store의 changelog 토픽 쓰기
- 출력 토픽 쓰기
세 가지 모두 하나의 트랜잭션에 들어간다. 장애 후 복구 시 checkpoint에서 다시 시작하면 중복 없이 정확히 한 번 처리된다.
6. Apache Flink의 End-to-End Exactly-Once
Flink는 Kafka Streams와 달리 범용 스트림 처리 엔진이다. Kafka와의 end-to-end exactly-once는 체크포인팅 + TwoPhaseCommitSinkFunction의 조합으로 달성한다.
체크포인트와 2PC의 결합
offset 추적
상태 저장
EXACTLY_ONCE
메시지 쓰기 시작
Barrier 주입
쓰기 flush
상태 스냅샷 저장
트랜잭션 커밋
트랜잭션 롤백
재시작
조건: Flink end-to-end exactly-once는 세 가지가 모두 충족되어야 한다.
- KafkaSource: Kafka 0.11+ 기반
- KafkaSink:
DeliveryGuarantee.EXACTLY_ONCE+transactionalIdPrefix설정 - 체크포인팅: 반드시 활성화
KafkaSink<String> sink = KafkaSink.<String>builder()
.setBootstrapServers("broker:9092")
.setRecordSerializer(...)
.setDeliveryGuarantee(DeliveryGuarantee.EXACTLY_ONCE)
.setTransactionalIdPrefix("my-flink-job") // 고유 접두사
.build();Flink의 exactly-once도 Kafka 경계 안의 보장이다. 외부 데이터베이스로 쓰려면 TwoPhaseCommitSinkFunction을 직접 구현하고 외부 시스템도 2PC를 지원해야 한다.
7. Sink 설계: 외부 시스템에서 중복을 막는 방법
Kafka 경계를 넘어가면 싱크 자체의 멱등성(idempotency)이 중복을 막는 유일한 수단이다.
멱등 싱크의 설계 패턴
1. Upsert (RDBMS)
INSERT INTO events (id, user_id, amount, processed_at)
VALUES (?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE
SET amount = EXCLUDED.amount,
processed_at = EXCLUDED.processed_at
WHERE events.version < EXCLUDED.version;Kafka 오프셋을 기반으로 id를 생성하면 재처리 시 같은 ID로 업서트되어 중복이 생기지 않는다.
2. 중복 제거 테이블 (Deduplication Store)
-- 처리 전 확인
SELECT 1 FROM processed_msgs WHERE msg_id = ?;
-- 없으면 처리 후 기록 (같은 트랜잭션)
BEGIN;
INSERT INTO target_table VALUES (...);
INSERT INTO processed_msgs (msg_id) VALUES (?);
COMMIT;3. HTTP API의 idempotency key
PUT /orders/payment
Idempotency-Key: {topic}-{partition}-{offset}
Content-Type: application/json
{ "amount": 10000, "currency": "KRW" }같은 키로 재호출되면 서버가 이전 결과를 반환한다. 스트라이프, 페이팔 등 주요 결제 API가 이 패턴을 지원한다.
시스템별 멱등성 특성
| 외부 시스템 | 기본 특성 | 권장 패턴 |
|---|---|---|
| PostgreSQL / MySQL | 비멱등 (INSERT) | Upsert + 버전 컬럼 |
| Elasticsearch | Kafka key → doc ID 매핑 시 사실상 멱등 | _id를 Kafka key로 설정 |
| S3 | Deterministic 파일명이면 덮어쓰기 = 멱등 | 오프셋 기반 파일명 |
| Redis | SET은 멱등, APPEND/INCR는 비멱등 | 상황에 따라 SET 사용 |
| 일반 HTTP API | 비멱등 | idempotency key 헤더 |
8. 흔한 함정과 해결책
함정 1: Auto-commit 사용
# ❌ 위험
enable.auto.commit=true
auto.commit.interval.ms=1000오프셋이 처리 완료 전에 자동으로 커밋된다. 처리 도중 크래시가 발생하면 메시지가 영구 손실된다.
# ✅ 안전
enable.auto.commit=false처리 완료 후 commitSync() 또는 commitAsync()를 명시적으로 호출한다.
함정 2: 외부 호출 후 오프셋 커밋
// ❌ 위험
callExternalAPI(record); // 외부 호출 성공
process(record); // DB 처리
consumer.commitSync(); // 그 다음 오프셋 커밋
// 외부 호출과 오프셋 커밋 사이에 크래시 → 외부 호출 중복 실행외부 시스템 호출은 Kafka 트랜잭션 밖이므로 항상 idempotency key를 사용해야 한다.
함정 3: 열린 트랜잭션이 너무 오래 지속
// ❌ 위험
beginTransaction();
enrichedData = callRemoteService(records); // 수 초 소요
sink.write(enrichedData);
commitTransaction();
// 트랜잭션 타임아웃 초과 → 브로커가 자동 abort
// → LSO 진전 없음 → 하류 컨슈머 전체 블로킹// ✅ 안전: 외부 I/O는 트랜잭션 밖에서 먼저 처리
List<String> enriched = new ArrayList<>();
for (Record r : records) {
enriched.add(callRemoteService(r)); // 트랜잭션 밖
}
beginTransaction(); // 빠른 Kafka 쓰기만
for (String e : enriched) {
sink.write(e);
}
commitTransaction();함정 4: Kafka Streams의 부수 효과
// ❌ 위험: 장애 후 복구 시 중복 호출
stream.map(record -> {
updateExternalCache(record.key()); // EOS 범위 밖 side effect
return process(record);
}).to("output");
// ✅ 안전: 멱등 호출 또는 EOS 토픽으로 전달
stream.map(record -> process(record)).to("output");
// 캐시 업데이트는 output 토픽을 구독하는 별도 컨슈머에서References
- Apache Kafka Documentation, "Idempotent Producer," https://kafka.apache.org/documentation/#producerconfigs_enable.idempotence
- Confluent Blog, "Exactly-once Semantics is Possible: Here's How Apache Kafka Does it," https://www.confluent.io/blog/exactly-once-semantics-are-possible-heres-how-apache-kafka-does-it/
- Confluent Blog, "Transactions in Apache Kafka," https://www.confluent.io/blog/transactions-apache-kafka/
- Confluent Blog, "Simplified, Robust Exactly-Once Semantics in Kafka 2.5," https://www.confluent.io/blog/simplified-robust-exactly-once-semantics-in-kafka-2-5/
- Confluent Documentation, "Message Delivery Guarantees for Apache Kafka," https://docs.confluent.io/kafka/design/delivery-semantics.html
- Apache Kafka KIP-732, "Deprecate eos-alpha and replace eos-beta with eos-v2," https://cwiki.apache.org/confluence/x/zJONCg
- Apache Kafka KIP-939, "Support Participation in 2PC," https://cwiki.apache.org/confluence/display/KAFKA/KIP-939:+Support+Participation+in+2PC
- Apache Flink Documentation, "An Overview of End-to-End Exactly-Once Processing in Apache Flink," https://flink.apache.org/2018/02/28/an-overview-of-end-to-end-exactly-once-processing-in-apache-flink-with-apache-kafka-too/
- Apache Flink Documentation, "Kafka Sink Connector," https://nightlies.apache.org/flink/flink-docs-stable/docs/connectors/datastream/kafka/
- Strimzi Blog, "Exactly-once semantics with Kafka transactions," https://strimzi.io/blog/2023/05/03/kafka-transactions/
- Conduktor, "Kafka Transactions Deep Dive," https://www.conduktor.io/glossary/kafka-transactions-deep-dive
- Conduktor Blog, "Building Idempotent Kafka Consumers: Patterns That Actually Work," https://www.conduktor.io/blog/building-idempotent-consumers