27. 낙관적 버전 충돌 재시도 시 재조회(refresh) 누락으로 갱신 유실 지속 (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 복합 상태형).
해설 — 낙관적 버전 충돌 재시도 시 재조회(refresh) 누락으로 갱신 유실 지속 (C++)
난이도: 중
요약
충돌(0행) 시 version만 다시 읽고(A), new_balance는 루프 밖에서 계산한 최초 스냅샷 기준 값을 재사용한다(B). 재시도는 "최신 버전 + 옛 잔액 기반 값"으로 UPDATE에 성공하므로 그 사이 커밋된 변경을 덮어쓴다. 낙관적 잠금이 충돌을 감지했는데도 재계산을 안 해 lost update가 지속된다.
문제점
- 분류태그: 낙관적 동시성 재시도 오류 / lost update / stale recompute
- 증상: 동시 가감산 시 잔액이 덜 반영. 재시도는 도는데 총량 불일치.
- 재현조건: 동일 행 동시 갱신.
- 근본원인: 재시도가 계산 입력(잔액)을 리프레시하지 않는다.
수정안
재시도 루프 안에서 상태 전체를 재조회하고 델타를 재적용한다.
void ApplyDelta(int player_id, int64_t delta) {
for (;;) {
int64_t version = 0;
int64_t balance = db_.Load(player_id, version); // 매 시도 최신 로드
int64_t new_balance = balance + delta; // 최신 기준 재계산
if (db_.Update(player_id, new_balance, version) == 1) return;
// 충돌이면 루프 처음으로
}
}
또는 델타형은 원자 조건부 UPDATE로 DB에 위임(권장): UPDATE player SET balance = balance + ? WHERE id = ? — 값 계산·재조회 없이 동시성 안전. 음수 방지 같은 불변식은 WHERE balance + ? >= 0 로 표현.
더 나은 설계
- 단순 가감산은 원자 상대 UPDATE가 낫고, 낙관적 버전 루프는 복합 상태(여러 필드 동시 검증)에 쓴다.
- 재시도 상한 + 지수 백오프 + 지터로 라이브락/파상 재시도를 방지.
면접 포인트
- 낙관적 잠금 충돌 시 재조회 범위(버전만이 아니라 계산 입력 전체).
- 낙관적 버전 루프 vs 원자 조건부 UPDATE의 선택 기준.