29. 경매 낙찰과 출품 회수의 동시 처리 (이중 귀속 방지)
난이도 하해설 — 경매 낙찰과 출품 회수의 동시 처리 (이중 귀속 방지)
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
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→Recalled를Interlocked.CompareExchange(또는 DBUPDATE ... WHERE status='Active'의 영향 행수 검사)로 만들면 락 없이도 "정확히 하나만 성공"을 보장한다. 트레이드오프: 지급 부작용까지 원자화하려면 결국 트랜잭션. - 출품을 액터/단일 스레드로: 한 출품의 모든 명령(입찰/낙찰/회수)을 한 큐에서 순차 처리하면 경합 자체가 사라진다. 트레이드오프: 샤딩/라우팅 복잡도.
- 지급은 멱등 트랜잭션으로: 아이템·골드 지급을 DB 트랜잭션 + 멱등 키(listingId+종결유형)로 하면, 크래시/재시도에도 이중 지급이 없다. (영속화 관점: persistence_data 트랙과 연결)
- 회수 가능 시점 제한: 마감 N초 전부터는 회수를 막아(잠금 구간) 경합 창 자체를 줄이는 정책적 방어도 병행 가치가 있다(완전한 해법은 아니지만 경합 확률을 낮춘다).
면접 포인트
- "검사했는데 왜 둘 다 통과하죠?" — check 와 act 사이에 다른 스레드가 끼어들기 때문. 상태 전이는 검사+변경을 원자적으로(락 또는 CAS) 해야 의미가 있다.
- 이중 귀속을 막는 핵심은 "지급" 이 아니라 "종결 권리의 선점" 을 원자화하는 것. 상태를 먼저 바꾼 쪽만 지급하게 하면 복제가 원천 차단된다.
- 부작용(외부 모듈 호출)을 락 안에 둘지 밖에 둘지는 데드락·락 보유시간 트레이드오프. 상태 전이만 짧게 락하고 지급은 멱등 트랜잭션으로 분리하는 패턴이 실무 정석.
해설 — 경매 낙찰과 출품 회수의 동시 처리 (이중 귀속 방지) · C++
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
SettleWin(마감 낙찰)과 Recall(출품자 회수)이 같은 Listing 을 뮤텍스 없이 동시에
만진다. 둘 다 status == Active 를 확인한 뒤(check) 아이템을 지급하고 status 를
바꾼다(act). 이 check-then-act 가 원자적이지 않아 두 스레드가 모두 Active 를 통과하고
각자 지급해 아이템이 복제되고(낙찰자 + 출품자 둘 다 받음), 출품자는 골드까지 챙긴다.
정답 한 줄: 출품 상태 전이를 락(또는 CAS)으로 원자화하고, 전이에 성공한 쪽만 지급하라.
변별: problem4(동시 입찰 최고가 갱신)·problem1(골드 총합 보존)과 달리, 두 종결 경로의 상호배제(낙찰 xor 회수)가 본질. C# 트윈과 결함은 같고, C++ 에선
unordered_map원소를 참조로 들고 도는 데서 오는 추가 위험(아래)이 더 두드러진다.
문제점
(A)(C) status != Active 확인 후 지급 — TOCTOU ★간판
- 분류 태그: time-of-check to time-of-use / 상태 전이 비원자성 / 데이터 레이스.
- 증상: 두 스레드가
(A)/(C)의Active검사를 거의 동시에 통과한다. 검사~변경 사이에 보호가 없어 둘 다 진행한다. 더해l.status에 대한 동시 읽기/쓰기 자체가 데이터 레이스 (UB)다 —enum class대입은 원자적이지 않다. - 재현 조건: 마감 타이머 스레드의
SettleWin과 IO 스레드의Recall이 겹침. - 근본 원인: 상태 검사·지급·갱신이 하나의 임계구역으로 묶이지 않음.
(B)(D) 분리된 지급 — 이중 귀속(복제)
- 분류 태그: double-grant / item duplication.
- 증상:
(B)낙찰자에게,(D)출품자에게 같은itemId가 동시에 들어가 아이템 2개. 골드도 출품자에게 들어가 "팔지 않았는데 골드 + 아이템". - 근본 원인: "상태를 선점한 쪽만 지급" 불변식이 코드로 강제되지 않음.
(C++ 특이) unordered_map 원소 참조 보유 + 동시 구조 변경
- 분류 태그: iterator/reference invalidation 가능성.
- 증상: 현재 코드는 종결 시 엔트리를 지우지 않아 즉시 UAF 는 없지만, "회수 시 erase",
"정리 스레드가 만료 출품 erase" 같은 변경을 더하면
Listing& l = it->second가 댕글링 참조가 된다(unordered_map::erase는 해당 원소 참조/이터레이터를 무효화). 락 없는 설계에 erase 를 더하는 순간 use-after-free 로 번진다. - 근본 원인: 컨테이너 동시 접근·수명 관리 부재.
수정안 (정확한 코드)
출품 단위 뮤텍스로 상태 전이 + 지급을 한 임계구역에 묶고, 상태를 먼저 선점한 경로만 지급한다.
#include <mutex>
class AuctionService
{
std::unordered_map<int64_t, Listing> listings_;
std::mutex mtx_; // 데모용 전역 락(실서비스는 출품/샤드 단위 락)
IItemBag& bag_;
IWallet& wallet_;
public:
AuctionService(IItemBag& bag, IWallet& wallet) : bag_(bag), wallet_(wallet) {}
void Register(const Listing& l)
{
std::lock_guard<std::mutex> lk(mtx_);
listings_[l.listingId] = l;
}
bool SettleWin(int64_t listingId)
{
std::lock_guard<std::mutex> lk(mtx_);
auto it = listings_.find(listingId);
if (it == listings_.end()) return false;
Listing& l = it->second;
if (l.status != ListingStatus::Active) return false;
if (l.highBidderId == 0) return false;
l.status = ListingStatus::Sold; // 선점 후
bag_.AddItem(l.highBidderId, l.itemId);
wallet_.AddGold(l.sellerId, l.highBid);
return true;
}
bool Recall(int64_t listingId, int64_t sellerId)
{
std::lock_guard<std::mutex> lk(mtx_);
auto it = listings_.find(listingId);
if (it == listings_.end()) return false;
Listing& l = it->second;
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 가
아니어서 자동 거절된다. 엔트리 erase 는 반드시 같은 락 아래에서만, 그리고 erase 전에
참조 사용을 끝낸 뒤 수행한다.
더 나은 설계 (+ 트레이드오프)
- 상태를
std::atomic<ListingStatus>+ CAS:compare_exchange_strong(expected=Active, Sold)로 "정확히 하나만 성공". 락 없이 종결 권리를 선점한다. 단 지급 부작용까지 원자화하려면 결국 트랜잭션/멱등성이 필요. - 출품 객체 수명 분리:
Listing을shared_ptr로 들고 map 에는 핸들만 두면, 종결 후 map 에서 빼도 처리 중 참조가 살아 있어 UAF 가 사라진다(인플라이트 안전 회수). - 지급은 멱등 트랜잭션: 아이템·골드 지급을 멱등 키(listingId+종결유형)로 감싸 재시도/재기동 이중 지급 차단(persistence 트랙과 연결).
- 회수 잠금 구간: 마감 직전 N초간 회수 금지로 경합 창을 줄이는 정책적 방어 병행.
면접 포인트
- C++ 에서
enum/bool플래그의 동시 읽기/쓰기도 데이터 레이스(UB) 다. "검사 후 변경"은 같은 뮤텍스(또는 atomic CAS)로 원자화해야 한다. - 락 없는 코드에
erase/정리 로직을 나중에 더하면 참조·이터레이터 무효화로 UAF 가 생긴다. 동시성 설계에서 컨테이너 구조 변경과 원소 수명은 항상 함께 본다. - 이중 귀속 차단의 본질은 지급이 아니라 종결 권리의 선점 원자화. 상태를 먼저 바꾼 쪽만 지급하면 복제가 원천 봉쇄된다.