← 문제로

18. cache-aside 쓰기 순서: 커밋 전 캐시 반영과 무효화 누락 (C#)

난이도 하
내 리뷰 · C#
해설 · C#

해설 — cache-aside 쓰기 순서: 커밋 전 캐시 반영과 무효화 누락 (C#)

난이도: 하

요약

UpdateProfileDB 커밋 전에 캐시를 먼저 갱신한다(A). 이후 _db.Update가 실패(B, 예외)하면 캐시에는 DB에 없는 값(phantom) 이 남아 모든 읽기에 계속 서빙된다. 또한 성공하더라도 "DB 갱신 후 캐시 갱신(double-write)"이 아니라 "캐시 먼저"라 동시 읽기와의 순서 문제로 stale이 굳을 수 있다. cache-aside의 정석은 DB를 먼저 확정하고, 캐시는 갱신이 아니라 무효화(삭제) 하는 것이다.

문제점

  • 분류: 캐시 일관성(무효화 순서) — 커밋 전 공개 + 실패 시 미보상.
  • 증상:
    1. Phantom write: 캐시 Set(A) 후 DB Update(B)가 예외로 실패하면, 캐시는 저장되지 않은 값을 TTL 동안 계속 반환. 클라이언트는 "저장됐다"고 오인하지만 DB엔 옛 값. 다른 서버/재시작 후 읽으면 값이 되돌아간다(유령 갱신).
    2. 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 경합.
  • "왜 갱신이 아니라 삭제인가?" → 삭제는 "다음에 진실의 원천에서 다시 읽어라"는 안전한 명령. 갱신은 읽기-채우기와 순서 경합으로 옛 값이 이길 수 있다.