"Kafka는 exactly-once인가요" — 질문이 틀렸다

"Kafka는 exactly-once를 보장하나요?" — Kafka를 배우면 누구나 받는 질문이다. 답은 "무엇의 exactly-once인지에 따라 다르다"다. 단일 partition 쓰기(idempotent)는 exactly-once다. consume-process-produce 패턴(EOS)도 exactly-once다. 하지만 시스템 외부(DB)까지는 아니다. 이 질문이 틀린 이유는 "exactly-once"를 메시지 자체의 마법 같은 속성으로 생각하기 때문이다. 실제로는 "출력 메시지와 consumer offset을 한 트랜잭션으로 묶어 원자 commit"하는 설계 패턴의 결과다.

이 글은 Kafka의 세 가지 전달 보장 — at-most-once, at-least-once(기본), exactly-once — 의 정의, 달성 방법, 그리고 정확한 경계를 다룬다. 중복을 그냥 두면 결제가 두 번 일어나고, 손실을 그냥 두면 주문이 사라진다. 전달 보장의 경계를 정확히 아는 것이 신뢰성 설계의 출발점이다.

세 가지 전달 보장 — 중복과 손실의 트레이드오프

택배에 비유하면 감이 온다:

  • at-most-once: 문 앞에 두고 간다(비가 와도 책임 안 짐). 잃을 수 있지만 중복은 안 된다.
  • at-least-once: 수령증 받을 때까지 다시 온다. 같은 소포가 두 번 올 수 있다(중복).
  • exactly-once: 수령증(출력 메시지)과 배송 완료 등록(offset commit)을 한 장부에 동시에 적는다. 둘 중 하나만 일어나는 일은 없다.
보장 어떻게 Kafka 구현 비용
at-most-once 확인 안 함, 재시도 안 함 acks=0 최저
at-least-once 확인하고, 실패 시 재시도(중복 가능) acks=all + 재시도, Kafka 기본 낮음
exactly-once 중복 제거 + 원자 쓰기 idempotent producer + transactions + read_committed 높음

Kafka의 전달 보장은 버전을 거치며 발전했다 (design.md — Message Delivery Semantics):

  • 0.11 이전: producer는 at-least-once(네트워크 에러 시 재전송 → 중복 가능).
  • 0.11.0.0부터: idempotent delivery(PID + sequence로 중복 제거) → 단일 partition exactly-once. transactions → 여러 partition 원자 쓰기.

전달 보장 발전사 — 타임라인으로 보는 진화

이 발전을 시간순으로 보면 각 단계가 "이전의 어떤 갭을 메웠는지" 보인다:

flowchart LR
    A["0.11 이전<br/>at-least-once만<br/>(중복 가능)"] --> B["0.11 (2017)<br/>idempotent producer<br/>(단일 partition 중복 제거)"]
    B --> C["0.11<br/>transactions<br/>(여러 partition 원자)"]
    C --> D["2.5+<br/>exactly_once_v2 (KIP-447)<br/>(consume-process-produce EOS)"]
    D --> E["3.0+<br/>idempotence 기본 true"]
    E --> F["4.x<br/>KRaft + EOS 안정화"]
단계 해결한 갭 남은 한계
at-least-once (기준선) 중복
idempotent producer 단일 partition 중복 여러 partition은 미지원
transactions 여러 partition 원자 consumer offset과 결합은 별도
EOS v2 consume-process-produce 원자 외부 시스템은 미지원, 성능 비용

이 타임라인이 "Kafka는 exactly-once인가"라는 질문이 왜 범위를 정해야 하는지 보여준다 — "exactly-once"가 버전마다 의미하는 바가 달랐고, 각 단계가 좁은 범위의 정확성을 추가해왔다. 4.x의 EOS도 "같은 클러스터 내 consume-process-produce"에 한정된다.

exactly-once의 정확한 범위 — 오해 주의

"exactly-once"가 어디까지 보장인지를 정확히 해야 한다:

  • 단일 partition 쓰기: idempotent producer로 exactly-once(장 03). 4.x에선 기본 동작.
  • consume-process-produce(consumer가 읽고 처리하고 다른 topic에 쓰는 패턴): EOS(Exactly-Once Semantics). 출력 메시지 + 입력 offset을 같은 트랜잭션으로 묶어 commit.
  • 시스템 외부(DB 등): Kafka만으로는 보장 못 함. 외부 시스템도 트랜잭션 참여 또는 멱등 처리 필요.

