Skip to content

Split View: Exactly-once는 환상인가: 결제·메시징의 정확성

|

Exactly-once는 환상인가: 결제·메시징의 정확성

들어가며 — "정확히 한 번"이라는 유혹

메시지 큐나 결제 시스템을 처음 다루면 누구나 같은 소원을 빕니다. "메시지가 딱 한 번만 처리되면 좋겠다." 결제는 한 번만 청구되어야 하고, 이메일은 한 번만 발송되어야 하며, 재고는 한 번만 차감되어야 합니다. 두 번 청구되면 고객이 분노하고, 한 번도 청구 안 되면 회사가 손해입니다. 그래서 우리는 **exactly-once(정확히 한 번)**를 갈망합니다.

그런데 분산 시스템을 공부하면 곧 당황스러운 문장을 만납니다. "exactly-once 전달은 불가능하다." 이 말은 우리의 소원을 정면으로 부정하는 듯 보입니다. 하지만 동시에 Kafka 같은 시스템은 "exactly-once semantics"를 광고합니다. 대체 뭐가 맞는 걸까요?

이 글의 목표는 이 혼란을 걷어내는 것입니다. 핵심은 하나의 구분에 있습니다. exactly-once "전달(delivery)"은 불가능하지만, exactly-once "처리(processing)"는 달성 가능하다. 이 문장의 의미를 끝까지 풀어내면, 결제와 메시징의 정확성을 어떻게 설계해야 하는지가 명확해집니다.

이 개념들과 이어지는 메시지 흐름을 눈으로 보고 싶다면, 이 사이트의 메시지 큐 놀이터에서 시각화해 볼 수 있습니다.

전달 보장의 세 등급

메시징 시스템이 "메시지를 전달한다"고 할 때, 그 보장에는 세 가지 등급이 있습니다. 이 셋을 정확히 구분하는 것이 모든 논의의 출발점입니다.

At-most-once(최대 한 번). 메시지는 한 번 전달되거나, 아예 전달되지 않습니다. 결코 중복은 없지만, 유실은 있을 수 있습니다. 보내는 쪽이 "일단 보내고 확인은 안 하는" 방식입니다. 빠르고 단순하지만, 잃어도 괜찮은 데이터(예: 대량의 메트릭 샘플, 일부 로그)에만 적합합니다.

At-least-once(최소 한 번). 메시지는 반드시 한 번 이상 전달됩니다. 유실은 없지만, 중복이 있을 수 있습니다. 보내는 쪽이 확인 응답(ack)을 받을 때까지 재전송하기 때문입니다. 대부분의 실용적 메시징 시스템의 기본값입니다. "잃지 않는" 대신 "중복될 수 있다"를 받아들이는 것입니다.

Exactly-once(정확히 한 번). 메시지가 정확히 한 번, 유실도 중복도 없이 전달됩니다. 가장 이상적으로 들리지만, 곧 보겠듯 "전달" 수준에서 이것을 순수하게 달성하는 것은 원리적으로 불가능합니다.

  at-most-once : 0 또는 1번  (유실 가능, 중복 없음)
  at-least-once: 1번 이상     (유실 없음, 중복 가능)
  exactly-once : 정확히 1번   (유실도 중복도 없음 — 이상)

여기서 중요한 직관: at-most-once와 at-least-once는 정반대의 트레이드오프입니다. 하나는 유실을 감수하고 중복을 없애고, 다른 하나는 중복을 감수하고 유실을 없앱니다. 그리고 현실의 신뢰성 있는 시스템은 거의 항상 at-least-once를 택합니다. 유실은 보통 중복보다 훨씬 치명적이기 때문입니다. 그렇다면 남는 문제는 "그 중복을 어떻게 무해하게 만드느냐"입니다.

왜 exactly-once 전달은 불가능한가

exactly-once 전달이 불가능한 이유는 심오한 정리가 아니라, 아주 단순한 상황에서 나옵니다. 바로 네트워크는 실패하고, 실패는 구분이 안 된다는 사실입니다.

발신자 A가 수신자 B에게 메시지를 보내고 ack를 기다리는 상황을 봅시다. A가 ack를 못 받았습니다. 그런데 A는 두 경우를 절대 구분할 수 없습니다.

  경우 1: 메시지가 B에 도달 못 함
    A --메시지--X (유실)     B
    -> B는 못 받음. A는 재전송해야 함.

  경우 2: 메시지는 도달했지만 ack가 유실됨
    A --메시지--> B (처리함!)
    A <--ack--X (유실)
    -> B는 이미 받았음. A가 재전송하면 중복!

