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.
할 일은 두 가지다. (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…
지금 코드는 이 둘을 따로 한다. 먼저 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…
그 틈에서 서버가 죽으면? 주문은 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…
그럼 둘을 하나의 트랜잭션으로 묶으면? 외부 호출은 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…
해결: 알림을 '보내는' 대신, '보낼 일'을 우리 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…
그 다음 별도의 일꾼(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…
두 번 갈 수 있다는 비용은 받는 쪽에서 막는다. 결제 서비스가 같은 주문 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단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.
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…