← 문제로

18. 크로스-샤드 비원자 읽기와 이체의 찢긴 스냅샷 (C#)

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

해설 — 크로스-샤드 비원자 읽기와 이체의 찢긴 스냅샷 (C#)

난이도: 최상

요약

GetTotalAssets는 두 샤드를 순차적으로 따로 읽어(A) 합산한다. 그 사이 TransferWalletToVault가 지갑에서 차감(B)만 하고 아직 창고에 더하지 않은 순간을 읽으면, 합계에서 amount통째로 사라진다(반대 인터리빙이면 이중 계산). 두 샤드 읽기와 이체가 하나의 원자적 스냅샷/트랜잭션이 아니라서 분산 찢긴 읽기(torn read) 가 발생한다. 이 문제는 특정 언어가 아니라 크로스-샤드 원자성/격리 설계 결함이다.

문제점

  • 분류: 크로스-샤드 원자성/스냅샷 격리 부재(비원자 다중 샤드 읽기 + 비원자 다중 샤드 쓰기).
  • 증상(불변식 "총액 보존" 위반):
    1. 돈이 사라짐: 합산이 wallet 읽기(차감 후) → vault 읽기(가산 전) 순으로 이체 중간에 끼면, 차감은 봤는데 가산은 못 봐서 amount 만큼 총액이 줄어 보인다. 안티치트가 "자산 감소"로 오탐하거나 잘못된 보정을 유발.
    2. 이중 계산: 반대로 이체가 vault 가산을 먼저 하는 구현/순서면, 합산이 가산은 봤는데 차감은 못 봐 amount 만큼 총액이 부풀어 보인다.
    3. 이체 자체의 비원자성(B): wallet.Apply(-amount) 성공 후 노드가 죽으면 vault.Apply(+amount)가 유실되어 실제로 돈이 사라진다(읽기뿐 아니라 쓰기도 원자적이지 않음).
  • 재현 조건: 이체와 합산이 서로 다른 노드에서 겹칠 때. 분산·샤딩 환경에서 필연적.
  • 근본 원인: 여러 샤드에 걸친 읽기/쓰기를 원자적 단위로 묶는 격리(스냅샷)나 분산 트랜잭션이 없음. 단일 노드의 트랜잭션 격리가 샤드 경계를 넘지 못한다.

수정안

읽기: 일관된 스냅샷(버전/타임스탬프) 기반 다중 샤드 읽기

// 각 샤드가 (값, 버전) 을 제공. 스냅샷 버전 v 로 두 샤드를 같은 시점으로 읽는다.
public long GetTotalAssets(string playerId)
{
    while (true)
    {
        var (w, wv) = _wallet.ReadVersioned(playerId);
        var (v, vv) = _vault.ReadVersioned(playerId);
        // 두 읽기 사이에 그 플레이어에 대한 이체가 커밋됐으면 재시도(낙관적 스냅샷).
        if (!_ledger.MutatedBetween(playerId, wv, vv))
            return w + v;
        // 또는 MVCC 스냅샷 타임스탬프 T 로 "T 시점 값"을 두 샤드에 요청.
    }
}

쓰기: 크로스-샤드 이체를 원자/멱등 단위로

public void TransferWalletToVault(string playerId, long amount, string txId)
{
    // (a) 2PC/사가 + 멱등키(txId): 한쪽만 반영되는 부분 실패를 제거·복구.
    // (b) 또는 "이체" 자체를 이벤트로 원장에 원자적으로 기록하고,
    //     각 샤드는 그 원장을 멱등하게 적용(합계는 원장 기준으로 계산).
    _ledger.AppendTransfer(txId, playerId, from: "wallet", to: "vault", amount); // 원자·내구
    // 샤드 반영은 원장으로부터 재생(멱등). 부분 실패해도 원장이 진실.
}

핵심:

  • 읽기 일관성: 두 샤드를 "같은 논리 시점"으로 읽어야 한다. MVCC 스냅샷 타임스탬프, 또는 버전 비교 후 재시도(낙관적)로 찢긴 읽기를 배제.
  • 쓰기 원자성: 크로스-샤드 이동은 2PC/사가 + 멱등키, 또는 단일 원자 원장 기록 후 멱등 재생으로 부분 반영을 없앤다. 합계를 "원장에서 파생"하면 두 샤드의 순간 상태에 의존하지 않는다.

더 나은 설계

  • 불변식을 원장으로: "총액 보존"은 이동을 차감/가산 두 행이 아니라 하나의 이체 레코드로 표현하면 자연히 지켜진다(복식부기). 잔액은 원장의 파생 뷰.
  • 동일 샤드 배치(colocated): 한 플레이어의 지갑·창고를 같은 샤드에 배치하면 이체·합산이 단일 샤드 로컬 트랜잭션이 되어 문제 자체가 사라진다(샤딩 키를 플레이어로). 크로스-플레이어 거래만 크로스-샤드로 남는다.
  • 경계 명시: 크로스-샤드 강한 일관성은 비싸다. 안티치트 감사처럼 "약간 지연된 일관 스냅샷"이면 충분한 경우와, 결제처럼 강한 원자성이 필요한 경우를 구분.
  • 읽기 재시도 상한: 낙관적 스냅샷은 이체 폭주 시 기아 가능 → 상한/백오프 또는 스냅샷 격리로 전환.

면접 포인트

  • "여러 샤드를 따로 읽어 합치면?" → 그 사이 크로스-샤드 쓰기가 끼면 찢긴 읽기로 불변식(총액)이 깨진다. 같은 논리 시점 스냅샷(MVCC/버전 비교 재시도)이 필요.
  • "크로스-샤드 이체의 원자성?" → 2PC/사가 + 멱등키, 또는 원자 원장 기록 후 멱등 재생. 부분 반영(한 샤드만)이 곧 자산 유실/복제다.
  • "가장 싼 해결책?" → 콜로케이션: 함께 바뀌는 데이터를 같은 샤드에 두어 크로스-샤드 자체를 없앤다. 샤딩 키 설계가 분산 원자성 비용을 좌우한다.