← 문제로

23. 실시간 랭킹 캐시와 원본 DB의 일관성 (C#)

난이도 최상
내 리뷰 · C#
해설 · C#

해설 — 실시간 랭킹 캐시와 원본 DB의 일관성 (C#)

난이도: 최상

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

요약

UpdateScore/TopN/RebuildFromDb공유 캐시(_scoreOf, _ranking)와 DB를 동기화 없이 만진다. (A) "DB 먼저, 캐시 나중" 인데 두 갱신의 캐시 반영 순서가 보장되지 않아 같은 플레이어에 더 낮은(옛) 점수가 캐시에 최종으로 남아 DB와 어긋난다(out-of-order/lost update). (B) 캐시 갱신이 "옛 (점수,id) Remove → 새 것 Add" 로 비원자라, 그 틈에 TopN 이 읽으면 플레이어가 랭킹에서 사라져 보이고, 두 갱신이 겹치면 같은 id 가 두 점수로 중복된다. (C) SortedSet/ Dictionary 는 스레드세이프하지 않아 동시 순회·갱신이 InvalidOperationException(컬렉션 변경) 이나 손상을 낸다. (D) RebuildFromDb 는 스냅샷을 읽는 동안 들어온 갱신을 Clear()통째로 덮어 유실시키고, 교체도 비원자다. 정답 한 줄: 캐시(두 구조)를 하나의 락으로 원자 갱신하고, 같은 플레이어 갱신엔 버전/시퀀스로 단조성을 강제하며, DB↔캐시는 버전 기반 write-through 로 수렴시키고 재구축은 버전 비교로 최신 갱신을 보존하라.

변별:

  • concurrency_memory/problem13(캐시-DB 이중 쓰기 일반): 그쪽은 단일 키 값의 last-write 일관성. 본 문제는 정렬 구조(Remove+Add 원자성)·랭킹 중복/소실·top-N 라는 정렬 캐시 특유의 위험.
  • concurrency_memory/problem22(인메모리 리더보드 값 CAS/타이브레이커): 인메모리 값 자체의 원자성. 본 문제는 캐시↔DB 동기화 계층의 정합(쓰기 순서·재구축·이중 구조).
  • persistence_data/problem1(복제 지연 read-your-writes): 본 문제의 재구축 위험은 복제 지연이 아니라 재구축 vs 동시 쓰기 경합이다.

문제점

(A) 비순서/비원자 dual-write — 캐시에 옛 점수 고착 (정합·동시성) ★간판

  • 분류 태그: out-of-order update / lost update.
  • 증상: T1(90), T2(100) 가 같은 플레이어에 동시 진입. DB에는 결국 100 이 남더라도 캐시 갱신부가 별도라 T2 가 캐시에 100 을 쓴 뒤 T1 이 90 을 덮어 캐시=90, DB=100 영구 불일치. TryGetValue→수정→Add 도 RMW 라 서로 덮는다.
  • 재현조건: 같은 플레이어에 동시/근접 갱신.
  • 근본 원인: DB·캐시 반영이 한 임계구역/순서 기준으로 묶이지 않고, 단조성 강제가 없음.

(B) 랭킹 "Remove+Add" 비원자 — 중복/소실 (정합·동시성) ★간판

  • 분류 태그: non-atomic compound update.
  • 증상: _ranking.Remove((old,id))_ranking.Add((new,id)) 사이에 TopN 이 읽으면 그 플레이어 누락, 두 갱신이 겹치면 같은 id 가 두 엔트리로 중복.
  • 근본 원인: 두 컬렉션에 걸친 복합 갱신이 원자가 아님.

(C) 비스레드세이프 컬렉션 동시 순회/갱신 — 예외/손상 (동시성)

  • 분류 태그: data race / collection-modified.
  • 증상: TopNforeach (_ranking) 도중 다른 스레드 Add/RemoveInvalidOperationException. _scoreOf 동시 읽기/쓰기도 손상.
  • 근본 원인: 읽기/쓰기 동기화 없음.

(D) 재구축이 동시 갱신을 덮음 — 유실 + 비원자 교체 (정합·동시성)

  • 분류 태그: lost update / non-atomic swap.
  • 증상: LoadAll() 스냅샷 동안 들어온 최신 갱신이 Clear()+재삽입으로 사라진다. Clear() 후 재삽입 사이 조회는 빈 랭킹을 본다.
  • 근본 원인: 재구축이 동시 쓰기와 격리되지 않고 교체가 비원자이며 버전 비교가 없음.

수정안

핵심: 캐시 전체를 한 락(ReaderWriterLockSlim)으로 보호, 두 구조를 함께 갱신, 같은 플레이어엔 버전 단조성, 재구축은 버전 비교로 최신 보존.

using System.Linq;
using System.Threading;

public class RankingCache
{
    private readonly IScoreDb _db;
    private readonly Dictionary<long, long> _scoreOf = new();
    private readonly Dictionary<long, long> _verOf = new();   // playerId -> 마지막 반영 버전
    private readonly SortedSet<(long score, long playerId)> _ranking = new();
    private readonly ReaderWriterLockSlim _lock = new();

    public RankingCache(IScoreDb db) { _db = db; }

    public void UpdateScore(long playerId, long newScore, long ver)
    {
        _db.WriteScoreIfNewer(playerId, newScore, ver);   // DB: ver 가 더 클 때만 반영(멱등/단조)

        _lock.EnterWriteLock();
        try
        {
            if (_verOf.TryGetValue(playerId, out var curVer) && ver <= curVer)
                return;                                   // 이미 더 최신 → 옛 갱신 무시(역전 방지)
            if (_scoreOf.TryGetValue(playerId, out var old))
                _ranking.Remove((old, playerId));         // 두 구조를 같은 락에서 함께
            _scoreOf[playerId] = newScore;
            _verOf[playerId]   = ver;
            _ranking.Add((newScore, playerId));
        }
        finally { _lock.ExitWriteLock(); }
    }

    public List<long> TopN(int n)
    {
        _lock.EnterReadLock();
        try
        {
            var outList = new List<long>();
            foreach (var e in _ranking.Reverse())         // 상위(높은 점수)부터
            {
                outList.Add(e.playerId);
                if (outList.Count >= n) break;
            }
            return outList;
        }
        finally { _lock.ExitReadLock(); }
    }

    public void RebuildFromDb()
    {
        var all = _db.LoadAllWithVer();                   // (playerId, score, ver)
        _lock.EnterWriteLock();
        try
        {
            foreach (var (pid, sc, ver) in all)
            {
                if (_verOf.TryGetValue(pid, out var cur) && ver <= cur) continue; // 최신 보존
                if (_scoreOf.TryGetValue(pid, out var old)) _ranking.Remove((old, pid));
                _scoreOf[pid] = sc; _verOf[pid] = ver;
                _ranking.Add((sc, pid));
            }
        }
        finally { _lock.ExitWriteLock(); }
    }
}

포인트

  • 두 컬렉션 갱신을 한 임계구역에서 → Remove+Add 원자, 중복/소실/찢어진 읽기·컬렉션 예외 제거.
  • 버전(ver) 단조성: 같은 플레이어엔 ver 가 더 큰 갱신만 이김 → out-of-order 역전 차단. DB도 WriteScoreIfNewer(... ver) 로 조건부 기록해 같은 기준으로 수렴(멱등).
  • 읽기는 EnterReadLock 다중 허용·쓰기 단독 → TopN 일관 스냅샷.
  • 재구축은 락 안 버전 비교 → 스냅샷보다 최신인 갱신을 덮지 않음.
  • (주의) ConcurrentDictionary 만으로는 "두 컬렉션 + Remove+Add" 복합 원자성을 못 준다 — 컬렉션 동시성과 연산 원자성은 별개.

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

  1. 단일 라이터(액터/파티션): 한 샤드 랭킹을 한 스레드가 순차 갱신, 읽기는 불변 스냅샷 교체. 트레이드오프: 샤딩/메모리.
  2. 권위를 Redis ZSET/Lua 로: ZADD + Lua 로 "더 클 때만" 을 원자화 → 다중 게임서버 공유 일관. 인메모리는 미러로 단순화. 트레이드오프: 왕복/운영.
  3. CDC/아웃박스 동기화: DB 변경 로그(버전)를 순서대로 캐시에 적용 → "DB 성공·캐시 실패" 영구 불일치를 재시도로 수렴. 트레이드오프: 파이프라인 지연.
  4. top-N + 경계 관리: 상위 N+버퍼만 캐시. 트레이드오프: 경계 근처 재조회.

면접 포인트 (예상 질문)

  1. "DB 먼저, 캐시 나중" 이어도 왜 캐시에 옛 점수가 남는가? 버전/시퀀스가 out-of-order 를 어떻게 막는가?
  2. 랭킹 Remove+Add 를 원자로 묶지 않으면 어떤 조회에서 중복/소실이 보이는가? ConcurrentDictionary 만으로 충분하지 않은 이유는?
  3. 재구축이 동시 갱신을 유실시키지 않게 하려면 무엇을 비교/격리하나? 이 문제가 복제 지연 (read-your-writes)과 어떻게 다른가?