11. 주기적 스냅샷 저장 vs 즉시 변경 (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 는 저장 성공 후 해제, 완료 순서는 비결정적.
- 예상 질문:
- "재기동 후 롤백되는 경로는?" → 오래된 스냅샷 Write 가 늦게 완료(order inversion). 캐릭터 단위 직렬화/조건부 Update 로 차단.
- "dirty 를 저장 전에 내리면?" → read-clear 누락 + 실패 시 유실. 버전 비교로 대체.
- "C# 에서 long 도 찢어질 수 있나?" → 32비트 런타임/IL2CPP 에서 비원자. Interlocked/락.
해설 — 주기적 스냅샷 저장 vs 즉시 변경 (C++)
난이도: 중상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
"읽기만 하니 락 불필요", "한 번에 하나만 저장" 이라는 두 전제가 모두 틀렸다. 결함이 네
갈래로 겹친다. (C) 스냅샷이 필드별로 락 없이 읽어 골드/레벨/경험치가 서로 다른 시점의
값이 섞인 찢어진 스냅샷(논리 불일치) + 64비트 값의 torn read. (D) dirty 를 저장
성공 전에 해제해서, 스냅샷 읽기와 dirty 해제 사이에 일어난 변경이 유실되고, 저장이
실패하면 그 변경도 영영 사라진다. (F) 여러 저장 워커가 같은 캐릭터의 스냅샷을 순서 보장
없이 DB 기록해 오래된 스냅샷이 새 스냅샷을 덮어쓰는(order inversion) 영속화 lost
update. (G)(H) 게임 워커가 gold/dirty 를 무동기 동시 변경해 data race(UB)·torn.
핵심: "스냅샷은 일관된 한 시점", "dirty 는 저장 성공 후 해제", "캐릭터별 저장 순서 보장".
문제점
(C) 필드별 무락 읽기 — 찢어진 스냅샷 + torn read (정확성/동시성) ★간판
- 증상: 골드는 거래 전, 레벨/경험치는 거래 후 값으로 저장되는 등 필드 간 불일치.
드물게 64비트
gold/exp가 절반만 갱신된 값(torn)으로 저장돼 말도 안 되는 숫자. - 재현 조건: 스냅샷이 (C)에서
gold를 읽은 직후 게임 워커가 골드/레벨을 바꾸고, 스냅샷이 이어서level/exp를 읽으면 서로 다른 시점이 섞인다. 64비트 변수의 비원자 읽기/쓰기가 겹치면 word tearing. - 근본 원인: 스냅샷도 공유 가변 상태를 읽는 동시 접근이다. 일관된 한 시점을 뜨려면 해당 캐릭터의 변경과 상호 배제(같은 락) 되어야 한다.
(D) dirty 를 저장 전에 해제 — 변경 유실 (정확성) ★간판2
- 증상: 방금 한 거래가 저장에 영영 반영되지 않는다.
- 재현 조건 1 (read-clear 사이 변경): (C)에서 값을 다 읽은 뒤 (D) dirty=false 직전에 게임 워커가 골드를 또 바꾸고 (H) dirty=true 로 세운다. 그런데 (D)가 그 뒤에 실행되면 dirty 를 다시 false 로 덮어 그 변경이 다음 사이클에 잡히지 않는다(누락).
- 재현 조건 2 (저장 실패): dirty 를 이미 내렸는데 (F)
db_.Write가 실패하면 재시도 근거(dirty)가 사라져 변경 영구 유실. - 근본 원인: dirty 는 "아직 영속화되지 않은 변경이 있음" 을 뜻해야 한다. 저장 성공이 확인된 뒤, 그리고 "스냅샷 시점 이후 새 변경이 없을 때만" 해제해야 한다(버전/세대 비교).
(F) 캐릭터별 저장 순서 미보장 — order inversion / 영속화 lost update (정확성/동시성)
- 증상: 재기동 후 상태가 과거로 되돌아간다.
- 재현 조건: 같은 캐릭터의 스냅샷 v1, v2(나중)가 큐에 들어가고, 저장 워커 A가 v1, 워커 B가 v2 를 꺼낸다. DB 왕복 지연 때문에 v1 의 Write 가 v2 보다 늦게 완료되면 오래된 v1 이 최신 v2 를 덮어쓴다.
- 근본 원인: 큐는 FIFO 라도 여러 워커의 완료 순서는 비결정적이다. 캐릭터 단위 순서 보장(샤딩/시퀀스/조건부 Write)이 없다.
(G)+(H) 무동기 동시 변경 — data race (동시성/UB) ★C++ 특화
- 게임 워커들끼리, 그리고 스냅샷 스레드와
gold/dirty를 락/atomic 없이 동시 접근. C++ 메모리 모델상 data race = 정의되지 않은 동작.gold += delta자체도 RMW 라 워커 둘이 동시에 하면 lost update.
(B) dirty 가시성 — 메모리 모델 (동시성)
- (H) 워커의
dirty=true가 스냅샷 스레드 (B)에 가시화되지 않을 수 있어, 변경된 캐릭터를 스킵할 수 있다. atomic/락 필요.
수정안
핵심: ① 캐릭터별 세대 카운터(version) 와 락으로 일관 스냅샷, ② dirty 대신 "마지막 변경 버전 vs 마지막 저장 버전" 비교, ③ 저장은 캐릭터 단위 직렬화 + 조건부 Write (저장하려는 버전이 DB 의 버전보다 새 것일 때만), ④ 저장 성공 후에만 savedVersion 갱신.
struct PlayerState
{
int64_t playerId;
int64_t gold; int level; int64_t exp;
std::mutex mtx; // 캐릭터 단위 락
uint64_t version = 0; // 변경될 때마다 +1
uint64_t savedVer = 0; // 마지막으로 DB 에 저장 성공한 버전
};
// 게임 워커: 같은 락 안에서 변경 + 버전 증가
void ApplyGold(PlayerState* p, int64_t delta)
{
std::lock_guard<std::mutex> g(p->mtx);
p->gold += delta;
p->version++; // 변경 표시 = 버전 증가
}
struct PlayerSnapshot { int64_t playerId; int64_t gold; int level; int64_t exp; uint64_t ver; };
// 스냅샷: 락 안에서 일관된 한 시점 + 버전을 함께 캡처
bool TrySnapshot(PlayerState* p, PlayerSnapshot& out)
{
std::lock_guard<std::mutex> g(p->mtx);
if (p->version == p->savedVer) return false; // 저장 이후 변경 없음
out = { p->playerId, p->gold, p->level, p->exp, p->version };
return true; // dirty 를 여기서 내리지 않는다!
}
// 저장 성공 후에만 savedVer 갱신(그 사이 더 새 변경이 있으면 다음 사이클이 다시 저장)
void OnSaved(PlayerState* p, uint64_t savedVer)
{
std::lock_guard<std::mutex> g(p->mtx);
if (savedVer > p->savedVer) p->savedVer = savedVer;
}
저장 순서 보장(F)은 둘 중 하나로:
- 캐릭터 단위 직렬화: playerId 를 해시해 항상 같은 저장 워커(또는 같은 키 큐)로 보내 한 캐릭터는 한 워커가 순서대로 기록.
- 조건부 Write:
UPDATE player SET ..., ver=? WHERE id=? AND ver < ?처럼 DB 가 "더 새 버전일 때만" 적용하게 해 오래된 스냅샷의 덮어쓰기를 거부.
핵심은 dirty 플래그를 버전 비교로 대체해 read-clear 레이스를 제거하고, savedVer 는 저장 성공 콜백에서만 올리는 것. 이러면 유실/순서역전/찢김이 모두 닫힌다.
더 나은 설계
1) 변경분을 명령 로그(WAL/이벤트)로
- 상태를 통째로 스냅샷하는 대신 변경 이벤트(골드 +N)를 append-only 로그로 영속화하면 순서가 자연히 보존되고 유실/찢김이 사라진다. 스냅샷은 압축(checkpoint)용으로만. 트레이드오프: 복구 시 리플레이 비용 vs 강한 정합성.
2) 캐릭터를 단일 액터로
- 한 캐릭터의 변경/스냅샷을 단일 스레드(액터)가 소유하면 락·data race·찢김이 구조적으로 사라진다. 저장도 그 액터가 순서대로 enqueue.
3) double-buffer / copy-on-snapshot
- 변경은 active 버퍼에, 스냅샷 순간 버퍼를 스왑해 immutable 사본을 저장하면 저장 중에도 게임이 막히지 않는다(짧은 락으로 스왑만).
4) 멱등 + 조건부 영속화
- 모든 저장에 버전을 싣고 DB 에서
ver < newVer조건부 적용 → 재시도/중복/순서역전에도 최신만 살아남는다.
면접 포인트
- 핵심: "스냅샷도 동시 읽기다(일관성/torn)", "dirty 는 저장 성공 후 해제(아니면 유실)", "여러 워커의 완료 순서는 비결정적(순서역전)". 셋을 버전+조건부 Write 로 한 번에.
- 예상 질문:
- "재기동 후 상태가 과거로 돌아가는 정확한 경로는?" → 오래된 스냅샷의 DB Write 가 새 스냅샷보다 늦게 완료(order inversion). 캐릭터 단위 직렬화 또는 조건부 Write 로 차단.
- "dirty 를 저장 전에 내리면 왜 유실되나?" → read-clear 사이 변경이 다음 사이클에 안 잡히고, 저장 실패 시 재시도 근거도 사라짐. 버전 비교 + 성공 후 savedVer 갱신으로 해결.
- "스냅샷에 락이 왜 필요한가? 읽기뿐인데?" → 동시 변경과 겹치면 필드 간 불일치 + 64비트 torn read. 일관된 시점엔 상호배제 필요.