"Kafka는 exactly-once인가"가 틀린 질문인 이유 — 범위를 안 정하고 묻기 때문. 단일 partition 쓰기는 이미 exactly-once(4.x 기본), consume-process-produce는 EOS로 가능, DB 쓰기는 불가.

EOS — consume-process-produce에서 exactly-once가 어떻게 나오나

여기서 핵심 질문: consume-process-produce에서 "출력과 offset을 한 트랜잭션으로"가 왜 exactly-once를 만드는가?

exactly-once는 메시지 자체의 마법 같은 속성이 아니다. "출력 메시지가 commit됐으면 입력 offset도 commit됐고, 입력 offset이 commit됐으면 출력도 commit됐다"는 원자성에서 나온다. 둘 중 하나만 일어나는 상태를 원천 차단한다.

EOS의 세 기둥

  1. consumer partition 배타 소유: 한 consumer group 내 partition은 한 consumer만(장 05). 같은 입력을 두 consumer가 동시에 처리하는 일 없음.
  2. producer 트랜잭션: 출력 record 생산 + consumer offsets 갱신을 하나의 트랜잭션으로 원자 commit.
  3. consumer마다 producer 1개: rebalance 시 소유권 이전을 정확히 처리.

consume-process-produce 흐름

sequenceDiagram
    participant C as consumer (read_committed)
    participant T as transaction
    participant O as output topic
    participant OS as __consumer_offsets
    C->>C: poll() → records
    C->>T: beginTransaction()
    C->>O: send(출력 메시지들)
    C->>OS: sendOffsetsToTransaction(offsets, groupMetadata)
    C->>T: commitTransaction()  // 출력과 offset이 원자적으로 commit
    Note over O,OS: 둘 다 commit 되거나 둘 다 abort — 중간 상태 없음

코드 패턴 (ExactlyOnceMessageProcessor.java):

producer.initTransactions();          // 좀비 차단
consumer.subscribe(...);
while (running) {
    records = consumer.poll(...);
    producer.beginTransaction();
    for (record : records) producer.send(transform(record));
    producer.sendOffsetsToTransaction(offsets, consumer.groupMetadata());
    producer.commitTransaction();      // 원자 commit
}

좀비 차단 — transactional.id가 왜 필요한가

producer가 크래시 났는데 사실은 살아있어서(좀비) 같은 데이터를 다시 쓰면 중복이 생긴다. 이걸 막는 것이 transactional.id다:

  • initTransactions() 시 broker가 이 transactional.idepoch를 올린다.
  • 이전 epoch(좀비)의 producer가 쓰려 하면 ProducerFencedException → 좀비 격리.
  • 이것이 "재시작해도 안전"을 보장하는 핵심.

transactional.id는 안정적이고 고유해야 한다 — 재시작해도 같은 값이 유지되어야 좀비 차단이 작동한다. 아무 값이나 쓰면 안 된다.

isolation.level=read_committed — abort된 트랜잭션을 안 보게

EOS consumer는 isolation.level=read_committed를 써야 한다:

  • consumer가 commit된 트랜잭션만 읽는다. abort된 것은 보이지 않는다.
  • EOS에서 offset은 트랜잭션으로 commit되므로 enable.auto.commit=false 필수(자동 commit이 트랜잭션을 우회하면 안 됨).

read_committed는 성능에 영향이 있다 — abort 처리·LISO(last stable offset) 추적으로 지연이 생긴다. 필요한 곳에만 쓴다.

LSO(last stable offset) — read_committed가 멈추는 지점

read_committed consumer는 LSO(last stable offset)까지만 읽을 수 있다. LSO는 "아직 진행 중이거나 abort 안 된 가장 오래된 트랜잭션의 시작점" 전까지다. 즉, 한 트랜잭션이 진행 중이면 그 이후의 commit된 메시지도 consumer는 LSO 때문에 못 본다(잠시 대기).

