← 문제로

50. 경매장 즉시구매와 가격 인하의 동시 처리 (이중 판매 / 가격 불일치)

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

해설 — 경매장 즉시구매와 가격 인하의 동시 처리 (이중 판매 / 가격 불일치)

난이도: 하

요약

Buyout 은 매물을 "조회 → Sold 검사 → 골드 차감 → Sold=true 표시 → 아이템/정산" 을 아무 동기화 없이 순차로 수행한다. 검사와 표시(A→B) 사이가 원자적이지 않고, EditBuyoutPrice 가 같은 매물을 동시에 수정하므로, 동시 요청이 겹치면 이중 판매·이중 지급·가격 불일치가 난다.

문제점

  • [원자성 위반 / TOCTOU] 이중 판매: 두 구매자가 거의 동시에 Buyout 을 호출하면 둘 다 (A) 직후 listing.Sold == false 를 통과한다. 이후 둘 다 골드를 차감하고 (B) 에서 Sold=true 를 쓴다. 결과: 아이템이 두 번 지급되고 판매자는 두 번 정산받으며 두 구매자 모두 골드를 잃는다. Sold 검사와 설정이 분리되어 있어 "이미 팔림" 판정이 무력화된다.
  • [TOCTOU] 가격 불일치: 구매자가 long price = listing.BuyoutPrice 로 값을 읽은 직후 판매자가 EditBuyoutPrice 로 가격을 내리면, 구매자는 화면과 다른(옛) 가격을 차감당한다. 반대로 올리면 방금 본 가격보다 비싸게 결제된다. 지불액·정산액이 사용자가 동의한 값과 어긋난다.
  • [자료구조 동시성] 무보호 Dictionary: Buyout/EditBuyoutPrice 가 락 없이 같은 Dictionary 를 읽고, 매물 등록/삭제가 동시에 쓰기를 하면 Dictionary 자체가 스레드 안전하지 않아 열거 중 예외나 손상이 발생할 수 있다.
  • 재현 조건: 인기 매물에 다수 구매 요청이 몰리거나(핫 아이템), 판매자가 팔리는 순간 가격을 조정할 때. 부하가 높을수록 (A)~(B) 창이 벌어져 확률이 커진다.

수정안

매물 단위로 임계 구역을 원자화한다. 검사-차감-표시를 한 락 안에서 처리하고, 구매자가 동의한 가격을 요청에 실어 받아 서버가 재확인한다.

private readonly object _gate = new();

public BuyResult Buyout(long buyerId, long listingId, long agreedPrice)
{
    lock (_gate)
    {
        if (!_listings.TryGetValue(listingId, out var listing))
            return BuyResult.NotFound;
        if (listing.Sold)
            return BuyResult.AlreadySold;
        if (listing.BuyoutPrice != agreedPrice)      // 가격 변동 시 재확인 유도
            return BuyResult.PriceChanged;

        long price = listing.BuyoutPrice;
        if (_wallet.GetGold(buyerId) < price)
            return BuyResult.NotEnoughGold;

        listing.Sold = true;                          // 검사와 표시를 한 임계구역에서
        _wallet.Deduct(buyerId, price);
        _inv.Give(buyerId, listing.ItemId, 1);
        _wallet.Add(listing.SellerId, price);
        return BuyResult.Ok;
    }
}

public void EditBuyoutPrice(long sellerId, long listingId, long newPrice)
{
    lock (_gate)
    {
        if (_listings.TryGetValue(listingId, out var l) && l.SellerId == sellerId && !l.Sold)
            l.BuyoutPrice = newPrice;
    }
}

더 나은 설계

  • 매물별 세분화 락 또는 매물 상태를 Sold(int)로 두고 Interlocked.CompareExchange 로 "판매 클레임" 을 원자적으로 선점 → 전역 락 경합을 줄인다. 골드/아이템 지급은 클레임에 성공한 스레드만 수행한다.
  • 가격 확인(agreedPrice) 전달: 클라가 본 가격을 서버가 대조해, 변경 시 "가격이 바뀌었습니다" 로 되돌려 재확인시키는 것이 UX·정합성 모두에 안전하다.
  • 지불·지급·정산을 하나의 트랜잭션 경계(또는 보상 가능한 단위)로 묶어 부분 실패 시 롤백한다.
  • 트레이드오프: 전역 락은 단순하지만 처리량 상한이 되고, 세분화 락/CAS 는 빠르지만 지갑·인벤토리와 묶는 순서를 잘못 잡으면 데드락이 생길 수 있어 락 순서 규약이 필요하다.

면접 포인트

  • "검사 후 실행(check-then-act)" 을 원자화하지 않으면 동시 요청에서 판정이 무력화된다는 점.
  • 사용자가 동의한 값(가격)을 서버가 재확인하는 것이 왜 정합성/부정방지에 중요한가.
  • 전역 락 vs 원자적 클레임(CAS)의 처리량·복잡도 트레이드오프.