백엔드 데이터 인프라 88편. Kafka Message Delivery Semantics — at-most-once·at-least-once·exactly-once 3가지 의미 보장의 정확한 차이, Idempotent Producer + Transactions 로 풀어낸 EOS, isolation.level read_committed, 실무 선택 가이드까지 풀어쓴 학습 노트.
이 글은 백엔드 데이터 인프라 시리즈 130편 중 88편이에요. 87편 까지 Consumer 설계를 풀었다면, 이번 88편은 Kafka 의 가장 중요한 보장 개념인 Message Delivery Semantics(메시지 전달 보장)를 다뤄요. 시스템 신뢰성과 데이터 정확성의 핵심.
Delivery Semantics가 어렵게 느껴지는 이유
at-most-once, at-least-once, exactly-once 라는 단어가 직관적으로 잘 분리되지 않아요.
첫째, "exactly-once 가 가능한가?" 자체가 분산 시스템에서 논쟁의 영역이에요. 많은 시스템이 "exactly-once 보장"이라고 주장하지만 세부 조건이 다 달라요.
둘째, Producer 측과 Consumer 측의 의미가 달라요. publish 시점 보장과 consume 시점 보장은 별도 영역.
셋째, EOS (Exactly-Once Semantics, 정확히 한 번 보장) 메커니즘이 복잡해요. Idempotent Producer, Transactions, Consumer isolation.level 의 3가지 조합으로 굴러가요.
이번 글에서 3가지 의미, 각각의 구현 패턴, EOS 메커니즘, 실무 선택까지 정리해요.
3가지 의미 보장
At-Most-Once
"메시지가 손실될 수 있지만 절대 중복 전달 안 됨"
- Producer 가 retry 안 함
- Consumer 가 process 전 commit
- 메시지가 한 번 또는 0번
언제 — 로그·메트릭 같은 손실 OK + 중복 X 데이터. 매우 드묾.
At-Least-Once (Kafka 기본)
"메시지가 절대 손실 안 되지만 중복 전달 가능"
- Producer 가 retry 함 (실패 시 재시도)
- Consumer 가 process 후 commit
- 메시지가 한 번 이상
언제 — 대부분의 환경. 중복은 멱등성으로 처리 가 일반적.
Exactly-Once
"메시지가 정확히 한 번"
- 손실 X + 중복 X
- 가장 강한 보장이지만 비용 큼
언제 — 결제·금융·법적 데이터. Kafka Streams 의 기본 모드.
Publishing 측 — Producer 보장
At-Most-Once
acks=0
retries=0
Producer 가 한 번 쏘고 잊어버려요(fire-and-forget). 실패하면 그냥 잃어요.
At-Least-Once (예전 기본)
acks=all
retries=2147483647
enable.idempotence=false
실패 시 retry → 성공했는데 ACK 못 받았으면 중복 전송이 일어나요.
Exactly-Once — Idempotent Producer
enable.idempotence=true # Kafka 3.0+ 기본
86편에서 본 메커니즘:
- Producer 가 Producer ID (PID, 프로듀서 고유 ID) 받음
- 각 메시지 sequence number 부여
- Broker 가 (PID, sequence) 추적 → 중복 거부
Producer 가 같은 메시지 두 번 보내도 broker 에 한 번만 저장돼요. publishing 단계의 exactly-once.
자동 활성 조건 (3.0+ 기본):
acks=allretries > 0max.in.flight.requests.per.connection ≤ 5
Consuming 측 — Consumer 보장
At-Most-Once
1. messages = consumer.poll()
2. consumer.commitSync() ← 먼저 commit
3. process(messages) ← 그 후 처리
3단계에서 죽으면 다음 consumer 가 이미 commit 된 다음 메시지부터 시작해요. 처리 못 한 메시지가 손실돼요.
At-Least-Once (기본)
1. messages = consumer.poll()
2. process(messages) ← 먼저 처리
3. consumer.commitSync() ← 그 후 commit
3단계 전에 죽으면 다음 consumer 가 commit 안 된 처음부터 다시 읽어요. 중복 처리가 발생해요.
대부분 환경에서는 멱등성으로 중복 처리를 무해화해요:
- DB INSERT 가 primary key 기반 — 두 번 처리해도 같은 결과
- Counter 증가 =
INCR key:N이 아닌SET key:N value같은 멱등 패턴
Exactly-Once — Transactions
여기가 가장 복잡한 자리예요. Producer + Consumer + 두 topic 사이가 모두 atomic 해야 해요.
시나리오 — Kafka Streams 흐름
Topic A → Stream Processor → Topic B
│
└─ offset commit to Kafka
3가지가 함께 성공하거나 함께 실패해야 해요:
- Topic B 에 결과 write
- Topic A 의 offset advance
- Stream Processor state update
Transactional Producer
producer.initTransactions();
producer.beginTransaction();
producer.send(new ProducerRecord<>("topic-B", ...));
producer.sendOffsetsToTransaction(offsets, consumerGroupMetadata);
producer.commitTransaction();
commitTransaction() 이 atomic 마커예요. 실패 시 abortTransaction().
Consumer 측 — isolation.level
기본값은 read_uncommitted — 진행 중이거나 실패한 transaction 메시지도 보여요.
EOS 환경 = read_committed:
isolation.level=read_committed
이렇게 두면 commit 된 transaction 메시지만 보여요. Abort 된 transaction 메시지는 consumer 에게 안 보여요.
Kafka Streams 의 EOS
props.put(StreamsConfig.PROCESSING_GUARANTEE_CONFIG, StreamsConfig.EXACTLY_ONCE_V2);
한 줄로 EOS 가 켜져요. 내부적으로 transactions + idempotent + read_committed 가 모두 자동으로 설정돼요.
EOS의 한계 — Kafka 외부 시스템
여기가 정말 중요해요. Kafka 안에서는 EOS 풀 보장이 가능해요. 하지만 Kafka → 외부 시스템(DB·HTTP API·파일)으로 데이터 sink 할 때는 얘기가 달라요:
- DB INSERT 후 Kafka offset commit 사이에 죽으면 DB 에는 들어갔는데 offset 은 안 올라가요. 다음 consumer 가 DB 에 또 INSERT 해서 중복이 생겨요.
해법 패턴:
1. Idempotent External Operations
DB INSERT 가 primary key 기반 upsert 면 중복 처리해도 같은 결과. 가장 단순해요.
2. Two-Phase Commit (XA, 2단계 커밋 표준)
DB 와 Kafka 사이를 2PC 로 묶어요. 극히 드물어요. 대부분 시스템이 2PC 를 지원하지 않아요.
3. Transactional Outbox + CDC (Change Data Capture, 변경 데이터 캡처)
DB 트랜잭션 안에서 outbox 테이블에 메시지를 저장해요. 별도 프로세스(Debezium·Kafka Connect)가 outbox 테이블을 읽어 Kafka 로 publish 해요. DB 와 Kafka 가 함께 atomic.
4. Consumer 가 Offset 도 Output Store 에 저장
Kafka Connect HDFS(Hadoop Distributed File System) connector 가 좋은 예. 데이터와 offset 을 같은 HDFS 파일에 함께 저장해요. 같은 곳에 있으니 atomic.
정리 — 보장 수준 선택
| 시나리오 | 권장 |
|---|---|
| 로그·메트릭 (손실 OK) | At-most-once (acks=0) |
| 대부분 일반 데이터 | At-least-once + 멱등성 |
| Kafka 내부 Stream Processing | EOS v2 (Kafka Streams) |
| 외부 DB sink + 정확성 결정적 | Transactional Outbox + at-least-once |
| 결제·금융 | Transactional Outbox + 강한 멱등성 |
기본 권장은 at-least-once + 멱등성. 대부분 환경에 충분하고 운영이 단순해요.
EOS 는 진짜 필요한 자리에만 써요. 메커니즘이 복잡하고 성능 부담이 약 20% 정도.
한계·실무 함정
1. EOS 가 "모든 상황 exactly-once" 가 아님
위에서 강조한 자리. Kafka 내부만 보장돼요. 외부 시스템은 별도 패턴.
2. Transaction timeout
기본값은 transaction.timeout.ms=60000 (60초). transaction 처리가 더 길어지면 abort 돼요. 큰 batch 환경이면 늘려야 해요.
3. read_committed 의 지연 시간
EOS consumer 는 transaction commit 까지 대기해요. Last Stable Offset(LSO, 안정 오프셋) 까지만 읽어서 지연 시간이 약간 늘어요.
4. Idempotent Producer 활성화 자동 효과
enable.idempotence=true (3.0+ 기본) 가 acks=all 과 retries 까지 자동 설정해요. 명시적으로 안 박아도 안전.
5. Old client + EOS
EOS 는 Kafka 2.0+ 가 필요해요. Old client 와는 호환되지 않아요.
시험 직전 한 번 더 — Kafka Delivery Semantics 함정 압축 노트
- 3가지 의미 = at-most-once · at-least-once · exactly-once
- At-most-once = 손실 가능, 중복 X — 로그·메트릭만
- At-least-once = 손실 X, 중복 가능 — 대부분 기본
- Exactly-once = 손실 X, 중복 X — 가장 강한 보장
- Publishing 보장 vs Consuming 보장 별도
- Producer at-most-once =
acks=0+retries=0 - Producer at-least-once =
acks=all+ retries (예전 기본) - Producer exactly-once (Publishing) = Idempotent Producer (
enable.idempotence=true) - Kafka 3.0+ = idempotent 기본값
- 자동 활성 조건 = acks=all + retries > 0 + max.in.flight ≤ 5
- Consumer at-most-once = poll → commit → process (실패 시 손실)
- Consumer at-least-once = poll → process → commit (실패 시 중복) — 기본
- 대부분 = at-least-once + 멱등성으로 중복 처리 무해화
- Consumer exactly-once = Transactions +
isolation.level=read_committed - Transactional Producer =
initTransactions·beginTransaction·sendOffsetsToTransaction·commitTransaction isolation.level=read_committed= commit 된 transaction 메시지만 보임- Kafka Streams EOS =
PROCESSING_GUARANTEE_CONFIG=EXACTLY_ONCE_V2한 줄 - EOS 한계 = Kafka 내부만, 외부 시스템 별도
- 외부 sink 패턴 — Idempotent ops · Two-Phase Commit · Transactional Outbox · Offset 도 output store 에
- Transactional Outbox = DB 트랜잭션에 outbox 테이블 → Debezium 으로 Kafka publish
- 기본 권장 = at-least-once + 멱등성
- EOS = 결제·금융·정확성 결정적 자리만
- EOS 비용 ~20% 성능 부담
- 함정 —
transaction.timeout.ms기본 60초, 큰 batch 면 늘림 - 함정 —
read_committed는 LSO 까지만 읽음, 약간 지연 - 함정 — Old client + EOS 호환 X (2.0+ 필요)
공식 문서: Kafka Design — Message Delivery Semantics 에서 자세한 사양을 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 83편 — Kafka Design: Motivation (왜 이렇게 설계됐나)
- 84편 — Kafka Design: Persistence (디스크가 빠르다)
- 85편 — Kafka Design: Efficiency (Zero-Copy · Batch 압축)
- 86편 — Kafka Design: Producer (Partition 선택·ACK·Idempotent)
- 87편 — Kafka Design: Consumer (Pull · Consumer Group · Offset)
다음 글:
- 89편 — Kafka Replication (ISR · Leader Election · Unclean)
- 90편 — Kafka API 5종 종합 (Producer · Consumer · Streams · Connect · Admin)
- 91편 — Kafka Producer API 깊이 (Serializer · Callback · Interceptor)
- 92편 — Kafka Consumer API 깊이 (Commit · Rebalance · Seek)
- 93편 — Kafka Admin Client API (Topic·ACL·Consumer Group 관리)