캐싱 패턴과 운영 트러블슈팅
Redis 캐시를 설계할 때 먼저 정해야 할 것
Redis를 캐시로 쓰는 이유는 단순하다. 애플리케이션이 매번 원본 데이터베이스를 읽지 않고, 자주 쓰는 결과를 메모리에서 빠르게 돌려주기 위해서다. 하지만 운영에서 중요한 질문은 “Redis를 붙이면 빨라지는가”가 아니라 어떤 데이터가, 얼마나 오래, 어떤 일관성 수준으로 Redis에 있어도 되는가다.
캐시는 원본 저장소가 아니다. 캐시된 값은 보통 DB, 검색엔진, 외부 API, 계산 결과 같은 원본에서 다시 만들 수 있어야 한다. 이 전제를 잊으면 캐시 장애가 곧 데이터 장애가 된다. 반대로 이 전제를 지키면 Redis 장애는 대부분 “정확성 장애”가 아니라 “성능 저하와 원본 부하 증가”로 제한된다.
좋은 캐시 설계는 다음 네 가지를 명시한다.
- 읽기 경로에서 cache miss가 났을 때 누가 원본을 읽는가.
- 쓰기 경로에서 DB와 캐시를 어떤 순서로 갱신하거나 무효화하는가.
- stale data를 어느 정도까지 허용하는가.
- Redis 메모리 압박, hot key, stampede가 생겼을 때 어떤 신호로 감지하고 완화하는가.
Cache-aside: 대부분의 서비스에서 출발점
Cache-aside, 또는 lazy loading은 애플리케이션이 Redis와 원본 DB를 직접 조정하는 방식이다. 읽기 흐름은 단순하다.
- 앱이 Redis에서 먼저 key를 조회한다.
- 값이 있으면 바로 반환한다.
- 없으면 DB나 원본 시스템을 읽는다.
- 읽은 결과를 TTL과 함께 Redis에 저장한다.
- 결과를 사용자에게 반환한다.
value = redis.get(key)
if value is not None:
return value
value = db.query(...)
redis.set(key, value, ex=ttl_seconds)
return value이 방식의 장점은 Redis가 비어 있어도 서비스가 동작한다는 점이다. 캐시는 실제로 요청된 데이터만 담기 때문에 비용도 비교적 예측하기 쉽다. 그래서 read-heavy 서비스, 조회 API, 제품 목록, 프로필, 설정값처럼 “조금 stale해도 되고 다시 만들 수 있는 값”에는 cache-aside가 기본 선택지가 된다.
단점도 명확하다. 첫 요청은 항상 느리고, 쓰기 직후 캐시를 지우지 않으면 오래된 값을 줄 수 있다. 또 인기 key가 동시에 만료되면 여러 요청이 한꺼번에 DB를 때리는 cache stampede가 생긴다.
실무에서는 쓰기 경로를 다음처럼 잡는 경우가 많다.
# update path
transactionally_update_db()
redis.delete(cache_key)DB를 먼저 갱신하고 캐시를 삭제하면 다음 읽기에서 새 값을 다시 채운다. 캐시에 새 값을 직접 쓰는 것보다 단순하고, DB가 최종 원본이라는 계약이 분명하다. 다만 DB 갱신과 캐시 삭제 사이의 짧은 창, 캐시 삭제 실패, 여러 key를 함께 무효화해야 하는 경우는 별도 보완이 필요하다. 그래서 TTL은 “혹시 invalidation이 실패해도 stale 상태가 무한히 지속되지 않게 하는 안전장치”로 봐야 한다.
Write-through, write-behind, read-through의 차이
모든 서비스가 cache-aside만 쓰는 것은 아니다. 패턴마다 지연시간, 일관성, 구현 복잡도가 다르다.
| 패턴 | 읽기 경로 | 쓰기 경로 | 장점 | 주의점 |
|---|---|---|---|---|
| Cache-aside | 앱이 Redis 확인 후 miss면 원본 조회 | DB 갱신 후 캐시 삭제 또는 갱신 | 단순하고 범용적 | 첫 요청 지연, stampede, invalidation 실패 |
| Read-through | 캐시 계층이 miss 시 원본 조회 | 보통 별도 쓰기 정책 필요 | 앱 코드가 단순 | Redis 자체 기능이라기보다 라이브러리/플랫폼 의존 |
| Write-through | 캐시와 DB를 동기적으로 함께 갱신 | 쓰기 성공 전에 양쪽 반영 | 캐시 hit 가능성 높음 | 쓰기 지연 증가, 안 읽을 데이터도 캐시에 들어감 |
| Write-behind | 캐시에 먼저 쓰고 나중에 DB 반영 | 비동기 flush | 쓰기 지연 낮음 | Redis나 flush 경로 장애 시 데이터 손실 위험 |
| Refresh-ahead | 만료 전 인기 key를 미리 갱신 | 기본 패턴과 조합 | hot key의 만료 순간 완화 | 예측과 background worker 필요 |
운영 관점에서 write-behind는 특히 조심해야 한다. Redis를 “진짜 쓰기 버퍼”로 쓰는 순간 Redis 장애, replica lag, persistence 설정, flush worker 장애가 모두 데이터 정합성 문제로 바뀐다. 결제, 주문 상태, 원장처럼 유실을 허용하지 않는 데이터라면 Redis write-behind보다 DB 커밋을 먼저 하고 Redis는 캐시나 파생 뷰로 두는 편이 안전하다.
write-through도 만능은 아니다. 모든 쓰기가 Redis까지 동기적으로 가야 하므로 쓰기 지연과 장애 결합이 늘어난다. 또한 한 번도 읽히지 않을 데이터까지 캐시에 들어가 메모리를 쓴다. 그래서 write-through는 “쓰기 직후 읽기가 매우 많고, 캐시가 최신일수록 가치가 큰 데이터”에 제한적으로 맞는다.
TTL은 만료 시간이 아니라 일관성 계약이다
TTL은 단순한 청소 기능이 아니다. TTL은 “이 값이 원본과 달라도 최대 이 정도 시간 안에는 자연스럽게 사라진다”는 계약이다. TTL이 없으면 invalidation이 한 번 실패했을 때 stale data가 무기한 남을 수 있다. 반대로 TTL이 너무 짧으면 hit ratio가 낮아지고 DB 부하가 다시 증가한다.
TTL을 정할 때는 데이터 성격을 나눠야 한다.
| 데이터 | TTL 예시 | 판단 기준 |
|---|---|---|
| 사용자 권한, 재고, 결제 상태 | 매우 짧거나 캐시 회피 | stale 비용이 큼 |
| 상품 상세, 게시글 본문 | 수분 단위 | 변경 빈도와 stale 허용 범위 균형 |
| 추천 결과, 통계 카드 | 수분~시간 단위 | 정확성보다 계산 비용 절감이 중요 |
| 외부 API 응답 | API 정책에 맞춤 | rate limit과 freshness 균형 |
| 음수 캐시, not found | 짧게 | 존재하게 된 뒤에도 없다고 답하는 위험 제한 |
인기 key가 동시에 만료되는 것도 문제다. 같은 TTL을 일괄 적용하면 배포 직후나 warm-up 직후에 많은 key가 같은 시각에 사라질 수 있다. 이를 줄이려면 TTL에 작은 jitter를 더한다.
ttl = base_ttl + random_between(0, jitter_seconds)
redis.set(key, value, ex=ttl)jitter는 정합성을 높이지는 않지만, 만료 이벤트가 한 시점에 몰리는 것을 줄여 DB 부하를 평평하게 만든다.
Eviction policy: 메모리가 찼을 때 무엇을 버릴 것인가
Redis에서 maxmemory를 설정하면 Redis는 메모리 사용량이 한계를 넘을 때 maxmemory-policy에 따라 key를 제거하거나 쓰기 명령에 오류를 반환한다. 순수 캐시라면 eviction을 허용하는 것이 자연스럽지만, 세션이나 큐처럼 잃으면 안 되는 데이터를 같은 인스턴스에 섞으면 판단이 어려워진다.
대표 정책은 다음과 같다.
| 정책 | 의미 | 적합한 경우 |
|---|---|---|
noeviction | 메모리가 차면 새 쓰기 오류 | 데이터 유실보다 쓰기 실패가 낫다 |
allkeys-lru | 모든 key 중 덜 최근에 사용된 key 제거 | 일반적인 캐시 기본값으로 무난 |
allkeys-lfu | 모든 key 중 덜 자주 사용된 key 제거 | 장기적으로 인기 key가 뚜렷함 |
allkeys-random | 무작위 제거 | 접근 빈도가 거의 균등함 |
volatile-lru | TTL이 있는 key 중 LRU 제거 | 캐시 key와 비캐시 key를 섞었지만 TTL key만 버리고 싶음 |
volatile-ttl | TTL이 있는 key 중 남은 TTL이 짧은 key 제거 | 앱이 TTL로 제거 우선순위를 표현함 |
Redis 공식 문서는 접근 패턴을 예측하기 어렵고 일부 key가 훨씬 자주 읽히는 일반적인 상황에서는 allkeys-lru를 좋은 기본값으로 설명한다. 다만 volatile-* 정책은 TTL이 걸린 key가 없으면 사실상 noeviction처럼 동작할 수 있다. 따라서 “메모리가 찼는데 왜 key가 안 지워지고 쓰기 오류가 나는가”를 볼 때는 정책 이름뿐 아니라 실제 key에 TTL이 있는지도 확인해야 한다.
복제나 AOF를 함께 쓰면 maxmemory만 보고 물리 메모리를 꽉 채우면 안 된다. Redis는 replica 전파나 AOF 관련 buffer도 메모리를 쓴다. 운영에서는 INFO memory의 used_memory, used_memory_rss, mem_not_counted_for_evict를 함께 보고, OS OOM killer가 Redis를 죽이지 않도록 여유분을 남긴다.
Cache stampede와 hot key
캐시 장애의 흔한 모습은 Redis가 죽는 것이 아니라, Redis가 비거나 key가 만료되면서 DB가 갑자기 무너지는 것이다. 특히 한 key가 매우 인기 있을 때 TTL이 끝나면 수백 개 요청이 동시에 miss를 보고 같은 DB 쿼리를 실행할 수 있다. 이를 cache stampede 또는 thundering herd라고 부른다.
완화책은 여러 가지다.
- Single flight: 같은 key에 대해 한 요청만 원본을 조회하고 나머지는 기다리거나 stale 값을 반환한다.
- 분산 lock:
SET lock:key token NX PX 5000처럼 짧은 lock으로 재계산 주체를 제한한다. - Stale-while-revalidate: 만료된 값을 잠시 더 제공하면서 background에서 갱신한다.
- Refresh-ahead: hot key는 TTL이 끝나기 전에 미리 갱신한다.
- TTL jitter: 많은 key의 만료 시점을 분산한다.
- Negative caching: 없는 값도 짧게 캐시해 반복 miss를 줄인다.
분산 lock을 쓸 때는 lock이 새 장애 원인이 되지 않게 해야 한다. lock TTL은 반드시 있어야 하고, unlock할 때는 내가 잡은 lock인지 token으로 확인해야 한다. 또한 원본 조회가 실패했을 때 lock만 풀고 끝내면 대기 요청들이 다시 stampede를 만들 수 있으므로, 실패 backoff나 짧은 negative cache를 함께 고려한다.
hot key는 Cluster에서도 문제다. Redis Cluster가 key를 여러 shard로 나누더라도 하나의 인기 key는 하나의 slot, 하나의 master에만 존재한다. read replica를 쓰거나 값을 여러 key로 복제하는 방법이 있지만, 이 경우 최신성, invalidation, client routing 복잡도가 올라간다. 먼저 key 설계와 TTL, 데이터 크기, 요청 패턴을 확인하는 것이 순서다.
운영 트러블슈팅: 증상별로 보는 순서
Redis 캐시 문제는 증상이 비슷해 보여도 원인이 다르다. 운영에서는 다음 순서로 좁히는 것이 좋다.
| 증상 | 먼저 볼 지표/명령 | 가능한 원인 | 조치 방향 |
|---|---|---|---|
| hit ratio 급락 | INFO stats의 keyspace_hits, keyspace_misses | 배포 후 key prefix 변경, TTL 과소, Redis flush, eviction | key naming diff 확인, TTL 조정, warm-up |
| DB 부하 급증 | app miss metric, DB slow query | stampede, Redis 장애 fallback, hot key 만료 | single flight, jitter, stale 반환 |
| Redis 메모리 지속 증가 | INFO memory, key count, TTL 분포 | TTL 누락, 큰 value, fragmentation | TTL audit, value 크기 제한, maxmemory 설정 |
| 쓰기 오류 | Redis error log, maxmemory-policy | noeviction, volatile 정책인데 TTL key 부족 | 정책 재검토, 메모리 증설, key 분리 |
| 지연시간 spike | SLOWLOG GET, latency monitor, commandstats | 큰 key 연산, blocking command, 네트워크 RTT | 큰 자료구조 분할, pipelining, 명령 제한 |
| 일부 key만 느림 | hot key metric, app trace | single hot key, 큰 sorted set/hash | key shard, replica read, 자료구조 재설계 |
cache hit ratio는 Redis 공식 문서가 제시하는 방식대로 계산할 수 있다.
keyspace_hits / (keyspace_hits + keyspace_misses) * 100이 숫자는 높을수록 좋지만, 무조건 99%를 목표로 잡으면 안 된다. 권한, 재고, 결제 상태처럼 stale 비용이 큰 데이터는 의도적으로 캐시하지 않거나 TTL을 짧게 잡을 수 있다. 중요한 것은 “기대한 hit ratio와 실제 hit ratio가 다를 때 이유를 설명할 수 있는가”다.
SLOWLOG도 유용하다. Redis slow log는 클라이언트와 통신하는 I/O 시간을 제외하고, Redis가 명령을 실행하는 데 걸린 시간이 threshold를 넘은 항목을 기록한다. 따라서 network 지연과 Redis 내부 명령 지연을 분리해서 볼 때 도움이 된다.
SLOWLOG GET 20
CONFIG GET slowlog-log-slower-than
CONFIG GET slowlog-max-len단, slow log에 아무것도 없는데 앱 지연이 높다면 Redis 명령 자체보다 네트워크, connection pool, TLS, client serialization, DB fallback 지연을 봐야 한다.
실무 체크리스트
Redis 캐시를 운영에 넣기 전에는 다음 항목을 명시적으로 확인하는 것이 좋다.
| 항목 | 확인 질문 |
|---|---|
| 원본성 | Redis 값은 사라져도 다시 만들 수 있는가 |
| TTL | 모든 캐시 key에 TTL이 있는가, stale 허용 시간과 맞는가 |
| invalidation | 쓰기 후 어떤 key를 삭제하거나 갱신하는가 |
| stampede | hot key 만료 시 DB를 보호할 장치가 있는가 |
| memory | maxmemory와 OS 여유 메모리가 설정되어 있는가 |
| eviction | 정책이 workload와 맞고, TTL 없는 key와 충돌하지 않는가 |
| 관측 | hit/miss, evicted_keys, expired_keys, memory, slowlog를 보고 있는가 |
| fallback | Redis 장애 시 DB/API가 감당할 수 있는 fallback rate를 아는가 |
| key 설계 | prefix, version, tenant, 개인정보 포함 여부가 정리되어 있는가 |
마지막 항목도 중요하다. key 이름에 이메일, 전화번호, token 같은 민감한 값을 그대로 넣으면 로그와 모니터링 도구에 노출될 수 있다. key는 디버깅 가능해야 하지만, 개인정보나 비밀을 담는 식별자가 되어서는 안 된다.
정리
Redis 캐싱의 핵심은 빠른 저장소를 하나 더 붙이는 것이 아니라, 원본 데이터와 캐시 사이의 계약을 설계하는 것이다. Cache-aside는 단순하고 강력한 기본값이지만 TTL, invalidation, stampede 대응이 없으면 장애 순간에 DB를 더 크게 흔들 수 있다. Write-through와 write-behind는 특정 상황에서 유용하지만, 쓰기 지연이나 데이터 손실 위험을 Redis 운영 문제로 끌어온다.
운영자는 hit ratio 하나만 볼 것이 아니라 memory, eviction, slowlog, hot key, DB fallback을 함께 봐야 한다. Redis 캐시가 잘 설계되었다는 것은 “항상 hit가 높다”가 아니라, miss와 만료, eviction, 장애가 발생해도 서비스의 정확성과 원본 시스템 부하가 통제 가능한 범위에 머문다는 뜻이다.