← 문제로

27. 낙관적 버전 충돌 재시도 시 재조회(refresh) 누락으로 갱신 유실 지속 (C#)

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

해설 — 낙관적 버전 충돌 재시도 시 재조회(refresh) 누락으로 갱신 유실 지속 (C#)

난이도: 중

요약

충돌(0행) 시 재시도하지만, version만 새로 읽고(A) newBalance는 최초 스냅샷 기준으로 계산한 값을 그대로 재사용한다(B). 그래서 재시도는 "최신 버전 + 옛 잔액 기반 값"으로 UPDATE에 성공해, 그 사이 다른 트랜잭션이 커밋한 변경을 덮어쓴다. 낙관적 잠금이 충돌을 감지했음에도 재계산을 안 해서 갱신 유실(lost update)이 그대로 발생한다.

예: 초기 잔액 0. A는 +100(newBalance=100), B는 +50(newBalance=50). A 커밋(잔액 100, version 1). B는 충돌 → version=1로 갱신하고 newBalance=50 그대로 UPDATE → 잔액 50. A의 +100이 사라진다.

문제점

  • 분류태그: 낙관적 동시성 재시도 오류 / lost update / 재조회 누락(stale recompute)
  • 증상: 동시 충전/차감이 겹치면 잔액이 덜 반영됨. 충돌 재시도 로그는 정상으로 보이는데 총량이 안 맞음.
  • 재현조건: 같은 행에 대한 동시 갱신 2건.
  • 근본원인: 재시도 시 최신 커밋 상태(잔액)를 다시 읽어 델타를 재적용하지 않는다. 낙관적 잠금은 "충돌하면 다시 읽고 다시 계산"이 전제인데, 계산 입력을 리프레시하지 않았다.

수정안

방법 1 — 재시도 루프 안에서 전체 상태를 재조회하고 델타를 재적용:

public void ApplyDelta(int playerId, long delta)
{
    while (true)
    {
        var (balance, version) = Load(playerId);   // 매 시도마다 최신 로드
        long newBalance = balance + delta;          // 최신 기준 재계산
        int affected = Exec(
            "UPDATE player SET balance=@b, version=version+1 WHERE id=@id AND version=@v",
            newBalance, playerId, version);
        if (affected == 1) return;
        // 충돌이면 루프 처음으로 → 다시 로드
    }
}

방법 2 — 애초에 **원자 조건부 UPDATE(상대 연산)**로 read-modify-write를 DB에 위임(권장):

UPDATE player SET balance = balance + @delta WHERE id = @id;

이러면 앱에서 값을 계산·재조회할 필요 없이 동시성 안전하다(단, 잔액 음수 방지 같은 불변식은 WHERE balance + @delta >= 0 등으로 함께 표현).

더 나은 설계

  • 델타형 변경(가감산)은 낙관적 버전 루프보다 원자 상대 UPDATE가 단순·견고하다. 낙관적 잠금은 "여러 필드를 함께 검증해야 하는 복합 변경"에 어울린다.
  • 무한 재시도를 막기 위해 재시도 상한 + 지수 백오프 + 지터를 둔다.

면접 포인트

  • 낙관적 잠금에서 충돌 시 "무엇을 다시 읽어야 하는가"(버전만이 아니라 계산 입력 전체).
  • 언제 낙관적 버전 루프를 쓰고 언제 원자 조건부 UPDATE를 쓰는가(단일 카운터형 vs 복합 상태형).