19. 해설 (C#) — 비동기 영속화 큐가 밀릴 때 최신값 유실/순서 역전
난이도 최상해설 (C#) — 비동기 영속화 큐가 밀릴 때 최신값 유실/순서 역전
난이도: 최상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
세 공유 자료구조(_latest/_inQueue/_dirty)에 락이 전혀 없다. 게임 로직 스레드 다수가
MarkDirty 를, 워커 다수가 WorkerStep 을 동시에 부르므로 비스레드세이프 Dictionary/
Queue/HashSet 동시 변경으로 손상/InvalidOperationException 이 난다. 동기화를 넣어도
구조적 결함이 둘 남는다. (A) 더티 표시를 꺼낼 때(write 전) 해제 + 다중 워커 → 같은
캐릭터 v1 을 W1 이 느리게 쓰는 사이 v2 가 재큐잉돼 W2 가 v2 를 먼저 쓰고 W1 의 v1 이 뒤늦게
덮어 순서 역전. (C) 버전이 없어 DB 가 도착 순서 last-writer-wins 라 막을 수단이 없다.
반대로 해제를 write "후" 로 옮기면 write 중 갱신이 재큐잉 못 돼 영구 유실. 정답 한 줄:
락으로 보호하고, 캐릭터별 single-flight + 단조 버전 + DB 조건부 쓰기(WHERE version < :v)로
역전을 막으며, 처리 후 버전을 재확인해 그 사이 갱신을 재큐잉(유실 방지)한다.
문제점
(공통) 무동기 동시 접근 — Dictionary/Queue 손상·예외 ★간판
- 증상:
MarkDirty(다수)·WorkerStep(다수)이_latest/_inQueue/_dirty를 락 없이 동시 변경 → 내부 손상,InvalidOperationException, 무한 루프._latest[id](없는 키 읽기)는KeyNotFoundException가능. - 근본 원인: 공유 가변 상태에 동기화 없음.
(A)+(C) 꺼낼 때 더티 해제 + 다중 워커 + 버전 부재 — 순서 역전 ★간판
- 인터리빙:
MarkDirty(id,v1)→ 큐에 id,_inQueue={id}.- W1:
Dequeue,_inQueue.Remove(id),snap=v1, 느린WriteToDb(v1)시작. - 사이에
MarkDirty(id,v2):_latest[id]=v2,_inQueue.Add(id)성공 → 재큐잉. - W2:
Dequeue,snap=v2,WriteToDb(v2). - W2(v2) 가 W1(v1) 보다 먼저 DB 도착 → W1 의 v1 이 v2 를 덮음 → 역전.
- 근본 원인: 같은 캐릭터 동시 write 허용(single-flight 아님) + DB 버전 가드 부재.
(B) 더티 해제 위치 딜레마 — (뒤로 옮기면) 유실 (동시성)
- 증상: 유실 막으려
_inQueue.Remove를WriteToDb뒤로 옮기면, write 중 들어온MarkDirty(id,v2)의_inQueue.Add가 실패(아직 제거 전) → 재큐잉 못 함 → 이후 제거되며 v2 가_latest에만 남아 영구 미반영(유실). 현재 구조는 앞=역전/뒤=유실 둘 다 깨짐. - 근본 원인: "더티 여부" 를 단일 집합 존재로만 추적, 처리 전/후 버전 비교가 없다.
수정안
public class PersistenceQueue
{
private sealed class Entry
{
public CharState State;
public long Version;
public bool Queued;
public bool InFlight;
}
private readonly object _gate = new();
private readonly Dictionary<long, Entry> _entries = new();
private readonly Queue<long> _dirty = new();
public void MarkDirty(long id, CharState s)
{
lock (_gate)
{
if (!_entries.TryGetValue(id, out var e)) { e = new Entry(); _entries[id] = e; }
e.State = s;
e.Version++;
if (!e.Queued && !e.InFlight) { e.Queued = true; _dirty.Enqueue(id); }
}
}
public void WorkerStep()
{
long id; CharState snap; long ver;
lock (_gate)
{
if (_dirty.Count == 0) return;
id = _dirty.Dequeue();
var e = _entries[id];
e.Queued = false;
if (e.InFlight) return; // single-flight
e.InFlight = true;
snap = e.State; ver = e.Version;
}
bool ok = WriteToDbIfNewer(id, snap, ver); // DB: saved_version < ver 일 때만
lock (_gate)
{
var e = _entries[id];
e.InFlight = false;
if (!ok || e.Version != ver) // 처리 중 새 갱신/실패 → 재큐잉
if (!e.Queued) { e.Queued = true; _dirty.Enqueue(id); }
}
}
private bool WriteToDbIfNewer(long id, CharState s, long ver) => true; // WHERE version < ver
}
포인트
- single-flight(
InFlight) 로 같은 캐릭터 동시 write 금지 → 역전 차단. - 버전 가드(
WHERE version < ver) 로 DB 계층 최종 방어(재시도/다중 서버에도 안전). - 처리 후 버전 재확인 으로 처리 중 갱신/실패를 재큐잉 → 유실 방지.
- DB I/O 는 락 밖에서(스냅샷만 떠서) → 락 보유시간 최소화.
더 나은 설계 (+트레이드오프)
- 키 기반 샤딩/액터: charId 해시로 워커 고정 → 같은 캐릭터 순서 처리 자연 보장. 트레이드오프: 핫 캐릭터 편중.
- WAL/append-only 로그 + 버전: 순서 있는 로그 재생으로 유실/역전에 강함. 컴팩션 비용.
- DB 낙관적 동시성(CAS):
UPDATE ... WHERE version < :v. 실패 재시도 로직 필요. - 백프레셔/유실 가시화: 큐 임계 초과 시 드롭 대신 백프레셔, 불가피한 드롭은 메트릭/알람.
면접 포인트 (예상 질문)
- 더티 해제를 "꺼낼 때" 하면 역전, "쓴 뒤" 하면 유실 — 왜 둘 다 나고 버전/single-flight 가 각각 무엇을 푸는가?
- 다중 워커에서 같은 캐릭터 순서 보장: 키 샤딩 vs single-flight+버전 가드의 장단점?
- 메모리 락만으로 왜 부족하고 DB 조건부 쓰기가 마지막 안전망인가? (재시도/다중 서버)
해설 (C++) — 비동기 영속화 큐가 밀릴 때 최신값 유실/순서 역전
난이도: 최상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
이 코드는 세 공유 자료구조(latest_/inQueue_/dirty_)에 락이 전혀 없다. 게임 로직
스레드 다수가 MarkDirty 를, 워커 스레드 다수가 WorkerStep 을 동시에 부르므로 우선
unordered_map/queue 동시 변경으로 UB(자료구조 손상) 가 난다. 동기화를 넣더라도
구조적 결함이 둘 남는다. (A) 더티 표시를 꺼낼 때(write 전에) 해제 + 다중 워커 →
같은 캐릭터의 v1 을 W1 이 느리게 쓰는 도중 v2 가 재큐잉돼 W2 가 v2 를 먼저 DB 에 쓰고,
뒤늦게 W1 의 v1 이 덮어 순서 역전(오래된 값이 최신을 덮음). (C) 버전/시퀀스가 없어
DB 가 도착 순서대로 last-writer-wins 라 역전을 막을 수단이 없다. 반대로 더티 해제를 write
"후" 로 옮기면 이번엔 write 중 들어온 갱신이 재큐잉되지 못해 영구 유실된다(둘 다 깨짐).
정답 한 줄: 공유 상태를 락으로 보호하고, 캐릭터별로 "동시에 하나의 write 만(single-flight)"
직렬화하며, 스냅샷에 단조 버전을 붙여 DB 에 조건부(WHERE version < :v) 기록하고, 더티 표시는
스냅샷을 떠서 쓰기를 확정한 뒤에만(혹은 버전 비교로) 해제한다.
문제점
(공통) 무동기 동시 접근 — 컨테이너 UB ★간판
- 증상:
MarkDirty(다수)와WorkerStep(다수)이latest_/inQueue_/dirty_를 락 없이 동시 변경.unordered_map리해시/std::queue(deque) push-pop 동시 실행은 정의되지 않은 동작 — 크래시 또는 조용한 메모리 오염. - 근본 원인: 공유 가변 상태에 동기화 없음. (이걸 고쳐야 아래 논리 결함을 논할 수 있다.)
(A)+(C) 꺼낼 때 더티 해제 + 다중 워커 + 버전 부재 — 순서 역전 ★간판
- 증상/인터리빙:
MarkDirty(id, v1)→ 큐에 id,inQueue_={id}.- W1:
dirty_.pop(),inQueue_.erase(id)(해제),snap=latest_[id]=v1, 느린WriteToDb(v1)시작. - 이 사이
MarkDirty(id, v2):latest_[id]=v2,inQueue_.insert(id)성공(방금 해제됨) → 재큐잉. - W2:
pop,erase,snap=v2,WriteToDb(v2)시작. - W2(v2) 가 W1(v1) 보다 먼저 DB 에 도착 → 마지막에 W1 의 v1 이 v2 를 덮어쓴다.
- 결과: 최신 v2 가 오래된 v1 로 역전된다. 재기동/조회 시 과거 값.
- 근본 원인: ① 같은 캐릭터에 대해 동시 write 가 허용된다(single-flight 아님). ② DB 쓰기에 버전/시퀀스 가드가 없어 도착 순서가 진실이 된다.
(B) coalesce 읽기와 더티 해제의 경계 — (해제 위치를 옮기면) 유실 (동시성)
- 증상: 만약 "유실을 막자" 며
inQueue_.erase(id)를WriteToDb뒤로 옮기면, write 진행 중(수백 ms)에 들어온MarkDirty(id, v2)는inQueue_.insert가 실패(아직 해제 전)해 재큐잉되지 못하고, 이후 해제되며 v2 는 큐에 없는 채latest_에만 남아 영원히 flush 안 됨(유실). 즉 현재 구조는 해제 위치를 어디에 둬도(앞=역전, 뒤=유실) 깨진다. - 근본 원인: "더티 여부" 를 단일 불린(집합 존재)으로만 추적하고, 처리 시작 시점의 버전과 처리 후 최신 버전을 비교하지 않는다. coalesce 의 핵심인 "처리 중 새 갱신 발생" 을 감지할 메커니즘이 없다.
(정합) 스냅샷 일관성 (정확성)
- 증상:
latest_[id]=s가 통째 대입이라 필드 간 찢김은 (구조체 복사라) 비교적 안전하나, 락이 없으면 워커의latest_[id]읽기와 겹쳐 찢긴 복사가 가능. 락으로 보호 필요.
수정안
핵심: ① 뮤텍스로 공유 상태 보호, ② 캐릭터별 단조 버전, ③ 처리 후 "그 사이 새 버전이 왔으면 재큐잉" (dirty 재확인), ④ DB 는 버전 조건부 쓰기로 역전 차단, ⑤ 같은 캐릭터는 single-flight.
#include <mutex>
struct CharState { std::int64_t gold = 0; int level = 0; };
class PersistenceQueue {
public:
void MarkDirty(std::int64_t id, const CharState& s) {
std::lock_guard<std::mutex> lk(m_);
auto& e = entries_[id];
e.state = s;
e.version++; // 단조 버전 증가
if (!e.queued && !e.inFlight) { // 큐에 없고 처리 중도 아니면
e.queued = true;
dirty_.push(id);
}
}
void WorkerStep() {
std::int64_t id; CharState snap; std::int64_t ver;
{
std::lock_guard<std::mutex> lk(m_);
if (dirty_.empty()) return;
id = dirty_.front(); dirty_.pop();
auto& e = entries_[id];
e.queued = false;
if (e.inFlight) return; // 같은 캐릭터는 동시에 하나만(single-flight)
e.inFlight = true;
snap = e.state; // 떠 둔 스냅샷과 그 버전
ver = e.version;
}
bool ok = WriteToDbIfNewer(id, snap, ver); // DB: version < ver 일 때만 기록
{
std::lock_guard<std::mutex> lk(m_);
auto& e = entries_[id];
e.inFlight = false;
// 처리 중 새 갱신(또는 실패)이 있었으면 재큐잉 → 유실 방지
if (!ok || e.version != ver) {
if (!e.queued) { e.queued = true; dirty_.push(id); }
}
}
}
private:
struct Entry { CharState state; std::int64_t version = 0; bool queued = false; bool inFlight = false; };
bool WriteToDbIfNewer(std::int64_t, const CharState&, std::int64_t) { return true; }
std::mutex m_;
std::unordered_map<std::int64_t, Entry> entries_;
std::queue<std::int64_t> dirty_;
};
포인트
- single-flight(
inFlight): 같은 캐릭터의 동시 DB 쓰기 자체를 금지 → 역전 원천 차단. - 버전 가드:
WriteToDbIfNewer가WHERE saved_version < :ver로 조건부 갱신 → 설령 재시도/경로가 꼬여도 오래된 값이 최신을 못 덮는다. - 처리 후 버전 재확인: 처리 중 들어온 갱신(
version != ver)이나 실패 시 재큐잉 → 유실 방지. - 모든 공유 상태 접근은 락 안에서(스냅샷을 떠서 DB I/O 는 락 밖에서 수행 → 락 보유시간 최소화).
더 나은 설계 (+트레이드오프)
- 캐릭터별 단일 처리 라인(키 기반 샤딩/액터): charId 해시로 워커를 고정하면 같은 캐릭터는 항상 같은 워커가 순서대로 처리 → single-flight·순서 보장이 자연스럽다. 트레이드오프: 핫 캐릭터 편중, 워커 부하 불균형.
- WAL / append-only 로그 + 버전: 변경을 순서 있는 로그로 적고 재생. 유실/역전에 강하고 재기동 복구가 쉽다. 트레이드오프: 저장량/컴팩션 비용.
- 조건부 쓰기(CAS) on DB:
UPDATE ... WHERE version < :v(또는 낙관적 동시성)로 DB 계층에서 역전을 최종 방어. 다중 서버에서도 안전. 트레이드오프: 쓰기 실패 재시도 로직. - 백프레셔/유실 가시화: 큐가 임계 초과 시 드롭 대신 게임 로직에 백프레셔를 주거나, 불가피한 드롭은 메트릭/알람으로 노출(조용한 유실 금지).
면접 포인트 (예상 질문)
- 더티 플래그를 "꺼낼 때" 해제하면 순서 역전, "쓴 뒤" 해제하면 유실 — 왜 둘 다 생기고, 버전/single-flight 가 각각 무엇을 해결하는가?
- 다중 워커에서 같은 캐릭터의 순서를 보장하는 두 방법(키 샤딩 vs single-flight+버전 가드)의 장단점은?
- DB 조건부 쓰기(
WHERE version < :v)가 왜 마지막 안전망으로 필요한가? 메모리 락만으로 부족한 경우(재시도·다중 서버)는?
구문 검증:
g++ -std=c++17 -fsyntax-only problem.cpp통과(문제 코드/수정안 모두).