← 문제로

6. DB 커밋과 이벤트 발행의 이중 쓰기(dual write) 불일치

난이도 상
내 리뷰 · 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 전달을 전제로 모든 소비자를 멱등하게 만드는 사고방식.