음악 스트리밍 파이프라인 만들기 - 06. Redis 캐시와 스탬피드, 그리고 캐시가 무의미했던 순간
문제
집계 테이블 기반 “실시간 TOP 100 차트” API. 모든 사용자가 같은 것을 본다 — 전형적인 읽기 편중 + 핫키 하나 워크로드다. 결정이 세 겹이었다.
결정 1 — 차트를 어디서 만드나: Redis Sorted Set 기각
“Redis 차트면 ZINCRBY + ZREVRANGE 지” 싶었는데, 기각했다. 결정타는 멱등성이다.
컨슈머가 같은 배치를 재처리하면 DB 는 event_id UNIQUE 로 걸러내지만
ZINCRBY 는 그대로 두 번 더해진다. 04편에서 세운 멱등성 체계 밖에 비원자
이중 쓰기 경로를 만드는 구조다. Redis 에도 중복 판정을 만들면 되지만, 그건 같은
체계를 두 번 구현하는 것이다. DB 집계(TOP 100 쿼리) + 캐시로 갔다.
설계 결정은 독립적이지 않다 — 앞의 결정이 뒤의 선택지를 제약한다.
결정 2 — Look-aside
Write-through 는 기각: 쓰기 주체(컨슈머, 초당 수백 건)와 읽기 주체(차트 API)가 달라서, 아무도 안 읽는 사이에도 집계마다 TOP 100 재계산을 강요하는 꼴이다. 차트는 엔티티 캐시가 아니라 쿼리 결과 캐시라 읽기 쪽에서 채우는 게 자연스럽다.
결정 3 — 스탬피드: 논리/물리 TTL 이원화 + 단일 비행
물리 TTL 하나만 쓰면 만료 순간 동시 요청 전부가 DB 로 몰린다. 캐시가 가장 필요한 순간에 캐시가 없다.
soft TTL 30s : 논리 만료. 지나면 stale — 즉시 응답하고 갱신은 백그라운드 1개 스레드만
hard TTL 120s : 물리 만료. Redis 가 실제로 지운다
완전 미스 : ReentrantLock 단일 비행 — 1개 요청만 DB, 나머지는 대기 후 재확인
Redis 장애 : DB 직접 조회 폴백 — 캐시는 성능 장치지 가용성 의존점이 아니다soft >= hard설정은 기동 실패로 막았다 — stale 서빙 창이 없으면 이 구조가 무의미해진다. 04편의 TTL 부등식과 같은 패턴: 설정값을 안전성 조건으로 다룬다.- jitter 는 키가 하나라 무의미, 분산 락은 단일 인스턴스에서 인프라만 느는 거래라 보류.
- 캐시 키도 하나 — TOP 100 전체를 캐싱하고 요청 size 는 잘라서 응답한다.
테스트 기법이 절반이다
| 검증 대상 | 방법 |
|---|---|
| 캐시 히트 | DB 를 바꾼 뒤 응답이 안 바뀌는 것으로 증명 (호출 횟수 카운팅보다 견고) |
| stale 동작 | 30초 대기 대신 generatedAt 을 과거로 조작한 캐시를 주입 |
| 단일 비행 | 빈 캐시에 10 스레드 동시 진입 → DB 호출 정확히 1회 단언 |
| Redis 장애 | 컨테이너를 멈추지 않는다 — 빈 포트를 바라보는 커넥션 팩토리를 조립. 장애는 기다리는 게 아니라 만드는 것 |
부하를 걸어 보니 — 예상이 뒤집혔다
k6 로 캐시 ON/OFF 를 비교했다 (별도 글 예정인 M4 부하 테스트의 일부).
| 시나리오 | 처리량 | p95 |
|---|---|---|
| 50 VU, 캐시 ON | 479 rps | 4.2ms |
| 50 VU, 캐시 OFF | 478 rps | 5.0ms |
| 200 VU 풀부하, 캐시 ON | 4,167 rps | 55.7ms |
| 200 VU 풀부하, 캐시 OFF | 3,562 rps | 99.3ms |
| Redis 정지 (캐시 ON) | 81 rps | 523ms · 실패율 0% |
저부하에서는 캐시가 무의미했다. 인덱스 타고 버퍼 풀에 올라간 DB 도 p95 5ms 다. “DB 가 느려서 캐시를 얹는다”는 전제가 이 규모에선 측정으로 기각된 것. 캐시의 진짜 역할은 고부하에서 드러났다 — 응답을 빠르게 하는 게 아니라 DB 를 병목에서 제거한다 (p95 43% 감소, 남은 지연은 웹 계층으로 병목 이동).
Redis 를 정지시킨 상태에서도 1,650 요청 실패율 0% — 다만 전 요청이 타임아웃 500ms 를 태우고 폴백했다. “다음 개선은 서킷 브레이커”라는 결론까지가 측정의 산출물이다.
상세: ADR-0005