← 문제로

8. DB read-modify-write의 갱신 유실(lost update) (C#)

난이도 상
내 리뷰 · C#
해설 · C#

해설 — DB read-modify-write의 갱신 유실(lost update) (C#)

난이도: 상

요약

SpendGold는 잔액을 SELECT로 읽어(A) 앱에서 balance - cost를 계산한 뒤 그 절댓값으로 UPDATE 한다(B). 같은 플레이어에 대해 동시에 두 트랜잭션이 돌면 둘 다 같은 초기 잔액을 읽고 각자 계산해 서로의 갱신을 덮어쓴다 — 전형적인 lost update다. 기본 격리수준(READ COMMITTED)은 이 이상현상을 막지 못한다.

문제점

  • [갱신 유실: read-modify-write 경합] (A)+(B) — SELECT(읽기)와 UPDATE(절댓값 쓰기)가 분리되어 있고, 그 사이에 다른 트랜잭션이 끼어들 수 있다.
    • 재현(수치): 잔액 100. T1(cost 30), T2(cost 50)이 동시 진입.
      • 둘 다 (A)에서 balance=100을 읽는다(READ COMMITTED는 커밋된 값을 읽을 뿐, 읽은 행을 잠그지 않는다).
      • T1은 newBalance=70, T2는 newBalance=50을 계산.
      • T1 커밋 → gold=70. 이어 T2 커밋 → gold=50.
      • 결과: 실제로는 80을 썼는데 잔액은 50 → 장부상 30이 증발(또는 반대로 한쪽이 무시되어 무료 구매). 잔액 부족 검사도 각자 옛 값으로 했으니 음수/초과지출이 가능.
    • 근본원인: "현재 값을 기준으로 한 증감"을 DB에서 원자적으로 하지 않고 앱이 읽은 스냅샷에 의존. READ COMMITTED/REPEATABLE READ(MySQL 기본) 모두 이 코드 형태의 lost update를 자동으로 막아주지 않는다(SERIALIZABLE이면 충돌로 한쪽이 직렬화 실패하지만, 이 코드는 그걸 가정하지 않는다).

수정안

선택지는 세 가지. 게임 재화처럼 단순 증감이면 원자적 조건부 UPDATE가 가장 단순하고 강하다.

// (1) 원자적 조건부 UPDATE — 읽기 없이 DB가 증감과 검사를 한 번에
public async Task<bool> SpendGold(long playerId, long cost)
{
    using var conn = _factory.Open();
    int affected = await Db.ExecuteAsync(conn, null,
        "UPDATE players SET gold = gold - @cost " +
        "WHERE id = @id AND gold >= @cost",
        new { cost, id = playerId });
    return affected == 1;          // 0이면 잔액 부족(원자적으로 거부됨)
}
-- (2) 낙관적 동시성: version 컬럼
UPDATE players SET gold = @newBalance, version = version + 1
WHERE id = @id AND version = @readVersion;   -- affected=0이면 충돌 → 재시도

-- (3) 비관적 잠금: 읽을 때 행을 잠금
SELECT gold FROM players WHERE id = @id FOR UPDATE;  -- 트랜잭션 끝까지 배타 잠금
  • (1) 원자 UPDATE: 읽기-수정-쓰기를 DB 한 문장으로 합쳐 경합을 원천 제거. affected==0으로 잔액 부족을 판정. 게임 재화/재고/카운터에 최적.
  • (2) 낙관적: 충돌이 드물 때 좋다. 충돌(affected=0) 시 재시도 루프 필요.
  • (3) 비관적: 복잡한 다중 행 갱신·교차 검증이 있을 때. 잠금 보유 동안 동시성↓·데드락 주의.

더 나은 설계

  • 불변 거래 원장(append-only ledger): 잔액을 직접 덮어쓰지 않고 (+/- 금액, 사유, 멱등키)를 삽입하고 잔액은 합계/스냅샷으로 유도. lost update가 구조적으로 불가능하고 감사·롤백·이중지급 방지가 쉽다.
    • 트레이드오프: 잔액 조회가 합계 계산/스냅샷 갱신을 요구해 읽기 비용↑. 보통 주기적 스냅샷 + 이후 델타로 절충.
  • 멱등성: 결제/보상 차감은 클라 재시도·재기동으로 중복될 수 있으니, 요청 단위 멱등키(거래 id)를 유니크 제약으로 두어 같은 차감이 두 번 적용되지 않게 한다.

면접 포인트

  • "READ COMMITTED면 안전하지 않나?" — 아니다. RC는 더티 리드만 막을 뿐 lost update를 막지 않는다. 막으려면 SERIALIZABLE(충돌 직렬화 실패+재시도), SELECT ... FOR UPDATE(비관적), version 조건부 UPDATE(낙관적), 또는 애초에 읽지 않는 원자 UPDATE.
  • 단순 증감은 "읽고 계산해서 절댓값 쓰기"가 아니라 "SET x = x ± ? WHERE 조건"으로. 읽기를 없애면 경합도 없다.