31. 환율 갱신과 환전의 경합 (시세 정합성 / 잔액 lost update)
난이도 중해설 — 환율 갱신과 환전의 경합 (시세 정합성 / 잔액 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 가 일관된 한 벌을 보게 한다.
면접 포인트
- C#에서
double/long필드의 읽기·쓰기는 무조건 원자적이지 않다 — 토막 읽기를 막으려면Interlocked/Volatile/불변 스냅샷 교체를 쓴다. - 잔액 같은 자원은 read-check-write 3단계 대신 조건부 단일 UPDATE/CAS 로 lost update 를 원천 차단한다.
- 금융성 연산(차감+지급)은 원자 트랜잭션으로 묶고, 체결에 쓰인 시세/버전을 기록해 사후 정합성을 보장한다.
해설 — 환율 갱신과 환전의 경합 (시세 데이터 레이스 / 잔액 lost update)
난이도: 중
요약
exchange 는 시세 스냅샷(C) → 환산(D) → 잔액 조회(E) → 확인(F) → 차감(G) → 지급(H)을 직렬화하지 않는다. 핵심 결함은 두 가지다. (1) 갱신 스레드의 rate_ 쓰기(A)와 요청 스레드의 rate_ 읽기(B/C)가 동기화 없이 동시 접근되어 C++ 메모리 모델상 데이터 레이스(정의되지 않은 동작) 다 — 찢긴 값/오래된 값/컴파일러 재배치가 모두 허용된다. (2) 잔액 read-modify-write(E~G)가 비원자라 같은 플레이어의 동시 환전이 같은 bal 을 읽고 각자 덮어써 골드는 1회만 빠지고 코인은 2회 지급되는 lost update 가 난다.
문제점
1. (데이터 레이스 / UB) 시세 필드 무동기 공유 — (A)(B)(C)
- 증상: 한 스레드가
rate_를 쓰는 동안 다른 스레드가 읽으면 동기화 없는 동시 접근이고, 표준상 UB. 실무적으로는 찢긴 double, stale 값, 또는 컴파일러가 읽기를 호이스팅/캐싱해 갱신을 영영 못 보는 결과로 나타난다. - 재현조건:
updateRate와exchange의 (C)가 동시 실행. TSan 이 즉시 잡는다. - 근본원인: 공유 가변
double rate_에std::atomic·뮤텍스 등 어떤 동기화도 없다.
2. (TOCTOU / lost update) 잔액 검사~차감 비원자 — (E)(F)(G)
- 증상: 두 환전이 (E)에서 같은
bal을 읽고 (F) 통과 후 각자setGold(bal-amount)→ 마지막 쓰기만 남아 1회분만 차감, 코인은 2회 지급. 골드 복제. - 근본원인: 잔액 RMW 가 한 임계영역으로 보호되지 않음.
3. (원자성) 차감과 지급의 부분 적용 — (G)(H)
- (G) 후 (H) 전에 예외/조기 반환/장애 시 골드만 사라지고 코인 미지급. 둘은 한 트랜잭션이어야 한다.
4. (정합성) 체결 시세 불확정 — (C)~(H)
- 경합 상태인
rate_에서 읽으므로 "이 거래가 어떤 환율로 체결됐는지"가 비결정적. 감사/정산이 불가능하다.
수정안
시세는 원자적으로, 지갑은 플레이어 단위 락(또는 조건부 단일 UPDATE)으로 보호한다.
#include <atomic>
#include <mutex>
class ExchangeMarket {
public:
void updateRate(double r) { rate_.store(r, std::memory_order_relaxed); }
double currentRate() const { return rate_.load(std::memory_order_relaxed); }
private:
std::atomic<double> rate_{1.0}; // 찢김·UB 없는 원자 접근
};
bool ExchangeService::exchange(long playerId, long goldAmount) {
if (goldAmount <= 0) return false;
double rate = market_.currentRate(); // 원자 스냅샷
long coins = static_cast<long>(goldAmount * rate);
std::scoped_lock lk(wallet_.lockFor(playerId)); // 플레이어 단위 직렬화
long bal = wallet_.getGold(playerId);
if (bal < goldAmount) return false;
wallet_.setGold(playerId, bal - goldAmount); // 검사+차감+지급 한 임계영역
wallet_.addCoin(playerId, coins);
return true;
}
std::atomic<double>는 (플랫폼에 따라 lock-free 일 수도 아닐 수도 있으나) 표준상 찢김 없는 정의된 접근을 보장한다. 단순 정밀도면 fixed-point(정수 환율 × 스케일)로 두면 정수 atomic 으로 더 가볍다.- DB라면
UPDATE wallet SET gold=gold-? WHERE id=? AND gold>=?의 조건부 UPDATE로 lost update·음수 잔액을 한 번에 막고 코인 적립까지 같은 트랜잭션.
더 나은 설계
- 시세는 불변 스냅샷 + 원자 포인터 교체(
std::atomic<std::shared_ptr<Quote>>또는 RCU 류)로 "환율+버전+유효시각"을 한 벌로 일관되게 읽게 한다. - 지갑 변경은 항상 조건부 원자 연산. RMW 3단계는 동시성에서 lost update 의 표준 원인.
- 체결에 쓴 환율/버전을 거래 로그에 기록해 사후 정합성을 결정적으로 만든다.
면접 포인트
- 둘 이상의 스레드가 공유 변수에 접근하고 그중 하나라도 쓰기면, 동기화 없는 접근은 그 자체로 데이터 레이스 = UB 다.
double한 워드라도 예외 없다. std::atomic은 찢김을 막을 뿐 아니라 가시성/재배치 보장을 준다 —volatile은 스레드 동기화 도구가 아니다.- 잔액·재고는 read-check-write 대신 조건부 단일 UPDATE/CAS, 차감+지급은 한 트랜잭션, 체결 시세는 명시적으로 고정·기록한다.