Workspace IndexAlgorithms › Day 67

Idempotency and "Exactly Once" — The Outbox Pattern TODO

Algorithms · Day 67 / 100 · D. Distributed Systems & Consensus (Day 52-68)

Concept

In distributed systems, message delivery is in practice either at-most-once or at-least-once — pure exactly-once delivery is impossible. The realistic goal instead is exactly-once processing: 'retries can happen as many times as needed, but the effect happens once,' achieved by making the receiver idempotent. This is typically implemented by attaching a unique idempotency key to each request, storing the processing result under that key, and returning the stored result instead of reprocessing when the same key arrives again. The outbox pattern solves a related problem: a state change and publishing a message about it live in two different systems that can't be committed atomically together (the dual-write problem). The business data change and the message to be published are committed together, in the same DB transaction, into an outbox table; a separate relay process reads the outbox and forwards to the broker. That delivery is at-least-once, so it still needs idempotency on the consumer side as its pair.

In flows like payments and settlement, where duplicate execution directly means lost money, duplication or loss will eventually happen without an idempotency key and an outbox, as long as retries and failure recovery exist at all.

Code & Formula

# 멱등성과 "정확히 한 번" — 아웃박스 패턴 — 상태 변경과 메시지 발행을 한 트랜잭션에 묶고, 재시도는 멱등 키로 걸러낸다.
# 같은 멱등 키로 재시도해도 잔고는 한 번만 차감되고, 릴레이가 outbox를 중복 전송해도 소비자는 한 번만 반영한다.

ledger = {"alice": 1000}
outbox = []          # 상태 변경과 같은 트랜잭션에서 커밋된다고 가정하는 발행 대기 이벤트
processed_keys = {}  # idem_key -> 처리 결과 캐시

def withdraw(idem_key, account, amount):
    if idem_key in processed_keys:
        return f"[중복 요청 무시] 캐시된 결과 반환: {processed_keys[idem_key]}"
    ledger[account] -= amount
    outbox.append({"key": idem_key, "type": "withdrawn", "account": account, "amount": amount})
    result = f"{account} 잔고 {amount} 차감, 잔액={ledger[account]}"
    processed_keys[idem_key] = result
    return result

print(withdraw("req-1", "alice", 100))
print(withdraw("req-1", "alice", 100))   # 네트워크 재시도로 같은 요청이 다시 옴
print(withdraw("req-1", "alice", 100))   # 또 재시도
print("최종 잔액:", ledger["alice"], "(3번 호출됐지만 100만 한 번 차감)")

# 릴레이: outbox를 읽어 브로커로 보내되 at-least-once라 중복 전송될 수 있다 — 소비자는 key로 dedupe
consumer_applied = set()

def consume(event):
    if event["key"] in consumer_applied:
        return "소비자: 이미 반영된 이벤트, 무시"
    consumer_applied.add(event["key"])
    return f"소비자: {event['type']} 반영 완료"

print()
for _ in range(2):  # 릴레이가 같은 이벤트를 두 번 보냈다고 가정
    print(consume(outbox[0]))

Exercise

Attach an idempotency-key table and an outbox table to a withdrawal-processing API, then force-kill the process right after publishing and restart it, and verify through scenario testing that the message still has exactly one effect.

Practical Connection

In Verex, submitting on-chain settlement transactions routinely involves retries and replacements, so binding the off-chain ledger update and the transaction issuance together via an outbox, and making chain-event consumption idempotent, is the standard fix to prevent double settlement.

If you study this on a given day, add a note link and a ✅ to this line in the source curriculum (docs/knowledge/dev-100-curriculum.md) and this spot will lead straight to the note body. You can also write directly on this page — but regenerating overwrites it, so it's safer to keep anything you want to save as markdown under docs/algorithms/.


한국어

멱등성과 "정확히 한 번" TODO

Algorithms · Day 67 / 100 · D. 분산시스템·합의 (Day 52–68)

아웃박스 패턴

개념

