Published on

Valkey(Redis) 캐시 운영 노하우 - KEY_PREFIX 환경분리, 버전 기반 무효화, 캐시 키 설계

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,
        "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 값입니다.

비용 관점에서는 합리적인 선택이지만, 운영하면서 체감한 주의점도 있습니다.

  • SCAN을 하면 모든 환경의 키가 같이 조회됩니다. match 패턴에 반드시 환경 prefix를 포함해야 실수를 줄일 수 있습니다.
  • prod 캐시를 건드리는 실수와 물리적으로 격리되어 있지 않습니다. 수동 조작시에는 지난 글에서 적었듯 대상 키를 먼저 조회해서 눈으로 확인하고 지우는 습관이 필요합니다.

용도별 캐시 별칭 분리

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

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. 운영하면서 정리된 원칙들

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

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

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

참고 자료

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