← 문제로

7. 한정 상점 구매와 재고 정합성

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

해설 — 한정 상점 구매와 재고 정합성

난이도: 중

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

재고 확인(C)과 차감(F)이 분리된 check-then-act 라 동시 구매가 모두 통과해 oversell(재고보다 더 팔림) 이 난다. Volatile.Read/Interlocked.Add 를 써서 "원자적" 으로 보이지만 두 연산 사이가 비어 있어 소용이 없다. 인당 한도 누적 맵(B)은 비원자적 read-modify-write + 스레드 비안전 Dictionary 라 한도 초과/맵 손상이 난다. 골드 차감(E)·재고 차감(F)·지급(H)이 서로 다른 동기화 수단으로 흩어져 원자성이 없고, 실패 시 롤백 경로도 없다. _shopLock(I)은 선언만 되고 쓰이지 않는다.


문제점

(C)→(F) 재고 check-then-act — oversell (정확성/동시성) ★간판

  • 증상: 재고 10개 상품이 20개, 50개씩 팔린다.
  • 재현 조건: 오픈 직후 재고 10 상품에 동시에 50개 요청이 들어옴. 모두 (C)에서 Volatile.Read(Stock)=10 >= 1 을 통과한 뒤 각자 (F) Interlocked.Add(Stock, -1) → 재고가 음수(-40)까지 내려가며 50명에게 팔린다. StockVolatile.Read/ Interlocked.Add 로 만져 각 연산은 원자적이지만, "확인 후 차감" 두 동작 사이가 비어 있어 확인 시점의 재고가 차감 시점엔 이미 남의 차감으로 사라진다(TOCTOU).
  • 근본 원인: Volatile/Interlocked 는 단일 연산만 원자적으로 만들 뿐, "조건부 차감(있으면 줄인다)" 이라는 복합 연산을 원자적으로 만들지 못한다. Interlocked.CompareExchange 루프나 lock 이 필요하다.

(B)+(D)+(G) 인당 한도 — 비원자 RMW + 비안전 Dictionary (정확성/동시성/보안)

  • 증상: 한도가 1인데 같은 플레이어가 동시에 2번 사서 2개를 갖는다. 최악엔 Dictionary 동시 접근으로 InvalidOperationException/내부 손상/무한 루프.
  • 재현 조건: 같은 플레이어가 동시에 두 번 구매. 둘 다 (D)에서 already=0 을 읽고 0+1 <= limit(1) 통과 → 둘 다 (G)에서 Bought[id] = 1 → 한도 우회로 2개 구매. 또한 (D)의 TryGetValue 읽기와 (G)의 쓰기가 다른 플레이어 요청들과 동시에 일어나면 Dictionary 의 resize/rehash 중 자료구조 손상(Dictionary 는 동시 쓰기 시 스레드 안전을 보장하지 않음 — 읽기 전용 동시 접근만 안전).
  • 근본 원인: 누적 구매의 read(D)-modify-write(G)가 원자적이지 않고, Dictionary 는 동시 쓰기에 안전하지 않은데 락 없이 동시 접근한다.

(E)+(F)+(G)+(H) 차감/지급이 한 트랜잭션이 아님 — 원자성/롤백 부재 (정확성/동시성)

  • 증상: 골드만 빠지고 재고/지급이 누락되거나(또는 그 반대), 재고만 줄고 한도 갱신 실패 등 부분 반영. 실패 분기에서 이미 차감한 골드를 되돌리는 경로가 없다.
  • 재현 조건: 골드 차감(E) 성공 후 (F)~(H) 어딘가에서 예외(Dictionary 손상 등) → 골드는 빠졌는데 아이템은 못 받음. 또는 재고는 줄었는데 (G)/(H) 실패 → 재고 누수.
  • 근본 원인: 구매는 "한도검증 + 재고차감 + 골드차감 + 지급" 이 하나의 원자 단위여야 하는데, 각기 다른 수단(buyer.Lock만 골드, Stock은 Interlocked, Bought/지급은 무락) 으로 쪼개져 있다. 전체를 감싸는 임계 구역이 없다.

(I) _shopLock 미사용 (유지보수/동시성)

  • 상품/상점 단위 직렬화를 의도한 듯한 락이 선언만 되어 한 번도 잠기지 않는다. 위 모든 복합 연산을 직렬화할 도구가 있는데도 안 쓴 셈.

서로 다른 동기화 수단 혼용 (동시성)

  • StockVolatile/Interlocked, 골드는 lock, Bought 는 무락. 하나의 구매 트랜잭션 안에서 동기화 모델이 제각각이라 어떤 불변식도 전체적으로 성립하지 않는다.

(E) 가격 계산 오버플로 (정확성)

  • item.Price * qtylong 이라 보통은 안전하나, 위조/버그성 거대 qty 면 오버플로 가능. qty 상한 검증이 없다(PerPlayerLimit 검증은 우회되므로 신뢰 불가).

수정안

