← 문제로

29. 경매 낙찰과 출품 회수의 동시 처리 (이중 귀속 방지)

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

해설 — 경매 낙찰과 출품 회수의 동시 처리 (이중 귀속 방지)

난이도: 하

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

요약

SettleWin(마감 낙찰)과 Recall(출품자 회수)이 같은 출품(listing) 을 보호 없이 동시에 만진다. 둘 다 Status == Active 인지 확인한 뒤(check) → 아이템을 지급하고 → Status 를 바꾼다(act). 이 check-then-act 가 원자적이지 않아, 두 요청이 모두 Active 를 통과한 뒤 각자 아이템을 지급할 수 있다. 결과: 아이템이 두 개로 복제된다 (낙찰자도 받고 출품자도 돌려받음). 게다가 출품자는 골드까지 받아 공짜로 아이템 + 골드를 얻는다. 정답 한 줄: 출품 상태 전이를 원자적 CAS(Active→Sold/Recalled)로 만들고, 지급은 전이에 성공한 쪽만 수행하라.

변별: problem4(경매 동시 입찰=최고가 갱신 경합), problem1(골드 이체 총합 보존)과 달리, 본 문제는 마감 낙찰과 출품 회수라는 두 종결 경로의 상호배제 — 어느 하나만 이겨야 한다.


문제점

(A)(C) Status != Active 확인 후 지급 — TOCTOU / check-then-act ★간판

  • 분류 태그: time-of-check to time-of-use / 상태 전이 비원자성 / lost update.
  • 증상: 두 스레드가 거의 동시에 (A)/(C)Active 검사를 모두 통과한다. 검사와 상태 변경 사이에 보호가 없어 둘 다 "내가 처음"이라 믿고 진행한다.
  • 재현 조건: 마감 타이머의 SettleWin 과 플레이어의 Recall 이 ms 단위로 겹침.
  • 근본 원인: 상태 검사·아이템 지급·상태 갱신이 하나의 임계구역으로 묶이지 않음. ListingStatus 비교/대입은 원자적 트랜잭션이 아니다.

(B)(D) 검사와 분리된 지급 — 이중 귀속(복제)

  • 분류 태그: double-grant / item duplication.
  • 증상: SettleWin(B) 에서 낙찰자에게 아이템을 넣고, 동시에 Recall(D) 에서 출품자에게 같은 아이템을 넣는다 → 세상에 아이템이 2개. 골드도 출품자에게 들어가 "팔지도 않았는데 골드 + 아이템" 이 된다.
  • 근본 원인: "상태를 선점한 쪽만 지급" 이라는 불변식이 코드로 강제되지 않음.

추가: 비스레드세이프 컬렉션 접근

  • Dictionary 자체도 동시 읽기/쓰기에 안전하지 않다. 여기서는 같은 키만 보지만, 일반적으로 _listings 에 대한 동시 Register/조회가 있으면 별도 보호가 필요하다.

수정안 (정확한 코드)

출품 단위 락으로 상태 전이 + 지급을 하나의 임계구역으로 묶는다. 전이에 성공한 경로만 지급한다(상태를 먼저 선점 → 그 다음 지급).

public class AuctionService
{
    private readonly Dictionary<long, AuctionListing> _listings = new();
    private readonly IItemBag _bag;
    private readonly IWallet  _wallet;
    private readonly object _gate = new();   // 데모용. 실서비스는 출품(또는 출품자) 단위 락.

    public AuctionService(IItemBag bag, IWallet wallet) { _bag = bag; _wallet = wallet; }
    public void Register(AuctionListing l) { lock (_gate) _listings[l.ListingId] = l; }

    public bool SettleWin(long listingId)
    {
        lock (_gate)
        {
            if (!_listings.TryGetValue(listingId, out var l)) return false;
            if (l.Status != ListingStatus.Active) return false;   // 이미 종결됨
            if (l.HighBidderId == 0) return false;

            // 상태를 먼저 선점(Active→Sold) → 지급. 같은 락 안이라 Recall 이 끼어들 수 없다.
            l.Status = ListingStatus.Sold;
            _bag.AddItem(l.HighBidderId, l.ItemId);
            _wallet.AddGold(l.SellerId, l.HighBid);
            return true;
        }
    }

    public bool Recall(long listingId, long sellerId)
    {
        lock (_gate)
        {
            if (!_listings.TryGetValue(listingId, out var l)) return false;
            if (l.SellerId != sellerId) return false;
            if (l.Status != ListingStatus.Active) return false;   // 이미 낙찰/회수됨

            l.Status = ListingStatus.Recalled;                    // 선점 후
            _bag.AddItem(l.SellerId, l.ItemId);                   // 반환
            return true;
        }
    }
}

핵심은 상태 전이가 곧 "이 출품을 종결할 권리의 획득" 이라는 점. 락 안에서 Active 를 확인하고 즉시 다른 상태로 바꾸면, 나중 도착한 경로는 Active 가 아니어서 자동으로 거절된다. 지급 부작용(AddItem/AddGold)이 같은 락 안에서 외부 모듈을 호출하는 게 부담이면, 수정안처럼 상태만 락 안에서 전이하고 지급 페이로드를 만든 뒤 락 밖에서 지급하는 2단계도 가능 (단, 지급 멱등성/실패 보상은 별도 보장).

더 나은 설계 (+ 트레이드오프)

  • 단일 상태 머신 + CAS: Active→Sold / Active→RecalledInterlocked.CompareExchange (또는 DB UPDATE ... WHERE status='Active' 의 영향 행수 검사)로 만들면 락 없이도 "정확히 하나만 성공"을 보장한다. 트레이드오프: 지급 부작용까지 원자화하려면 결국 트랜잭션.
  • 출품을 액터/단일 스레드로: 한 출품의 모든 명령(입찰/낙찰/회수)을 한 큐에서 순차 처리하면 경합 자체가 사라진다. 트레이드오프: 샤딩/라우팅 복잡도.
  • 지급은 멱등 트랜잭션으로: 아이템·골드 지급을 DB 트랜잭션 + 멱등 키(listingId+종결유형)로 하면, 크래시/재시도에도 이중 지급이 없다. (영속화 관점: persistence_data 트랙과 연결)
  • 회수 가능 시점 제한: 마감 N초 전부터는 회수를 막아(잠금 구간) 경합 창 자체를 줄이는 정책적 방어도 병행 가치가 있다(완전한 해법은 아니지만 경합 확률을 낮춘다).

면접 포인트

  1. "검사했는데 왜 둘 다 통과하죠?" — check 와 act 사이에 다른 스레드가 끼어들기 때문. 상태 전이는 검사+변경을 원자적으로(락 또는 CAS) 해야 의미가 있다.
  2. 이중 귀속을 막는 핵심은 "지급" 이 아니라 "종결 권리의 선점" 을 원자화하는 것. 상태를 먼저 바꾼 쪽만 지급하게 하면 복제가 원천 차단된다.
  3. 부작용(외부 모듈 호출)을 락 안에 둘지 밖에 둘지는 데드락·락 보유시간 트레이드오프. 상태 전이만 짧게 락하고 지급은 멱등 트랜잭션으로 분리하는 패턴이 실무 정석.