23. 실시간 랭킹 캐시와 원본 DB의 일관성 (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.
- 증상:
TopN의foreach (_ranking)도중 다른 스레드Add/Remove→InvalidOperationException._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" 복합 원자성을 못 준다 — 컬렉션 동시성과 연산 원자성은 별개.
더 나은 설계 (+트레이드오프)
- 단일 라이터(액터/파티션): 한 샤드 랭킹을 한 스레드가 순차 갱신, 읽기는 불변 스냅샷 교체. 트레이드오프: 샤딩/메모리.
- 권위를 Redis ZSET/Lua 로:
ZADD+ Lua 로 "더 클 때만" 을 원자화 → 다중 게임서버 공유 일관. 인메모리는 미러로 단순화. 트레이드오프: 왕복/운영. - CDC/아웃박스 동기화: DB 변경 로그(버전)를 순서대로 캐시에 적용 → "DB 성공·캐시 실패" 영구 불일치를 재시도로 수렴. 트레이드오프: 파이프라인 지연.
- top-N + 경계 관리: 상위 N+버퍼만 캐시. 트레이드오프: 경계 근처 재조회.
면접 포인트 (예상 질문)
- "DB 먼저, 캐시 나중" 이어도 왜 캐시에 옛 점수가 남는가? 버전/시퀀스가 out-of-order 를 어떻게 막는가?
- 랭킹 Remove+Add 를 원자로 묶지 않으면 어떤 조회에서 중복/소실이 보이는가?
ConcurrentDictionary만으로 충분하지 않은 이유는? - 재구축이 동시 갱신을 유실시키지 않게 하려면 무엇을 비교/격리하나? 이 문제가 복제 지연 (read-your-writes)과 어떻게 다른가?
해설 — 실시간 랭킹 캐시와 원본 DB의 일관성 (C++)
난이도: 최상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
updateScore/topN/rebuildFromDb 가 공유 캐시(scoreOf_, ranking_)와 DB를 동기화 없이
만진다. (A) "DB 먼저 쓰고 캐시 갱신" 인데 두 갱신의 DB 반영 순서와 캐시 반영 순서가 독립이라,
같은 플레이어에 100→90 또는 동시 80/90 이 들어오면 캐시에 더 낮은(옛) 점수가 최종으로 남아
DB와 어긋난다(out-of-order/lost update). (B) 캐시 갱신이 "옛 (점수,id) erase → 새 것 insert" 로
비원자라, 그 틈에 topN 이 읽으면 플레이어가 랭킹에서 사라져 보이고, 두 갱신이 겹치면
같은 플레이어의 (옛점수,id)/(새점수,id) 두 엔트리가 동시에 존재(중복)한다. (C) topN 이
갱신과 동시 순회하면 std::set 반복자 무효화/UB. (D) rebuildFromDb 는 loadAll() 스냅샷을
읽는 동안 들어온 갱신을 clear() 로 통째로 덮어 유실시키고, 교체도 비원자다. 정답 한 줄:
캐시(두 구조)를 하나의 락(또는 단일 라이터)으로 원자 갱신하고, 같은 플레이어 갱신엔
버전/시퀀스로 단조성을 강제하며, DB↔캐시는 버전 기반 write-through 로 수렴시키고 재구축은
"버전 비교로 더 최신 갱신을 덮지 않게" 하라.
변별:
- concurrency_memory/problem13(캐시-DB 이중 쓰기 일반): 그쪽은 단일 키 값의 last-write 일관성. 본 문제는 정렬 구조(remove+insert 원자성)·랭킹 중복/소실·top-N 라는 정렬 캐시 특유의 위험.
- concurrency_memory/problem22(인메모리 리더보드 값 CAS/타이브레이커): 그쪽은 인메모리 값 자체의 원자성. 본 문제는 캐시↔DB 동기화 계층의 정합(쓰기 순서·재구축·이중 구조).
- persistence_data/problem1(복제 지연 read-your-writes): 본 문제의 재구축 위험은 복제 지연이 아니라 재구축 vs 동시 쓰기 경합이다(복제본 가정 없음).
문제점
(A) 비순서/비원자 dual-write — 캐시에 옛 점수 고착 (정합·동시성) ★간판
- 분류 태그: out-of-order update / lost update.
- 증상: T1(newScore=90), T2(newScore=100) 가 같은 플레이어에 동시 진입. DB에는 어떤 순서로든
결국 100 이 남더라도, 캐시 갱신부는 별도라 T2 가 캐시에 100 을 쓴 뒤 T1 이 90 을 덮어쓸
수 있다 → 캐시=90, DB=100 으로 영구 불일치. 단일 키 갱신도
find→수정→insert가 RMW 라 서로 덮는다. - 재현조건: 같은 플레이어에 동시/근접 갱신.
- 근본 원인: DB 반영과 캐시 반영이 하나의 임계구역/순서 기준으로 묶이지 않음. 단조성 (더 최신 갱신만 이김) 강제 장치 없음.
(B) 랭킹 "remove+insert" 비원자 — 중복/소실 (정합·동시성) ★간판
- 분류 태그: non-atomic compound update.
- 증상:
ranking_.erase({old,id})와ranking_.insert({new,id})사이에topN이 읽으면 그 플레이어가 랭킹에서 누락돼 보인다. 두updateScore가 겹치면 erase 가 한쪽 옛 값만 지우고 양쪽이 insert 해 같은 id 가 두 점수로 중복 등장 → 순위/보상 계산이 틀어진다. - 근본 원인: 두 컨테이너에 걸친 복합 갱신이 원자가 아니다.
(C) 동시 순회 vs 갱신 — 반복자 무효화/UB (동시성/메모리)
- 분류 태그: iterator invalidation / data race.
- 증상:
topN이ranking_를 역순 순회하는 동안 다른 스레드가insert/erase하면std::set반복자가 무효화돼 UB(크래시/잘못된 결과).scoreOf_(unordered_map) 도 rehash 시 동일. - 근본 원인: 읽기/쓰기 동기화 없음.
(D) 재구축이 동시 갱신을 덮음 — 유실 + 비원자 교체 (정합·동시성)
- 분류 태그: lost update / non-atomic swap.
- 증상:
loadAll()로 스냅샷을 뜨는 동안 들어온 최신 갱신이, 뒤이은clear()+재삽입으로 통째로 사라진다(DB에는 있는데 캐시엔 옛 스냅샷만).clear()후 재삽입 사이의 조회는 빈 랭킹을 본다. - 근본 원인: 재구축이 동시 쓰기와 격리되지 않고, 교체가 원자적이지 않으며, "스냅샷 이후 갱신" 을 반영할 버전 비교가 없다.
수정안
핵심: 캐시 전체를 한 락으로 보호(또는 단일 라이터), 두 구조를 함께 갱신, 같은 플레이어엔 버전 단조성, 재구축은 버전 비교로 최신 갱신 보존.
#include <mutex>
#include <shared_mutex>
class RankingCache {
public:
explicit RankingCache(ScoreDb& db) : db_(db) {}
void updateScore(int64_t playerId, int64_t newScore, uint64_t ver) {
// ver: 이 갱신의 단조 버전(요청 시퀀스/타임스탬프). DB도 같은 ver 로 조건부 기록.
db_.writeScoreIfNewer(playerId, newScore, ver); // DB: ver 가 더 클 때만 반영(멱등/단조)
std::unique_lock lk(mu_);
auto it = scoreOf_.find(playerId);
if (it != scoreOf_.end()) {
if (ver <= verOf_[playerId]) return; // 이미 더 최신 → 옛 갱신 무시(역전 방지)
ranking_.erase({ it->second, playerId }); // 두 구조를 같은 락에서 함께 갱신
it->second = newScore;
} else {
scoreOf_[playerId] = newScore;
}
verOf_[playerId] = ver;
ranking_.insert({ newScore, playerId });
}
std::vector<int64_t> topN(int n) {
std::shared_lock lk(mu_); // 읽기 공유 락 → 일관된 스냅샷
std::vector<int64_t> out;
for (auto rit = ranking_.rbegin(); rit != ranking_.rend() && (int)out.size() < n; ++rit)
out.push_back(rit->second);
return out;
}
void rebuildFromDb() {
auto all = db_.loadAllWithVer(); // (playerId, score, ver)
std::unique_lock lk(mu_);
for (auto& [pid, sc, ver] : all) {
auto vit = verOf_.find(pid);
if (vit != verOf_.end() && ver <= vit->second) continue; // 더 최신 갱신 보존
auto sit = scoreOf_.find(pid);
if (sit != scoreOf_.end()) ranking_.erase({ sit->second, pid });
scoreOf_[pid] = sc; verOf_[pid] = ver;
ranking_.insert({ sc, pid });
}
}
private:
ScoreDb& db_;
std::shared_mutex mu_;
std::unordered_map<int64_t, int64_t> scoreOf_;
std::unordered_map<int64_t, uint64_t> verOf_; // playerId -> 마지막 반영 버전
std::set<std::pair<int64_t,int64_t>> ranking_;
};
포인트
- 두 컨테이너 갱신을 한 임계구역에서 → remove+insert 원자, 중복/소실/찢어진 읽기 제거.
- 버전(ver) 단조성: 같은 플레이어엔 ver 가 더 큰 갱신만 이김 → out-of-order 역전 차단.
DB도
writeScoreIfNewer(... ver)로 조건부 기록해 캐시와 같은 기준으로 수렴(멱등). topN은shared_lock으로 일관된 스냅샷(읽기 다중·쓰기 단독). 반복자 무효화 제거.- 재구축은 락 안에서 버전 비교 → 스냅샷보다 최신인 갱신을 덮지 않음. (교체도 락 안 원자.)
더 나은 설계 (+트레이드오프)
- 단일 라이터(액터/파티션): 한 샤드의 랭킹을 한 스레드가 순차 갱신 → 락 경합 없이 두 구조 원자성. 읽기는 불변 스냅샷 포인터 교체(RCU)로 무락. 트레이드오프: 샤딩/메모리.
- 권위를 Redis ZSET/Lua 로:
ZADD+ Lua 로 "더 클 때만 갱신" 을 원자화하면 다중 게임서버가 같은 랭킹을 공유해도 일관. 인메모리 캐시는 ZSET 의 read-through 미러로 단순화. 트레이드오프: 왕복/운영. - CDC/아웃박스로 캐시 동기화: DB 변경 로그(버전 포함)를 순서대로 캐시에 적용 → write-through 경합 제거, "DB 성공·캐시 실패" 영구 불일치를 재시도로 수렴. 트레이드오프: 파이프라인 지연.
- top-N 만 유지 + 경계 관리: 전체가 아니라 상위 N+버퍼만 캐시하고, 경계 점수 미만은 lazy 조회. 트레이드오프: 경계 근처 갱신 시 재조회 비용.
면접 포인트 (예상 질문)
- "DB 먼저, 캐시 나중" 이어도 왜 캐시에 옛 점수가 남는가? 버전/시퀀스가 out-of-order 를 어떻게 막는지 설명하라.
- 랭킹의 remove+insert 를 원자로 묶지 않으면 어떤 조회에서 중복/소실이 보이는가?
std::set동시 순회의 위험은? - 재구축(rebuild)이 동시 갱신을 덮어 유실시키지 않게 하려면 무엇을 비교/격리해야 하는가? 이 문제가 복제 지연(read-your-writes) 문제와 어떻게 다른가?