4. 경매장 동시 입찰
난이도 상해설 — 경매장 동시 입찰
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
입찰 로직이 여러 필드를 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와 같은 부류.)
- 환불 금액 버그: (F)는
- 근본 원인: 가격 갱신, 묶기, 환불이 하나의 트랜잭션이어야 하는데 독립
Interlocked연산들로 흩어졌다.Interlocked는 단일 변수의 원자성만 보장하지, 여러 변수에 걸친 불변식("최고가 = 묶인 금액 = 최고입찰자의 차감액")을 지켜주지 않는다.
(A)+(G) 마감/즉시구매 판정 레이스 — 종료 후 입찰 (정확성/동시성)
- 증상: 즉시구매로 이미 낙찰됐는데 동시에 들어온 다른 입찰이 (A)·(B)를 통과해 최고가를 덮어쓴다. 마감 직전 tick에서도 여러 입찰이 동시에 (A)를 통과.
- 근본 원인:
_closed확인과 입찰 처리가 분리. 즉구 도달 판정(G)이 가장 마지막이라 그 사이 다른 입찰이 끼어든다. "마감/낙찰 확정" 과 "입찰 수락" 이 같은 임계 구역이어야 함.
Player.Gold 를 Interlocked 와 일반 접근이 혼용 (동시성)
- 골드는 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 가 단일CompareExchange나lock으로 묶여야 한다는 것, 그리고 부수효과(골드 묶기/환불)는 단순 Interlocked 로 원자화 불가 → 트랜잭션이 필요하다는 통찰. - 예상 질문:
- "
Interlocked두 번으로 (가격, 입찰자)를 갱신하면 왜 깨지나?" → 두 Exchange 사이에 다른 스레드가 끼어 불일치. 둘을 한 객체로 묶어 CAS하거나 락 필요. - "환불 금액을
cur로 하면 뭐가 틀리나?" → 이전 입찰자가 실제 묶은 금액과 다를 수 있다. 입찰자별 묶인 금액을 기록해야 함. - "같은
Gold필드를 한 곳은 lock, 한 곳은 Interlocked 로 만지면?" → 상호 배제가 깨진다. Interlocked 는 lock 을 모른다. 접근 방식을 통일해야 한다.
- "
해설 — 경매장 동시 입찰
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
입찰 로직이 여러 std::atomic 을 개별적으로 갱신한다. 각 연산은 원자적이지만
"최고가 확인 → 갱신 → 묶기 → 환불 → 즉구 처리" 라는 복합 연산 전체는 원자적이지
않다. 그 결과 골드 증발/복사, 환불 누락·중복 환불, 최고가/최고입찰자 불일치,
경매 종료 후 입찰 이 동시에 발생한다. "atomic을 썼으니 안전하다" 는 흔한 착각의 정수.
문제점
(B)+(C) check-then-act 가 원자적이지 않음 — 최고가 갱신 레이스 (동시성/정확성)
- 증상: 두 입찰이 동시에 들어오면 더 낮은 금액이 최종 최고가로 남거나, 최고가와 최고입찰자가 서로 다른 입찰을 가리키는 불일치 상태가 된다.
- 재현 조건: cur=100. 스레드1(amount=150), 스레드2(amount=120) 가 동시에 (B)에서
cur=100 을 읽음. 둘 다 통과. 스레드2가 (C)를 늦게 실행하면
highestBid_가 120 으로 덮어써져 150 입찰이 120 보다 낮게 기록. 또한highestBid_.store와highestBidder_.exchange가 별개 원자 연산이라, 그 사이에 다른 스레드가 끼면 최고가=150인데 최고입찰자=다른사람 식으로 어긋난다. - 근본 원인: "현재 최고가보다 높으면 갱신" 은 compare-and-swap 류 단일 원자 연산이거나 락으로 묶어야 하는데, load→비교→store 로 쪼개졌다. 게다가 갱신 대상이 (가격, 입찰자) 두 변수라 하나의 CAS로도 부족 — 둘을 묶은 상태가 필요.
(D)+(E)+(F) 골드 묶기/환불이 가격 갱신과 분리 — 증발/복사·환불 오류 (정확성/동시성/보안)
- 증상: 골드가 증발하거나 복사된다. 환불이 누락되거나 중복된다.
- 재현 조건:
- 환불 금액 버그: (F)는
cur(이전 최고가) 만큼 환불하지만, 이전 입찰자가 실제로 묶은 금액은 그 사람이 입찰한 금액이지 현재cur가 아니다. 다단계 입찰에서cur와 실제 묶인 금액이 달라 환불액 불일치 → 골드 증발/복사. - 환불 누락/중복: 두 입찰이 동시에 갱신하면
exchange로 얻은prevBidder가 겹치거나 누락되어 같은 사람을 두 번 환불하거나 아무도 환불 못 받는다. - 입찰자 잔액 검증이 전혀 없다.
gold.fetch_sub(amount)는 잔액이 모자라도 음수로 만든다 → 빚/복사. (problem1·2와 같은 부류.)
- 환불 금액 버그: (F)는
- 근본 원인: 가격 갱신, 묶기, 환불이 하나의 트랜잭션이어야 하는데 독립 원자 연산들로 흩어졌다. atomic은 단일 변수의 원자성만 보장하지, 여러 변수에 걸친 불변식 ("최고가 = 묶인 금액 = 최고입찰자의 차감액")을 지켜주지 않는다.
(A)+(G) 마감/즉시구매 판정 레이스 — 종료 후 입찰 (정확성/동시성)
- 증상: 즉시구매로 이미 낙찰됐는데 동시에 들어온 다른 입찰이 (A)·(B)를 통과해 최고가를 덮어쓴다. 마감 직전 tick에서도 여러 입찰이 동시에 (A)를 통과.
- 근본 원인:
closed_확인과 입찰 처리가 분리. 즉구 도달 판정(G)이 가장 마지막이라 그 사이 다른 입찰이 끼어든다. "마감/낙찰 확정" 과 "입찰 수락" 이 같은 임계 구역이어야 함.
fetch_sub 의 음수 잔액 (정확성/보안)
- 입찰자 골드가 부족해도 묶기가 성공한다. 입찰 전
gold >= amount검증을 원자적으로 (CAS 루프 또는 락) 해야 한다.
수정안
핵심: 경매 한 건의 상태 전이는 단일 mutex 로 묶어 트랜잭션화한다. 입찰자별 "묶은 금액" 을 기록해 정확히 환불한다. atomic 흩뿌리기를 버린다.
class Auction {
public:
Auction(int64_t itemId, int64_t startPrice, int64_t buyout, int64_t closeTick)
: itemId_(itemId), startPrice_(startPrice), buyout_(buyout),
closeTick_(closeTick), highestBid_(startPrice), highestBidder_(-1) {}
bool Bid(Player& bidder, int64_t amount, int64_t nowTick,
std::unordered_map<int64_t, Player*>& players) {
std::lock_guard<std::mutex> lk(mtx_); // 전체 트랜잭션 보호
if (closed_ || nowTick >= closeTick_) return false;
if (amount <= highestBid_) return false; // check-then-act 원자화
// 입찰자 잔액 검증 + 묶기 (잔액 보호는 골드 서비스 락과 통합 필요)
if (bidder.gold.load() < amount) return false;
bidder.gold.fetch_sub(amount);
// 이전 최고 입찰자에게 '그가 실제로 묶은 금액' 환불
if (highestBidder_ >= 0 && lockedAmount_ > 0) {
auto it = players.find(highestBidder_);
if (it != players.end())
it->second->gold.fetch_add(lockedAmount_);
}
highestBid_ = amount;
highestBidder_ = bidder.id;
lockedAmount_ = amount; // 이번에 묶은 금액 기록 → 다음 환불에 사용
if (amount >= buyout_) { closed_ = true; } // 같은 임계 구역에서 낙찰 확정
return true;
}
private:
int64_t itemId_, startPrice_, buyout_, closeTick_;
int64_t highestBid_;
int64_t highestBidder_;
int64_t lockedAmount_ = 0; // 현재 최고 입찰자가 묶은 실제 금액
bool closed_ = false;
std::mutex mtx_;
};
주의: 골드 차감/환불은 사실 경매 락과 플레이어 골드 락의 경계를 넘나든다. 두 락이 얽히면 데드락 위험 → 골드 이체를 별도 원장 서비스(problem1)에 위임하고 멱등성 키로 환불/차감을 처리하는 게 안전하다.
더 나은 설계
1) lock-free 가 정말 필요하면: 상태를 하나의 워드로 압축 + CAS
(price, bidderId) 를 하나의 128비트 구조로 묶어 compare_exchange 루프로 갱신하면
가격/입찰자 불일치가 사라진다. 단 골드 묶기/환불은 부수효과 라 CAS만으로 원자화
불가 — 결국 보상 트랜잭션이나 별도 정산 큐가 필요.
- 트레이드오프: 구현 난도 급상승, 디버깅 지옥. 입찰은 보통 mutex로 충분히 빠르다. lock-free는 경합이 극심하고 지연이 치명적일 때만 정당화된다.
2) 경매를 단일 스레드(액터)로 직렬 처리
입찰을 큐에 넣고 경매 소유 스레드가 순서대로 처리. 레이스·데드락이 원천 소멸하고 정합성 추론이 쉬워진다. 인기 매물은 큐 길이로 백프레셔.
- 트레이드오프: 단일 경매 처리량 상한. 대신 경매는 보통 샤딩 가능(매물별 독립).
3) 골드는 별도 원장 + 멱등성, 환불은 이벤트
입찰 시 "묶기" 와 환불을 원장 트랜잭션으로 기록(bid_id 기준 멱등). 경매 객체는 가격/입찰자만 관리. 정산은 마감 시 일괄 처리(승자 차감 확정, 패자 환불). 크래시 복구 가능.
면접 포인트
- 면접관이 듣고 싶은 핵심: "atomic 변수를 여러 개 쓴다고 복합 연산이 원자적이진 않다". check-then-act / read-modify-write 가 단일 CAS나 락으로 묶여야 한다는 것, 그리고 부수효과(골드 묶기/환불)는 단순 atomic으로 원자화 불가 → 트랜잭션이 필요하다는 통찰.
- 예상 질문:
- "atomic 두 개로 (가격, 입찰자)를 갱신하면 왜 깨지나?" → 두 store 사이에 다른 스레드가 끼어 불일치. 둘을 한 단위로 CAS하거나 락 필요.
- "환불 금액을
cur로 하면 뭐가 틀리나?" → 이전 입찰자가 실제 묶은 금액과 다를 수 있다. 입찰자별 묶인 금액을 기록해야 함. - "여기서 lock-free가 이득인가?" → 골드 부수효과 때문에 순수 lock-free 불가. 락/액터가 더 안전하고 보통 충분.