왜 이렇게까지 하는가 — abort될 수 있는 트랜잭션의 데이터를 consumer에게 보였다가 나중에 "사실 취소"할 수 없기 때문. LSO는 "이 지점까지는 모든 트랜잭션이 결정났다(또는 abort 처리됐다)"는 안전 경계다. 장기 실행 트랜잭션이 LSO를 멈추게 하므로(지연 유발), transaction.timeout.ms로 트랜잭션 시간 상한을 둬야 한다 — 안 두면 하나의 느린 트랜잭션이 전체 consumer 지연을 만든다.

EOS 실패 모드 — 트랜잭션 중간에 크래시 나면?

EOS의 진짜 가치는 실패 상황에서 정확성을 유지하는 데 있다. consumer가 트랜잭션 중간에 크래시 나는 시나리오를 보자:

flowchart TD
    A["beginTransaction"] --> B["send(출력들)"]
    B --> C["sendOffsetsToTransaction"]
    C --> D{"commitTransaction 도달?"}
    D -->|O 크래시 전| E["트랜잭션 미완료 → abort 타임아웃 → 출력 안 보임(read_committed)"]
    D -->|O commit 직후 크래시| F["commit 성공 → 출력+offset 영속 → 재시작 시 다음부터"]
    E --> G["재시작: offset 안 넘어감 → 입력 재처리(at-least-once) → EOS 보장"]

핵심: 트랜잭션이 commit되지 않고 끝나면(크래시·타임아웃) broker가 그 트랜잭션을 abort로 처리 → 출력 메시지는 read_committed consumer에게 안 보인다 → 마치 처음부터 없었던 것처럼. consumer offset도 commit 안 됐으므로, 재시작 시 같은 입력을 다시 처리한다(at-least-once 재시도). 결국 "모두 일어나거나 아무것도 안 일어나거나"가 보장된다.

transaction.timeout.ms — 좀비·지연 방지

트랜잭션에 시간 상한(transaction.timeout.ms, 기본 60초)을 둔다:

  • producer가 이 시간 내에 commit/abort 안 하면 broker가 강제 abort → 장기 미완료 트랜잭션이 LSO를 멈추게 하는 걸 방지.
  • 좀비 producer도 같은 원리로 차단 — transactional.id의 epoch가 올라가면 옛 epoch의 트랜잭션은 abort.

좀비 차단 worked example — zombie fencing이 어떻게 동작하는가

좀비 시나리오를 구체적으로 보자. producer P1(transactional.id=order-processor-1)이 트랜잭션 도중 네트워크 분단으로 "죽은 줄 알았는데 살아있다(좀비)". 한편 같은 transactional.id를 가진 P1'가 재시작됐다:

  1. P1'initTransactions() 호출 → broker가 order-processor-1epoch를 5→6으로 올림.
  2. 좀비 P1(epoch=5)이 늦게 commit 시도 → broker가 ProducerFencedException 발생 → P1 격리.
  3. P1'(epoch=6)만 정상 동작 → 같은 데이터 두 번 쓰는 일 차단.

이게 transactional.id가 "안정적이고 고유"해야 하는 이유다 — 같은 논리 역할(예: order-processor-1)의 재시작은 같은 id를 써야 좀비가 잡힌다. 반대로 서로 다른 역할이 같은 id를 쓰면 서로를 fence해버리는 사고가 난다. 운영에선 id 할당 체계를 명확히 설계해야 한다.

at-least-once vs exactly-once — 비용 비교

"왜 모든 곳에 EOS를 안 쓰나" — 비용 때문이다. 두 보장의 실사용 비교:

항목 at-least-once (acks=all+재시도) EOS (transactional + read_committed)
처리량 높음 낮음(트랜잭션 오버헤드 10 ~ 20%+ 추정, 환경 의존)
지연 낮음 높음(LSO 대기·commit 비용)
구현 복잡도 낮음(재시도만) 높음(transactional API·좀비 처리)
중복 가능성 있음(애플리케이션 멱등 설계로 완화) 없음(단일 클러스터 내)
적합 로그·메트릭·멱등 처리 가능한 곳 결제·주문·정확성 필수

그래서 "무조건 EOS"가 정답이 아니다. 애플리케이션이 멱등 처리(DB upsert 등)로 at-least-once 중복을 흡수할 수 있으면, at-least-once가 훨씬 싸다. EOS는 정말 "정확히 한 번"이 요구되는 좁은 영역(결제·원장)에 쓴다.

전달 보장 결정 트리 — 어느 보장을 택할까

