7. 한정 상점 구매와 재고 정합성
난이도 중해설 — 한정 상점 구매와 재고 정합성
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
재고 확인(C)과 차감(F)이 분리된 check-then-act 라 동시 구매가 모두 통과해
oversell(재고보다 더 팔림) 이 난다. Volatile.Read/Interlocked.Add 를 써서
"원자적" 으로 보이지만 두 연산 사이가 비어 있어 소용이 없다. 인당 한도 누적 맵(B)은
비원자적 read-modify-write + 스레드 비안전 Dictionary 라 한도 초과/맵 손상이
난다. 골드 차감(E)·재고 차감(F)·지급(H)이 서로 다른 동기화 수단으로 흩어져 원자성이
없고, 실패 시 롤백 경로도 없다. _shopLock(I)은 선언만 되고 쓰이지 않는다.
문제점
(C)→(F) 재고 check-then-act — oversell (정확성/동시성) ★간판
- 증상: 재고 10개 상품이 20개, 50개씩 팔린다.
- 재현 조건: 오픈 직후 재고 10 상품에 동시에 50개 요청이 들어옴. 모두 (C)에서
Volatile.Read(Stock)=10 >= 1을 통과한 뒤 각자 (F)Interlocked.Add(Stock, -1)→ 재고가 음수(-40)까지 내려가며 50명에게 팔린다.Stock을Volatile.Read/Interlocked.Add로 만져 각 연산은 원자적이지만, "확인 후 차감" 두 동작 사이가 비어 있어 확인 시점의 재고가 차감 시점엔 이미 남의 차감으로 사라진다(TOCTOU). - 근본 원인:
Volatile/Interlocked는 단일 연산만 원자적으로 만들 뿐, "조건부 차감(있으면 줄인다)" 이라는 복합 연산을 원자적으로 만들지 못한다.Interlocked.CompareExchange루프나lock이 필요하다.
(B)+(D)+(G) 인당 한도 — 비원자 RMW + 비안전 Dictionary (정확성/동시성/보안)
- 증상: 한도가 1인데 같은 플레이어가 동시에 2번 사서 2개를 갖는다. 최악엔
Dictionary동시 접근으로InvalidOperationException/내부 손상/무한 루프. - 재현 조건: 같은 플레이어가 동시에 두 번 구매. 둘 다 (D)에서
already=0을 읽고0+1 <= limit(1)통과 → 둘 다 (G)에서Bought[id] = 1→ 한도 우회로 2개 구매. 또한 (D)의TryGetValue읽기와 (G)의 쓰기가 다른 플레이어 요청들과 동시에 일어나면Dictionary의 resize/rehash 중 자료구조 손상(Dictionary는 동시 쓰기 시 스레드 안전을 보장하지 않음 — 읽기 전용 동시 접근만 안전). - 근본 원인: 누적 구매의 read(D)-modify-write(G)가 원자적이지 않고,
Dictionary는 동시 쓰기에 안전하지 않은데 락 없이 동시 접근한다.
(E)+(F)+(G)+(H) 차감/지급이 한 트랜잭션이 아님 — 원자성/롤백 부재 (정확성/동시성)
- 증상: 골드만 빠지고 재고/지급이 누락되거나(또는 그 반대), 재고만 줄고 한도 갱신 실패 등 부분 반영. 실패 분기에서 이미 차감한 골드를 되돌리는 경로가 없다.
- 재현 조건: 골드 차감(E) 성공 후 (F)~(H) 어딘가에서 예외(
Dictionary손상 등) → 골드는 빠졌는데 아이템은 못 받음. 또는 재고는 줄었는데 (G)/(H) 실패 → 재고 누수. - 근본 원인: 구매는 "한도검증 + 재고차감 + 골드차감 + 지급" 이 하나의 원자 단위여야 하는데, 각기 다른 수단(buyer.Lock만 골드, Stock은 Interlocked, Bought/지급은 무락) 으로 쪼개져 있다. 전체를 감싸는 임계 구역이 없다.
(I) _shopLock 미사용 (유지보수/동시성)
- 상품/상점 단위 직렬화를 의도한 듯한 락이 선언만 되어 한 번도 잠기지 않는다. 위 모든 복합 연산을 직렬화할 도구가 있는데도 안 쓴 셈.
서로 다른 동기화 수단 혼용 (동시성)
Stock은Volatile/Interlocked, 골드는lock,Bought는 무락. 하나의 구매 트랜잭션 안에서 동기화 모델이 제각각이라 어떤 불변식도 전체적으로 성립하지 않는다.
(E) 가격 계산 오버플로 (정확성)
item.Price * qty는long이라 보통은 안전하나, 위조/버그성 거대qty면 오버플로 가능.qty상한 검증이 없다(PerPlayerLimit 검증은 우회되므로 신뢰 불가).
수정안
원칙: 상품 단위로 "한도검증 + 재고차감 + 골드차감 + 지급" 전체를 직렬화한다.
상품 락(ShopItem.Lock)을 도입해 재고·누적맵을 보호하고, 그 안에서 골드까지 처리한다.
락 순서는 항상 item.Lock → buyer.Lock 로 고정해 데드락을 피한다.
public class ShopItem
{
public long ItemId;
public long Price;
public int Stock; // Interlocked 불필요(락으로 보호)
public int PerPlayerLimit;
public Dictionary<long, int> Bought = new();
public readonly object Lock = new object(); // 상품 단위 직렬화
}
public bool Buy(ShopItem item, Player buyer, int qty)
{
if (qty <= 0 || qty > item.PerPlayerLimit) return false; // qty 상한
long cost = item.Price * (long)qty;
// 락 순서 고정: item.Lock 먼저, 그 안에서 buyer.Lock
lock (item.Lock)
{
// 재고 확인+차감을 한 임계 구역에서 → oversell 차단
if (item.Stock < qty) return false;
// 인당 한도 확인 (read와 write가 같은 락 안 → RMW 원자적)
int already = item.Bought.TryGetValue(buyer.Id, out var c) ? c : 0;
if (already + qty > item.PerPlayerLimit) return false;
// 골드 확인/차감
lock (buyer.Lock)
{
if (buyer.Gold < cost) return false;
buyer.Gold -= cost;
}
// 여기까지 오면 모두 통과 → 커밋(같은 임계 구역이라 부분 반영 없음)
item.Stock -= qty;
item.Bought[buyer.Id] = already + qty;
buyer.GiveItem(item.ItemId, qty);
return true;
}
}
모든 실패 검증(재고/한도/골드)을 상태 변경 전에 끝내므로 롤백이 필요 없다. 골드 차감만 buyer.Lock 안에서 하되, 차감 후 더 이상 실패 분기가 없으므로 "골드만 빠지는" 부분 반영이 생기지 않는다. 락 순서가
item → buyer로 항상 같아 데드락이 없다.
더 나은 설계
1) 재고는 원자적 조건부 차감(CAS) 또는 DB로
- 락 없이 가려면
Interlocked.CompareExchange루프:
단 이건 재고만 보호할 뿐, 한도·골드·지급과의 원자성은 별도다. 실패 시 차감 복구 필요.int cur; do { cur = Volatile.Read(ref item.Stock); if (cur < qty) return false; } while (Interlocked.CompareExchange(ref item.Stock, cur - qty, cur) != cur); - 실서비스 한정판매는 DB 행 잠금/조건부 UPDATE가 정석:
골드 차감/지급/한도 갱신을 같은 트랜잭션에 묶고UPDATE shop_item SET stock = stock - ? WHERE item_id = ? AND stock >= ?; -- affected=1만 진행(item,player)한도는 UNIQUE/누적 컬럼으로. 트레이드오프: DB 부하. 플래시 세일은 캐시/큐로 완화.
2) 단일 스레드 액터 / 큐
- 한정 상품 하나를 단일 스레드(액터) 가 직렬 처리하면 락이 사라지고 oversell·한도
버그가 구조적으로 불가능. 오픈 직후 폭주 트래픽을
Channel<T>큐로 흡수해 순서대로 처리.Bought도ConcurrentDictionary보다 단일 스레드 소유가 추론이 쉽다. - 트레이드오프: 단일 상품 처리량 한계 → 인기 상품은 샤딩/재고 분할.
3) 멱등성
클라 재전송 대비 requestId 멱등성 키로 같은 구매가 두 번 반영되지 않게 한다.
면접 포인트
- 면접관이 듣고 싶은 핵심:
Interlocked/Volatile재고만으로는 oversell 을 못 막는다(확인-차감이 복합 연산), 인당 한도 RMW 와Dictionary접근도 직렬화 필요, 그리고 구매 전체가 하나의 트랜잭션이라는 것. CAS/lock/DB 조건부 UPDATE 의 트레이드오프를 말할 수 있으면 강하다. - 예상 질문:
- "Stock 을
Interlocked.Add로 줄이면 oversell 이 막히나?" → 아니다. Read-후-Add 사이가 비어 TOCTOU.CompareExchange루프나 lock 으로 조건부 차감. - "인당 한도가 동시에 뚫리는 이유는?
Dictionary는 왜 위험한가?" → 누적값 read-modify-write 가 비원자(lost update). 게다가Dictionary동시 쓰기는 스레드 안전하지 않아 손상/예외. 같은 락/트랜잭션 안에서 처리해야. - "오픈 직후 폭주 트래픽은?"
→
Channel큐/단일 액터로 직렬화하거나 DB 조건부 UPDATE + 캐시. 락 순서는 item→buyer 고정.
- "Stock 을
해설 — 한정 상점 구매와 재고 정합성
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
재고 확인(C)과 차감(F)이 분리된 check-then-act 라 동시 구매가 모두 통과해
oversell(재고보다 더 팔림) 이 난다. 인당 한도 누적 맵(B)은 비원자적
read-modify-write + 스레드 비안전 unordered_map 이라 한도 초과/맵 손상이 난다.
골드 차감(E)·재고 차감(F)·지급(H)이 서로 다른 락(또는 무락)으로 흩어져 원자성이
없고, 실패 시 롤백 경로도 없다. shopLock_(I)은 선언만 되고 쓰이지 않는다.
문제점
(C)→(F) 재고 check-then-act — oversell (정확성/동시성) ★간판
- 증상: 재고 10개 상품이 20개, 50개씩 팔린다.
- 재현 조건: 오픈 직후 재고 10 상품에 동시에 50개 요청이 들어옴. 모두 (C)에서
stock(10) >= 1을 통과한 뒤 각자 (F)fetch_sub(1)→ 재고가 음수(-40)까지 내려가며 50명에게 팔린다.stock이atomic이라fetch_sub자체는 원자적이지만, "확인 후 차감" 두 동작 사이가 비어 있어 확인 시점의 재고가 차감 시점엔 이미 남의 차감으로 사라진다(TOCTOU). - 근본 원인:
atomic은 단일 연산만 원자적으로 만들 뿐, "조건부 차감(있으면 줄인다)" 이라는 복합 연산을 원자적으로 만들지 못한다. CAS 루프나 락이 필요하다.
(B)+(D)+(G) 인당 한도 — 비원자 RMW + 비안전 맵 (정확성/동시성/보안)
- 증상: 한도가 1인데 같은 플레이어가 동시에 2번 사서 2개를 갖는다. 최악엔
unordered_map동시 접근으로 컨테이너 손상/크래시. - 재현 조건: 같은 플레이어가 동시에 두 번 구매. 둘 다 (D)에서
already=0을 읽고0+1 <= limit(1)통과 → 둘 다 (G)에서bought[id] = 1→ 한도 우회로 2개 구매. 또한 (D)의item.bought[buyer.id]는 키가 없으면 삽입까지 하는데, 동시에 다른 플레이어가 (G)로 맵을 쓰면unordered_map의 동시 읽기/쓰기(rehash)로 UB. - 근본 원인: 누적 구매의 read(D)-modify-write(G)가 원자적이지 않고,
unordered_map은 스레드 안전하지 않은데 락 없이 동시 접근한다.
(E)+(F)+(G)+(H) 차감/지급이 한 트랜잭션이 아님 — 원자성/롤백 부재 (정확성/동시성)
- 증상: 골드만 빠지고 재고/지급이 누락되거나(또는 그 반대), 재고만 줄고 한도 갱신 실패 등 부분 반영. 실패 분기에서 이미 차감한 골드를 되돌리는 경로가 없다.
- 재현 조건: 골드 차감(E) 성공 후 (F)~(H) 어딘가에서 예외/크래시 → 골드는 빠졌는데 아이템은 못 받음. 또는 재고는 줄었는데 (G)/(H) 실패 → 재고 누수.
- 근본 원인: 구매는 "한도검증 + 재고차감 + 골드차감 + 지급" 이 하나의 원자 단위여야 하는데, 각기 다른 락(buyer.lock만 골드, stock은 atomic, bought/지급은 무락)으로 쪼개져 있다. 전체를 감싸는 임계 구역이 없다.
(I) shopLock_ 미사용 (유지보수/동시성)
- 상품 단위 직렬화를 의도한 듯한 락이 선언만 되어 한 번도 잠기지 않는다. 위 모든 복합 연산을 직렬화할 도구가 있는데도 안 쓴 셈.
(E) 가격 계산 오버플로 (정확성)
item.price * qty가int64_t라 보통은 안전하나, 위조/버그성 거대qty면 오버플로 가능.qty상한 검증이 없다(perPlayerLimit 검증은 우회되므로 신뢰 불가).
수정안
원칙: 상품 단위로 "한도검증 + 재고차감 + 골드차감 + 지급" 전체를 직렬화한다.
상품 락(item.lock)을 도입해 재고·누적맵을 보호하고, 그 안에서 골드까지 처리한다.
락 순서는 항상 item.lock → buyer.lock 로 고정해 데드락을 피한다.
struct ShopItem {
int64_t itemId;
int64_t price;
int32_t stock; // atomic 불필요(락으로 보호)
int32_t perPlayerLimit;
std::unordered_map<int64_t, int32_t> bought;
std::mutex lock; // 상품 단위 직렬화
};
bool Buy(ShopItem& item, Player& buyer, int32_t qty) {
if (qty <= 0 || qty > item.perPlayerLimit) return false; // qty 상한
int64_t cost = item.price * (int64_t)qty;
// 락 순서 고정: item.lock 먼저, 그 안에서 buyer.lock
std::lock_guard<std::mutex> shopLk(item.lock);
// 재고 확인+차감을 한 임계 구역에서 → oversell 차단
if (item.stock < qty) return false;
// 인당 한도 확인 (read와 write가 같은 락 안 → RMW 원자적)
auto it = item.bought.find(buyer.id);
int32_t already = (it == item.bought.end()) ? 0 : it->second;
if (already + qty > item.perPlayerLimit) return false;
// 골드 확인/차감
{
std::lock_guard<std::mutex> buyerLk(buyer.lock);
if (buyer.gold < cost) return false;
buyer.gold -= cost;
}
// 여기까지 오면 모두 통과 → 커밋(같은 임계 구역이라 부분 반영 없음)
item.stock -= qty;
item.bought[buyer.id] = already + qty;
buyer.GiveItem(item.itemId, qty);
return true;
}
모든 실패 검증(재고/한도/골드)을 상태 변경 전에 끝내므로 롤백이 필요 없다. 골드 차감만 buyer.lock 안에서 하되, 차감 후 더 이상 실패 분기가 없으므로 "골드만 빠지는" 부분 반영이 생기지 않는다. 락 순서가
item → buyer로 항상 같아 데드락이 없다.
더 나은 설계
1) 재고는 원자적 조건부 차감(CAS) 또는 DB로
- 락 없이 가려면 CAS 루프:
단 이건 재고만 보호할 뿐, 한도·골드·지급과의 원자성은 별도다. 실패 시 차감 복구 필요.int32_t cur = stock.load(); do { if (cur < qty) return false; } while (!stock.compare_exchange_weak(cur, cur - qty)); - 실서비스 한정판매는 DB 행 잠금/조건부 UPDATE가 정석:
골드 차감/지급/한도 갱신을 같은 트랜잭션에 묶고UPDATE shop_item SET stock = stock - ? WHERE item_id = ? AND stock >= ?; -- affected=1만 진행(item,player)한도는 UNIQUE/누적 컬럼으로. 트레이드오프: DB 부하. 플래시 세일은 캐시/큐로 완화.
2) 단일 스레드 액터 / 큐
- 한정 상품 하나를 단일 스레드(액터) 가 직렬 처리하면 락이 사라지고 oversell·한도 버그가 구조적으로 불가능. 오픈 직후 폭주 트래픽을 큐로 흡수해 순서대로 처리.
- 트레이드오프: 단일 상품 처리량 한계 → 인기 상품은 샤딩/재고 분할.
3) 멱등성
클라 재전송 대비 requestId 멱등성 키로 같은 구매가 두 번 반영되지 않게 한다.
면접 포인트
- 면접관이 듣고 싶은 핵심:
atomic재고만으로는 oversell 을 못 막는다(확인-차감이 복합 연산), 인당 한도 RMW 와 맵 접근도 직렬화 필요, 그리고 구매 전체가 하나의 트랜잭션이라는 것. CAS/락/DB 조건부 UPDATE 의 트레이드오프를 말할 수 있으면 강하다. - 예상 질문:
- "stock 을 atomic 으로 두면 oversell 이 막히나?" → 아니다. load-후-fetch_sub 사이가 비어 TOCTOU. CAS 루프나 락으로 조건부 차감.
- "인당 한도가 동시에 뚫리는 이유는?" → 누적값 read-modify-write 가 비원자. 같은 락/트랜잭션 안에서 처리해야.
- "오픈 직후 폭주 트래픽은?" → 큐/단일 액터로 직렬화하거나 DB 조건부 UPDATE + 캐시. 락 순서는 item→buyer 고정.