분산 시스템에서 메시지 전달은 실질적으로 at-most-once 아니면 at-least-once이며, 순수한 exactly-once 전달은 불가능하다. 대신 현실적인 목표는 '재시도는 얼마든지 일어나되 효과는 한 번'인 exactly-once 처리이고, 이는 수신 측이 멱등해야 달성된다. 멱등성은 보통 요청마다 고유한 멱등 키를 붙이고, 처리 결과를 그 키로 저장해 두었다가 같은 키가 다시 오면 새로 처리하지 않고 저장된 결과를 돌려주는 식으로 구현한다. 아웃박스 패턴은 '상태 변경'과 '메시지 발행'이 서로 다른 시스템이라 원자적으로 묶이지 않는 문제(이중 쓰기)를 해결한다. 비즈니스 데이터 변경과 발행할 메시지를 같은 DB 트랜잭션 안에서 outbox 테이블에 함께 커밋하고, 별도 릴레이 프로세스가 outbox를 읽어 브로커로 보내며, 그 전송은 at-least-once이므로 소비자 쪽 멱등성이 짝으로 필요하다.

결제·정산처럼 중복 실행이 곧 금전 손실인 흐름에서, 재시도와 장애 복구가 있는 한 멱등 키와 아웃박스 없이는 언젠가 반드시 중복이나 유실이 발생한다.

코드 · 수식

# 멱등성과 "정확히 한 번" — 아웃박스 패턴 — 상태 변경과 메시지 발행을 한 트랜잭션에 묶고, 재시도는 멱등 키로 걸러낸다.
# 같은 멱등 키로 재시도해도 잔고는 한 번만 차감되고, 릴레이가 outbox를 중복 전송해도 소비자는 한 번만 반영한다.

ledger = {"alice": 1000}
outbox = []          # 상태 변경과 같은 트랜잭션에서 커밋된다고 가정하는 발행 대기 이벤트
processed_keys = {}  # idem_key -> 처리 결과 캐시

def withdraw(idem_key, account, amount):
    if idem_key in processed_keys:
        return f"[중복 요청 무시] 캐시된 결과 반환: {processed_keys[idem_key]}"
    ledger[account] -= amount
    outbox.append({"key": idem_key, "type": "withdrawn", "account": account, "amount": amount})
    result = f"{account} 잔고 {amount} 차감, 잔액={ledger[account]}"
    processed_keys[idem_key] = result
    return result

print(withdraw("req-1", "alice", 100))
print(withdraw("req-1", "alice", 100))   # 네트워크 재시도로 같은 요청이 다시 옴
print(withdraw("req-1", "alice", 100))   # 또 재시도
print("최종 잔액:", ledger["alice"], "(3번 호출됐지만 100만 한 번 차감)")

# 릴레이: outbox를 읽어 브로커로 보내되 at-least-once라 중복 전송될 수 있다 — 소비자는 key로 dedupe
consumer_applied = set()

def consume(event):
    if event["key"] in consumer_applied:
        return "소비자: 이미 반영된 이벤트, 무시"
    consumer_applied.add(event["key"])
    return f"소비자: {event['type']} 반영 완료"

print()
for _ in range(2):  # 릴레이가 같은 이벤트를 두 번 보냈다고 가정
    print(consume(outbox[0]))

연습

출금 처리 API에 멱등 키 테이블과 outbox 테이블을 붙여, 발행 직후 프로세스를 강제 종료했다 재시작해도 메시지가 정확히 한 번의 효과만 내는지 시나리오 테스트로 확인하라.

실무 · Verex 연결

Verex에서 온체인 정산 트랜잭션 제출은 재시도·리플레이스가 일상이므로, 오프체인 원장 갱신과 트랜잭션 발행을 아웃박스로 묶고 체인 이벤트 소비를 멱등하게 만드는 것이 이중 정산을 막는 표준 해법이다.

공부한 날 원본 커리큘럼(docs/knowledge/dev-100-curriculum.md)의 이 줄에 노트 링크와 ✅ 를 붙이면, 이 자리는 노트 본문으로 바로 이어집니다. 노트 없이 이 페이지에 바로 적어도 됩니다 — 다만 다시 생성하면 덮어쓰이므로, 남길 글은 docs/algorithms/ 의 마크다운으로 쓰는 편이 안전합니다.

← 66. PBS·MEV 경매·타이밍 게임68. [복습] 장애 모델과 신뢰 가정을 먼저 쓰는 습관 →