10. Cache-Aside 읽기 재채움 vs 쓰기 무효화 경합(stale 부활) (C#)
난이도 최상내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — Cache-Aside 읽기 재채움 vs 쓰기 무효화 경합(stale 부활) (C#)
난이도: 최상
요약
"DB 갱신 → 캐시 삭제"(무효화)와 "캐시 미스 → DB 읽기 → 캐시 채움"이 인터리브되면, 느린 읽기 스레드가 갱신 전(옛) DB 값을 캐시에 되써 무효화를 무효화한다. 그 결과 DB 는 새 값인데 캐시엔 옛 값이 TTL 만료까지 부활한다. "삭제로 무효화"(update 대신 delete)만으로는 이 경합이 사라지지 않는다는 점이 핵심 — 버전/CAS 또는 지연 이중 삭제가 필요하다.
문제점
- [읽기 재채움 vs 무효화 경합 → stale 부활] (A)(B)
_cache.Set(playerId, v, Ttl)↔_cache.Delete(playerId)- 인터리빙:
- 읽기 R: 캐시 미스 →
_db.Read로 옛 값 v0 을 얻음(아직 캐시에 안 씀). - 쓰기 W:
_db.Write(v1)(DB=v1) →_cache.Delete(캐시 비어 있어 무의미, 또는 R 이 아직 안 씀). - 읽기 R:
_cache.Set(playerId, v0)→ 캐시=v0(옛 값). DB=v1 인데 캐시=v0.
- 읽기 R: 캐시 미스 →
- 증상: 이후 모든 GetProfile 이 TTL(10분) 동안 옛 프로필을 반환. 랭킹/과금/권한 값이면 심각(잘못된 판단·환불 분쟁).
- 근본원인: 캐시 채움이 무조건 덮어쓰기라, 그 사이 값이 바뀌었는지(버전) 확인하지 않는다. 무효화는 "그 순간의 항목"만 지우지 캐시를 채우려는 진행 중 읽기를 막지 못한다.
- 인터리빙:
- [삭제 vs 갱신은 이 경합의 해법이 아님]
- "무효화 시 캐시에 새 값을 쓰지 말고 지워라"는 (다른 종류의 dual-write 불일치엔 유효하나) 이 부활 경합은 못 막는다. R 이 삭제 이후에 옛 값을 쓰기 때문.
- [쓰기 순서·원자성] (B) DB 갱신과 캐시 무효화가 별도 단계(dual write) — 사이에 크래시하면 캐시가 옛 값으로 남는다(무효화 유실).
distributed_systems/problem4(무효화 전파)·persistence_data/problem6(아웃박스)와 같은 계열.
수정안
A. 버전/CAS 기반 캐시 채움 (근본적)
DB 행에 단조 증가 version(또는 갱신 타임스탬프)을 두고, 읽기가 읽은 시점의 version 과 함께 캐시에 CAS 로 쓴다. 더 큰 version 이 이미 있으면 덮지 않는다.
public string GetProfile(string playerId)
{
if (_cache.TryGet(playerId, out var cached)) return Deserialize(cached).Value;
var (v, ver) = _db.ReadWithVersion(playerId); // 값 + version
// 캐시에 {value, version} 저장하되, 기존 version 이 더 크면 덮지 않음(Lua/CAS)
_cache.SetIfNewer(playerId, v, ver, Ttl); // (A) 조건부 채움
return v;
}
public void UpdateProfile(string playerId, string val)
{
long ver = _db.WriteReturningVersion(playerId, val); // DB 갱신 + version++
_cache.SetIfNewer(playerId, val, ver, Ttl); // 무효화 대신 최신 버전으로 갱신
// (또는) _cache.DeleteIfOlder(playerId, ver);
}
- 읽기가 옛 version(v0) 으로 쓰려 해도 캐시엔 이미 더 큰 version(v1) 이 있어 거부 → 부활 차단.
B. 지연 이중 삭제(delayed double delete) — 버전 못 넣을 때
public void UpdateProfile(string playerId, string val)
{
_cache.Delete(playerId); // 1차 삭제(진행 중 읽기 최소화)
_db.Write(playerId, val);
_cache.Delete(playerId); // 2차 삭제(즉시)
ScheduleAfter(500 /*ms*/, () => _cache.Delete(playerId)); // 3차: 늦은 재채움 제거
}
- 갱신 도중 시작된 느린 읽기가 옛 값을 되쓸 창(수십~수백 ms)을 지연 삭제로 덮어 정리. 완벽하진 않지만 부활 지속시간을 창 밖으로 축소.
C. 그 밖의 보강
- 짧은 TTL + 지터: 부활이 나도 오래 안 가게(그리고 스탬피드 방지,
persistence_data/problem4). - single-flight: 같은 키 미스를 하나의 DB 조회로 합쳐 재채움 경합 자체를 축소.
- write-through / 무효화 큐: 쓰기 경로가 캐시를 최신 버전으로 원자 갱신하고, 무효화는 아웃박스로 신뢰성 전파(
persistence_data/problem6).
더 나은 설계
- 버전이 정답에 가깝다: Cache-Aside 의 이 경합은 "덮어쓰기에 버전 조건"을 넣는 게 정공법. Facebook memcache 의 lease, Redis 의
SET+Lua CAS 가 같은 아이디어. - 정합성 요구에 맞게: 강한 정합이 필요하면 캐시를 쓰지 않거나 write-through+버전; 게임 프로필처럼 수렴만 되면 되는 값은 짧은 TTL+지연 이중 삭제로 실용 타협.
- 관측: 캐시-DB 불일치 감지용 샘플 검증(주기적으로 캐시 값 vs DB 대조)으로 부활을 조기 발견.
면접 포인트
- Cache-Aside 의 고전적 경합: 읽기 재채움이 무효화를 되돌린다 — 왜 "delete 로 무효화"만으론 못 막는지.
- 버전/CAS 캐시 쓰기가 근본 해법인 이유, 지연 이중 삭제의 원리와 한계(창 축소일 뿐 완전 제거 아님).
- TTL·single-flight·write-through 의 역할 구분(
persistence_data/problem1,4,distributed_systems/problem4와의 연결).
해설 · C++
해설 — Cache-Aside 읽기 재채움 vs 쓰기 무효화 경합(stale 부활) (C++)
난이도: 최상
요약
"DB 갱신 → 캐시 삭제"(무효화)와 "캐시 미스 → DB 읽기 → 캐시 채움"이 인터리브되면, 느린 읽기 스레드가 갱신 전(옛) DB 값을 캐시에 되써 무효화를 되돌린다. DB 는 새 값인데 캐시엔 옛 값이 TTL 만료까지 부활한다. "삭제로 무효화"(update 대신 delete)만으로는 이 경합이 사라지지 않는다 — 버전/CAS 또는 지연 이중 삭제가 필요.
문제점
- [읽기 재채움 vs 무효화 경합 → stale 부활] (A)(B)
cache_->set(...)↔cache_->del(...)- 인터리빙:
- 읽기 R: 캐시 미스 →
db_->read로 옛 값 v0 획득(아직 미기록). - 쓰기 W:
db_->write(v1)(DB=v1) →cache_->del(캐시 비어 무의미). - 읽기 R:
cache_->set(v0)→ 캐시=v0. DB=v1, 캐시=v0.
- 읽기 R: 캐시 미스 →
- 증상: 이후 getProfile 이 TTL(600초) 동안 옛 값 반환. 과금·권한·랭킹 값이면 심각.
- 근본원인: 캐시 채움이 무조건 덮어쓰기 — 그 사이 값이 바뀌었는지(버전) 확인 안 함. 무효화는 "그 순간 항목"만 지우고 진행 중 읽기의 재채움을 막지 못한다.
- 인터리빙:
- [C++ 로컬 캐시라면 데이터 레이스까지] (A)(B)
- 캐시가 프로세스 내
unordered_map이면,set(삽입·rehash)과del(erase)과get(순회)을 여러 워커가 동기화 없이 하면 데이터 레이스=UB(컨테이너 손상). 논리적 부활 위에 메모리 안전 문제까지 겹친다. (외부 Redis 면 이 층은 없지만 부활 경합은 동일.)
- 캐시가 프로세스 내
- [쓰기 순서·원자성] (B) DB 갱신과 캐시 무효화가 dual write — 사이 크래시 시 캐시가 옛 값으로 남음(
distributed_systems/problem4,persistence_data/problem6계열).
수정안
A. 버전/CAS 기반 캐시 채움 (근본적)
DB 행에 단조 증가 version 을 두고, 읽기가 읽은 시점 version 과 함께 저장하되 더 큰 version 이 이미 있으면 덮지 않는다.
struct Versioned { std::string val; uint64_t ver; };
std::string getProfile(const std::string& id) {
std::string cached;
if (cache_->get(id, cached)) return decode(cached).val;
auto [v, ver] = db_->readWithVersion(id); // 값 + version
cache_->setIfNewer(id, v, ver, std::chrono::seconds(600)); // (A) 조건부(원자 CAS/Lua)
return v;
}
void updateProfile(const std::string& id, const std::string& val) {
uint64_t ver = db_->writeReturningVersion(id, val); // DB 갱신 + version++
cache_->setIfNewer(id, val, ver, std::chrono::seconds(600)); // 무효화 대신 최신 버전 반영
}
- 읽기가 옛 version(v0) 으로 쓰려 해도 캐시엔 더 큰 version(v1) 이 있어 거부 → 부활 차단.
setIfNewer는 Redis Lua/WATCH-MULTI(CAS)로 원자 구현.
B. 지연 이중 삭제(delayed double delete) — 버전 못 넣을 때
void updateProfile(const std::string& id, const std::string& val) {
cache_->del(id); // 1차
db_->write(id, val);
cache_->del(id); // 2차(즉시)
scheduleAfter(std::chrono::milliseconds(500), [this, id]{ cache_->del(id); }); // 3차
}
- 갱신 도중 시작된 느린 읽기가 옛 값을 되쓰는 창(수십~수백 ms)을 지연 삭제로 정리. 부활 지속을 창 밖으로 축소(완전 제거는 아님).
C. 보강 + 로컬 캐시 안전
- 짧은 TTL + 지터(스탬피드 방지,
persistence_data/problem4), single-flight 로 재채움 경합 축소. - 프로세스 내 캐시면
set/get/del을 락 또는 lock-free 맵으로 보호(안전 공개는concurrency_memory/problem28).
더 나은 설계
- 버전 조건부 쓰기가 정공법: memcache lease, Redis Lua CAS 가 같은 아이디어. Cache-Aside 의 이 경합은 "덮어쓰기에 버전 조건"으로 푼다.
- 정합성 등급에 맞게: 강정합 필요 시 write-through+버전 또는 캐시 미사용; 수렴만 되면 되는 값은 TTL+지연 이중 삭제로 실용 타협.
- 불일치 감시: 캐시-DB 대조 샘플링으로 부활 조기 탐지.
면접 포인트
- Cache-Aside 고전 경합: 읽기 재채움이 무효화를 되돌린다 — "delete 무효화"만으론 못 막는 이유.
- 버전/CAS 캐시 쓰기의 근본성과 지연 이중 삭제의 원리·한계.
- C++ 로컬 캐시의 동시 접근 UB(외부 Redis 와의 차이)와 락/lock-free 보호.