LLM WikiAccess-protected knowledge portal
← 스터디 홈
4편 · 약 26분

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. 메시지 전달 보장의 세 가지 수준

At-Most-Once (최대 1회)
메시지가 손실될 수 있음
중복은 없음

구현: acks=0, 재시도 없음
용도: 로그 수집 (손실 허용)
At-Least-Once (최소 1회)
메시지가 손실되지 않음
중복이 발생할 수 있음

구현: acks=all + 재시도
용도: 대부분의 파이프라인 (기본값)
Exactly-Once (정확히 1회)
손실도 없고 중복도 없음
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와 시퀀스 번호

Producer PID: 42 Partition 0 seq: 0 Partition 1 seq: 0 msg [PID=42, P0, seq=0] 정상 전송 → 브로커 수락 msg [PID=42, P0, seq=0] 네트워크 오류 후 재시도 ← 같은 seq=0 Broker (Partition Leader) 추적: max(PID, seq) per partition seq=0 도착 → seq 기대값 0 ✓ 수락 후 기대값 = 1로 업데이트 seq=0 재도착 → 기대값 1 0 < 1 → 중복! → 조용히 버림 중요한 한계 프로듀서 재시작(process crash) 후 PID가 바뀌어 단일 세션 내에서만 보장. 재시작 후 중복은 transactions으로만 방지 가능.
Producer Idempotence 메커니즘

브로커는 파티션별로 (PID, 최신 seq) 조합을 추적한다. 같거나 낮은 시퀀스 번호가 오면 중복으로 판단하고 버린다. 이 정보는 레플리케이션 로그에 저장되므로 리더 페일오버 후에도 유지된다.

idempotence가 보장하지 않는 것:

  • 프로듀서 프로세스 재시작 후 중복 (PID가 새로 할당됨)
  • 여러 파티션에 걸친 원자적 쓰기
  • 외부 시스템과의 원자성

3. Kafka Transactions: 여러 파티션에 걸친 원자성

트랜잭션은 여러 파티션(또는 여러 토픽)에 쓰기를 하나의 원자적 단위로 묶는다. 일부만 커밋되거나 일부만 롤백되는 상황이 생기지 않는다.

설정

transactional.id=my-app-instance-0   # 인스턴스마다 고유한 ID

transactional.id는 프로듀서 인스턴스를 식별한다. 같은 ID로 새 프로듀서가 시작되면 이전 인스턴스를 펜싱(fencing)해서 좀비 쓰기를 막는다.

트랜잭션 코디네이터와 2단계 커밋

Application
1. beginTransaction()
2. producer.send() × N
3. sendOffsetsTo
Transaction()
4. commitTransaction()
Transaction Coordinator
• __transaction_state 토픽에
상태 기록 (durable)
• 참여 파티션 목록 관리
• Phase 1: PrepareCommit 기록
• Phase 2: 각 파티션에 COMMIT 마커 전송
• CompleteCommit 기록
Broker Partitions
• 메시지는 uncommitted 상태로 저장
• COMMIT 마커 수신 후
read_committed 컨슈머에게 노출
• ABORT 마커 수신 후 필터링
원자성 보장: PrepareCommit가 __transaction_state에 기록된 후에는 코디네이터가 재시작돼도 커밋을 완료한다. PrepareCommit 전에 실패하면 모두 롤백.
Kafka 트랜잭션 2단계 커밋 프로토콜

핵심 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 보장 범위 ✓ Kafka 경계 밖 ✗ Producer Idempotence 단일 세션 내 재시도 중복 제거 Kafka Transactions 파티션 간 원자적 쓰기 + offset commit read_committed Consumer 커밋된 데이터만 읽음 (LSO 기반) Kafka Streams exactly_once_v2 state store + offset + output = 하나의 트랜잭션 RDBMS (INSERT / UPDATE) 중복 가능 — 멱등성 설계 필요 외부 HTTP API 호출 중복 가능 — idempotency key 필요 S3 / 객체 저장소 결정적 파티셔너 필요 Elasticsearch 문서 ID = Kafka key → 사실상 멱등 로그, 메트릭, 캐시 업데이트 중복 가능 — 비즈니스 허용 여부 판단 경계
Kafka 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 차이

EOS-alpha (exactly_once, 지원 중단)
• Task마다 별도의 Producer 생성
• Task 100개 → Producer 100개
• 브로커 연결 100개, 트랜잭션 코디네이터 부하 폭발
• 처리량 낮음, 레이턴시 높음
EOS-v2 (exactly_once_v2, 권장)
• Thread마다 단 1개의 Producer 공유
• Thread 8개 → Producer 8개
• 브로커 연결 대폭 감소, 코디네이터 부하 최소화
• 처리량 10~50% 향상, 레이턴시 감소
EOS-v2는 컨슈머 메타데이터(group.id, partition)를 트랜잭션에 함께 전달해 브로커가 직접 펜싱을 판단한다. Task마다 Producer를 둘 필요가 없어 리소스가 대폭 줄어든다.
Kafka Streams EOS-alpha vs EOS-v2 비교

EOS-v2에서 Kafka Streams가 하나의 트랜잭션으로 묶는 것:

  1. 입력 토픽의 오프셋 커밋
  2. 내부 state store의 changelog 토픽 쓰기
  3. 출력 토픽 쓰기

세 가지 모두 하나의 트랜잭션에 들어간다. 장애 후 복구 시 checkpoint에서 다시 시작하면 중복 없이 정확히 한 번 처리된다.


6. Apache Flink의 End-to-End Exactly-Once

Flink는 Kafka Streams와 달리 범용 스트림 처리 엔진이다. Kafka와의 end-to-end exactly-once는 체크포인팅 + TwoPhaseCommitSinkFunction의 조합으로 달성한다.

체크포인트와 2PC의 결합

KafkaSource
offset 추적
Processing
상태 저장
KafkaSink
EXACTLY_ONCE
체크포인트 사이클 (예: 30초 간격)
① beginTransaction()
메시지 쓰기 시작
② 체크포인트 트리거
Barrier 주입
③ preCommit()
쓰기 flush
④ 체크포인트 완료
상태 스냅샷 저장
⑤ commit()
트랜잭션 커밋
실패 발생
체크포인트 실패
abort()
트랜잭션 롤백
마지막 체크포인트부터
재시작
Flink End-to-End Exactly-Once 동작 흐름

조건: Flink end-to-end exactly-once는 세 가지가 모두 충족되어야 한다.

  1. KafkaSource: Kafka 0.11+ 기반
  2. KafkaSink: DeliveryGuarantee.EXACTLY_ONCE + transactionalIdPrefix 설정
  3. 체크포인팅: 반드시 활성화
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 + 버전 컬럼
ElasticsearchKafka key → doc ID 매핑 시 사실상 멱등_id를 Kafka key로 설정
S3Deterministic 파일명이면 덮어쓰기 = 멱등오프셋 기반 파일명
RedisSET은 멱등, 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