← 문제로

19. 해설 (C#) — 비동기 영속화 큐가 밀릴 때 최신값 유실/순서 역전

난이도 최상
내 리뷰 · C#
해설 · 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) 꺼낼 때 더티 해제 + 다중 워커 + 버전 부재 — 순서 역전 ★간판

  • 인터리빙:
    1. MarkDirty(id,v1) → 큐에 id, _inQueue={id}.
    2. W1: Dequeue, _inQueue.Remove(id), snap=v1, 느린 WriteToDb(v1) 시작.
    3. 사이에 MarkDirty(id,v2): _latest[id]=v2, _inQueue.Add(id) 성공 → 재큐잉.
    4. W2: Dequeue, snap=v2, WriteToDb(v2).
    5. W2(v2) 가 W1(v1) 보다 먼저 DB 도착 → W1 의 v1 이 v2 를 덮음 → 역전.
  • 근본 원인: 같은 캐릭터 동시 write 허용(single-flight 아님) + DB 버전 가드 부재.

(B) 더티 해제 위치 딜레마 — (뒤로 옮기면) 유실 (동시성)

  • 증상: 유실 막으려 _inQueue.RemoveWriteToDb 뒤로 옮기면, 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 는 락 밖에서(스냅샷만 떠서) → 락 보유시간 최소화.

더 나은 설계 (+트레이드오프)

  1. 키 기반 샤딩/액터: charId 해시로 워커 고정 → 같은 캐릭터 순서 처리 자연 보장. 트레이드오프: 핫 캐릭터 편중.
  2. WAL/append-only 로그 + 버전: 순서 있는 로그 재생으로 유실/역전에 강함. 컴팩션 비용.
  3. DB 낙관적 동시성(CAS): UPDATE ... WHERE version < :v. 실패 재시도 로직 필요.
  4. 백프레셔/유실 가시화: 큐 임계 초과 시 드롭 대신 백프레셔, 불가피한 드롭은 메트릭/알람.

면접 포인트 (예상 질문)

  1. 더티 해제를 "꺼낼 때" 하면 역전, "쓴 뒤" 하면 유실 — 왜 둘 다 나고 버전/single-flight 가 각각 무엇을 푸는가?
  2. 다중 워커에서 같은 캐릭터 순서 보장: 키 샤딩 vs single-flight+버전 가드의 장단점?
  3. 메모리 락만으로 왜 부족하고 DB 조건부 쓰기가 마지막 안전망인가? (재시도/다중 서버)