Published on

Valkey(Redis) 캐시 운영에서 다시 생각한 것들 - 환경 분리, 버전 무효화, 캐시 키 설계

Authors

지난 글에서 캐시가 갱신되지 않는 이슈를 디버깅한 과정을 다뤘는데, 그때 예고한대로 이번에는 캐시 운영 구조 자체를 정리합니다.

팀에서 인프라를 전환하며 캐시를 AWS ElastiCache Valkey Serverless로 옮겼고, 저는 이 구조 위에서 각 도메인 API에 캐시를 적용하고 운영하는 일을 맡았습니다.

운영해보니 잘 동작한 패턴도 있었지만, 캐시 관련 버그를 몇 번 겪고 나서 "이건 처음부터 다르게 갔어야 했다"고 생각하게 된 결정도 있었습니다. 그래서 이 글은 잘 정리된 노하우 모음이라기보다, 실제로 부딪힌 것들을 디버깅과 비판 관점에서 되짚는 기록에 가깝습니다. 특히 환경을 하나의 캐시 인스턴스에 몰아넣은 결정은, 지금이라면 하지 않을 부분입니다.

1. 환경을 하나의 인스턴스에 KEY_PREFIX로 몰아넣은 것 — 다시 하지 않을 결정

이 구조에서 가장 먼저 짚고 싶은 부분입니다.

dev/staging/prod가 각각의 캐시 인스턴스를 갖는 대신, 하나의 Valkey 인스턴스를 공유하고 KEY_PREFIX로만 환경을 구분합니다.

# settings
CACHES = {
    "global_cache": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": VALKEY_URL,             # dev/staging/prod가 같은 엔드포인트
        "KEY_PREFIX": f"{STAGE}_global",    # dev_global / staging_global / prod_global
        ...
    },
    "user_cache": {
        ...
        "KEY_PREFIX": f"{STAGE}_user",
    },
}

Django 캐시 프레임워크는 실제 키를 prefix:version:key 형태로 만들어주기 때문에, 저장되는 키는 이런 모양이 됩니다.

dev_global:1:categories:web:list:hash:a1b2c3d4

가운데의 1은 Django 캐시의 기본 VERSION 값입니다.

비용만 보면 인스턴스 하나로 끝나니 매력적으로 보입니다. 하지만 운영하면서 이 결정이 캐시 버그의 배경이 되는 걸 여러 번 겪었고, 지금이라면 환경은 인스턴스 단위로 분리했을 겁니다. 이유는 이렇습니다.

  • 물리적 격리가 없습니다. prod와 dev/staging이 한 인스턴스에 있으니, prod를 건드리는 실수를 막아주는 건 환경 경계가 아니라 KEY_PREFIX 문자열 하나뿐입니다. 지난 글에서 캐시를 수동으로 지울 때 대상 키를 먼저 눈으로 확인해야 했던 것도 결국 이 구조 때문이었습니다.
  • 작업·장애 반경이 전 환경입니다. SCAN을 돌리면 모든 환경의 키가 함께 잡히고, 인스턴스 단위의 점검·장애·페일오버는 prod와 비프로덕션을 동시에 때립니다.
  • 노이즈 네이버가 생깁니다. dev/staging에서 돌린 부하 테스트나 대량 적재가 같은 인스턴스의 메모리·커넥션을 잠식하면, 그 여파가 prod 캐시의 eviction과 지연으로 넘어옵니다. 환경이 나뉘어 있으면 애초에 없을 간섭입니다.
  • 디버깅이 조심스러워집니다. 캐시가 의심되는 상황에서도 옆에 prod가 같이 들어 있으니 명령 하나하나가 조심스럽고, 그만큼 원인 분리가 느려집니다.

정리하면 환경 분리는 KEY_PREFIX가 아니라 인스턴스로 할 일입니다. 비용이 정말 부담이라면 최소한 prod만이라도 독립 인스턴스로 떼어내는 것이 우선입니다. 참고로 ElastiCache Serverless(클러스터 모드)는 DB 인덱스가 0 하나뿐이라 SELECT로 논리 DB를 나누는 우회조차 안 됩니다. 환경을 가르는 현실적인 방법은 인스턴스 분리뿐입니다.

반대로 KEY_PREFIX 자체가 나쁜 건 아닙니다. 한 환경 안에서 용도별 네임스페이스를 나누는 데는 오히려 잘 맞습니다.

용도별 캐시 별칭 분리

전체 캐시를 하나의 별칭으로 쓰지 않고 용도별로 나눴습니다.

global_cache   장소, 공지, 배너, 카테고리 (전역 데이터)
user_cache     멤버십, 패스 (사용자별 데이터)
place_cache    대시보드, 세션 현황 (지점별 데이터)
version_cache  버전 번호 관리 (아래 4번에서 설명)

이렇게 나누는 실질적인 이유는 무효화 반경 때문입니다. 전역 데이터가 바뀌었을때 사용자별 캐시까지 날릴 이유가 없고, 반대의 경우도 마찬가지입니다.

2. TTL은 임의 숫자 대신 등급으로

캐시를 적용하다보면 cache.set(key, data, 300) 처럼 매직넘버 TTL이 코드 곳곳에 흩어지기 쉽습니다.

TTL을 의미 단위의 상수로 등급화해두니 코드 리뷰에서도 "이 데이터가 왜 이 등급인지"로 대화할 수 있어서 좋았습니다.

class CacheConfig:
    TTL_PERMANENT = None   # 무제한 (버전 무효화로만 갱신)
    TTL_LONG = 3600        # 1시간
    TTL_MEDIUM = 600       # 10분
    TTL_SHORT = 60         # 1분
    TTL_REALTIME = 30      # 30초

