← 문제로

4. 경매장 동시 입찰

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

해설 — 경매장 동시 입찰

난이도: 상

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

요약

입찰 로직이 여러 필드를 Interlocked개별적으로 갱신한다. 각 Interlocked 연산은 그 변수 하나에 대해서만 원자적이지만 "최고가 확인 → 갱신 → 묶기 → 환불 → 즉구 처리" 라는 복합 연산 전체는 원자적이지 않다. 그 결과 골드 증발/복사, 환불 누락·중복 환불, 최고가/최고입찰자 불일치, 경매 종료 후 입찰 이 동시에 발생한다. "Interlocked 를 썼으니 lock-free 라 안전하다" 는 흔한 착각의 정수.


문제점

(B)+(C) check-then-act 가 원자적이지 않음 — 최고가 갱신 레이스 (동시성/정확성)

  • 증상: 두 입찰이 동시에 들어오면 더 낮은 금액이 최종 최고가로 남거나, 최고가와 최고입찰자가 서로 다른 입찰을 가리키는 불일치 상태가 된다.
  • 재현 조건: cur=100. 스레드1(amount=150), 스레드2(amount=120) 가 동시에 (B)에서 Interlocked.Read 로 cur=100 을 읽음. 둘 다 통과. 스레드2가 (C)를 늦게 실행하면 _highestBid 가 120 으로 덮어써져 150 입찰이 120 보다 낮게 기록. 또한 Interlocked.Exchange(_highestBid)Interlocked.Exchange(_highestBidder)별개 원자 연산이라, 그 사이에 다른 스레드가 끼면 최고가=150인데 최고입찰자= 다른사람 식으로 어긋난다.
  • 근본 원인: "현재 최고가보다 높으면 갱신" 은 Interlocked.CompareExchange(CAS) 류 단일 원자 연산이거나 lock 으로 묶어야 하는데, Read→비교→Exchange 로 쪼개졌다. 게다가 갱신 대상이 (가격, 입찰자) 두 변수라 하나의 CAS로도 부족 — 둘을 묶은 상태가 필요. Interlocked 는 단일 변수의 원자성만 보장한다.

(D)+(E)+(F) 골드 묶기/환불이 가격 갱신과 분리 — 증발/복사·환불 오류 (정확성/동시성/보안)

  • 증상: 골드가 증발하거나 복사된다. 환불이 누락되거나 중복된다.
  • 재현 조건:
    • 환불 금액 버그: (F)는 cur(이전 최고가) 만큼 환불하지만, 이전 입찰자가 실제로 묶은 금액은 그 사람이 입찰한 금액이지 현재 cur 가 아니다. 다단계 입찰에서 cur 와 실제 묶인 금액이 달라 환불액 불일치 → 골드 증발/복사.
    • 환불 누락/중복: 두 입찰이 동시에 갱신하면 Exchange 로 얻은 prevBidder 가 겹치거나 누락되어 같은 사람을 두 번 환불하거나 아무도 환불 못 받는다.
    • 입찰자 잔액 검증이 전혀 없다. Interlocked.Add(ref bidder.Gold, -amount) 는 잔액이 모자라도 음수로 만든다 → 빚/복사. (problem1·2와 같은 부류.)
  • 근본 원인: 가격 갱신, 묶기, 환불이 하나의 트랜잭션이어야 하는데 독립 Interlocked 연산들로 흩어졌다. Interlocked 는 단일 변수의 원자성만 보장하지, 여러 변수에 걸친 불변식("최고가 = 묶인 금액 = 최고입찰자의 차감액")을 지켜주지 않는다.

(A)+(G) 마감/즉시구매 판정 레이스 — 종료 후 입찰 (정확성/동시성)

  • 증상: 즉시구매로 이미 낙찰됐는데 동시에 들어온 다른 입찰이 (A)·(B)를 통과해 최고가를 덮어쓴다. 마감 직전 tick에서도 여러 입찰이 동시에 (A)를 통과.
  • 근본 원인: _closed 확인과 입찰 처리가 분리. 즉구 도달 판정(G)이 가장 마지막이라 그 사이 다른 입찰이 끼어든다. "마감/낙찰 확정" 과 "입찰 수락" 이 같은 임계 구역이어야 함.

Player.GoldInterlocked 와 일반 접근이 혼용 (동시성)

  • 골드는 problem1(이체)·problem5(가챠) 등 다른 서비스에서 lock 으로 보호한다고 가정되는데, 여기선 Interlocked.Add 로 만진다. 같은 필드를 한 곳은 락, 한 곳은 Interlocked 로 접근하면 상호 배제가 깨진다(Interlocked 는 락을 모른다). 골드 접근 방식이 시스템 전체에서 일관돼야 한다.

수정안

핵심: 경매 한 건의 상태 전이는 단일 lock 으로 묶어 트랜잭션화한다. 입찰자별 "묶은 금액" 을 기록해 정확히 환불한다. Interlocked 흩뿌리기를 버린다.

