
모니터링 대시보드를 열었더니 어제까지 90%를 넘던 캐시 히트율이 오늘따라 60%대로 주저앉아 있는 걸 본 적 있으실 겁니다. Redis 캐시 히트율이 낮을 때는 대부분 TTL 설정과 키 설계, 이 두 곳 중 하나에 원인이 있는 경우가 많습니다. 이 글에서는 실제 redis-cli 출력과 함께 어디를 먼저 봐야 하는지 순서대로 정리했습니다.
캐시 히트율, 감으로 말고 redis-cli로 직접 확인하기
“캐시 히트율이 낮은 것 같다”는 느낌만으로 TTL을 늘리거나 키 구조를 바꾸면 원인을 못 찾은 채 코드만 복잡해집니다. Redis는 INFO stats 섹션에 히트·미스 카운터를 그대로 제공하므로, 먼저 숫자부터 확인하는 게 순서입니다.
redis-cli info stats | grep keyspace
# 출력 예시
keyspace_hits:8421
keyspace_misses:1932
히트율은 keyspace_hits / (keyspace_hits + keyspace_misses)로 계산합니다. 위 값이면 8421 / (8421 + 1932) ≈ 81.3%입니다. 이 카운터는 서버가 재시작되거나 CONFIG RESETSTAT을 실행하면 초기화되므로, 특정 시간대(배치 직후, 트래픽 피크 시간)만 따로 재기 위해서는 그 구간 전후로 스냅샷을 두 번 떠서 차이를 계산하는 방식이 정확합니다.
TTL 하나 잘못 잡으면 벌어지는 일
가장 흔한 실수는 대량으로 캐시를 채울 때 동일한 TTL을 그대로 넣는 코드입니다. 배치가 새벽 3시에 10만 건을 SET key value EX 3600으로 채우면, 정확히 한 시간 뒤인 새벽 4시에 이 키들이 한꺼번에 만료됩니다. 그 순간 요청이 몰리는 시간대와 겹치면 캐시 미스가 폭증하면서 DB로 요청이 쏟아지고, 히트율 그래프가 절벽처럼 떨어지는 패턴이 만들어집니다.
해결책은 TTL에 임의의 오프셋(jitter)을 섞는 것입니다.

// ioredis 기준
const base = 3600; // 기본 TTL(초)
const jitter = Math.floor(Math.random() * 300); // 0~300초 랜덤
await redis.set(key, value, 'EX', base + jitter);
이렇게 하면 만료 시점이 5분 구간에 걸쳐 분산되어 동시 만료로 인한 미스 폭증을 완화할 수 있습니다. 다만 TTL을 무작정 늘리는 방향으로만 접근하면 최신성이 떨어진 데이터를 오래 돌려주게 되므로, 데이터 변경 주기와 허용 가능한 지연 시간을 먼저 정하고 TTL 범위를 잡는 순서가 맞습니다.
키에 조건을 다 욱여넣으면 캐시가 캐시 역할을 못 합니다
user:123:products:page=1:sort=price:filter=color=red,size=M처럼 요청 파라미터를 그대로 키에 이어 붙이는 방식은 조건 조합이 늘어날수록 사실상 매번 다른 키를 만들어냅니다. 캐시는 같은 키로 재요청이 들어와야 의미가 있는데, 조합의 경우의 수가 수천 가지로 늘어나면 같은 키가 두 번 이상 불릴 확률 자체가 낮아지고 히트율은 구조적으로 오르지 않습니다.
이럴 때는 키의 세분화 수준을 한 단계 낮추는 방향으로 설계를 바꿉니다.
- 정렬·필터 조합을 키에 그대로 넣지 않고, 원본 결과 목록만 캐시한 뒤 정렬·필터는 애플리케이션 단에서 처리
- 좌표값처럼 요청마다 사실상 고유한 값은 격자 단위(예: 소수점 3자리 반올림)로 묶어서 키 생성
- 사용자별 캐시가 꼭 필요한 게 아니라면
user:123:...대신 공통 리소스 키(product:456:detail)로 캐시 대상을 옮기기
키 설계를 바꿀 때는 무효화 시점도 같이 손봐야 합니다. 캐시 레이어와 캐시 계층 구조를 먼저 정리해 놓으면, 어느 계층에서 무효화 이벤트를 발생시킬지 설계하기가 한결 수월해집니다.
무효화 전략별로 히트율이 어떻게 달라질까요?

TTL만 믿고 자연 만료를 기다릴지, 데이터가 바뀌는 시점에 직접 지워줄지에 따라 히트율과 최신성이 서로 반대 방향으로 움직입니다.
| 전략 | 적용 상황 | 히트율에 미치는 영향 | 트레이드오프 |
|---|---|---|---|
| TTL만 사용 (Cache-Aside) | 조회가 대부분, 쓰기 드묾 | TTL 구간 내내 안정적 | miss 발생 순간 DB로 요청 몰림 |
| Write-through | 쓰기 시점에 캐시도 같이 갱신 | 만료 미스가 거의 없음 | 쓰기 지연 증가, 구현 복잡도 상승 |
| 변경 시 명시적 삭제(DEL) | 데이터 변경 시점이 코드로 명확히 파악됨 | 불필요한 stale 응답 없이 히트율 유지 | 삭제 누락 시 오래된 데이터가 계속 캐시됨 |
| Refresh-ahead(백그라운드 재갱신) | 조회가 몰리는 인기 키 | TTL 임박 시 미리 갱신해 미스 자체를 줄임 | 불필요하게 자주 갱신되면 DB 부하로 되돌아옴 |
실무에서는 조회 위주 데이터는 TTL + jitter, 자주 바뀌는 상세 정보는 변경 시 명시적 삭제를 함께 쓰는 혼합 방식이 많이 쓰입니다. 어느 한쪽만 적용하면 표의 단점이 그대로 드러나는 경우가 흔합니다.
TTL·키 설계를 고쳐도 안 오르는 경우, 그리고 다음에 할 일
TTL과 키 설계를 아무리 손봐도 구조적으로 히트율이 오르지 않는 데이터가 있습니다. 실시간 좌표 기반 검색 결과나 초 단위로 바뀌는 시세처럼 요청 자체가 매번 사실상 고유한 경우입니다. 이런 데이터는 캐시 대상에서 제외하고, DB 인덱스 최적화나 읽기 전용 복제본 분리 같은 다른 병목 구간을 찾는 편이 낫습니다.
또 하나 확인할 부분은 메모리 정책입니다. maxmemory-policy가 noeviction으로 설정된 상태에서 메모리가 가득 차면 새 키 저장 자체가 거부되어 미스가 쌓이는데, 이 경우 TTL이나 키를 아무리 고쳐도 히트율이 오르지 않습니다.
redis-cli config get maxmemory-policy
# noeviction
redis-cli config set maxmemory-policy allkeys-lfu
allkeys-lfu는 Redis 4.0부터 추가된 정책으로, 접근 빈도가 낮은 키부터 만료를 기다리지 않고 우선 제거해 자주 쓰이는 키가 메모리에 남도록 해줍니다. 상세 옵션은 Redis 공식 커맨드 레퍼런스에서 확인할 수 있습니다.
지금 바로 할 수 있는 순서는 이렇습니다. redis-cli info stats로 현재 히트율을 숫자로 확인하고, TTL이 한 값으로 몰려 있는지 코드를 검색해 jitter를 추가하고, 키 패턴을 redis-cli --scan --pattern으로 뽑아 조합 폭발이 있는지 점검하는 순서로 진행하면 원인을 좁혀갈 수 있습니다.