등급을 고르는 기준은 단순하게 잡았습니다. 데이터가 틀렸을때의 비용이 클수록 짧게, 원본 조회 비용이 클수록 길게. 둘 다 크다면 TTL로 버티지 말고 무효화를 설계해야 하는 대상입니다.

3. 캐시 키 설계: 계층 + 필터 해시

캐시 키는 계층 구조로 만들고, 쿼리 필터같은 가변 조합은 해시로 눌러서 붙였습니다.

class CacheKeyBuilder:
    @staticmethod
    def build(*parts, **filters) -> str:
        base_key = ":".join(str(p) for p in parts)

        if filters:
            filter_str = json.dumps(filters, sort_keys=True)
            filter_hash = hashlib.md5(filter_str.encode()).hexdigest()[:8]
            return f"{base_key}:hash:{filter_hash}"

        return base_key
CacheKeyBuilder.build("user", 123, "memberships")
# 'user:123:memberships'

CacheKeyBuilder.build("categories", "web", "list", place_id=2)
# 'categories:web:list:hash:a1b2c3d4'

필터를 해시로 만드는 이유는 조합 폭발 때문입니다. 필터 파라미터를 키에 그대로 이어붙이면 키가 한없이 길어지고, 순서 문제(a=1&b=2 vs b=2&a=1)로 같은 조건이 다른 키가 되는 사고도 생깁니다. sort_keys=True로 직렬화한 뒤 해시하면 두 문제가 같이 해결됩니다.

해시의 단점은 키만 보고 어떤 조건인지 역추적이 안된다는 것인데, 운영중에 특정 조건의 캐시만 찾아야 할 일은 생각보다 드물고, 필요하면 코드에서 같은 방식으로 해시를 만들어 조회하면 됩니다.

4. 버전 기반 무효화

이 구조에서 제일 마음에 드는 부분입니다.

패턴 삭제(SCAN으로 키를 찾아 DEL) 방식은 키가 많아질수록 느려지고, 삭제 도중 새 캐시가 쓰이는 타이밍 문제도 있습니다. 대신 네임스페이스별 버전 번호를 두고, 무효화는 버전 증가로만 처리합니다.

class VersionedCache:
    def get(self, key, namespace):
        version = self.version_cache.get(f"{namespace}:version", 1)
        return self.data_cache.get(f"{key}:v{version}")

    def set(self, key, value, namespace, timeout=None):
        version = self.version_cache.get(f"{namespace}:version", 1)
        self.data_cache.set(f"{key}:v{version}", value, timeout)

    def invalidate(self, namespace):
        # 삭제 없이 버전만 올린다. 이후 조회는 전부 캐시 미스가 된다.
        key = f"{namespace}:version"
        current = self.version_cache.get(key)
        if current is None:
            # incr는 키가 없으면 ValueError를 내므로 직접 처리한다 (1이 초기값이므로 2로)
            self.version_cache.set(key, 2, timeout=None)
        else:
            self.version_cache.incr(key)

무효화가 키 개수와 무관하게 연산 한번으로 끝나고, 네임스페이스가 분리되어 있어서 다른 캐시 영역에 영향이 없습니다.

대신 공짜는 아닙니다. 구버전 키들은 지워지지 않고 남습니다.

지난 글의 디버깅 중에 실제로 prod에서 수백개의 구버전 키가 쌓여있는 것을 확인했는데, 영구 TTL과 버전 무효화를 조합하면 이 누적이 계속됩니다.

그래서 버전 무효화 대상이라도 TTL을 아예 무제한으로 두기보다는 적당한 상한(예: 3시간)을 같이 걸어서, 구버전 키가 자연 만료되도록 하는 쪽으로 정리했습니다.

5. 운영하면서 정리된 원칙들

  • 환경은 KEY_PREFIX가 아니라 인스턴스로 나눕니다. 논리 분리는 물리 격리를 대신하지 못하고, 그 대가는 대부분 prod에서 돌아옵니다.
  • 캐시 무효화 시점은 트랜잭션 커밋 이후로. 데이터 변경과 무효화를 같은 함수에서 하더라도, 커밋 전에 무효화하면 다른 요청이 롤백될 데이터를 다시 캐시에 실어버릴 수 있습니다. transaction.on_commit와 함께 쓰는 것이 안전합니다.
  • 캐시 킬스위치를 만들어둡니다. CACHE_ENABLED 전역 플래그와 별칭별 플래그(CACHE_GLOBAL_ENABLED 등)를 settings에 두면, 캐시가 의심되는 장애 상황에서 코드 배포 없이 캐시만 끄고 원인을 분리할 수 있습니다.
  • KEYS는 쓰지 않습니다. Valkey Serverless는 아예 명령을 막아뒀지만, 셀프 호스팅이라도 SCAN이 기본이어야 합니다.
  • 배포로 직렬화 스키마가 바뀌면 무효화를 같이 챙깁니다. 캐시에는 이전 스키마의 데이터가 남아있기 때문에, serializer 필드 변경이 있는 배포에서는 해당 네임스페이스 버전을 올려주는 것까지가 배포 작업입니다.

캐시는 적용하는 것보다 지우는 것이 어렵다는 말을 체감한 반년이었습니다.

적용할때 "이 데이터는 언제, 누가, 어떻게 무효화하는가"에 대한 답이 없으면 그 캐시는 언젠가 지난 글 같은 디버깅의 대상이 됩니다.

참고 자료

  • hongreat 블로그의 글을 봐주셔서 감사합니다!^^
  • 내용에 잘못된 부분이나 의문점이 있으시다면 댓글 부탁 & 환영 합니다~!
  • (하단의 버튼을 누르시면 댓글을 보거나 작성할 수 있습니다.)
Buy Me A Coffee