실제 설계에서 "우리는 뭘 써야 하나"를 결정하는 흐름:

flowchart TD
    Q1{데이터 손실 허용?} -->|O| AMO["at-most-once (acks=0)"]
    Q1 -->|X| Q2{"중복 처리가 안전한가?<br/>(멱등/DB upsert 가능?)"}
    Q2 -->|O| ALO["at-least-once (acks=all+재시도)<br/>+ 애플리케이션 멱등"]
    Q2 -->|X| Q3{"consume-process-produce 패턴?<br/>같은 Kafka 클러스터 내?"}
    Q3 -->|O| EOS["EOS (transactional + read_committed)"]
    Q3 -->|X 외부 DB/HTTP| OUTBOX["at-least-once + outbox/멱등 키 패턴<br/>(Kafka만으로는 한계)"]

이 트리가 보여주는 핵심: exactly-once는 항상 정답이 아니다. 손실을 감내하면 at-most-once(로그), 중복을 애플리케이션이 흡수하면 at-least-once, consume-process-produce면 EOS, 외부 시스템이면 outbox 패턴. 각기 비용·복잡도가 다르다.

외부 시스템까지 exactly-once — outbox 패턴

Kafka 트랜잭션은 같은 클러스터 내만 보장한다. "메시지 처리 후 DB에 쓰기"처럼 외부 DB까지 정확히 한 번이 필요하면, Kafka 트랜잭션이 DB를 묶을 수 없으므로 별도 패턴이 필요하다.

대표적 해법 — transactional outbox:

  1. 비즈니스 로직이 DB에 "보낼 메시지"를 outbox 테이블에 같은 DB 트랜잭션으로 쓴다(비즈니스 데이터 + outbox가 원자).
  2. 별도 프로세스(또는 Debezium CDC)가 outbox 테이블을 읽어 Kafka로 produce.
  3. produce 성공하면 outbox 행을 삭제/마킹.

이렇게 하면 "DB 쓰기 + Kafka produce"가 (outbox를 매개로) 원자가 된다. 핵심은 DB 트랜잭션과 Kafka 트랜잭션을 직접 묶지 않고, outbox라는 중간 매개로 우회하는 것. 반대 방향(Kafka → DB)은 consumer가 DB에 멱등 쓰기(중복 키 허용)로 at-least-once 중복을 흡수한다.

이 패턴이 Kafka "exactly-once"의 경계를 보여준다 — 클러스터 내는 EOS, 외부는 설계 패턴으로 보완해야 한다.

Kafka Streams가 EOS를 "자동"으로 만드는 것

EOS를 직접 구현하려면 transactional API(initTransactions/beginTransaction/sendOffsetsToTransaction/commitTransaction)를 다뤄야 한다. 복잡하고 실수하기 쉽다. Kafka Streams는 이걸 프레임워크가 자동 처리한다 — processing.guarantee=exactly_once_v2 한 줄이면:

  • 내부적으로 transactional producer + read_committed consumer + offset 원자 commit을 자동 구성.
  • rebalance 시 트랜잭션 정리도 자동.
  • 개발자는 topology 로직만 짠다.

이게 Streams가 EOS를 쓰는 애플리케이션에서 인기인 핵심 이유다 — 직접 트랜잭션 API를 다루는 복잡도를 프레임워크가 흡수한다. 단, exactly_once (v1, KIP-98)은 폐지됐고 exactly_once_v2 (KIP-447, 2.5+)를 써야 한다 — 구형 자료 주의.

전달 보장 관련 자주 묻는 추가 질문

질문
"read_committed가 처리량을 얼마나 깎나" LSO 대기·abort 처리로 지연. 정량값은 환경 의존(미검증), 보통 수 ~ 10%+
"EOS 없이 consume-process-produce 정확성?" at-least-once만 → 중복 발생. 애플리케이션 멱등으로 보완하거나 EOS 써야
"transactional.id를 바꾸면?" 다른 producer로 인식 → 좀비 차단 안 됨. 재시작은 같은 id 유지 필수
"EOS consumer와 일반 consumer 혼합?" read_committed consumer만 abort 제외. 일반(read_uncommitted)은 abort된 것도 봄
"외부 DB 쓰기까지 EOS?" 불가(outbox 패턴 등 별도 설계). Kafka 클러스터 내 한정