A 입장에서 두 경우는 똑같이 보입니다. "메시지를 보냈는데 ack가 안 왔다." 여기서 A는 딜레마에 빠집니다.

  • 재전송하면: 경우 1은 해결되지만, 경우 2에서는 B가 메시지를 두 번 받습니다(중복). → at-least-once
  • 재전송 안 하면: 경우 2는 안전하지만, 경우 1에서는 메시지가 영영 유실됩니다. → at-most-once

즉 "ack가 안 왔을 때 재전송할지 말지"라는 단 하나의 선택에서 at-least-once와 at-most-once가 갈립니다. 그리고 그 사이의 완벽한 지점, "유실도 중복도 없는 전달"은 이 근본적 모호성 때문에 전달 자체의 수준에서는 존재할 수 없습니다. 네트워크가 언제든 끊길 수 있는 한, 발신자는 "상대가 받았는가"를 확실히 알 방법이 없기 때문입니다.

두 장군 문제 — 합의의 불가능성

이 모호성을 가장 선명하게 보여 주는 고전이 **두 장군 문제(Two Generals' Problem)**입니다. 분산 합의의 근본적 어려움을 드러내는 사고 실험입니다.

두 장군이 각각 언덕에 진을 치고 있고, 사이의 계곡에는 적이 있습니다. 두 군대가 동시에 공격해야만 이깁니다. 한쪽만 공격하면 패배합니다. 장군들은 전령을 계곡 너머로 보내 통신하는데, 전령은 적에게 붙잡혀 사라질 수 있습니다.

  장군 A: "새벽에 공격하자" --전령--> 장군 B
    (전령이 잡히면? A는 B가 받았는지 모름)

  장군 B가 받고 "동의!" --전령--> 장군 A
    (이 전령이 잡히면? B는 A가 자기 동의를 받았는지 모름)

  A가 그 동의를 받고 "확인!" --전령--> ...
    (이것도 잡힐 수 있음. 끝없는 확인의 확인의 확인...)

핵심은 이것입니다. 아무리 많은 확인 메시지를 주고받아도, 마지막 메시지가 도달했는지는 보낸 쪽이 결코 확신할 수 없습니다. 마지막 전령이 잡혔을 수도 있으니까요. 그래서 두 장군은 "우리 둘 다 확실히 안다"는 **공통 지식(common knowledge)**에 유한한 메시지 교환으로는 도달할 수 없습니다. 이것이 증명된 불가능성입니다.

두 장군 문제가 우리에게 주는 교훈은 명확합니다. 신뢰할 수 없는 채널 위에서 "확실한 한 번의 합의"는 불가능합니다. 그래서 우리는 확실성을 포기하는 대신, 다른 층위에서 문제를 푸는 우회로를 찾아야 합니다. 그 우회로가 바로 다음 이야기입니다.

전달이 아니라 처리 — 발상의 전환

여기서 결정적 전환이 일어납니다. exactly-once 전달이 불가능하다면, 목표를 바꿉시다. 우리가 진짜로 원하는 것은 "메시지가 정확히 한 번 전달되는 것"이 아니라 "그 결과가 정확히 한 번 반영되는 것"입니다.

고객이 원하는 것은 "결제 메시지가 네트워크를 정확히 한 번 건너는 것"이 아닙니다. "청구가 정확히 한 번만 일어나는 것"입니다. 이 둘은 다릅니다. 메시지가 네트워크를 두 번 건너와도, 청구가 한 번만 일어나면 고객은 만족합니다.

이것이 **exactly-once processing(정확히 한 번 처리)**의 아이디어입니다. 전달은 at-least-once로 두고(즉 중복을 허용하고), 대신 수신 측에서 중복을 무해하게 흡수하여 최종 효과가 정확히 한 번이 되게 만드는 것입니다.

  포기: exactly-once 전달   (원리적으로 불가능)
  채택: at-least-once 전달  (중복 가능하지만 유실 없음)
        + 수신 측에서 중복 흡수
        = exactly-once 처리  (최종 효과는 한 번)

이 전환의 아름다움은, 불가능한 것을 억지로 하려 하지 않고 가능한 것들의 조합으로 원하는 결과를 만든다는 데 있습니다. 그 조합의 핵심 재료가 세 가지, 멱등성·중복 제거·트랜잭션 아웃박스입니다. 하나씩 봅시다.

멱등성 — 몇 번 해도 같은 결과

exactly-once 처리의 가장 중요한 무기는 **멱등성(idempotency)**입니다. 어떤 연산이 멱등하다는 것은, 그것을 한 번 하든 여러 번 하든 결과가 같다는 뜻입니다.

몇 가지 예로 감을 잡읍시다.

  • "잔액을 200으로 설정한다" → 멱등합니다. 몇 번 실행해도 결과는 잔액 200입니다.
  • "잔액에서 100을 뺀다" → 멱등하지 않습니다. 두 번 실행하면 200이 빠집니다.
  • "이 주문을 '결제 완료' 상태로 표시한다" → 멱등합니다. 여러 번 해도 상태는 '결제 완료' 하나입니다.

핵심은, 중복 전달이 일어나도 처리가 멱등하면 중복이 자동으로 무해해진다는 것입니다. 그래서 exactly-once 처리 설계의 첫걸음은 "가능한 한 연산을 멱등하게 만들기"입니다.

문제는 "100을 뺀다" 같은 본질적으로 멱등하지 않은 연산입니다. 결제나 재고 차감이 대개 이런 성질입니다. 이런 경우 연산 자체를 멱등하게 바꿀 수 없다면, "이미 처리했는지 기억"해서 중복을 걸러야 합니다. 그것이 다음 재료인 중복 제거입니다.

중복 제거 — 이미 본 것을 기억하기

본질적으로 멱등하지 않은 연산을 멱등하게 만드는 표준 기법이 **중복 제거(deduplication)**입니다. 아이디어는 단순합니다. 각 메시지(또는 요청)에 고유한 식별자를 붙이고, 수신 측이 "이미 처리한 식별자 목록"을 기억해, 같은 식별자가 다시 오면 무시합니다.

  요청에 고유 키를 붙임:  idempotency-key: "abc-123"

  수신 측 처리:
    IF "abc-123"를 이미 처리했는가?
      YES -> 아무것도 안 하고, 지난번 결과를 반환 (중복 흡수)
      NO  -> 처리하고, "abc-123 처리됨"을 기록

결제 API에서 흔히 보는 **멱등성 키(idempotency key)**가 바로 이것입니다. 클라이언트가 결제 요청에 고유 키를 담아 보내면, 서버는 그 키로 중복을 판별합니다. 네트워크 문제로 클라이언트가 같은 요청을 재전송해도, 서버는 같은 키를 보고 "아, 이건 이미 처리했다"며 새 청구를 만들지 않고 지난 결과를 돌려줍니다.

여기서 미묘하지만 결정적인 지점이 있습니다. "처리했다는 기록"과 "실제 처리"가 원자적으로 함께 일어나야 합니다. 만약 처리는 했는데 "처리됨" 기록을 남기기 직전에 죽으면, 다음 재시도 때 또 처리해 버립니다. 반대로 기록만 남기고 처리 전에 죽으면, 처리가 유실됩니다. 그래서 중복 제거는 반드시 처리와 하나의 트랜잭션으로 묶여야 하고, 이 지점에서 세 번째 재료가 등장합니다.

트랜잭션 아웃박스 — 처리와 발행을 하나로

앞 글에서 다룬 아웃박스 패턴이 여기서 다시 핵심 역할을 합니다. exactly-once 처리에서 아웃박스는 두 가지를 원자적으로 묶어 줍니다. 하나는 "메시지 처리 결과(상태 변경)"이고, 다른 하나는 "그 처리를 했다는 기록(그리고 필요하면 발행할 다음 메시지)"입니다.

전형적인 소비자의 exactly-once 처리 흐름은 이렇습니다.

  메시지 수신 (메시지에는 고유 id가 있음)
    |
    v
  하나의 로컬 트랜잭션 안에서:
    BEGIN
      IF 이 메시지 id가 처리 테이블에 이미 있으면 -> 아무것도 안 하고 종료
      비즈니스 처리 (예: 상태 변경)
      INSERT 처리된_메시지_id            -- "봤다"는 기록
      INSERT INTO outbox (다음 이벤트)   -- 필요하면 다음 메시지
    COMMIT   -- 처리·기록·발행이 전부 함께, 또는 전무

이 구조의 힘은, "처리했다는 기록"과 "실제 처리"가 같은 데이터베이스의 같은 트랜잭션 안에 있어 절대 어긋날 수 없다는 것입니다. 트랜잭션이 커밋되면 셋 다 반영되고, 실패하면 셋 다 없던 일이 됩니다. 중간에 죽어도 일관됩니다. 그리고 발행은 여전히 at-least-once지만(아웃박스가 그렇습니다), 다음 소비자도 같은 멱등/중복 제거 구조를 가지면, 이 사슬 전체가 각 단계에서 "정확히 한 번의 효과"를 유지합니다.

정리하면, exactly-once 처리는 세 재료의 조합입니다.

재료역할
at-least-once 전달유실을 막는다 (중복은 감수)
멱등성 / 중복 제거중복을 무해하게 만든다
트랜잭션 아웃박스처리와 기록을 원자적으로 묶는다

Kafka의 exactly-once는 무엇을 보장하나

이 지점에서 자주 나오는 질문. "그런데 Kafka는 exactly-once를 지원한다고 하지 않나?" 맞습니다. 다만 그것이 무엇을 뜻하는지 정확히 알아야 합니다.

Kafka의 **exactly-once semantics(EOS)**는 마법으로 물리 법칙을 이긴 것이 아닙니다. 위에서 본 원리들을 프로토콜 수준에서 구현한 것입니다. 두 축이 핵심입니다.

  • 멱등 프로듀서(idempotent producer): 프로듀서가 각 메시지에 시퀀스 번호를 붙이고, 브로커가 이를 검사해 재전송으로 인한 중복 기록을 브로커 로그 수준에서 제거합니다. 즉 프로듀서가 네트워크 문제로 재전송해도 로그에는 한 번만 남습니다.
  • 트랜잭션(transactions): 여러 파티션에 걸친 쓰기와, 소비 오프셋 커밋을 하나의 원자적 트랜잭션으로 묶습니다. 이로써 "메시지를 읽고(consume) → 처리하고 → 결과를 쓰고(produce) → 오프셋을 커밋"하는 사슬이 전부 함께 확정되거나 전부 취소됩니다.

여기서 결정적인 조건이 있습니다. Kafka의 EOS가 진짜로 성립하는 것은 "Kafka에서 읽어 Kafka로 쓰는" 닫힌 세계 안에서입니다. 처리와 오프셋 커밋과 출력이 모두 Kafka 트랜잭션에 들어가기 때문입니다. 그런데 처리가 외부 시스템(예: 결제 게이트웨이 호출, 이메일 발송, 다른 DB 쓰기)에 부수 효과를 내는 순간, 그 외부 효과는 Kafka 트랜잭션 밖에 있습니다.

  Kafka EOS가 완결되는 경우:
    Kafka 읽기 -> 처리 -> Kafka 쓰기 (+오프셋)  <- 전부 한 트랜잭션

  EOS가 자동으로 커버 못 하는 경우:
    Kafka 읽기 -> 외부 결제 API 호출  <- 이 호출은 Kafka 밖!
    -> 여기는 여전히 멱등성 키/중복 제거가 필요

그래서 Kafka의 exactly-once조차, 외부 부수 효과가 있는 순간에는 결국 우리가 앞서 본 멱등성과 중복 제거로 그 경계를 막아야 합니다. Kafka EOS는 강력하지만 만능이 아니며, "닫힌 스트림 처리"라는 조건 안에서 강력한 것입니다. 이 조건을 오해하면 "Kafka를 쓰니 중복 걱정 끝"이라는 위험한 착각에 빠집니다.

결제 시스템에서의 실전

이 원리들을 결제라는 가장 민감한 영역에 적용해 봅시다. 결제는 중복이 곧 돈이므로, exactly-once 처리의 원칙이 특히 중요합니다.

  • 모든 결제 요청에 멱등성 키를 요구하세요. 클라이언트가 생성한 고유 키(주문 id 기반이 흔합니다)를 결제 요청에 담게 하고, 서버는 이 키로 중복 청구를 차단합니다. 네트워크 타임아웃 후 클라이언트가 재시도해도 이중 청구가 안 나게 하는 유일하게 견고한 방법입니다.
  • "청구했다는 기록"과 "청구"를 원자적으로 묶으세요. 외부 결제 게이트웨이 호출은 트랜잭션 밖이므로 미묘합니다. 흔한 패턴은 "요청 시작"을 먼저 기록하고(pending), 게이트웨이 응답을 받아 상태를 확정하며, 그 사이 재시도는 멱등성 키로 판별하는 것입니다.
  • 웹훅 수신도 멱등하게. 결제 게이트웨이가 보내는 결제 완료 웹훅은 대개 at-least-once로, 같은 이벤트가 여러 번 올 수 있습니다. 웹훅 처리도 반드시 이벤트 id 기반 중복 제거를 해야 합니다.
  • 조정(reconciliation)을 두세요. 아무리 잘 설계해도 분산 시스템은 어긋날 수 있습니다. 주기적으로 우리 기록과 결제사 기록을 대조해 불일치를 찾아내는 조정 절차가 최후의 안전망입니다.

핵심 메시지는 이것입니다. 결제에서 "exactly-once"는 네트워크가 마법처럼 보장해 주는 것이 아니라, 멱등성 키와 중복 제거와 조정으로 우리가 만들어 내는 성질입니다.

실무 지침 정리

지금까지의 내용을 압축합니다.

"exactly-once 전달"을 약속하는 시스템을 경계하세요. 순수한 exactly-once 전달은 원리적으로 불가능합니다. 그런 약속은 대개 "at-least-once + 중복 제거"를 그렇게 부르는 것이거나, 특정 닫힌 조건 안에서만 성립합니다. 조건을 반드시 확인하세요.

at-least-once를 기본으로 받아들이세요. 유실은 중복보다 대개 더 치명적입니다. 그래서 신뢰성 있는 시스템은 at-least-once를 택하고, 남는 중복 문제를 처리 층에서 풉니다.

연산을 최대한 멱등하게 설계하세요. "설정"이 "증감"보다 낫습니다. 본질적으로 멱등하지 않다면 고유 식별자로 중복 제거하세요.

중복 제거와 실제 처리를 하나의 트랜잭션으로 묶으세요. "봤다는 기록"과 "처리"가 어긋나면 exactly-once가 무너집니다. 아웃박스 패턴이 이 원자성을 줍니다.

최후의 안전망으로 조정을 두세요. 특히 결제처럼 중복이 치명적인 곳에서는, 주기적 대조로 불일치를 잡아내는 절차가 반드시 필요합니다.

마치며

"exactly-once는 환상인가?"라는 물음에 대한 답은 "예이면서 아니오"입니다. exactly-once 전달은 환상입니다. 네트워크가 끊길 수 있고 실패가 구분되지 않는 한, 두 장군 문제가 말하듯 신뢰할 수 없는 채널 위의 확실한 합의는 불가능합니다. 그러나 exactly-once 처리는 환상이 아닙니다. at-least-once 전달을 받아들이고, 멱등성과 중복 제거로 중복을 무해하게 만들고, 트랜잭션 아웃박스로 처리와 기록을 원자적으로 묶으면, 최종 효과가 정확히 한 번인 시스템을 실제로 만들 수 있습니다.

Kafka의 exactly-once조차 이 원리 위에 서 있습니다. 그것은 닫힌 스트림 처리 안에서 멱등 프로듀서와 트랜잭션으로 이 개념들을 구현한 것이며, 외부 부수 효과가 끼는 순간 우리는 다시 멱등성과 중복 제거로 경계를 지켜야 합니다.

그래서 실무의 지혜는 "정확히 한 번을 전달받으려" 애쓰는 것이 아니라, "여러 번 와도 한 번의 효과만 남도록" 설계하는 것입니다. 이 관점의 전환이야말로, 결제와 메시징의 정확성을 환상이 아닌 공학으로 만드는 열쇠입니다.

참고 자료

The Truth About Exactly-Once Semantics

Introduction — The Temptation of "Exactly Once"

The first time anyone works with a message queue or a payment system, they make the same wish: "I hope each message is processed exactly once." A payment should be charged once, an email sent once, stock decremented once. Charge twice and the customer is furious; charge zero times and the company loses money. So we crave exactly-once.

But study distributed systems and you soon meet a bewildering sentence: "exactly-once delivery is impossible." This seems to flatly deny our wish. Yet at the same time, systems like Kafka advertise "exactly-once semantics." So which is it?

The goal of this post is to clear up that confusion. The key is a single distinction: exactly-once delivery is impossible, but exactly-once processing is achievable. Unpack that sentence fully, and it becomes clear how to design for correctness in payments and messaging.

If you want to see the message flow that connects to these ideas, you can visualize it in this site's Message Queue Playground.

The Three Delivery Guarantees

When a messaging system says it "delivers a message," that guarantee comes in three grades. Distinguishing these precisely is the starting point for everything.

At-most-once. A message is delivered once, or not at all. There is never a duplicate, but there can be loss. The sender "fires and doesn't check." Fast and simple, but suitable only for data you can afford to lose (e.g., bulk metric samples, some logs).

At-least-once. A message is always delivered at least once. There is no loss, but there can be duplicates. The sender retransmits until it receives an acknowledgment (ack). This is the default of most practical messaging systems. You accept "may be duplicated" in exchange for "never lost."

Exactly-once. A message is delivered exactly once, with neither loss nor duplication. It sounds the most ideal, but as we'll see, achieving this purely at the "delivery" level is fundamentally impossible.

  at-most-once : 0 or 1 time   (loss possible, no duplicates)
  at-least-once: 1 or more     (no loss, duplicates possible)
  exactly-once : exactly 1     (no loss, no duplicates — the ideal)

An important intuition here: at-most-once and at-least-once are opposite trade-offs. One tolerates loss to eliminate duplicates; the other tolerates duplicates to eliminate loss. And real reliable systems almost always choose at-least-once, because loss is usually far more catastrophic than duplication. The remaining problem, then, is "how do we make that duplication harmless?"

Why Exactly-Once Delivery Is Impossible

The reason exactly-once delivery is impossible is not a deep theorem — it emerges from a very simple situation: the network fails, and failures are indistinguishable.

Consider a sender A sending a message to receiver B and waiting for an ack. A didn't receive an ack. But A can never distinguish between two cases.

  Case 1: the message never reached B
    A --message--X (lost)     B
    -> B didn't receive it. A must retransmit.

  Case 2: the message arrived but the ack was lost
    A --message--> B (processed it!)
    A <--ack--X (lost)
    -> B already received it. If A retransmits, that's a duplicate!

From A's point of view, the two cases look identical: "I sent a message but no ack came back." Here A faces a dilemma.

  • If it retransmits: Case 1 is resolved, but in Case 2, B receives the message twice (duplicate). → at-least-once
  • If it doesn't retransmit: Case 2 is safe, but in Case 1, the message is lost forever. → at-most-once

So at-least-once and at-most-once fork on a single choice: "whether to retransmit when no ack arrives." And the perfect point in between — "delivery with neither loss nor duplication" — cannot exist at the level of delivery itself, because of this fundamental ambiguity. As long as the network can disconnect at any time, the sender has no way to know for certain "did the other side receive it."

The Two Generals Problem — The Impossibility of Agreement

The classic that shows this ambiguity most sharply is the Two Generals' Problem, a thought experiment that reveals the fundamental difficulty of distributed agreement.

Two generals are each encamped on a hill, with the enemy in the valley between them. Their armies win only if they attack simultaneously. If only one attacks, they lose. The generals communicate by sending a messenger across the valley, but the messenger can be captured and lost.

  General A: "attack at dawn" --messenger--> General B
    (if the messenger is captured? A doesn't know B received it)

  General B receives it and "agreed!" --messenger--> General A
    (if this messenger is captured? B doesn't know A got the agreement)

  A receives the agreement and "confirmed!" --messenger--> ...
    (this too can be captured. an endless confirmation of confirmation...)

The key is this: no matter how many confirmation messages they exchange, the sender can never be certain the last message arrived — because the last messenger might have been captured. So the two generals cannot reach the common knowledge that "we both know for sure" through any finite exchange of messages. This is a proven impossibility.

The lesson the Two Generals Problem gives us is clear. Over an unreliable channel, "certain, single-shot agreement" is impossible. So instead of pursuing certainty, we must find a detour that solves the problem at a different level. That detour is the next part of the story.

Not Delivery but Processing — A Shift in Thinking

Here a decisive shift happens. If exactly-once delivery is impossible, let's change the goal. What we truly want is not "the message is delivered exactly once" but "its effect is applied exactly once."

What the customer wants is not "the payment message crosses the network exactly once." It's "the charge happens exactly once." These are different. Even if the message crosses the network twice, as long as the charge happens once, the customer is satisfied.

This is the idea of exactly-once processing. Leave delivery as at-least-once (that is, allow duplicates), and instead absorb the duplicates harmlessly on the receiving side so that the final effect is exactly once.

  Give up: exactly-once delivery   (fundamentally impossible)
  Adopt:   at-least-once delivery  (duplicates possible, no loss)
           + absorb duplicates on the receiving side
           = exactly-once processing  (final effect is once)

The beauty of this shift is that instead of forcing the impossible, we compose achievable things to produce the desired result. The three key ingredients of that composition are idempotency, deduplication, and the transactional outbox. Let's look at each.

Idempotency — The Same Result No Matter How Many Times

The most important weapon of exactly-once processing is idempotency. An operation being idempotent means that doing it once or many times produces the same result.

Let's get a feel with a few examples.

  • "Set the balance to 200" → idempotent. However many times you run it, the result is a balance of 200.
  • "Subtract 100 from the balance" → not idempotent. Run it twice and 200 is subtracted.
  • "Mark this order as 'paid'" → idempotent. However many times you do it, the state is a single 'paid'.

The key is that even if duplicate delivery occurs, if the processing is idempotent, the duplicate automatically becomes harmless. So the first step of designing exactly-once processing is "make operations idempotent wherever possible."

The problem is inherently non-idempotent operations like "subtract 100." Payments and stock decrements are usually of this kind. In such cases, if you can't make the operation itself idempotent, you must "remember whether it was already processed" and filter out duplicates. That is the next ingredient: deduplication.

Deduplication — Remembering What You've Already Seen

The standard technique for making an inherently non-idempotent operation idempotent is deduplication. The idea is simple: attach a unique identifier to each message (or request), have the receiving side remember a "list of already-processed identifiers," and ignore any identifier that arrives again.

  Attach a unique key to the request:  idempotency-key: "abc-123"

  Receiving-side processing:
    IF "abc-123" has already been processed?
      YES -> do nothing, return the previous result (absorb the duplicate)
      NO  -> process it, and record "abc-123 processed"

The idempotency key you often see in payment APIs is exactly this. When the client sends a payment request with a unique key, the server uses that key to detect duplicates. Even if a network problem makes the client retransmit the same request, the server sees the same key, thinks "ah, this was already processed," and returns the previous result rather than creating a new charge.

There's a subtle but decisive point here. The "record that it was processed" and the "actual processing" must happen atomically together. If it processes but dies just before recording "processed," the next retry processes it again. Conversely, if it records but dies before processing, the processing is lost. So deduplication must be bundled with processing into one transaction — and this is where the third ingredient appears.

The Transactional Outbox — Bundling Processing and Publishing

The outbox pattern covered in the previous post plays a key role again here. In exactly-once processing, the outbox atomically bundles two things: the "result of processing a message (state change)" and the "record that this processing happened (plus, if needed, the next message to publish)."

A typical consumer's exactly-once processing flow looks like this:

  Receive a message (the message has a unique id)
    |
    v
  Within one local transaction:
    BEGIN
      IF this message id is already in the processed table -> do nothing, end
      business processing (e.g., state change)
      INSERT processed_message_id           -- the record that we "saw" it
      INSERT INTO outbox (next event)        -- the next message, if needed
    COMMIT   -- processing, record, and publish all together, or none

The power of this structure is that the "record that it was processed" and the "actual processing" live in the same transaction of the same database, so they can never drift apart. If the transaction commits, all three take effect; if it fails, all three never happened. It stays consistent even if it dies in the middle. And publishing is still at-least-once (that's how the outbox works), but if the next consumer also has the same idempotency/deduplication structure, the whole chain maintains "exactly-once effect" at each stage.

To summarize, exactly-once processing is a composition of three ingredients:

IngredientRole
at-least-once deliveryprevents loss (tolerates duplicates)
idempotency / deduplicationmakes duplicates harmless
transactional outboxatomically bundles processing and record

What Kafka Exactly-Once Actually Guarantees

At this point, a common question: "But doesn't Kafka support exactly-once?" It does. You just need to know precisely what that means.

Kafka's exactly-once semantics (EOS) did not beat the laws of physics by magic. It implements the very principles above at the protocol level. Two axes are key.

  • Idempotent producer: the producer attaches a sequence number to each message, and the broker checks it to eliminate duplicate writes caused by retransmission at the broker-log level. So even if the producer retransmits due to a network problem, the log retains it only once.
  • Transactions: writes across multiple partitions, and the consumer offset commit, are bundled into one atomic transaction. This makes the chain "consume a message → process → produce results → commit the offset" all finalize together or all cancel together.

There's a decisive condition here. Kafka's EOS truly holds within the closed world of "read from Kafka, write to Kafka" — because the processing, offset commit, and output all go into the Kafka transaction. But the moment the processing produces a side effect on an external system (e.g., calling a payment gateway, sending an email, writing to another DB), that external effect is outside the Kafka transaction.

  Where Kafka EOS is complete:
    read Kafka -> process -> write Kafka (+offset)  <- all one transaction

  Where EOS does not automatically cover:
    read Kafka -> call external payment API  <- this call is outside Kafka!
    -> here you still need an idempotency key / deduplication

So even Kafka's exactly-once, the moment there's an external side effect, ultimately needs the idempotency and deduplication we saw earlier to seal that boundary. Kafka EOS is powerful but not omnipotent — it is powerful within the condition of "closed stream processing." Misunderstand this condition and you fall into the dangerous illusion that "since we use Kafka, duplicate worries are over."

In Practice, in Payment Systems

Let's apply these principles to the most sensitive domain: payments. Because in payments a duplicate is literally money, the principles of exactly-once processing matter especially.

  • Require an idempotency key on every payment request. Have the client generate a unique key (often based on the order id) and include it in the payment request, and have the server block duplicate charges by that key. It's the only robust way to prevent a double charge when the client retries after a network timeout.
  • Bundle the "record of charging" with the "charge" atomically. The external payment gateway call is outside the transaction, so it's subtle. A common pattern is to record "request started" first (pending), finalize the state on the gateway's response, and use the idempotency key to detect retries in between.
  • Make webhook receipt idempotent too. The payment-completed webhook the gateway sends is usually at-least-once, so the same event can arrive several times. Webhook processing must also deduplicate based on the event id.
  • Have a reconciliation process. No matter how well designed, distributed systems can drift. A reconciliation procedure that periodically compares your records against the payment provider's records to catch discrepancies is the last safety net.

The core message is this. In payments, "exactly-once" is not something the network magically guarantees — it is a property we create through idempotency keys, deduplication, and reconciliation.

Practical Guidance

Let's compress everything.

Be wary of any system that promises "exactly-once delivery." Pure exactly-once delivery is fundamentally impossible. Such a promise is usually calling "at-least-once + deduplication" by that name, or holds only within a specific closed condition. Always check the condition.

Accept at-least-once as the default. Loss is usually more catastrophic than duplication. So reliable systems choose at-least-once and solve the remaining duplication problem at the processing layer.

Design operations to be idempotent as much as possible. "Set" beats "increment/decrement." If something is inherently non-idempotent, deduplicate with a unique identifier.

Bundle deduplication and the actual processing into one transaction. If the "record that it was seen" and the "processing" drift apart, exactly-once collapses. The outbox pattern gives you this atomicity.

Have reconciliation as the last safety net. Especially where duplicates are catastrophic, like payments, a procedure that catches discrepancies through periodic comparison is essential.

Wrapping Up

The answer to "is exactly-once an illusion?" is "yes and no." Exactly-once delivery is an illusion. As long as the network can disconnect and failures are indistinguishable, certain agreement over an unreliable channel is impossible, just as the Two Generals Problem says. But exactly-once processing is not an illusion. Accept at-least-once delivery, make duplicates harmless with idempotency and deduplication, and atomically bundle processing and record with a transactional outbox, and you really can build a system whose final effect is exactly once.

Even Kafka's exactly-once stands on these principles. It implements these concepts with an idempotent producer and transactions within closed stream processing, and the moment an external side effect enters, we must again guard the boundary with idempotency and deduplication.

So the practical wisdom is not to strive to "be delivered exactly once," but to design so that "even if it arrives many times, only one effect remains." This shift in perspective is precisely the key to making correctness in payments and messaging an engineering discipline rather than an illusion.

References