25. READ COMMITTED 하 비반복 읽기로 인한 판정-갱신 불일치 (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한 방으로 검사+차감을 원자화하는 이유.
해설 — READ COMMITTED 하 비반복 읽기로 인한 판정-갱신 불일치 (C++)
난이도: 하
요약
단일 트랜잭션에서 잔액을 두 번 읽는데, 격리수준 READ COMMITTED 는 두 읽기 사이의 다른 커밋을 그대로 노출한다(비반복 읽기). 1차 읽기로 구매 가능을 판정하고 2차 읽기 값에서 가격을 빼 절대값으로 UPDATE 하므로, 동시 구매가 겹치면 갱신 유실과 음수 잔액이 생긴다.
문제점
분류 태그: 비반복 읽기 / check-then-act 갱신 유실(lost update)
증상·재현: C# 트윈과 동일. 초기 잔액 100, price=100 인 두 구매 T1·T2 동시:
- T1 (A) balance=100 통과, T2 (A) balance=100 통과.
- T1: 2차 balanceNow=100 → UPDATE balance=0 커밋.
- T2: 2차 balanceNow=0(T1 커밋 반영) → newBalance=-100 → UPDATE balance=-100 커밋.
- 결과 잔액 -100(요구사항 1·2 위반).
근본 원인: READ COMMITTED 는 각 문장이 "그 시점의 커밋된 스냅샷"을 보므로 같은 트랜잭션 내 두 SELECT 가 다른 값을 반환할 수 있다(repeatable read 아님). 여기에 애플리케이션이 읽은 절대값을 되쓰는 패턴이 겹쳐 갱신 유실을 만든다. 2차 읽기는 안전장치가 아니라 stale 판정을 덮어쓰는 창을 넓힐 뿐이다.
수정안
읽기-계산-쓰기를 단일 원자 UPDATE 로 대체하고 불변식을 WHERE 로 표현한다.
bool purchase(std::int64_t accountId, int itemId, std::int64_t price)
{
db_->begin("READ COMMITTED");
// affectedRows 를 돌려주는 exec 를 가정
int affected = db_->execAffected(
"UPDATE wallet SET balance = balance - ? "
"WHERE account_id = ? AND balance >= ?", price, accountId, price);
if (affected == 0) { db_->rollback(); return false; } // 잔액 부족
db_->grantItem(accountId, itemId);
db_->commit();
return true;
}
행 잠금 하의 원자 UPDATE 가 검사+차감을 직렬화한다(두 구매 중 하나는 balance >= price 불충족으로 0행). 절대값을 쓰지 않아 갱신 유실도 사라진다.
대안: 애플리케이션 검사가 불가피하면 1차 조회를 SELECT ... FOR UPDATE 로 잠그거나(비관적), version 컬럼 낙관적 잠금 + 실패 재시도. 격리수준을 REPEATABLE READ/SERIALIZABLE 로 올리면 비반복 읽기는 막히나, 절대값 UPDATE 는 직렬화 충돌 시 재시도 로직이 필요하다(드라이버별 재시도 예외 처리).
더 나은 설계
- 가감 자원은 상대 연산 + WHERE 불변식으로. 읽은 값을 절대값으로 되쓰지 않는다.
- 멱등 결제 키로 재전송/재시도 중복 흡수.
- 원장(ledger) 으로 잔액을 파생시키면 감사·정합성이 강화.
- 트레이드오프: 단일 원자 UPDATE 는 단순·안전, 다중 자원 조건에는 FOR UPDATE/직렬화+재시도.
면접 포인트
- READ COMMITTED vs REPEATABLE READ 가 각각 막는 이상현상(더티/비반복/팬텀).
- 절대값 쓰기가 왜 lost update 를 부르고, 상대 연산 UPDATE 가 왜 이를 없애는가.
- FOR UPDATE·낙관적 버전·격리수준 상향의 트레이드오프.