캐시 일관성의 어려움은 삭제 명령 자체보다 삭제 이전에 시작된 읽기가 나중에 완료되는 데서 생깁니다. 읽기 결과에 버전과 세대를 붙이고, 게시 순간의 비교를 원자 경계로 묶어야 이미 관찰한 최신 버전보다 뒤로 가는 재삽입과 캐시 재시작 뒤의 옛 작업을 구분해 막을 수 있습니다.
삭제 후 옛값 재등장 경합
캐시에 상품이 없으면 DB에서 읽어 채우고, 상품을 수정하면 DB를 커밋한 뒤 캐시를 삭제하는 구조를 생각해 보겠습니다. 순서가 그럴듯하지만 진행 중인 읽기가 남아 있으면 삭제 뒤에도 옛값이 다시 들어갈 수 있습니다.
읽기 A가 DB 버전 11을 읽은 뒤 잠시 멈춥니다. 쓰기 B는 버전 12를 커밋하고 캐시 삭제까지 끝냅니다. 이제 A가 돌아와 버전 11을 캐시에 넣습니다. 삭제 명령은 실패하지 않았습니다. 삭제보다 늦게 도착한 옛 읽기 결과가 문제입니다.
장애를 볼 때는 “삭제 성공” 로그 다음에 어떤 읽기 버전이 게시됐는지 확인합니다. invalidate(v12) 뒤 publish(v11)이 저장됐다면 단순 cache-aside의 경합이 재현된 것이고, 게시가 거절됐지만 그 v11을 읽기 응답으로 이미 반환했다면 캐시 저장 안전성과 사용자에게 전달된 신선도는 별개입니다. 세대가 바뀐 뒤 옛 세대 게시가 통과하면 장벽 보존보다 복구 경계가 먼저 고쳐져야 합니다.
읽기·쓰기·캐시 상태 시간표
| 순서 | 읽기 A | 쓰기 B | 캐시 |
|---|---|---|---|
| 1 | miss, DB에서 v11 읽음 | 없음 | |
| 2 | 결과 게시 직전 멈춤 | DB에 v12 커밋 | 없음 |
| 3 | 계속 멈춤 | 캐시 삭제 성공 | 없음 |
| 4 | v11을 SET | 완료 | v11 |
이 구조가 캐시 어사이드(cache-aside)이며, 마지막 현상을 오래된 값의 지연 채우기(stale refill)라고 부릅니다. 쓰기 뒤 삭제를 두 번 하거나 시간을 조금 기다리는 방식은 특정 경합 가능성을 줄일 수 있지만, 읽기 지연의 상한을 모르면 모든 실행 순서를 보장하지 못합니다.
그림을 글로 읽기
- A가 v11 읽음: 게시 전 일시 정지
- B가 v12 커밋: 캐시 삭제 또는 장벽 갱신
- A가 늦게 게시: v11을 허용할지 판단
- A가 v11 읽음 → B가 v12 커밋: A가 멈춘 사이
- B가 v12 커밋 → A가 늦게 게시: A 재개
빈 값과 최신 버전 장벽의 분리
값을 지우면서 “최소 버전 12 이상만 받는다”는 표식을 남길 수 있습니다. 이 버전 장벽은 payload가 없는 상태에서도 최신 기준을 기억합니다. 다음 모형은 값·장벽·세대가 같은 원자 저장 범위에 있고, 원본 버전이 단조 증가한다는 전제입니다.
invalidate(key, committedVersion, generation):
atomically:
require generation == currentGeneration
minimum[key] = max(minimum[key], committedVersion)
if cached[key] exists and cached[key].version < minimum[key]:
remove cached[key]
publish(key, readVersion, value, readGeneration):
atomically:
if readGeneration != currentGeneration: return rejected
if readVersion < minimum[key]: return rejected
if cached[key] exists and readVersion < cached[key].version:
return rejected
cached[key] = (readVersion, value)
return stored
검사 후 저장을 서로 다른 GET·SET으로 하면 그 사이 새 무효화가 들어올 수 있습니다. 따라서 검사와 게시가 같은 원자 경계에 있어야 합니다. v12를 본 뒤 늦은 v11 무효화가 도착해도 max가 장벽을 낮추지 않습니다.
이 모형은 캐시가 이미 관찰한 버전보다 뒤로 가지 않도록 하는 것이지, DB의 모든 새 커밋을 즉시 안다는 뜻이 아닙니다. DB 커밋과 무효화 전달 사이에는 여전히 지연이 있습니다. CDC나 Outbox는 전달을 보강하지만 지연·중복·옛 읽기의 존재를 없애지 않습니다.
장벽 퇴거와 세대 복구의 안전성
캐시가 v12 장벽을 퇴거시킨 뒤 A의 v11 게시를 받으면, 캐시는 이를 “처음 들어온 값”으로 잘못 받아들일 수 있습니다. 값과 장벽의 수명이 같다는 사실만으로는 이미 진행 중인 읽기를 막지 못하므로, 진행 중인 읽기의 최대 수명보다 장벽을 오래 보존하거나, 장벽을 잃고 캐시를 초기화할 때 새로운 세대를 발급해 이전 세대의 게시를 거절하는 복구 절차를 마련해야 합니다.
세대 값을 같은 퇴거 가능한 캐시에만 두고 재시작 때 0부터 재사용하면, 옛 게시가 새 작업의 세대와 같다고 판단되어 통과할 수 있습니다. 세대 재사용을 막고, 게시 중인 작업이 현재 세대와의 일치를 원자적으로 확인하게 해야 합니다. 복구할 때 원본 버전과 새 세대를 어떤 기준으로 초기화할지 정하지 못했다면, 캐시 게시를 잠시 막고 원본 조회로 우회하는 편이 더 안전할 수 있습니다.
TTL과 강한 결정의 경계
TTL 60초는 캐시에 넣은 값의 보관 시간을 뜻합니다. v11을 읽은 A가 5분 뒤 게시하면서 새 TTL 60초를 받으면 원본 변경 이후 60초 안에 최신화된다고 할 수 없습니다. 원본 복제본도 늦게 읽힐 수 있습니다. 오래됨을 제한하려면 원본 버전·읽기 시점·게시 지연과 허용 최대 나이를 함께 다룹니다.
상품 설명 화면은 잠시 옛값을 허용할 수 있지만 최종 재고 차감·권한 변경·중복 결제 판단은 권위 저장소의 조건 갱신으로 확인합니다. 화면 캐시를 강한 저장소처럼 만들려다가 복잡한 분산 프로토콜을 추가하는 비용과 실제 요구를 비교해야 합니다.
| 장치 | 줄이는 문제 | 남는 문제 |
|---|---|---|
| DB 커밋 뒤 삭제 | 정상 경로의 낡은 캐시 | 늦은 옛 읽기의 재삽입 |
| CDC·Outbox | 무효화 전달 누락의 복구 | 전달 지연·지연 채우기 |
| 원자 버전 비교 | 관찰한 버전보다 뒤로 가는 게시 | 장벽 손실·새 커밋 미관찰 |
| TTL | 저장된 값의 체류 시간 | 원본 변경 후 최신화의 절대 기한 |
지연 읽기·무효화 순서별 게시 시험
A가 DB를 읽은 직후 멈추고 B 커밋·무효화를 끝낸 뒤 A를 재개합니다. 단순 SET에서는 v11이 남고, v12 장벽이 있는 원자 게시에서는 거절돼야 합니다. 다음에는 v12·v11 무효화를 역순으로 보내 장벽이 12 아래로 내려가지 않는지 확인합니다.
장벽 퇴거와 전체 캐시 재시작도 별도 사례입니다. 새로운 세대가 시작된 뒤 옛 작업의 게시가 거절되어야 하며, 거절된 읽기 결과를 사용자에게 그대로 반환할지 재조회할지도 신선도 계약에 맞게 정합니다. 캐시 저장이 거절됐다는 사실만으로 사용자에게 보낸 값까지 최신이 되는 것은 아닙니다.