6. DB 커밋과 이벤트 발행의 이중 쓰기(dual write) 불일치
난이도 상내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — DB 커밋과 이벤트 발행의 이중 쓰기(dual write) 불일치
난이도: 상
요약
DB 트랜잭션 커밋(A)과 메시지 버스 발행(B)이 서로 다른 두 시스템에 대한 별도 쓰기다. 둘을 하나로 묶는 원자성이 없어, 커밋과 발행 사이에 프로세스가 죽거나 버스가 장애면 DB 에는 보상이 있는데 이벤트는 없음(이벤트 유실) 이 된다. 순서를 바꿔 발행을 먼저 하면 커밋 실패 시 유령 이벤트가 나간다. 또 발행 실패 시 재시도하면 다운스트림이 멱등하지 않아 중복 반영된다. 이것이 전형적인 dual write 문제이며, 해법은 트랜잭셔널 아웃박스(transactional outbox) 다.
문제점
(1) [원자성 부재] 커밋–발행 사이 크래시 → 이벤트 유실 — (A)→(B)
- 증상:
tx.Commit()직후_bus.Publish전에 프로세스가 죽으면, 보상은 DB 에 영구 반영됐지만 랭킹/도감/캐시 무효화 이벤트는 영영 안 나간다. 다운스트림이 계속 옛 상태(캐시 stale, 랭킹 누락). - 근본원인: DB 와 메시지 버스는 같은 트랜잭션에 들 수 없는데, 두 쓰기를 순차로 했다. 그 사이 어떤 실패도 불일치를 만든다.
(2) [순서 바꿔도 대칭 문제] 발행 먼저 → 유령 이벤트
- 발행을 커밋보다 먼저 하면, 커밋이 롤백/실패할 때 "지급되지도 않은 보상" 이벤트가 이미 나가 다운스트림이 잘못 반영한다. 순서를 바꾸는 것은 해법이 아니다.
(3) [멱등성 부재] 발행 재시도 → 중복 반영 — (B)
- 증상: 버스 일시 장애로 재시도하면 같은
RewardGranted가 2번 발행될 수 있다. 이벤트에 고유 ID 가 없고 소비자가 중복 제거를 안 하면 랭킹 점수·도감 카운트가 이중 집계된다. - 근본원인: at-least-once 전달을 전제하지 않은 비멱등 소비.
수정안 — 트랜잭셔널 아웃박스 + 릴레이 + 멱등 소비
1) 이벤트를 보상과 같은 트랜잭션으로 아웃박스 테이블에 기록
public void GrantReward(long playerId, int itemId, long gold, Guid requestId)
{
using var tx = _db.BeginTransaction();
// (선택) 멱등: 같은 requestId 가 이미 처리됐으면 무시
if (AlreadyProcessed(tx, requestId)) { tx.Commit(); return; }
Exec(tx, "INSERT INTO inventory(...) VALUES(...)", ...);
Exec(tx, "UPDATE wallet SET gold = gold + @g WHERE player_id=@p", gold, playerId);
// 이벤트를 "같은 DB 트랜잭션"으로 outbox 에 적재 → 보상과 원자적으로 커밋
Exec(tx,
"INSERT INTO outbox(event_id, type, payload, status) VALUES(@id,'RewardGranted',@json,'PENDING')",
Guid.NewGuid(), Serialize(playerId, itemId, gold));
Exec(tx, "INSERT INTO processed(request_id) VALUES(@r)", requestId);
tx.Commit(); // 보상 + 이벤트 적재가 한 번에 커밋되거나 둘 다 롤백
}
2) 별도 릴레이(poller/CDC) 가 outbox 를 읽어 버스로 발행
// 백그라운드: PENDING 이벤트를 읽어 발행하고 SENT 로 표시(at-least-once)
foreach (var e in db.Query("SELECT * FROM outbox WHERE status='PENDING' ORDER BY id"))
{
_bus.Publish(e); // 실패하면 PENDING 유지 → 다음에 재시도
db.Execute("UPDATE outbox SET status='SENT' WHERE id=@id", e.Id);
}
- 발행과 "SENT 표시" 사이 크래시여도 다음 폴링이 재발행한다(중복 가능).
그래서 소비자는
event_id로 멱등 처리(이미 본 ID 면 무시)해야 한다.
3) 소비자 멱등성
INSERT INTO consumed(event_id) VALUES(@id) ON CONFLICT DO NOTHING; -- 처음일 때만 반영
더 나은 설계 (트레이드오프)
- CDC(Change Data Capture): outbox 폴링 대신 DB WAL/binlog 를 Debezium 등으로 읽어 발행. 폴링 지연·부하가 없지만 인프라 복잡도 ↑.
- 리스닝 지연 vs 정합성: outbox 는 "결국 일관성(eventual)" 이다. 강한 동기 통지가 필요하면 지연이 커진다. 게임 보상 통지는 보통 eventual 로 충분.
- 순서 보장: 파티션 키(player_id)별로 outbox/토픽 순서를 유지해 역전 방지.
면접 포인트
- dual write 문제: "두 개의 외부 시스템에 순차로 쓰면 어떤 순서든 부분 실패가 불일치를 만든다." 순서 바꾸기는 해법이 아니다.
- 트랜잭셔널 아웃박스: 이벤트를 비즈니스 데이터와 같은 트랜잭션으로 DB 에 적재 → 릴레이가 at-least-once 발행 → 소비자 멱등. 3박자가 한 세트.
- at-least-once 전달을 전제로 모든 소비자를 멱등하게 만드는 사고방식.
해설 · C++
해설 — DB 커밋과 이벤트 발행의 이중 쓰기(dual write) 불일치 (C++)
난이도: 상
요약
DB 트랜잭션 커밋(A)과 메시지 버스 발행(B)은 서로 다른 두 시스템에 대한 별도
쓰기다. 원자성이 없어 커밋과 발행 사이에 프로세스가 죽거나 버스가 장애면
DB 에는 보상이 있는데 이벤트는 없음(유실), 순서를 바꾸면 유령 이벤트,
발행 재시도 시 중복 반영이 난다. 전형적인 dual write 문제 이며, 해법은
트랜잭셔널 아웃박스 다. (C++ 에서는 bus_.publish 가 던지는 예외가
"커밋은 됐는데 통지는 안 됨" 상태를 그대로 드러낸다는 점도 동일하다.)
문제점
(1) [원자성 부재] 커밋–발행 사이 크래시 → 이벤트 유실 — (A)→(B)
- 증상:
tx.commit()직후bus_.publish전에 프로세스가 죽으면 보상은 영구 반영됐는데 이벤트가 안 나간다. 다운스트림(랭킹/캐시)이 영영 stale. - 근본원인: DB 와 버스는 한 트랜잭션에 못 묶이는데 두 쓰기를 순차 실행.
(2) [순서 바꿔도 대칭] 발행 먼저 → 유령 이벤트
- 발행을 커밋보다 먼저 하거나, 커밋 후
publish가 예외를 던져 상위에서 롤백/재시도하면 "지급 안 된 보상" 또는 "이중 지급" 이벤트가 나간다. 순서 교체는 해법이 아니다.
(3) [멱등성 부재] 발행 재시도 → 중복 — (B)
- 버스 장애 재시도로 같은 이벤트가 2번 나가면, 이벤트 ID·소비자 중복제거가 없어 랭킹 점수·도감 카운트가 이중 집계된다.
수정안 — 트랜잭셔널 아웃박스 + 릴레이 + 멱등 소비
1) 이벤트를 보상과 같은 트랜잭션으로 outbox 에 적재
void grantReward(int64_t playerId, int itemId, int64_t gold, const std::string& requestId) {
DbTx tx = db_.begin();
if (alreadyProcessed(tx, requestId)) { tx.commit(); return; } // 멱등
tx.exec("INSERT INTO inventory(...) VALUES(...)");
tx.exec("UPDATE wallet SET gold = gold + ? WHERE player_id = ?");
// 이벤트를 같은 트랜잭션으로 적재 → 보상과 원자적으로 커밋되거나 둘 다 롤백
tx.exec("INSERT INTO outbox(event_id,type,payload,status) "
"VALUES(?, 'RewardGranted', ?, 'PENDING')");
tx.exec("INSERT INTO processed(request_id) VALUES(?)");
tx.commit(); // 보상 + outbox 적재가 하나의 원자 단위
}
2) 별도 릴레이 스레드 가 outbox 를 폴링해 발행(at-least-once)
// PENDING 을 읽어 publish 성공 시에만 SENT 로 갱신. 실패하면 PENDING 유지→재시도.
for (auto& e : db.query("SELECT * FROM outbox WHERE status='PENDING' ORDER BY id")) {
bus.publish(e);
db.exec("UPDATE outbox SET status='SENT' WHERE id = ?");
}
- publish 후 SENT 표시 전 크래시 → 재발행 가능 → 소비자가 event_id 로 멱등 처리.
3) 소비자 멱등성
INSERT INTO consumed(event_id) VALUES(?) ON CONFLICT DO NOTHING; -- 처음만 반영
더 나은 설계 (트레이드오프)
- CDC(Debezium 등) 로 WAL 을 읽어 발행하면 폴링 지연·부하가 없으나 인프라 복잡도 ↑.
- outbox 는 결국 일관성 — 강한 동기 통지가 필요하면 지연이 커진다.
- 파티션 키(player_id)별 순서 유지로 이벤트 역전 방지.
- C++ 측 실무 팁:
publish의 예외/타임아웃을 grantReward 경로에서 직접 처리하지 말고 릴레이로 옮겨, 요청 처리 지연·부분 실패 전파를 끊는다.
면접 포인트
- dual write 문제: 두 외부 시스템에 순차 쓰기는 어떤 순서든 부분 실패가 불일치를 만든다. 순서 바꾸기는 해법이 아니다.
- 트랜잭셔널 아웃박스 3박자: 같은 트랜잭션 적재 + at-least-once 릴레이 + 멱등 소비.
- "커밋했지만 통지 못 함"을 예외로 만났을 때 절대 그 자리에서 롤백/재발행으로 때우려 하지 말 것 — 이미 커밋된 사실은 되돌릴 수 없다.