Outbox: don't dual-write

outbox
Mechanism lab

Outbox: don't dual-write

Dual-write (DB commit then external call) loses the event if the process crashes between them. Fix: write an outbox row in the same transaction and have a relay deliver it later (at-least-once).

You'll predict the failure first, then write the fix and watch your own code drive the behavior.

Understand it step by step (한국어로 이해 → 영어로 말하기)

주문을 DB에 저장하고, 결제 서비스에도 "주문 생겼다"고 알려야 한다. 이 둘을 따로 하면 중간에 서버가 죽었을 때 주문은 저장됐는데 알림은 안 가는 일이 생긴다. 한 번 저장된 주문은 무조건 알림이 가게 만들어야 한다.
  1. 1

    할 일은 두 가지다. (1) 주문을 우리 DB에 저장한다. (2) 결제 서비스에 '주문 생겼어'라고 알린다.

    // two things: persist the order, and notify the payment service
    void placeOrder(String orderId) {
        db.add(orderId);        // (1) save the order
        delivered.add(orderId); // (2) notify the payment service
    }
    🗣 영어로 말해

    I need to do two things: save the order, and notify the payment service.

    checking microphone…

  2. 2

    지금 코드는 이 둘을 따로 한다. 먼저 DB에 저장(커밋)하고, 그 다음에 결제 서비스를 부른다.

    ⚖️ Trade-off: 두 동작 사이에 '틈'이 생긴다. 저장은 이미 끝났는데 알림은 아직 안 보낸 그 순간.

    // buggy: commit the order, THEN notify as a separate step
    void placeOrder(String orderId, boolean crashAfterCommit) {
        db.add(orderId);              // step 1: commit to DB (durable)
        if (crashAfterCommit) return;
        delivered.add(orderId);      // step 2: external call (separate)
    }
    🗣 영어로 말해

    Right now it commits the order first, then makes the external call as a separate step.

    checking microphone…

  3. 3

    그 틈에서 서버가 죽으면? 주문은 DB에 그대로 남아있다(저장됐으니까). 그런데 알림은 영영 안 간다. 조용히 사라진다.

    ⚖️ Trade-off: 두 동작이 '같이 성공 아니면 같이 실패'가 아니라서 한쪽만 되는 상태가 가능하다. 이게 dual-write 문제다.

    🔧 도구:dual-write (안티패턴)

    // crash lands in the gap: order durable, event gone
    db.add(orderId);              // committed
    if (crashAfterCommit) return; // <-- die here; notify never runs, never retried
    delivered.add(orderId);
    🗣 영어로 말해

    If the server crashes in that gap, the order is saved but the event is silently lost.

    checking microphone…

  4. 4

    그럼 둘을 하나의 트랜잭션으로 묶으면? 외부 호출은 DB 트랜잭션 안에 못 넣는다. 남의 서비스가 느리거나 죽으면 우리 트랜잭션이 같이 막힌다.

    ⚖️ Trade-off: 외부 시스템을 내 트랜잭션 안에 끌어들이면, 남의 사정에 내 DB가 묶인다.

    🔧 도구:@Transactional (외부 호출 감싸면 X)

    // can't wrap the external call in the DB transaction
    beginTransaction();
    db.add(orderId);
    paymentService.notify(orderId); // their slowness/outage blocks my tx
    commit();
    🗣 영어로 말해

    I can't put the external call inside the DB transaction — their slowness would block mine.

    checking microphone…

  5. 5

    해결: 알림을 '보내는' 대신, '보낼 일'을 우리 DB에 같이 적는다. 주문 저장과 'outbox 행 추가'를 한 트랜잭션으로 묶는다. 둘 다 저장되거나 둘 다 안 되거나.

    ⚖️ Trade-off: 이건 외부 호출이 아니라 그냥 내 DB에 한 줄 더 쓰는 것뿐이라 트랜잭션 안에 안전하게 들어간다.

    ✅ Fix: 이제 주문이 저장됐다면 outbox 행도 무조건 같이 있다. 한쪽만 되는 일이 사라진다.

    🔧 도구:outbox table단일 DB 트랜잭션

    // fix: order + outbox row in the SAME transaction
    void placeOrder(String orderId) {
        beginTransaction();
        db.add(orderId);       // the order
        outbox.add(orderId);   // the 'to-send' row, same tx
        commit();              // both commit or neither does
    }
    🗣 영어로 말해

    Instead, I write an outbox row in the same transaction as the order, so they commit together.

    checking microphone…

  6. 6

    그 다음 별도의 일꾼(relay)이 돌면서 outbox에서 '아직 안 보낸 행'을 읽어 결제 서비스에 보낸다. 성공하면 그 행을 '완료'로 표시한다.

    ⚖️ Trade-off: relay가 보내고 나서 '완료' 표시를 하기 전에 죽을 수 있다.

    ✅ Fix: 그러면 다음에 그 행을 또 보낸다 → 같은 알림이 두 번 갈 수 있다(at-least-once). 안 보내지는 것보다 두 번 가는 게 낫다.

    🔧 도구:relay/pollerCDC (Debezium)status: pending/done

    // relay drains pending outbox rows, marks each done on success
    void runRelay(int tick) {
        Iterator<String> it = outbox.iterator();
        while (it.hasNext()) {
            String id = it.next();
            delivered.add(id); // deliver downstream
            it.remove();       // mark done
        }
    }
    🗣 영어로 말해

    A background relay reads the pending rows and delivers them, marking each done on success.

    checking microphone…

  7. 7

    두 번 갈 수 있다는 비용은 받는 쪽에서 막는다. 결제 서비스가 같은 주문 ID를 두 번 받아도 한 번만 처리하게 만든다(멱등).

    ⚖️ Trade-off: 받는 쪽이 멱등하지 않으면 중복 처리(이중 결제 같은)가 생긴다.

    ✅ Fix: 주문마다 고유 키를 붙이고, 받는 쪽이 본 적 있는 키면 무시한다. 그러면 두 번 와도 안전하다.

    🔧 도구:idempotency keydedup on consumer

    // consumer is idempotent: ignore an order id it has already seen
    if (seen.contains(orderId)) return; // duplicate from at-least-once
    seen.add(orderId);
    processPayment(orderId);
    🗣 영어로 말해

    Duplicates are fine because the consumer is idempotent — it ignores a key it's already seen.

    checking microphone…

7단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.

Explain it in English

Say it out loud in your own words — hit these terms — then check against the gold answer.

Say it in your own words — but hit these: outbox · dual write · at least once · consistency · distributed systems

checking microphone…