18. 크로스-샤드 비원자 읽기와 이체의 찢긴 스냅샷 (C#)
난이도 최상내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — 크로스-샤드 비원자 읽기와 이체의 찢긴 스냅샷 (C#)
난이도: 최상
요약
GetTotalAssets는 두 샤드를 순차적으로 따로 읽어(A) 합산한다. 그 사이
TransferWalletToVault가 지갑에서 차감(B)만 하고 아직 창고에 더하지 않은 순간을
읽으면, 합계에서 amount가 통째로 사라진다(반대 인터리빙이면 이중 계산). 두 샤드
읽기와 이체가 하나의 원자적 스냅샷/트랜잭션이 아니라서 분산 찢긴 읽기(torn read)
가 발생한다. 이 문제는 특정 언어가 아니라 크로스-샤드 원자성/격리 설계 결함이다.
문제점
- 분류: 크로스-샤드 원자성/스냅샷 격리 부재(비원자 다중 샤드 읽기 + 비원자 다중 샤드 쓰기).
- 증상(불변식 "총액 보존" 위반):
- 돈이 사라짐: 합산이
wallet읽기(차감 후) →vault읽기(가산 전) 순으로 이체 중간에 끼면, 차감은 봤는데 가산은 못 봐서amount만큼 총액이 줄어 보인다. 안티치트가 "자산 감소"로 오탐하거나 잘못된 보정을 유발. - 이중 계산: 반대로 이체가
vault가산을 먼저 하는 구현/순서면, 합산이 가산은 봤는데 차감은 못 봐amount만큼 총액이 부풀어 보인다. - 이체 자체의 비원자성(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/사가 + 멱등키, 또는 원자 원장 기록 후 멱등 재생. 부분 반영(한 샤드만)이 곧 자산 유실/복제다.
- "가장 싼 해결책?" → 콜로케이션: 함께 바뀌는 데이터를 같은 샤드에 두어 크로스-샤드 자체를 없앤다. 샤딩 키 설계가 분산 원자성 비용을 좌우한다.
해설 · C++
해설 — 크로스-샤드 비원자 읽기와 이체의 찢긴 스냅샷 (C++)
난이도: 최상
요약
getTotalAssets는 두 샤드를 순차적으로 따로 읽어(A) 합산한다. 그 사이
transferWalletToVault가 지갑 차감(B)만 하고 창고 가산 전인 순간을 읽으면 합계에서
amount가 사라진다(반대 인터리빙이면 이중 계산). 두 샤드 읽기와 이체가 하나의 원자적
스냅샷/트랜잭션이 아니라 분산 찢긴 읽기(torn read) 다. 언어 무관한 크로스-샤드
원자성/격리 설계 결함이다(C++ 노드라고 다르지 않다).
문제점
- 분류: 크로스-샤드 원자성/스냅샷 격리 부재(비원자 다중 샤드 읽기 + 비원자 다중 샤드 쓰기).
- 증상(불변식 "총액 보존" 위반):
- 돈이 사라짐: 합산이 wallet(차감 후)→vault(가산 전) 순으로 이체 중간에 끼면
차감만 보여 총액이
amount줄어 보인다 → 안티치트 오탐/오보정. - 이중 계산: 이체가 vault 가산을 먼저 하는 순서면 가산만 보여 총액이 부풀어 보인다.
- 이체의 비원자성(B):
wallet.apply(-amount)후 노드 크래시면vault.apply(+amount)유실 → 실제 자산 소실(읽기뿐 아니라 쓰기도 비원자).
- 돈이 사라짐: 합산이 wallet(차감 후)→vault(가산 전) 순으로 이체 중간에 끼면
차감만 보여 총액이
- 재현 조건: 이체와 합산이 다른 노드에서 겹칠 때. 샤딩 환경에서 필연적.
- 근본 원인: 여러 샤드에 걸친 읽기/쓰기를 원자 단위로 묶는 스냅샷 격리/분산 트랜잭션 부재. 단일 노드 트랜잭션 격리는 샤드 경계를 넘지 못한다.
수정안
읽기: 일관된 스냅샷(버전/타임스탬프)
long long getTotalAssets(const std::string& playerId) {
for (;;) {
auto [w, wv] = wallet_.readVersioned(playerId);
auto [v, vv] = vault_.readVersioned(playerId);
// 두 읽기 사이 그 플레이어 이체가 커밋됐으면 재시도(낙관적 스냅샷)
if (!ledger_.mutatedBetween(playerId, wv, vv))
return w + v;
// 또는 MVCC 스냅샷 타임스탬프 T 로 "T 시점 값"을 두 샤드에 요청
}
}
쓰기: 크로스-샤드 이체를 원자/멱등 단위로
void transferWalletToVault(const std::string& playerId, long long amount,
const std::string& txId) {
// (a) 2PC/사가 + 멱등키(txId): 부분 실패 제거·복구
// (b) 또는 이체를 원장에 원자·내구 기록 후 각 샤드가 멱등 재생.
ledger_.appendTransfer(txId, playerId, /*from=*/"wallet", /*to=*/"vault", amount);
// 샤드 반영은 원장에서 재생(멱등). 부분 실패해도 원장이 진실.
}
핵심: ① 두 샤드를 같은 논리 시점으로 읽는다(MVCC 스냅샷 또는 버전 비교 재시도). ② 크로스-샤드 이동은 2PC/사가 + 멱등키, 또는 단일 원자 원장 기록 후 멱등 재생 으로 부분 반영을 없앤다. 합계를 원장에서 파생하면 두 샤드의 순간 상태에 의존하지 않는다.
더 나은 설계
- 복식부기 원장: 이동을 "차감/가산 두 행"이 아니라 하나의 이체 레코드로 표현하면 총액 보존이 자연히 성립. 잔액은 원장의 파생 뷰.
- 콜로케이션: 한 플레이어의 지갑·창고를 같은 샤드에 배치(샤딩 키=플레이어)하면 이체·합산이 단일 샤드 로컬 트랜잭션이 되어 문제 자체가 사라진다. 크로스-플레이어 거래만 크로스-샤드로 남는다.
- 일관성 경계 명시: 감사(약간 지연된 일관 스냅샷 OK) vs 결제(강한 원자성 필요)를 구분해 비용을 지불.
- 재시도 상한: 낙관적 스냅샷은 이체 폭주 시 기아 → 상한/백오프 또는 스냅샷 격리 전환.
면접 포인트
- "여러 샤드를 따로 읽어 합치면?" → 사이에 크로스-샤드 쓰기가 끼면 찢긴 읽기로 총액 불변식이 깨진다. 같은 논리 시점 스냅샷이 필요.
- "크로스-샤드 이체 원자성?" → 2PC/사가 + 멱등키, 또는 원자 원장 기록 후 멱등 재생. 부분 반영이 곧 자산 유실/복제.
- "가장 싼 해결?" → 콜로케이션으로 크로스-샤드 자체를 제거. 샤딩 키 설계가 분산 원자성 비용을 결정한다.