← 문제로

1. 글로벌 거래소를 여러 지역 서버가 공유할 때의 정합성

난이도 최상
내 리뷰 · C#
해설 · C#

해설 — 글로벌 거래소를 여러 지역 서버가 공유할 때의 정합성

난이도: 최상

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

요약

세 곳에서 깨진다. (A) 중앙 거래소 Decrement 가 "검사(cur<qty) → 차감" 을 비원자로 하고 공유 Dictionary 를 락 없이 다룬다 → 여러 지역이 동시에 호출하면 둘 다 cur>=qty 를 통과해 oversell. (B) 각 지역이 로컬 캐시 재고로 구매 가능 판단 → 캐시는 다른 지역의 차감을 모르므로, 동시 구매 시 합산이 실제 재고를 초과한다. (C) 지역이 캐시를 먼저 줄이고 아이템을 지급한 뒤 중앙 차감을 요청하며 반환값을 무시 → 중앙이 거부(재고 0)해도 이미 지급해 무한정 oversell + 이중 지급. 정답 한 줄: 재고의 단일 권위는 중앙이며, 구매는 중앙의 원자적 compare-and-decrement(또는 직렬화 트랜잭션) 성공 후에만 지급한다. 로컬 캐시는 판단의 권위가 아니라 힌트일 뿐이다.

변별: concurrency_memory/problem16(존 간 이전 분산 트랜잭션), problem12(분산 락 펜싱/ 만료)와 달리, 본 문제는 다중 지역이 공유하는 단일 재고의 권위·원자 차감과 로컬 캐시 신뢰 오류(oversell) 가 핵심이다.


문제점

(A) 중앙 Decrement 의 비원자 검사-차감 — oversell (분산/동시성) ★간판

  • 분류 태그: check-then-act / lost update (across RPC).
  • 증상: if (cur < qty) return false; _remaining = cur - qty; 가 원자가 아니다. 재고 1, 두 지역이 동시에 Decrement(1) → 둘 다 cur=1>=1 통과 → 둘 다 성공, 재고 -1. 단일 권위인데도 동시 요청에 oversell. Dictionary 자체도 동시 쓰기로 손상.
  • 재현조건: 서로 다른 지역의 구매 RPC 가 거의 동시에 중앙에 도달.
  • 근본 원인: 권위 노드의 차감이 원자적(락/CAS/직렬화)이지 않다.

(B) 로컬 캐시 재고로 구매 판단 — 분산 oversell (분산/일관성) ★간판

  • 분류 태그: stale-distributed-cache / authority-bypass.
  • 증상: 지역 A·B 가 각자 캐시(예: 둘 다 5)를 보고 각각 5개를 판다 → 합계 10인데 실제 재고는 5. 캐시는 다른 지역의 차감을 실시간 반영하지 못한다.
  • 근본 원인: "구매 가능" 의 권위를 로컬 캐시에 둔다(캐시는 힌트여야 함).

(C) 선지급 후 사후 차감 + 반환 무시 — 이중 지급/무한 oversell (정합) ★간판

  • 분류 태그: act-before-commit / ignored-failure.
  • 증상: _localCache 를 줄이고 GrantItemAndCharge먼저 지급한 뒤 _central .Decrement(...) 를 호출하고 반환값을 무시한다. 중앙이 재고 부족으로 거부해도 아이템은 이미 나갔다. 보상/롤백도 없다.
  • 근본 원인: "권위 커밋(중앙 차감 성공)" 전에 부작용(지급)을 실행한다.

수정안

핵심: ① 중앙 차감을 원자 compare-and-decrement, ② 지급은 중앙 성공 이후에만, ③ 로컬 캐시는 판단 권위가 아닌 힌트.

public class CentralExchange
{
    private readonly Dictionary<long, int> _remaining = new();
    private readonly object _gate = new();

    // 원자 차감: 성공 시 true. 단일 임계구역에서 검사+차감.
    public bool TryDecrement(long listingId, int qty)
    {
        if (qty <= 0) return false;
        lock (_gate)
        {
            int cur = _remaining.TryGetValue(listingId, out var q) ? q : 0;
            if (cur < qty) return false;       // 검사
            _remaining[listingId] = cur - qty; // 차감 — 같은 락 안이라 원자
            return true;
        }
    }
}

public bool Buy(long buyerId, long listingId, int qty)
{
    // (B) 로컬 캐시는 '빠른 사전 거절' 힌트로만 사용(정확성 근거 아님)
    if (_localCache.TryGetValue(listingId, out var hint) && hint < qty)
    {
        // 힌트가 부족이라 말해도, 갱신 후 한 번 더 시도해볼 수 있음(여기선 빠른 거절)
    }

    // (A)(C) 권위(중앙)의 원자 차감이 성공한 뒤에만 지급
    if (!_central.TryDecrement(listingId, qty))
    {
        RefreshCache(listingId);               // 캐시 보정
        return false;                          // 재고 없음 → 지급 안 함
    }

    GrantItemAndCharge(buyerId, listingId, qty);  // 커밋 이후 지급
    _localCache[listingId] = _central.GetRemaining(listingId);  // 힌트 갱신
    return true;
}

포인트

  • 단일 권위(중앙)의 원자 차감이 oversell 을 막는다(락/CAS/SELECT ... FOR UPDATE/Redis Lua/DECRBY+검사).
  • 지급은 차감 성공 후에만 → 거부 시 부작용 없음(이중 지급 차단).
  • 로컬 캐시는 힌트(빠른 거절·UX)일 뿐, 구매 성공 판단의 권위가 아니다.
  • 지급 자체도 멱등하게(주문ID 기반) 해 재시도 안전(아래 설계).

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

  1. 중앙 원자 연산 + 멱등 주문: 구매를 주문ID로 멱등화하고 중앙에서 qty 를 원자 차감. 재시도/네트워크 중복에도 한 번만 반영. 트레이드오프: 주문 저장소·중복 제거 비용.
  2. 예약(reserve)–확정(commit) 2단계: 중앙이 재고를 잠깐 예약(TTL) → 지급 성공 시 확정, 실패/타임아웃 시 자동 복원. 분산 트랜잭션의 사가(saga). 트레이드오프: 예약 만료/보상 설계.
  3. 지역 샤딩 또는 재고 리스풀(lease): 매물 재고를 지역에 일정량 리스로 나눠줘(예: 각 지역 N개) 핫 매물의 중앙 경합을 줄이고, 소진 시 재요청. 트레이드오프: 잔여 회수/재분배 복잡.
  4. 단일 파티션 직렬화: 한 매물의 모든 구매를 같은 파티션(액터/큐)이 순차 처리 → 락 없이 원자. 트레이드오프: 핫스팟 매물의 처리량 한계.

면접 포인트 (예상 질문)

  1. 단일 권위 노드인데도 Decrement 가 oversell 을 내는 인터리빙을 설명하라. 원자 compare-and-decrement 를 DB/Redis 로 어떻게 구현하나?
  2. 로컬 캐시 재고로 구매를 판단하면 왜 분산 oversell 이 나나? 캐시를 "힌트"로만 쓴다는 게 무슨 뜻인가?
  3. "지급 후 차감" 대신 "차감 성공 후 지급"이어야 하는 이유, 그리고 예약-확정(saga) 패턴이 네트워크 실패에서 어떻게 안전한가?