Redis hot cache와 stale fallback
빠른 응답과 마지막 성공값을 분리해 오래된 값을 최신처럼 보이지 않게 했다
한강자리 백엔드에서 Redis는 단순한 성능 도구가 아니다. 현재 값을 빠르게 읽게 하고, 이미 만든 응답을 다시 쓰고, 외부 출처나 DB 조합이 순간적으로 실패했을 때 마지막으로 확인한 값을 내줄 수 있게 한다.
하지만 cache가 사용자를 속이면 안 된다. 빠른 응답보다 먼저 지켜야 할 것은 오래된 값을 최신처럼 보이지 않게 하는 것이다.
Redis를 붙이면 숫자는 좋아진다. latency가 낮아지고 DB query 수가 줄어든다. 하지만 사용자에게는 빠른 오답이 느린 정답보다 더 나쁠 때가 있다. 한강자리에서는 cache hit 자체보다, 그 값이 언제 확인한 출처에서 왔는지가 더 크게 봤다.
Redis에는 다시 만들 수 있는 값만 둔다
한강자리에는 성격이 다른 cache가 있다.
| Cache | 예 | 목적 |
|---|---|---|
| Status cache | 주차장 최신 값 | lot 단위 빠른 조회 |
| Forecast cache | 예측 overview/timeline | 계산된 응답 재사용 |
| Home summary hot cache | 첫 화면 summary | 짧은 TTL의 빠른 응답 |
| Home summary stale cache | 마지막 성공 summary | rebuild 실패 시 fallback |
| 출처 상태 cache | 출처 확인 결과 | 반복 조회 비용 절감 |
Redis는 기준 데이터를 보관하는 곳이 아니다. 기준 데이터와 이력은 Postgres에 있고, Redis는 다시 만들 수 있는 조회 결과를 빠르게 읽게 한다.
이 선을 정해두면 장애 대응이 단순해진다. Redis가 비었을 때 복구해야 할 데이터가 사라진 것이 아니라, 다시 만들 조회 결과가 사라진 것이다. 반대로 Postgres의 기준 데이터가 손상되면 이야기가 완전히 달라진다. 두 저장소의 무게를 같게 보지 않기로 했다.
flowchart LR API["API 읽기"] --> Redis["Redis 캐시"] Redis -->|적중| Response["응답"] Redis -->|미스| Postgres["Postgres 행"] Postgres --> Build["페이로드 만들기"] Build --> Redis Build --> Response
주차 cache가 깨져도 DB로 내려간다
주차 값은 lot 단위로 자주 읽힌다. 앱, 위젯, home summary, 예측 입력이 모두 최신 주차 값을 필요로 한다.
코드에서는 현재 상태 캐시 역할을 RedisStatusCache가 맡는다. lot ID별 key에 JSON payload를 저장하고, cache를 읽을 때 malformed payload나 Redis 장애가 있으면 해당 entry를 무시하고 DB로 내려간다.
여기서 지킨 것은 두 가지다.
- cache 장애는 API 장애가 아니어야 한다.
- cache hit라도 오래된 값인지 다시 확인한다.
sequenceDiagram
autonumber
participant Query as 주차 조회
participant Cache as 상태 캐시
participant DB as Postgres
Query->>Cache: 주차 상태 캐시 확인
alt 캐시 적중
Cache-->>Query: 최신 주차 상태 반환
Query->>Query: 출처 시각으로 최신성 판단
else 미스 또는 캐시 사용 불가
Query->>DB: 저장소의 마지막 상태 확인
DB-->>Query: 저장된 상태 또는 없음
Query->>Query: 오래됐거나 비어 있는지 판단
end
stale 여부는 cache 저장 시점이 아니라 출처에서 확인한 시각으로 본다. 그래야 Redis TTL이 길거나 짧아도 사용자에게 보여주는 값의 의미가 흔들리지 않는다.
빠른 cache와 마지막 성공 cache를 나눴다
home-summary는 만들 때 비용이 큰 응답이다. 주차, 예측, 나들이, 출처 확인 결과를 모아야 한다.
그래서 두 종류의 cache를 둔다.
- hot cache: 정상 응답을 빠르게 재사용하는 짧은 TTL cache.
- stale cache: 마지막 성공 payload를 더 오래 보관하는 backup cache.
정상적으로 다시 만들기에 성공하면 hot과 stale cache를 함께 갱신한다. hot cache가 비었고 다시 만들기에 실패하면 stale cache를 확인한다.
flowchart TD
Request["home-summary 요청"] --> Hot{"핫 캐시 적중?"}
Hot -->|예| ReturnHot["핫 응답 반환"]
Hot -->|아니오| Rebuild["DB/유스케이스에서 다시 만들기"]
Rebuild -->|성공| StoreBoth["핫 + 보관 캐시 저장"]
StoreBoth --> ReturnFresh["새 응답 반환"]
Rebuild -->|실패| Stale{"보관 캐시 적중?"}
Stale -->|예| ReturnStale["보관 응답 반환"]
Stale -->|아니오| Error["오류 발생"]
이 fallback은 장애를 감추기 위한 것이 아니다. payload 안의 갱신 상태와 출처 확인 결과가 유지되어야 사용자가 “마지막으로 확인한 값”으로 해석할 수 있다.
stale cache의 목적은 “성공처럼 보이기”가 아니다. 마지막으로 믿을 만했던 응답을 보여주되, 그 응답이 오래됐다는 사실도 같이 알려주는 것이다. 이 차이가 없으면 fallback은 사용자를 돕는 방식이 아니라 문제를 숨기는 방식이 된다.
앱 안의 반복 요청만 줄인다
서버 내부 Redis cache와 HTTP cache는 역할이 다르다. home-summary 응답에는 private cache header와 ETag를 붙인다. 같은 앱 인스턴스가 짧은 시간 안에 같은 첫 화면 값을 반복 요청할 때
304를 받을 수 있게 하기 위해서다.
공개 cache로 모든 사용자가 같은 응답을 공유하는 방식이 아니다. 응답은 앱 접근 조건과 사용자별 요청 맥락을 고려해야 하므로 private 값으로 다룬다.
바뀐 출처가 어떤 응답을 흔드는지 본다
한강자리의 cache는 직접 고치는 데이터가 아니라 다시 만드는 조회 결과다. 그래서 무효화도 pattern 단위로 생각한다.
예측 결과가 바뀌면 forecast cache와 home summary cache가 영향을 받는다. 나들이 출처 확인 결과가 바뀌면 home summary에도 영향이 있다. 주차 값 cache는 polling 작업이 최신 snapshot을 쓰면서 갱신한다.
무효화는 “정확히 한 key만 지운다”보다 “어떤 응답이 출처 변경을 반영해야 하는가”에 가깝다.
fallback은 오래됐다고 말할 때만 쓸모 있었다
Redis에는 기준 데이터가 아니라 다시 만들 수 있는 조회 결과를 두는 편이 안전했다. cache hit 후에도 출처에서 확인한 시각을 봐야 오래된 값을 최신처럼 말하지 않을 수 있었다.
hot cache와 stale cache를 나누면 빠른 응답과 장애 fallback을 동시에 다룰 수 있다.
다만 stale fallback은 payload의 갱신 상태를 유지해야 하고, cache 장애는 가능한 DB fallback으로 흡수한 뒤 지표로 남겨야 한다. HTTP private cache와 서버 Redis cache도 목적이 달랐다.
Redis를 붙인 뒤에도 결론은 단순했다. cache는 빠르게 만들기 위한 도구지만, 사용자를 안심시키는 근거는 아니다. 오래된 값이 오래됐다고 말할 수 있을 때만 fallback이 쓸모 있는 경험이 된다.