← 문제로

11. 주기적 스냅샷 저장 vs 즉시 변경 (C#)

난이도 중
내 리뷰 · C#
해설 · C#

해설 — 주기적 스냅샷 저장 vs 즉시 변경 (C#)

난이도: 중상

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

요약

"읽기만 하니 락 불필요", "한 번에 하나만 저장" 두 전제가 모두 틀렸다. (C) 스냅샷이 필드별로 락 없이 읽어 골드/레벨/경험치가 서로 다른 시점인 찢어진 스냅샷(논리 불일치) + 32비트 런타임에서 long 의 torn read. (D) dirty 를 저장 성공 전에 해제해 스냅샷~해제 사이의 변경 유실, 저장 실패 시 영구 유실. (F) 여러 저장 워커가 같은 캐릭터 스냅샷을 완료 순서 보장 없이 기록해 오래된 것이 새것을 덮어쓰는 영속화 lost update. (G)(H) 게임 워커가 Gold/Dirty 를 무동기 동시 변경(Gold += delta 도 RMW 라 lost update). 핵심: 일관 스냅샷 + 저장 성공 후 해제 + 캐릭터별 순서 보장.


문제점

(C) 필드별 무락 읽기 — 찢어진 스냅샷 + torn read (정확성/동시성) ★간판

  • 증상: 골드는 거래 전, 레벨/경험치는 거래 후로 저장되는 필드 간 불일치. 32비트 런타임 (또는 IL2CPP/모바일)에서 long Gold 가 절반만 갱신된 값으로 읽힐 수 있다.
  • 재현 조건: (C)에서 Gold 를 읽은 직후 게임 워커가 Gold/Level 을 바꾸고, 스냅샷이 이어서 Level/Exp 를 읽으면 시점이 섞인다.
  • 근본 원인: 스냅샷도 공유 가변 상태에 대한 동시 읽기다. 일관된 시점엔 변경과 같은 락으로 상호배제해야 한다. (C# 에서 long 의 원자성은 Interlocked/Volatile 로만 보장.)

(D) dirty 를 저장 전에 해제 — 변경 유실 (정확성) ★간판2

  • 재현 1 (read-clear 레이스): (C) 읽기 후 (D) Dirty=false 직전에 워커가 또 변경하고 (H) Dirty=true 로 세웠는데 (D)가 그 뒤에 실행되면 그 변경이 다음 사이클에 안 잡힘.
  • 재현 2 (저장 실패): dirty 를 이미 내렸는데 (F) Write 실패 → 재시도 근거 소멸 → 영구 유실.
  • 근본 원인: dirty 는 "미영속 변경 있음" 을 뜻해야 하며 저장 성공 후, 그 사이 새 변경이 없을 때만 해제해야 한다(버전 비교).

(F) 캐릭터별 저장 순서 미보장 — order inversion (정확성/동시성)

  • 증상: 재기동 후 과거 값으로 롤백.
  • 재현 조건: 같은 캐릭터의 스냅샷 v1, v2(나중)를 워커 A/B가 각각 꺼내 Write. DB 지연으로 v1 이 v2 보다 늦게 완료 → 오래된 v1 이 최신 v2 를 덮어쓴다.
  • 근본 원인: 큐는 FIFO 라도 여러 워커의 완료 순서는 비결정적. 캐릭터 단위 순서 보장(샤딩/조건부 Update)이 없다.

(G)+(H) 무동기 동시 변경 (동시성)

  • 게임 워커들끼리, 스냅샷 스레드와 Gold/Dirty 동시 접근. Gold += delta 는 비원자 RMW → 동시 호출 시 lost update. Dirty 가시성도 미보장.

수정안

핵심: ① 캐릭터별 버전 카운터 + 락으로 일관 스냅샷, ② dirty 대신 "version vs savedVer" 비교, ③ 저장은 캐릭터 단위 직렬화 + 조건부 Update(DB 버전보다 새것일 때만), ④ 저장 성공 후에만 savedVer 갱신.

public class PlayerState
{
    public long PlayerId;
    public long Gold; public int Level; public long Exp;
    public readonly object Sync = new object();
    public ulong Version;     // 변경마다 +1
    public ulong SavedVer;    // 마지막 저장 성공 버전
}

public static void ApplyGold(PlayerState p, long delta)
{
    lock (p.Sync) { p.Gold += delta; p.Version++; }   // 변경 + 버전 증가
}

public bool TrySnapshot(PlayerState p, out PlayerSnapshot snap, out ulong ver)
{
    lock (p.Sync)
    {
        if (p.Version == p.SavedVer) { snap = default; ver = 0; return false; }
        snap = new PlayerSnapshot { PlayerId = p.PlayerId, Gold = p.Gold,
                                    Level = p.Level, Exp = p.Exp };
        ver  = p.Version;     // dirty 를 여기서 내리지 않는다
        return true;
    }
}

public void OnSaved(PlayerState p, ulong savedVer)
{
    lock (p.Sync) { if (savedVer > p.SavedVer) p.SavedVer = savedVer; }
}

저장 순서 보장(F):

  • 캐릭터 단위 직렬화: PlayerId 해시로 항상 같은 워커/키 큐에 보내 순서대로 기록.
  • 조건부 Update: UPDATE player SET ..., ver=@v WHERE id=@id AND ver < @v 로 DB 가 더 새 버전만 적용 → 오래된 스냅샷의 덮어쓰기 거부.

dirty 플래그를 버전 비교로 대체해 read-clear 레이스를 없애고, savedVer 는 저장 성공 콜백에서만 올린다. 유실/순서역전/찢김이 한 번에 닫힌다.


더 나은 설계

1) 변경 이벤트 로그(WAL)

  • 상태 통째 스냅샷 대신 변경 이벤트를 append-only 로 영속화 → 순서 자연 보존, 유실/찢김 제거. 스냅샷은 checkpoint 용. 트레이드오프: 복구 시 리플레이 비용.

2) 캐릭터를 단일 액터로

  • 한 캐릭터의 변경/스냅샷/저장 enqueue 를 단일 스레드가 소유 → 락·레이스·찢김 구조적 제거.

3) copy-on-snapshot

  • 짧은 락으로 immutable 사본만 떠서 저장 → 저장 중 게임 로직이 막히지 않음.

4) 멱등 + 조건부 영속화

  • 모든 저장에 버전을 싣고 ver < newVer 조건부 적용 → 재시도/중복/순서역전에도 최신만 생존.

면접 포인트

  • 핵심: 스냅샷도 동시 읽기(일관성/torn), dirty 는 저장 성공 후 해제, 완료 순서는 비결정적.
  • 예상 질문:
    1. "재기동 후 롤백되는 경로는?" → 오래된 스냅샷 Write 가 늦게 완료(order inversion). 캐릭터 단위 직렬화/조건부 Update 로 차단.
    2. "dirty 를 저장 전에 내리면?" → read-clear 누락 + 실패 시 유실. 버전 비교로 대체.
    3. "C# 에서 long 도 찢어질 수 있나?" → 32비트 런타임/IL2CPP 에서 비원자. Interlocked/락.