50. 경매장 즉시구매와 가격 인하의 동시 처리 (이중 판매 / 가격 불일치)
난이도 하내 리뷰 · C#
내 리뷰 · 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)의 처리량·복잡도 트레이드오프.
해설 · C++
해설 — 경매장 즉시구매 vs 가격 수정/취소 (이중 판매 · 가격 불일치 · 삭제 후 사용)
난이도: 중
요약
buyout 은 find → sold 검사 → 차감 → sold=true → 지급/정산 을 무동기화로 수행한다.
검사와 표시(A→B) 사이가 원자적이지 않고, editPrice/cancel 이 같은 매물을 동시에 수정·삭제하므로
이중 판매, 지불 가격 불일치, 그리고 C++ 특유의 삭제된 매물 포인터 접근(use-after-free) 이 난다.
문제점
- [원자성 위반] 이중 판매: 두 구매 스레드가
(A)직후l->sold==false를 함께 통과하면 둘 다 차감·지급하고(B)에sold=true를 쓴다. 아이템 이중 지급, 판매자 이중 정산이 발생한다. - [TOCTOU] 가격 불일치:
int64_t price = l->buyoutPrice를 읽은 뒤editPrice가 값을 바꾸면 구매자가 화면과 다른 금액을 지불한다. - [수명/UAF] 취소와 구매의 경합:
buyout이(A)에서 얻은Listing* l을 계속 쓰는 동안cancel이listings_.erase(it)로 원소를 파괴하면, 이후l->itemId/l->sellerId접근은 해제된 메모리를 읽는 것이다. 또한 다른 스레드의put(삽입)이unordered_map재해시를 유발하면 보관 중이던 반복자/포인터가 무효화된다. - [자료구조 동시성] 무보호
unordered_map: 동시find/erase/insert자체가 데이터 레이스다. - 재현 조건: 핫 매물에 구매가 몰리거나, 판매자가 팔리는 순간 가격을 조정/취소할 때.
수정안
매물 컨테이너를 뮤텍스로 보호하고, 검사-차감-표시를 한 임계구역에서 처리하며, 포인터를 임계구역 밖으로 들고 나가지 않는다. 지급에 필요한 값은 락 안에서 지역 변수로 복사한다.
#include <mutex>
BuyResult buyout(int64_t buyerId, int64_t listingId, int64_t agreedPrice) {
int64_t itemId, sellerId, price;
{
std::lock_guard<std::mutex> lk(mu_);
auto it = listings_.find(listingId);
if (it == listings_.end()) return BuyResult::NotFound;
Listing& l = it->second;
if (l.sold) return BuyResult::AlreadySold;
if (l.buyoutPrice != agreedPrice) return BuyResult::PriceChanged;
if (wallet_.gold(buyerId) < l.buyoutPrice) return BuyResult::NotEnoughGold;
l.sold = true; // 클레임을 락 안에서 확정
itemId = l.itemId; sellerId = l.sellerId; price = l.buyoutPrice;
}
// 지급/정산은 복사한 지역 값으로 (매물이 이후 삭제돼도 안전)
wallet_.deduct(buyerId, price);
inv_.give(buyerId, itemId, 1);
wallet_.add(sellerId, price);
return BuyResult::Ok;
}
// editPrice / cancel 도 같은 mu_ 로 보호하고, cancel 은 sold==true 매물을 지우지 않도록 한다.
더 나은 설계
- 매물을
shared_ptr<Listing>로 관리하면,buyout이 소유 지분을 쥐는 동안cancel이 맵에서 지워도 객체 수명이 유지되어 UAF 를 원천 차단한다(맵은 뮤텍스로 보호). - 상태를
enum하나로 두고Active→Sold전이를 CAS 로 원자화하면 전역 락 경합을 줄일 수 있다. - 지불/지급/정산은 실패 시 되돌릴 수 있는 순서(먼저 클레임 → 차감 → 지급)로 배치하고 예외 경로를 정의한다.
- 트레이드오프:
shared_ptr는 수명 안전을 주지만 원자적 참조계수 비용이 있고, 전역 뮤텍스는 단순하나 처리량 상한이 된다.
면접 포인트
- C++ 에서 "컨테이너 원소의 포인터/참조를 임계구역 밖으로 들고 나가면" 삭제·재해시로 무효화된다는 점.
unordered_map의erase/insert가 반복자·포인터 무효화에 미치는 영향.- check-then-act 를 원자화하는 방법(전역 락 vs 상태 CAS)과 수명 안전(shared_ptr)의 조합.