본문으로 건너뛰기
개발 머꼬
개발 노트Redis
hohyeon.dev28

캐시를 도입했더니 가끔 옛날 데이터가 보이는 이유

  • #Database
  • #Engineering Note
  • #Performance
  • #Redis

문제 발생

DB 부하를 줄이려고 상품 정보 조회에 Redis 캐시를 도입했는데, 상품 정보를 수정한 직후 다시 조회하면 가끔 수정 전 값이 그대로 보이는 문제가 발생했습니다.

def update_product(id, data):
    db.update(id, data)
    # 캐시를 지우는 걸 깜빡함

def get_product(id):
    cached = redis.get(f"product:{id}")
    if cached:
        return cached  # 옛날 값이 남아있으면 그대로 반환
    value = db.query(id)
    redis.set(f"product:{id}", value, ex=300)
    return value

원인 분석

가장 흔한 캐싱 패턴인 **cache-aside(lazy loading)**는 "캐시에 없으면 DB에서 가져와 캐시에 채운다"는 조회 흐름은 잘 다루지만, 데이터가 바뀌었을 때 캐시를 어떻게 갱신할지는 별도로 챙겨야 하는 책임입니다. 위 코드는 update_product가 DB만 갱신하고 캐시는 그대로 두었기 때문에, TTL(위 예시에서는 300초)이 끝날 때까지 캐시가 옛날 값을 계속 반환합니다.

이건 "캐시 무효화(cache invalidation)"라고 불리는, 캐싱을 도입하면 필연적으로 따라오는 근본적인 어려움입니다 — 원본과 캐시라는 두 개의 진실 소스가 생기는 순간, 그 둘을 계속 일치시키는 책임이 생깁니다.

해결 방안

  1. 데이터를 변경하는 모든 경로에서 관련 캐시를 함께 무효화합니다 — 가장 직접적인 방법입니다.
def update_product(id, data):
    db.update(id, data)
    redis.delete(f"product:{id}") # 캐시를 지워서, 다음 조회가 DB에서 새로 채우게 함

캐시를 새 값으로 직접 덮어쓰는 대신 지우는(delete) 방식이 보통 더 안전합니다 — 갱신 로직을 두 곳(DB 쓰기, 캐시 쓰기)에서 반복하며 서로 미묘하게 다른 값을 쓰게 되는 실수를 피할 수 있고, 그 캐시가 실제로 다시 조회될 때만 새로 채워지므로 불필요한 캐시 쓰기도 줄어듭니다.

  1. TTL(만료 시간)을 항상 함께 둡니다 — 무효화 로직에서 실수로 빠뜨린 캐시가 있더라도, TTL이 있으면 그 실수의 영향이 영원하지 않고 일정 시간 안에 스스로 해소됩니다. "완벽한 무효화"를 100% 보장하기보다 "어떤 실수가 있어도 최대 이만큼만 오래된 데이터를 보여준다"는 상한을 두는 현실적인 방어선입니다.
  2. 데이터의 특성에 따라 얼마나 최신이어야 하는지가 다릅니다 — 상품 가격처럼 정확성이 중요한 데이터는 짧은 TTL이나 즉시 무효화가 필요하고, 자주 안 바뀌는 정적 콘텐츠는 긴 TTL로도 충분합니다. 모든 데이터에 같은 캐싱 정책을 기계적으로 적용하지 않습니다.
  3. 여러 서버 인스턴스가 각자 로컬 캐시(인메모리)를 갖는 구조라면, 한 인스턴스에서의 무효화가 다른 인스턴스의 로컬 캐시까지 지우지 못합니다 — 이런 경우 Redis 같은 공유 캐시를 쓰거나, pub/sub으로 무효화 이벤트를 모든 인스턴스에 전파하는 추가 장치가 필요합니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.