Kafka 4.3 실습 — 직접 확인하기

사전: KRaft 단일 노드. EOS는 트랜잭션 API 코드가 필요 → console 도구로 완전 재현은 어렵다(미검증: console 한계).

transaction 상태 관찰

# Kafka 4.3 — 트랜잭션 내부 topic 확인(구조적)
bin/kafka-topics.sh --list --bootstrap-server localhost:9092
# __transaction_state 가 보임 (트랜잭션 상태 저장, compact 정책)

확인할 것: __transaction_state 내부 topic 존재(트랜잭션 로그). EOS 앱 기동 후 이 topic에 상태가 쓰인다.

완전한 EOS 검증(출력+offset 원자성)은 두 터미널에 producer/consumer + ExactlyOnceMessageProcessor 예제를 실행해야 한다. 본 노트에선 개념·내부 topic 확인으로 한계 명시. idempotence 효과(중복 제거) 시각화도 broker 재시작·네트워크 에러 시뮬레이션이 필요해 console 도구로는 어렵다.

EOS 검증 — 무엇을 어떻게 확인하는가

EOS가 진짜 동작하는지 검증하려면 고의 장애 주입이 필요하다. 절차(개념):

  1. 환경: ExactlyOnceMessageProcessor 예제 기동(input topic → 처리 → output topic, transactional.id 설정).
  2. 정상 처리 확인: input에 메시지 → output에 정확히 한 번 나타나는지 카운트.
  3. 장애 주입: 처리 중간에 consumer 프로세스 kill(트랜잭션 도중 크래시 시뮬레이션).
  4. 재시작 후 확인: 같은 transactional.id로 재기동. output에 중복 없이 이어지는지(abort된 미완료 트랜잭션의 출력이 안 보이는지).
  5. __transaction_state 관찰: 트랜잭션 상태가 (Begin→PrepareCommit→Complete)로 전이하는지(구조적 확인).
  6. read_committed vs read_uncommitted 대비: read_uncommitted consumer는 abort된 것도 보는지, read_committed는 안 보는지 교차 확인.

이 절차가 "출력+offset 원자 commit"이 실제로 중복·손실을 막는지 눈으로 확인하는 방법이다. 단, 단일 노드 + console 도구로는 3 ~ 4번(장애 주입·재시작)이 어려워 애플리케이션 코드가 필수다.

흔히 묻는 것, 흔히 틀리는 것

오해 정정
"Kafka는 항상 exactly-once다" 기본은 at-least-once. exactly-once는 idempotence/transactions 명시 적용 시
"exactly-once는 메시지 속성이다" 아님. 출력+offset 원자 commit이라는 설계 패턴의 결과
"exactly-once가 DB 쓰기까지 보장한다" Kafka 내부 한정. 외부 DB는 별도 트랜잭션/멱등 필요
"idempotent producer = exactly-once" 단일 partition에 한정. 여러 partition/offset은 transactions 필요
"transactional.id는 아무 값이나 된다" 안정적이고 고유해야. 좀비 차단의 기준
"read_committed는 성능에 무관하다" abort 처리·LISO 지연 있음. 필요 시만
"exactly_once와 exactly_once_v2는 같다" 구형(KIP-98)은 폐지, v2(KIP-447) 사용. 구형 자료 주의
"transaction.timeout.ms는 안 설정해도 된다" 장기 트랜잭션이 LSO를 멈춰 전체 consumer 지연 유발. 반드시 상한 설정
"EOS를 쓰면 외부 DB도 자동 exactly-once" 아니다. 클러스터 내 한정. 외부는 outbox/멱등 패턴 별도 필요
"idempotent producer는 4.x에서도 켜야 한다" 4.x 기본 true. 별도 지정 불필요(단, 구형 자료는 false 기준)

더 깊이

  • Kafka Streams의 EOS: Streams는 consume-process-produce를 프레임워크 수준에서 자동화한다. processing.guarantee=exactly_once_v2(KIP-447, 2.5+)로 EOS 활성화 — 내부적으로 transactional producer + read_committed consumer + offset 원자 commit을 자동 처리. 개발자가 직접 트랜잭션 API를 다루지 않아도 EOS 달성 → Streams가 인기인 이유 중 하나.
  • exactly-once의 경계: 같은 Kafka 클러스터 내는 EOS 보장 가능. 다른 클러스터(MirrorMaker 등)는 source offset과 target 메시지를 한 트랜잭션으로 못 묶어 at-least-once가 한계(2.x 이후 일부 EOS 지원, 미검증). 외부 DB/HTTP는 Kafka 트랜잭션 밖 → 멱등 키·outbox 패턴 등 별도 설계.

