← 문제로

31. 환율 갱신과 환전의 경합 (시세 정합성 / 잔액 lost update)

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

해설 — 환율 갱신과 환전의 경합 (시세 정합성 / 잔액 lost update)

난이도: 중

요약

Exchange 는 시세 스냅샷(C) → 환산(D) → 잔액 조회(E) → 확인(F) → 차감(G) → 지급(H)을 하나의 임계영역으로 묶지 않는다. 두 가지 결함이 겹친다. (1) 갱신 스레드의 currentRate_ 쓰기(A)와 요청 스레드의 읽기(C)가 동기화 없이 공유되어, double 의 비원자 읽기(torn read)·가시성 문제·검사~사용 사이의 변경으로 잘못된 환율이 적용될 수 있다. (2) 잔액 read-check-write(E~G)가 원자적이지 않아 같은 플레이어의 동시 환전 두 건이 같은 bal 을 읽고 각자 차감해 골드가 한 번만 빠지고 코인은 두 번 지급되는 lost update(중복 환전) 가 난다.

문제점

1. (데이터 레이스 / 가시성) 시세 필드 무동기 공유 — (A)(C)

  • 증상: 갱신 스레드가 currentRate_ 를 쓰는 동안 요청 스레드가 읽으면, C#에서 double 읽기/쓰기는 원자성이 보장되지 않아(특히 32비트) 상·하위 워드가 섞인 garbage 환율이 나올 수 있다. 또한 메모리 가시성 보장이 없어 요청 스레드가 오래된 환율을 계속 볼 수도 있다.
  • 재현조건: UpdateRate 호출과 Exchange 의 (C)가 동시 실행.
  • 근본원인: 공유 가변 상태(currentRate_)에 잠금·volatile·Interlocked·Volatile.Read/Write 등 어떤 동기화도 없다.

2. (TOCTOU / lost update) 잔액 검사~차감 비원자 — (E)(F)(G)

  • 증상: 같은 플레이어의 두 환전이 동시에 (E)에서 같은 bal(예: 1000)을 읽고, 둘 다 (F) 통과 후 각자 SetGold(bal - amount) 를 쓰면 마지막 쓰기만 남아 골드는 1회분만 차감되는데 코인은 (H)에서 2회 지급된다. 골드 복제/코인 인플레.
  • 근본원인: 잔액에 대한 read-modify-write 가 직렬화되지 않음(검사와 변경이 한 임계영역이 아님).

3. (원자성) 차감과 지급의 부분 적용 — (G)(H)

  • (G) 골드 차감 성공 후 (H) 코인 지급 전에 예외/장애가 나면 골드만 사라지고 코인은 미지급. 둘은 한 트랜잭션이어야 한다.

4. (정합성) 환율 스냅샷과 결제의 시점 불일치 — (C)~(H)

  • (C)에서 읽은 환율로 (D) 계산하지만, 차감·지급은 한참 뒤다. 환율을 스냅샷했더라도 그 스냅샷이 결제 전체와 같은 트랜잭션 경계 안에서 일관되게 쓰여야 감사·정산이 맞는다. 지금은 환율 출처가 경합 상태라 "어떤 환율로 체결됐는지" 자체가 불확정.

수정안

플레이어 지갑을 단위로 검사+차감+지급을 한 임계영역(또는 DB 트랜잭션)으로 묶고, 시세는 원자적으로 읽는다.

public class ExchangeMarket
{
    private long rateBits_ = BitConverter.DoubleToInt64Bits(1.0);
    public void UpdateRate(double r) =>
        Interlocked.Exchange(ref rateBits_, BitConverter.DoubleToInt64Bits(r));
    public double CurrentRate =>
        BitConverter.Int64BitsToDouble(Interlocked.Read(ref rateBits_)); // 찢김 없는 원자 읽기
}

public bool Exchange(long playerId, long goldAmount)
{
    if (goldAmount <= 0) return false;
    double rate = _market.CurrentRate;                 // 원자 스냅샷
    long coins = (long)(goldAmount * rate);

    lock (_wallet.LockFor(playerId))                   // 플레이어 단위 직렬화
    {
        long bal = _wallet.GetGold(playerId);
        if (bal < goldAmount) return false;
        _wallet.SetGold(playerId, bal - goldAmount);   // 검사+차감+지급이 한 임계영역
        _wallet.AddCoin(playerId, coins);
    }
    return true;
}
  • DB라면 UPDATE wallet SET gold = gold - @amt WHERE player_id=@id AND gold >= @amt조건부 단일 UPDATE(영향 행 0이면 잔액부족)로 lost update 와 음수 잔액을 동시에 막고, 코인 적립까지 같은 트랜잭션에 둔다.
  • 시세는 Volatile/Interlocked 로 찢김 없이 읽거나, 환율을 immutable 스냅샷 객체(record)로 통째 교체(volatile 참조 swap)해 "환율+버전"을 원자적으로 본다.

더 나은 설계

  • 체결 환율을 명시적으로 고정: 요청 시점의 환율과 버전을 거래 레코드에 기록(예: rate, rateVersion). 정산·분쟁 시 "어떤 시세로 체결됐는지"가 결정적이어야 한다.
  • 지갑 변경은 항상 조건부 원자 연산으로. 읽고-빼고-쓰는 3단계는 동시성 환경에서 거의 항상 lost update 를 부른다.
  • 시세 같은 핫 공유 값은 개별 필드가 아니라 불변 스냅샷 + 원자 포인터 교체로 다뤄 reader 가 일관된 한 벌을 보게 한다.

면접 포인트

  1. C#에서 double/long 필드의 읽기·쓰기는 무조건 원자적이지 않다 — 토막 읽기를 막으려면 Interlocked/Volatile/불변 스냅샷 교체를 쓴다.
  2. 잔액 같은 자원은 read-check-write 3단계 대신 조건부 단일 UPDATE/CAS 로 lost update 를 원천 차단한다.
  3. 금융성 연산(차감+지급)은 원자 트랜잭션으로 묶고, 체결에 쓰인 시세/버전을 기록해 사후 정합성을 보장한다.