목차
- 이 에러부터 보자
- 캐싱 전략을 왜 설계해야 하는가
- Cache-Aside 패턴 — 가장 많이 쓰고, 가장 많이 실수하는 패턴
- Write-Through와 Write-Behind — 쓰기 시점에 캐시를 관리하는 전략
- TTL 설계 — "그냥 1시간"이 안 되는 이유
- 캐시 무효화 — 제일 어렵고 제일 자주 틀리는 부분
- 키 네이밍 컨벤션
- 모니터링 — 캐시를 달고 끝이 아니다
- 실무 체크리스트
- Redis 캐시 관련 참고 자료
이 에러부터 보자
WRONGTYPE Operation against a key holding the wrong kind of value
상품 가격을 업데이트하는 API에서 이 에러가 간헐적으로 터졌다. Redis에 캐시를 붙인 지 이틀째였고, 원인은 같은 키에 String과 Hash를 번갈아 쓰고 있었기 때문이다. 키 네이밍 규칙도 없이 product:{id}에 아무 자료구조나 넣은 결과였다.
근데 이건 시작에 불과했다. 진짜 문제는 가격을 DB에서 바꿨는데 사용자에게 여전히 옛날 가격이 보인다는 CS였다. TTL을 24시간으로 잡아놨으니 당연한 결과다. Redis 캐싱 전략 실무에서 가장 흔한 실수가 바로 이거다 — 캐시를 "빠른 저장소"로만 생각하고, 데이터 일관성 설계를 빼먹는 것.
이 글은 그때 시행착오를 거치면서 정리한 Redis 캐싱 전략의 실무 패턴이다. Cache-Aside, Write-Through, TTL 설계 기준, 캐시 무효화까지 신입한테 설명하듯 순서대로 풀어본다.
캐싱 전략을 왜 설계해야 하는가
Redis를 캐시로 쓰는 건 어렵지 않다. SET하고 GET하면 된다. 문제는 그다음이다.
데이터가 변경되면 캐시는 어떻게 하나? 캐시에 없는 데이터를 요청하면? 동시에 수백 개 요청이 같은 캐시 미스를 일으키면? 이런 질문에 답이 없는 상태로 Redis를 붙이면, 캐시가 오히려 장애 포인트가 된다.
캐싱 전략이란 결국 세 가지 질문에 대한 답이다:
- 언제 캐시에 데이터를 넣을 것인가 (읽을 때? 쓸 때?)
- 언제 캐시에서 데이터를 제거할 것인가 (TTL? 수동 무효화?)
- 캐시와 DB 사이 데이터 불일치를 어떻게 처리할 것인가
이 세 가지를 정하지 않고 Redis를 도입하면, 체감상 한 달 안에 "캐시 때문에 데이터가 안 맞아요" 이슈를 마주하게 된다.
Cache-Aside 패턴 — 가장 많이 쓰고, 가장 많이 실수하는 패턴
기본 동작 원리
Cache-Aside(Lazy Loading)는 애플리케이션이 직접 캐시를 관리하는 패턴이다. 흐름은 단순하다:
- 캐시에서 데이터를 찾는다
- 있으면 반환 (Cache Hit)
- 없으면 DB에서 조회 → 캐시에 저장 → 반환 (Cache Miss)
import redis
import json
r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
def get_product(product_id: int) -> dict:
cache_key = f"product:detail:{product_id}"
# 캐시 조회
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# DB 조회 (캐시 미스)
product = db.query("SELECT * FROM products WHERE id = %s", (product_id,))
# 캐시에 저장, TTL 1시간
r.setex(cache_key, 3600, json.dumps(product))
return product
코드만 보면 간단하다. 근데 이걸 프로덕션에 그대로 넣으면 문제가 생긴다.
Cache Stampede 문제
캐시가 만료되는 순간, 동시에 100개 요청이 들어오면 100개 전부 DB를 때린다. 이걸 Cache Stampede(혹은 Thundering Herd)라고 한다. TTL을 1분으로 짧게 잡았을 때 실제로 겪었는데, DB 커넥션 풀이 순식간에 고갈됐다.
해결법은 몇 가지가 있다:
import time
def get_product_safe(product_id: int) -> dict:
cache_key = f"product:detail:{product_id}"
lock_key = f"lock:product:{product_id}"
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# 분산 락으로 하나의 요청만 DB 조회
if r.set(lock_key, "1", nx=True, ex=5): # 5초 락
try:
product = db.query("SELECT * FROM products WHERE id = %s", (product_id,))
r.setex(cache_key, 3600, json.dumps(product))
return product
finally:
r.delete(lock_key)
else:
# 락 획득 실패 시 짧은 대기 후 캐시 재조회
time.sleep(0.1)
cached = r.get(cache_key)
return json.loads(cached) if cached else get_product_safe(product_id)
분산 락(SET NX)으로 하나의 요청만 DB를 조회하게 만드는 방식이다. 나머지 요청은 잠깐 기다렸다가 캐시에서 가져간다. 완벽하진 않지만 Stampede는 막을 수 있다.
Cache-Aside의 한계
이 패턴은 읽기에만 관여한다. 데이터가 변경되면 캐시를 직접 지워줘야 하는데, 이걸 빼먹으면 stale data가 TTL 만료까지 남아있게 된다. 아까 말한 "가격이 안 바뀌어요" 이슈가 바로 이 경우다.
Write-Through와 Write-Behind — 쓰기 시점에 캐시를 관리하는 전략
Cache-Aside가 "읽을 때 채우기"라면, Write-Through는 "쓸 때 같이 넣기"다.
Write-Through
DB에 데이터를 쓸 때 캐시도 동시에 업데이트한다. 코드로 보면 이렇다:
def update_product_price(product_id: int, new_price: int) -> None:
# DB 업데이트
db.execute(
"UPDATE products SET price = %s WHERE id = %s",
(new_price, product_id)
)
# 캐시도 즉시 업데이트
product = db.query("SELECT * FROM products WHERE id = %s", (product_id,))
cache_key = f"product:detail:{product_id}"
r.setex(cache_key, 3600, json.dumps(product))
장점은 캐시와 DB가 항상 일치한다는 것이다. 단점은 쓰기 레이턴시가 늘어난다는 점. DB 쓰기 + 캐시 쓰기를 동기적으로 처리하니까.
Write-Behind (Write-Back)
Write-Behind는 캐시에 먼저 쓰고, DB 반영은 비동기로 나중에 한다. 쓰기 성능은 좋지만 캐시가 날아가면 데이터가 유실될 수 있다. 금융이나 결제 쪽에서는 쓰기 어렵다. 로그성 데이터나 조회수 카운터 같은 곳에는 괜찮다.
이건 직접 구현해본 적은 없다. Celery 같은 비동기 큐와 조합해야 하는데, 복잡도 대비 이점이 큰 서비스에서만 의미가 있다고 본다.
패턴 선택 기준
| 패턴 | 읽기 성능 | 쓰기 성능 | 데이터 일관성 | 구현 난이도 | 적합한 경우 |
|---|---|---|---|---|---|
| Cache-Aside | 높음 (Hit 시) | 영향 없음 | TTL 의존 | 낮음 | 읽기 비율이 높은 API |
| Write-Through | 높음 | 약간 느림 | 높음 | 중간 | 데이터 정합성이 중요한 서비스 |
| Write-Behind | 높음 | 높음 | 낮음 (유실 위험) | 높음 | 로그, 카운터 등 유실 허용 데이터 |
실무에서는 Cache-Aside + 쓰기 시 캐시 무효화를 조합하는 경우가 가장 많다. Write-Through를 전면 적용하는 것보다, 변경이 발생하면 해당 키를 DEL로 지우고 다음 읽기 때 Cache-Aside로 다시 채우는 방식이 운영하기 편하다.
TTL 설계 — "그냥 1시간"이 안 되는 이유
TTL(Time To Live)을 대충 잡으면 두 가지 문제가 동시에 온다. 너무 짧으면 캐시 히트율이 떨어져서 Redis를 쓰는 의미가 없고, 너무 길면 stale data가 오래 남는다.
데이터 성격별 TTL 기준
TTL은 데이터가 얼마나 자주 바뀌는지, 그리고 stale data가 얼마나 허용되는지에 따라 달라진다.
거의 안 바뀌는 데이터 — 카테고리 목록, 약관, 공지사항 같은 건 TTL을 24시간 이상 잡아도 된다. 변경 시 수동으로 캐시를 지우면 그만이다.
자주 바뀌지만 즉시 반영 안 해도 되는 데이터 — 상품 목록, 검색 결과, 추천 리스트는 5분~30분 정도가 적당하다. 사용자가 "왜 아직 안 바뀌었지?" 하고 느끼기 전에 갱신되는 수준.
실시간 정합성이 필요한 데이터 — 재고 수량, 가격, 결제 관련 데이터는 캐시를 안 쓰거나, Write-Through로 즉시 갱신하는 게 맞다. TTL에 의존하면 안 된다.
Jitter 추가
같은 TTL을 가진 키가 수천 개 있으면, 만료 시점이 겹쳐서 한꺼번에 Cache Miss가 발생한다. 이것도 일종의 Stampede다. 해결은 간단하다 — TTL에 랜덤 값을 더한다.
import random
def set_with_jitter(key: str, value: str, base_ttl: int) -> None:
# base_ttl의 ±10% 범위에서 랜덤 TTL
jitter = random.randint(-base_ttl // 10, base_ttl // 10)
actual_ttl = base_ttl + jitter
r.setex(key, actual_ttl, value)
이렇게 하면 3600초 TTL 기준으로 3240~3960초 사이에 분산되어 만료된다. 별거 아닌 것 같지만, 트래픽이 몰리는 서비스에서는 DB 부하 차이가 체감될 정도다.
캐시 무효화 — 제일 어렵고 제일 자주 틀리는 부분
Phil Karlton의 유명한 말이 있다. "컴퓨터 과학에서 어려운 것은 딱 두 가지다. 캐시 무효화와 이름 짓기." 농담 같지만 실제로 캐시 무효화는 설계 실수가 가장 잦은 영역이다.
Delete vs Update
캐시를 무효화할 때 두 가지 선택이 있다. 키를 삭제하거나(DEL), 새 값으로 덮어쓰거나(SET).
결론부터 말하면 삭제가 낫다. 이유는:
- 업데이트 시 DB 조회가 한 번 더 필요하다
- Race condition이 발생할 수 있다 — 두 개의 쓰기 요청이 동시에 오면, 나중 DB 값이 아니라 먼저 온 요청의 값이 캐시에 남을 수 있다
- 삭제하면 다음 읽기 때 Cache-Aside가 알아서 최신 값을 채운다
def update_product(product_id: int, data: dict) -> None:
db.execute("UPDATE products SET name=%s, price=%s WHERE id=%s",
(data['name'], data['price'], product_id))
# 캐시 업데이트가 아니라 삭제
r.delete(f"product:detail:{product_id}")
# 관련 목록 캐시도 함께 삭제
r.delete(f"product:list:category:{data['category_id']}")
연쇄 무효화 문제
상품 하나를 수정했는데, 이 상품이 포함된 캐시가 여러 개일 수 있다. 상품 상세, 카테고리 목록, 검색 결과, 추천 목록… 전부 무효화해야 하나?
이론적으로는 그렇다. 현실적으로는 전부 추적하기 어렵다. 이 문제에 대한 실무적인 접근은 캐시를 계층으로 나누는 것이다:
- L1 (짧은 TTL, 5분): 목록, 검색 결과 — TTL에 맡긴다
- L2 (긴 TTL, 1시간): 상세 데이터 — 변경 시 명시적 삭제
목록 캐시까지 전부 관리하려 하면 코드가 끔찍하게 복잡해진다. 목록은 TTL이 짧으니까 잠깐의 불일치를 허용하는 게 실용적이다.
키 네이밍 컨벤션
처음에 product:1, user:1 이런 식으로 대충 지었다가, 나중에 어떤 키가 뭔지 구분이 안 되는 상황이 왔다. Redis CLI에서 KEYS product:* 쳤을 때 상세인지 목록인지 장바구니인지 알 수가 없었다.
지금 쓰는 규칙은 이렇다:
{서비스}:{리소스}:{용도}:{식별자}
예시:
shop:product:detail:12345shop:product:list:category:7shop:user:session:abc-def-ghishop:search:result:hash-of-query
이렇게 하면 SCAN 0 MATCH shop:product:*으로 상품 관련 키만 뽑을 수 있고, 모니터링 도구에서도 패턴별 메모리 사용량을 추적하기 좋다. (참고: KEYS 명령은 프로덕션에서 쓰면 안 된다. O(N)이라 키가 수십만 개면 Redis가 블로킹된다. SCAN을 쓰자.)
모니터링 — 캐시를 달고 끝이 아니다
Redis를 붙이고 나면 최소한 이 지표들은 봐야 한다:
캐시 히트율이 가장 기본이다. Redis의 INFO stats에서 keyspace_hits와 keyspace_misses를 볼 수 있다.
# Redis CLI에서 히트율 확인
redis-cli INFO stats | grep keyspace
# keyspace_hits:48923741
# keyspace_misses:2847193
히트율이 80% 아래로 떨어지면 TTL이 너무 짧거나, 캐시 대상 선정이 잘못된 것이다. 90% 이상이면 양호하다고 보는데, 서비스 특성마다 기준은 다르다.
메모리 사용량도 중요하다. maxmemory 설정 없이 운영하면 Redis가 서버 메모리를 전부 먹어버릴 수 있다. maxmemory-policy를 allkeys-lru로 설정해두면, 메모리가 차면 가장 오래 안 쓴 키부터 자동으로 제거된다.
# redis.conf 또는 런타임 설정
redis-cli CONFIG SET maxmemory 2gb
redis-cli CONFIG SET maxmemory-policy allkeys-lru
Eviction이 자주 발생하면 메모리를 늘리거나, 캐시에 넣는 데이터를 줄여야 한다. INFO stats의 evicted_keys 값이 계속 올라간다면 경고 알림을 걸어두는 게 좋다.
실무 체크리스트
Redis 캐시를 도입할 때 빠뜨리기 쉬운 것들을 정리한다.
도입 전 확인:
- [ ] 캐시 대상 데이터의 읽기/쓰기 비율 확인 — 쓰기가 읽기보다 많으면 캐시 효과가 없다
- [ ] stale data 허용 범위 정의 — "5분까지는 OK"인지 "1초도 안 됨"인지
- [ ]
maxmemory와maxmemory-policy설정
구현 시 확인:
- [ ] 키 네이밍 컨벤션 통일
- [ ] TTL을 데이터 성격별로 분리 설정
- [ ] TTL Jitter 적용
- [ ] 쓰기 시 관련 캐시 키 삭제 로직 포함
- [ ] 캐시 미스 시 DB 조회 + 캐시 저장이 원자적으로 동작하는지 확인
운영 시 확인:
- [ ] 히트율 모니터링 (80% 이하면 재검토)
- [ ] evicted_keys 모니터링
- [ ] Redis 다운 시 fallback 처리 — Redis 못 쓰면 DB 직접 조회로 degradation
- [ ] 직렬화 포맷 통일 (JSON vs MessagePack 등)
Redis 6.2 이상에서는 CLIENT NO-EVICT 옵션으로 특정 연결의 키를 eviction 대상에서 제외할 수 있다 (출처: Redis 공식 문서, redis.io/commands/client-no-evict). 세션 같은 절대 날아가면 안 되는 키에 유용하다.
직렬화 관련해서 한 가지 — JSON은 디버깅할 때 redis-cli에서 값을 눈으로 확인할 수 있어서 좋다. MessagePack이나 Protobuf가 더 빠르고 작지만, 운영 중 문제 생겼을 때 바로 값을 확인하기 어렵다. 초기에는 JSON으로 시작하고, 성능이 병목이 되면 바이너리 포맷으로 전환하는 게 현실적이다.
Redis 캐시 관련 참고 자료
Redis 공식 문서의 캐싱 가이드(redis.io/docs/manual/client-side-caching/)에 client-side caching 패턴이 잘 정리되어 있다. 서버 사이드 캐싱과는 다른 개념이니 혼동하지 말 것. AWS ElastiCache 문서(docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/Strategies.html)에도 Cache-Aside, Write-Through 패턴별 아키텍처 다이어그램이 있어서, 처음 접하는 사람한테 보여주기 좋다.
다음에는 Redis Cluster 환경에서의 캐시 샤딩과 Lua 스크립트를 활용한 원자적 캐시 갱신 패턴을 정리해볼 생각이다.
관련 글
- PostgreSQL pgvector 벡터 검색 — 임베딩 저장부터 유사도 쿼리 최적화까지 – 프론트엔드에서 백엔드로 전환한 지 2년. 벡터 검색이 필요해졌을 때 별도 벡터 DB를 붙일지 PostgreSQL pgvector로 해결할지…
- PostgreSQL vs MySQL 선택 기준 — 2026년 신규 프로젝트 실전 비교 – PostgreSQL과 MySQL 중 뭘 골라야 하는지, JSON 쿼리 성능·확장성·AI 인프라 연동까지 직접 써보고 비교한 기록이다. 통념…
- GraphQL N+1 문제 해결 — DataLoader 도입기와 실무 체크리스트 – GraphQL resolver 구조 때문에 주문 20건 조회에 SELECT가 62개 실행되는 상황을 겪었다. DataLoader를 적용해 …