요약 — 이 글의 결론

  • 기본 = at-least-once(중복 가능). 0.11부터 exactly-once 옵션.
  • idempotent producer(4.x 기본) = 단일 partition exactly-once(PID+sequence 중복 제거).
  • transactions = 여러 partition 원자 쓰기 + consumer offsets과 결합.
  • EOS(consume-process-produce) = 출력 메시지 + 입력 offset을 한 트랜잭션으로 원자 commit. "exactly-once는 메시지 속성이 아니라 이 원자성의 결과".
  • 좀비 차단 = transactional.id의 epoch 증가 → ProducerFencedException.
  • isolation.level=read_committed = commit된 트랜잭션만 consumer가 읽음(EOS 필수, 단 LSO 지연 비용).
  • Streams EOS = processing.guarantee=exactly_once_v2. 프레임워크가 자동 처리(트랜잭션 API 복잡도 흡수).
  • 발전사: at-least-once(0.11 이전) → idempotent(0.11) → transactions(0.11) → EOS v2(2.5+) → 기본 true(3.0+). 각 단계가 좁은 범위 정확성 추가.
  • 경계: Kafka 클러스터 내만. 외부 DB/HTTP는 멱등 설계 필요. "Kafka가 exactly-once인가"는 범위를 정하고 물어야.
  • LSO(last stable offset): read_committed consumer가 읽을 수 있는 안전 경계. 진행 중 트랜잭션이 LSO를 멈추게 함 → transaction.timeout.ms로 상한 필수.
  • transaction.timeout.ms: 트랜잭션 시간 상한(기본 60초). 초과 시 강제 abort → LSO 정체·좀비 방지.
  • outbox 패턴: 외부 DB까지 exactly-once가 필요할 때 Kafka 트랜잭션 대신 outbox 테이블로 DB+produce를 원자화.
  • 결정 트리: 손실 허용→at-most-once, 중복 흡수 가능→at-least-once, consume-process-produce→EOS, 외부→outbox.

생각해 볼 문제

  1. at-most-once, at-least-once, exactly-once의 차이를 중복/손실 관점에서 설명하라.
  2. idempotent producer가 "exactly-once"인 정확한 범위는? 왜 "단일 partition" 한정인가?
  3. consume-process-produce에서 "출력과 offset을 한 트랜잭션으로"가 왜 exactly-once를 만드는가?
  4. transactional.id가 좀비(zombie)를 차단하는 원리를 설명하라. 왜 안정적이어야 하나?
  5. read_committed consumer가 abort된 트랜잭션을 안 보는 것이 왜 중요한가? 비용은?
  6. Kafka만으로 외부 DB 쓰기까지 exactly-once로 만들 수 없는 이유는? 어떤 패턴으로 보완하는가?
  7. Kafka Streams가 EOS를 "자동"으로 달성한다는 것이 왜 강력한가?
  8. LSO(last stable offset)가 무엇이고, 왜 진행 중 트랜잭션이 consumer 지연을 만드는가?
  9. transactional.id를 재시작마다 바꾸면 어떤 일이 벌어지는가?
  10. 외부 DB 쓰기까지 exactly-once가 필요할 때 outbox 패턴이 어떻게 동작하는가?
  11. at-least-once + 애플리케이션 멱등 vs EOS — 비용 관점에서 언제 어느 쪽을 택하는가?
  12. 전달 보장 발전사에서 idempotent producer와 transactions가 각각 어떤 갭을 메웠는가?
  13. EOS 검증에서 "고의 장애 주입 후 output에 중복 없는지 확인"이 핵심인 이유는?

참고

'Tech Artifacts > Kafka' 카테고리의 다른 글

Kafka - 10. kafka connect  (0) 2026.07.09
Kafka - 09. schema registry  (1) 2026.07.09
Kafka - 07. log retention  (0) 2026.07.09
Kafka - 06. replication  (0) 2026.07.09
Kafka - 05. consumer group  (0) 2026.07.09