← 문제로

25. READ COMMITTED 하 비반복 읽기로 인한 판정-갱신 불일치 (C#)

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

해설 — READ COMMITTED 하 비반복 읽기로 인한 판정-갱신 불일치 (C#)

난이도: 하

요약

같은 트랜잭션 안에서 잔액을 두 번 읽는데, 격리수준이 READ COMMITTED 라 두 읽기 사이에 다른 트랜잭션이 커밋한 값이 반영된다(비반복 읽기, non-repeatable read). 1차 읽기로 "구매 가능"을 판정하고, 2차 읽기 값에서 가격을 빼 절대값으로 UPDATE 하기 때문에, 동시 구매가 겹치면 갱신 유실과 음수 잔액이 발생한다.

문제점

분류 태그: 비반복 읽기(non-repeatable read) / check-then-act 갱신 유실(lost update)

증상: 동시에 구매가 여러 건 커밋되면, 실제 차감 총액이 구매 금액 합보다 작다(재화가 "덜 빠진다"). 극단적으로는 잔액이 음수가 된다. 저부하에선 안 보이다가 이벤트·핫아이템 오픈 때 재현.

재현 조건: 같은 계정에 구매 T1·T2 동시 실행. 초기 잔액 100, 각 구매 price=100.

  • T1 (A): balance=100 읽음 → 통과.
  • T2 (A): balance=100 읽음 → 통과.
  • T1: 2차 읽기 balanceNow=100 → newBalance=0 → UPDATE balance=0 → 커밋.
  • T2: 2차 읽기 balanceNow=0(T1 커밋 반영, READ COMMITTED) → newBalance=-100 → UPDATE balance=-100 → 커밋.
  • 결과: 잔액 -100, 요구사항 1·2 동시 위반.

근본 원인: READ COMMITTED 는 "커밋된 값만 읽는다"만 보장할 뿐 같은 트랜잭션 내 반복 읽기의 안정성(repeatable read) 을 보장하지 않는다. 게다가 새 값을 balanceNow - price 같은 애플리케이션이 읽은 절대값으로 쓰기 때문에, 읽고→계산하고→쓰는 사이의 다른 커밋을 덮어써 갱신이 유실된다. 2차 읽기는 "안전하게"가 아니라 오히려 문제를 키운다.

수정안

가장 견고한 방법은 읽기-계산-쓰기를 단일 원자 UPDATE 로 바꾸는 것이다. 절대값 대신 상대 차감(balance - price)을 DB가 계산하고, 조건을 WHERE 에 넣어 음수를 원천 차단한다.

public bool Purchase(long accountId, int itemId, long price)
{
    using IDbConnection db = _dbf.Open();
    using IDbTransaction tx = db.BeginTransaction(IsolationLevel.ReadCommitted);

    int affected = Exec(db, tx,
        "UPDATE wallet SET balance = balance - @p " +
        "WHERE account_id=@a AND balance >= @p", price, accountId);

    if (affected == 0) { tx.Rollback(); return false; }  // 잔액 부족 → 실패

    GrantItem(db, tx, accountId, itemId);
    tx.Commit();
    return true;
}

이 UPDATE 는 행 잠금 하에서 원자적으로 검사+차감하므로 두 구매가 직렬화된다(둘 중 하나는 balance >= price 실패). 애플리케이션 읽기값을 쓰지 않아 갱신 유실도 없다.

대안: 반드시 애플리케이션에서 검사해야 한다면 1차 읽기를 SELECT ... FOR UPDATE 로 잠그거나(비관적), version 컬럼으로 낙관적 잠금(WHERE version=@v)을 걸고 실패 시 재시도한다. 격리수준을 REPEATABLE READ/SERIALIZABLE 로 올리면 비반복 읽기는 막히지만, 절대값 UPDATE 는 여전히 직렬화 실패(재시도 필요)를 유발한다.

더 나은 설계

  • "읽은 값을 그대로 쓰지 말라": 잔액 같은 가감 자원은 항상 상대 연산(balance - x)을 DB가 하게 하고, 불변식은 WHERE 로 표현.
  • 멱등 결제: 클라이언트 요청 id 로 중복 커밋을 흡수(재전송 대비).
  • 원장(ledger) 모델: 잔액을 단일 컬럼이 아니라 append-only 거래 기록의 합으로 두면 감사·정합성이 강해진다(architecture_data 참고).
  • 트레이드오프: 단일 원자 UPDATE 는 가장 단순·안전하나 복잡한 다중 자원 조건에는 부족 → 그때만 FOR UPDATE/직렬화+재시도.

면접 포인트

  • READ COMMITTED 가 막는 것(더티 read)과 막지 않는 것(non-repeatable read, lost update).
  • "절대값 쓰기 vs 상대 연산 쓰기"가 동시성에 미치는 차이.
  • UPDATE ... WHERE balance>=p 한 방으로 검사+차감을 원자화하는 이유.