18. cache-aside 쓰기 순서: 커밋 전 캐시 반영과 무효화 누락 (C#)
난이도 하내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — cache-aside 쓰기 순서: 커밋 전 캐시 반영과 무효화 누락 (C#)
난이도: 하
요약
UpdateProfile이 DB 커밋 전에 캐시를 먼저 갱신한다(A). 이후 _db.Update가
실패(B, 예외)하면 캐시에는 DB에 없는 값(phantom) 이 남아 모든 읽기에 계속
서빙된다. 또한 성공하더라도 "DB 갱신 후 캐시 갱신(double-write)"이 아니라 "캐시
먼저"라 동시 읽기와의 순서 문제로 stale이 굳을 수 있다. cache-aside의 정석은
DB를 먼저 확정하고, 캐시는 갱신이 아니라 무효화(삭제) 하는 것이다.
문제점
- 분류: 캐시 일관성(무효화 순서) — 커밋 전 공개 + 실패 시 미보상.
- 증상:
- Phantom write: 캐시 Set(A) 후 DB Update(B)가 예외로 실패하면, 캐시는 저장되지 않은 값을 TTL 동안 계속 반환. 클라이언트는 "저장됐다"고 오인하지만 DB엔 옛 값. 다른 서버/재시작 후 읽으면 값이 되돌아간다(유령 갱신).
- Stale 고착(double-write 경합): "캐시에 새 값 쓰기"를 갱신으로 쓰면, 동시에 일어난 읽기-미스 채우기가 옛 DB 값을 캐시에 덮어써 stale이 남을 수 있다. 캐시 갱신과 읽기 채우기의 순서를 보장할 수 없기 때문.
- 재현 조건: DB 오류/타임아웃, 또는 갱신과 읽기-미스가 겹치는 동시성. 정상 경로만 테스트하면 안 보인다.
- 근본 원인: 미확정 상태를 캐시로 먼저 공개. 캐시는 DB(진실의 원천)의 팔로워인데 리더보다 앞서 나갔다. 실패 시 캐시를 되돌리는 보상도 없다.
수정안
DB를 먼저 확정하고, 성공 후 캐시를 삭제(무효화) 한다. 다음 읽기가 새 값으로 자연스럽게 채운다.
public void UpdateProfile(string playerId, string newValue)
{
_db.Update(playerId, newValue); // 1) 진실의 원천을 먼저 확정(실패 시 여기서 중단)
_cache.Remove(playerId); // 2) 커밋 성공 후 캐시 무효화
}
- DB가 실패하면 예외로 중단되고 캐시는 손대지 않았으므로 phantom이 없다.
- "갱신" 대신 "삭제"를 쓰는 이유: 동시 읽기-채우기와의 순서 경합에서 stale을 굳히지 않는다. 다음 읽기 미스가 최신 DB 값을 읽어 채운다.
더 나은 설계
- 삭제 실패 대비:
_cache.Remove가 실패할 수 있으므로, 캐시 항목에 짧은 TTL을 두어 최악의 경우에도 stale이 한정 시간만 산다. 무효화 재시도 큐(아웃박스)로 보강하면 더 견고. - 원본→캐시 전파(write-through/뒤늦은 무효화): 여러 노드·리전이 있으면 로컬 삭제만으론 부족하다. 커밋을 이벤트로 발행(아웃박스/CDC)해 각 캐시가 무효화하도록 전파. 트레이드오프: 전파 지연 동안 짧은 불일치 허용.
- read-your-writes: 방금 쓴 사용자에게는 stale이 특히 눈에 띈다. 쓰기 직후 해당 세션은 잠시 DB에서 직접 읽거나 버전 토큰으로 최신 보장.
- 동시 쓰기 순서: 두 갱신이 겹치면 "DB then invalidate"라도 마지막 삭제가 마지막 쓰기가 아닐 수 있다. 버전/타임스탬프로 늦은 무효화를 무시하거나 CAS로 직렬화.
면접 포인트
- "cache-aside 쓰기의 정석 순서?" → DB 먼저, 그다음 캐시 무효화(삭제). 캐시를 먼저 쓰거나 새 값으로 갱신하면 실패 시 phantom·동시 읽기와 stale 경합.
- "왜 갱신이 아니라 삭제인가?" → 삭제는 "다음에 진실의 원천에서 다시 읽어라"는 안전한 명령. 갱신은 읽기-채우기와 순서 경합으로 옛 값이 이길 수 있다.
해설 · C++
해설 — cache-aside 쓰기 순서: 커밋 전 캐시 반영과 무효화 누락 (C++)
난이도: 하
요약
updateProfile이 DB 커밋 전에 캐시를 먼저 갱신한다(A). db_.update가 예외로
실패(B)하면 캐시에는 DB에 없는 값(phantom)이 남아 이후 모든 읽기에 서빙된다.
C++에선 예외가 던져지면 updateProfile이 즉시 스택을 풀며 반환하므로, 이미 실행된
cache_.set을 되돌릴 기회가 없다(보상 없음). cache-aside 정석은 DB 먼저 확정 →
캐시 무효화(삭제) 다.
문제점
- 분류: 캐시 일관성(무효화 순서) — 커밋 전 공개 + 예외 시 미보상.
- 증상:
- Phantom write:
cache_.set(A) 후db_.update(B) 예외 → 캐시에 미저장 값이 남는다. 다른 노드/재시작 후 DB를 읽으면 값이 되돌아간다(유령 갱신). - 예외 안전성 부재: 이 함수는 basic guarantee조차 못 지킨다. 예외가 나가도 캐시가 롤백되지 않아 관찰 가능한 상태가 오염된 채 남는다.
- Stale 고착: "캐시에 새 값 쓰기"가 동시 읽기-미스 채우기와 겹치면 옛 DB 값이 캐시를 덮어써 stale이 굳을 수 있다.
- Phantom write:
- 재현 조건: DB 오류/타임아웃, 또는 갱신과 읽기-미스 동시성.
- 근본 원인: 미확정 상태를 캐시로 먼저 공개하고 실패 경로 보상이 없음. 캐시는 DB(진실의 원천)의 팔로워인데 앞서 나갔다.
수정안
void updateProfile(const std::string& playerId, const std::string& newValue) {
db_.update(playerId, newValue); // 1) 원천 먼저 확정(실패하면 여기서 예외 전파)
cache_.remove(playerId); // 2) 커밋 성공 후 캐시 무효화
}
db_.update가 던지면cache_.remove에 도달하지 않으므로 캐시는 손대지 않은 상태 그대로다(strong guarantee에 가깝다). Phantom 없음.- "갱신" 대신 "삭제"로 동시 읽기-채우기와의 순서 경합에서 stale을 굳히지 않는다.
더 나은 설계
- 짧은 TTL:
cache_.remove자체가 실패할 수 있으니 항목에 TTL을 두어 최악의 경우에도 stale 수명을 한정. 무효화 재시도(아웃박스)로 보강. - 다노드 전파: 로컬 삭제만으론 다른 서버 캐시가 stale. 커밋을 이벤트로 발행해 각 노드가 무효화. 트레이드오프: 전파 지연 동안 짧은 불일치 허용.
- 버전 태깅: 캐시 값에 버전을 실어, 늦게 도착한 무효화/채우기가 최신을 덮지 않도록 CAS/버전 비교. 동시 갱신 순서 문제 방지.
- RAII 보상(부득이 캐시 선반영이 필요하면):
scope_guard로 예외 시 자동cache_.remove를 걸어 basic guarantee 확보. 단 정석은 애초에 커밋을 앞세우는 것.
면접 포인트
- "cache-aside 쓰기 정석 순서?" → DB 먼저, 그다음 캐시 삭제. 캐시를 먼저 쓰면 실패 시 phantom, 동시 읽기와 stale 경합.
- "C++에서 이 함수의 예외 안전성은?" → 원래 코드는 예외가 나가면 캐시 오염이 남아 basic guarantee도 못 지킨다. 커밋을 앞세우면 실패 시 캐시를 안 건드려 강한 보장에 가까워진다.