원칙: 상품 단위로 "한도검증 + 재고차감 + 골드차감 + 지급" 전체를 직렬화한다. 상품 락(ShopItem.Lock)을 도입해 재고·누적맵을 보호하고, 그 안에서 골드까지 처리한다. 락 순서는 항상 item.Lock → buyer.Lock 로 고정해 데드락을 피한다.

public class ShopItem
{
    public long ItemId;
    public long Price;
    public int  Stock;                               // Interlocked 불필요(락으로 보호)
    public int  PerPlayerLimit;
    public Dictionary<long, int> Bought = new();
    public readonly object Lock = new object();      // 상품 단위 직렬화
}

public bool Buy(ShopItem item, Player buyer, int qty)
{
    if (qty <= 0 || qty > item.PerPlayerLimit) return false;   // qty 상한
    long cost = item.Price * (long)qty;

    // 락 순서 고정: item.Lock 먼저, 그 안에서 buyer.Lock
    lock (item.Lock)
    {
        // 재고 확인+차감을 한 임계 구역에서 → oversell 차단
        if (item.Stock < qty) return false;

        // 인당 한도 확인 (read와 write가 같은 락 안 → RMW 원자적)
        int already = item.Bought.TryGetValue(buyer.Id, out var c) ? c : 0;
        if (already + qty > item.PerPlayerLimit) return false;

        // 골드 확인/차감
        lock (buyer.Lock)
        {
            if (buyer.Gold < cost) return false;
            buyer.Gold -= cost;
        }

        // 여기까지 오면 모두 통과 → 커밋(같은 임계 구역이라 부분 반영 없음)
        item.Stock -= qty;
        item.Bought[buyer.Id] = already + qty;
        buyer.GiveItem(item.ItemId, qty);
        return true;
    }
}

모든 실패 검증(재고/한도/골드)을 상태 변경 전에 끝내므로 롤백이 필요 없다. 골드 차감만 buyer.Lock 안에서 하되, 차감 후 더 이상 실패 분기가 없으므로 "골드만 빠지는" 부분 반영이 생기지 않는다. 락 순서가 item → buyer 로 항상 같아 데드락이 없다.


더 나은 설계

1) 재고는 원자적 조건부 차감(CAS) 또는 DB로

  • 락 없이 가려면 Interlocked.CompareExchange 루프:
    int cur;
    do { cur = Volatile.Read(ref item.Stock); if (cur < qty) return false; }
    while (Interlocked.CompareExchange(ref item.Stock, cur - qty, cur) != cur);
    
    단 이건 재고만 보호할 뿐, 한도·골드·지급과의 원자성은 별도다. 실패 시 차감 복구 필요.
  • 실서비스 한정판매는 DB 행 잠금/조건부 UPDATE가 정석:
    UPDATE shop_item SET stock = stock - ? WHERE item_id = ? AND stock >= ?; -- affected=1만 진행
    
    골드 차감/지급/한도 갱신을 같은 트랜잭션에 묶고 (item,player) 한도는 UNIQUE/누적 컬럼으로. 트레이드오프: DB 부하. 플래시 세일은 캐시/큐로 완화.

2) 단일 스레드 액터 / 큐

  • 한정 상품 하나를 단일 스레드(액터) 가 직렬 처리하면 락이 사라지고 oversell·한도 버그가 구조적으로 불가능. 오픈 직후 폭주 트래픽을 Channel<T> 큐로 흡수해 순서대로 처리. BoughtConcurrentDictionary 보다 단일 스레드 소유가 추론이 쉽다.
  • 트레이드오프: 단일 상품 처리량 한계 → 인기 상품은 샤딩/재고 분할.

3) 멱등성

클라 재전송 대비 requestId 멱등성 키로 같은 구매가 두 번 반영되지 않게 한다.


면접 포인트

  • 면접관이 듣고 싶은 핵심: Interlocked/Volatile 재고만으로는 oversell 을 못 막는다(확인-차감이 복합 연산), 인당 한도 RMW 와 Dictionary 접근도 직렬화 필요, 그리고 구매 전체가 하나의 트랜잭션이라는 것. CAS/lock/DB 조건부 UPDATE 의 트레이드오프를 말할 수 있으면 강하다.
  • 예상 질문:
    1. "Stock 을 Interlocked.Add 로 줄이면 oversell 이 막히나?" → 아니다. Read-후-Add 사이가 비어 TOCTOU. CompareExchange 루프나 lock 으로 조건부 차감.
    2. "인당 한도가 동시에 뚫리는 이유는? Dictionary 는 왜 위험한가?" → 누적값 read-modify-write 가 비원자(lost update). 게다가 Dictionary 동시 쓰기는 스레드 안전하지 않아 손상/예외. 같은 락/트랜잭션 안에서 처리해야.
    3. "오픈 직후 폭주 트래픽은?" → Channel 큐/단일 액터로 직렬화하거나 DB 조건부 UPDATE + 캐시. 락 순서는 item→buyer 고정.