8. DB read-modify-write의 갱신 유실(lost update) (C#)
난이도 상내 리뷰 · 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이 증발(또는 반대로 한쪽이 무시되어 무료 구매). 잔액 부족 검사도 각자 옛 값으로 했으니 음수/초과지출이 가능.
- 둘 다 (A)에서
- 근본원인: "현재 값을 기준으로 한 증감"을 DB에서 원자적으로 하지 않고 앱이 읽은 스냅샷에 의존. READ COMMITTED/REPEATABLE READ(MySQL 기본) 모두 이 코드 형태의 lost update를 자동으로 막아주지 않는다(SERIALIZABLE이면 충돌로 한쪽이 직렬화 실패하지만, 이 코드는 그걸 가정하지 않는다).
- 재현(수치): 잔액 100. T1(cost 30), T2(cost 50)이 동시 진입.
수정안
선택지는 세 가지. 게임 재화처럼 단순 증감이면 원자적 조건부 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 조건"으로. 읽기를 없애면 경합도 없다.
해설 · 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을 읽는다(RC는 행을 잠그지 않는다). - T1
new=70, T2new=50계산 후 각자 UPDATE. - T1 커밋(gold=70) → T2 커밋(gold=50). 실제 지출 80인데 잔액 50 → 30 증발. 잔액 검사도 옛 값 기준이라 음수/초과지출 가능.
- 둘 다 (A)에서
- 근본원인: "현재 값 기준 증감"을 DB에서 원자적으로 하지 않고 앱이 읽은 스냅샷에 의존. READ COMMITTED는 더티 리드만 막을 뿐 lost update는 막지 않는다.
- 재현(수치): 잔액 100, T1(cost 30)·T2(cost 50) 동시 진입.
수정안
게임 재화처럼 단순 증감이면 원자적 조건부 UPDATE가 가장 단순·강력하다(읽기 자체를 없앤다).
// (1) 원자 조건부 UPDATE — DB가 증감과 잔액검사를 한 문장으로
bool spendGold(DbConn& db, int64_t playerId, int64_t cost) {
int affected = db.execute(
"UPDATE players SET gold = gold - ? "
"WHERE id = ? AND gold >= ?",
cost, playerId /*, cost: 드라이버에 맞춰 바인딩*/);
return affected == 1; // 0 == 잔액 부족(원자적으로 거부)
}
-- (2) 낙관적: version 컬럼
UPDATE players SET gold = ?, version = version + 1
WHERE id = ? AND version = ?; -- affected=0이면 충돌 → 재시도 루프
-- (3) 비관적: 읽을 때 잠금
SELECT gold FROM players WHERE id = ? FOR UPDATE; -- 트랜잭션 끝까지 배타 잠금
- (1) 원자 UPDATE: read-modify-write를 한 문장으로 합쳐 경합 제거,
affected==0으로 부족 판정. 재화/재고/카운터에 최적. (바인딩 파라미터 개수는 드라이버 API에 맞춰 확장.) - (2) 낙관적: 충돌이 드물 때. 충돌 시 재시도.
- (3) 비관적: 복잡한 교차 검증·다중 행이면. 잠금 동안 동시성↓·데드락 주의(잠금 순서 고정).
더 나은 설계
- 불변 원장(append-only ledger): 잔액을 덮어쓰지 않고 델타를 삽입, 잔액은 합계/스냅샷으로 유도. lost update 구조적 불가 + 감사/롤백 용이.
- 트레이드오프: 잔액 조회 비용↑ → 주기적 스냅샷 + 이후 델타로 절충.
- 멱등성: 차감 요청에 멱등키(거래 id) 유니크 제약 → 재시도/재기동 중복 차감 방지.
- 연결/트랜잭션 수명: 원자 UPDATE면 트랜잭션 경계가 단순해지고, 예외 경로에서도 RAII로 연결·트랜잭션이 확실히 반납/롤백되어야 한다(별도 결함의 단골).
면접 포인트
- "READ COMMITTED면 안전?" — 아니다. RC는 더티 리드만 막는다. lost update는 SERIALIZABLE+재시도,
FOR UPDATE(비관), version 조건부 UPDATE(낙관), 또는 읽지 않는 원자 UPDATE로 막는다. - 단순 증감은 절댓값을 다시 쓰지 말고
SET x = x ± ? WHERE 조건으로. 읽기를 제거하면 경합도 사라진다.