public class Auction
{
    private readonly object _lock = new object();
    private readonly long _itemId, _buyout, _closeTick;
    private long _highestBid;
    private long _highestBidder = -1;
    private long _lockedAmount = 0;     // 현재 최고 입찰자가 묶은 실제 금액
    private bool _closed = false;

    public Auction(long itemId, long startPrice, long buyout, long closeTick)
    {
        _itemId = itemId; _buyout = buyout; _closeTick = closeTick;
        _highestBid = startPrice;
    }

    public bool Bid(Player bidder, long amount, long nowTick, Dictionary<long, Player> players)
    {
        lock (_lock)                                      // 전체 트랜잭션 보호
        {
            if (_closed || nowTick >= _closeTick) return false;
            if (amount <= _highestBid) return false;       // check-then-act 원자화

            // 입찰자 잔액 검증 + 묶기 (잔액 보호는 골드 서비스 락과 통합 필요)
            // 시스템 전체에서 골드 접근 방식을 통일해야 함(아래 더 나은 설계 참고)
            if (bidder.Gold < amount) return false;
            bidder.Gold -= amount;

            // 이전 최고 입찰자에게 '그가 실제로 묶은 금액' 환불
            if (_highestBidder >= 0 && _lockedAmount > 0
                && players.TryGetValue(_highestBidder, out var prev))
            {
                prev.Gold += _lockedAmount;
            }

            _highestBid = amount;
            _highestBidder = bidder.Id;
            _lockedAmount = amount;       // 이번에 묶은 금액 기록 → 다음 환불에 사용

            if (amount >= _buyout) _closed = true;   // 같은 임계 구역에서 낙찰 확정
            return true;
        }
    }

    public bool IsClosed(long nowTick)
    {
        lock (_lock) { return _closed || nowTick >= _closeTick; }
    }
}

주의: 골드 차감/환불은 사실 경매 락과 플레이어 골드 락의 경계를 넘나든다. 두 락이 얽히면 데드락 위험 → 골드 이체를 별도 원장 서비스(problem1)에 위임하고 멱등성 키로 환불/차감을 처리하는 게 안전하다. 최소한 골드 접근을 시스템 전체에서 한 가지 방식 (락 또는 원장)으로 통일해야 한다.


더 나은 설계

1) lock-free 가 정말 필요하면: 상태를 하나로 묶어 CAS

(price, bidderId) 를 하나의 참조 가능한 불변 객체(예: record class BidState)로 묶고 Interlocked.CompareExchange<BidState> 루프로 교체하면 가격/입찰자 불일치가 사라진다. 단 골드 묶기/환불은 부수효과 라 CAS만으로 원자화 불가 — 결국 보상 트랜잭션이나 별도 정산 큐가 필요하다.

  • 트레이드오프: 구현 난도 급상승, 디버깅 지옥. 입찰은 보통 lock 으로 충분히 빠르다. lock-free는 경합이 극심하고 지연이 치명적일 때만 정당화된다.

2) 경매를 단일 스레드(액터)로 직렬 처리

입찰을 큐(예: Channel<Bid>)에 넣고 경매 소유 스레드가 순서대로 처리. 레이스·데드락이 원천 소멸하고 정합성 추론이 쉬워진다. 인기 매물은 큐 길이로 백프레셔.

  • 트레이드오프: 단일 경매 처리량 상한. 대신 경매는 보통 샤딩 가능(매물별 독립).

3) 골드는 별도 원장 + 멱등성, 환불은 이벤트

입찰 시 "묶기" 와 환불을 원장 트랜잭션으로 기록(bidId 기준 멱등). 경매 객체는 가격/입찰자만 관리. 정산은 마감 시 일괄 처리(승자 차감 확정, 패자 환불). 크래시 복구 가능.


면접 포인트

  • 면접관이 듣고 싶은 핵심: Interlocked 를 여러 변수에 쓴다고 복합 연산이 원자적이진 않다. check-then-act / read-modify-write 가 단일 CompareExchangelock 으로 묶여야 한다는 것, 그리고 부수효과(골드 묶기/환불)는 단순 Interlocked 로 원자화 불가 → 트랜잭션이 필요하다는 통찰.
  • 예상 질문:
    1. "Interlocked 두 번으로 (가격, 입찰자)를 갱신하면 왜 깨지나?" → 두 Exchange 사이에 다른 스레드가 끼어 불일치. 둘을 한 객체로 묶어 CAS하거나 락 필요.
    2. "환불 금액을 cur 로 하면 뭐가 틀리나?" → 이전 입찰자가 실제 묶은 금액과 다를 수 있다. 입찰자별 묶인 금액을 기록해야 함.
    3. "같은 Gold 필드를 한 곳은 lock, 한 곳은 Interlocked 로 만지면?" → 상호 배제가 깨진다. Interlocked 는 lock 을 모른다. 접근 방식을 통일해야 한다.