본문으로 건너뛰기
김신건의 로그

[Django] Caching: per-site, per-view, low-level

· 수정 · 📖 약 2분 · 602자/단어 #django #cache #redis #memcached #performance
django cache, cache_page, Redis cache, Memcached, cache.get, vary_on_cookie

정의

Django 캐싱은 데이터/페이지 재생성 비용 회피의 핵심. 4단계 그래뉴얼리티 제공: site-wide → per-view → template fragment → low-level. Redis, Memcached, DB, 로컬 메모리, 파일 시스템 등 백엔드 선택.

캐시 백엔드 설정

# settings.py
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379/1",
    }
}

빌트인 백엔드:

BACKEND용도
redis.RedisCache (4.0+)프로덕션 표준
memcached.PyMemcacheCache프로덕션 표준
db.DatabaseCacheDB 활용 (간단하지만 느림)
locmem.LocMemCache프로세스 로컬 (기본)
filebased.FileBasedCache파일 시스템
dummy.DummyCache캐시 안 함 (개발)

여러 캐시 동시 설정 가능:

CACHES = {
    "default": {"BACKEND": "...Redis...", "LOCATION": "redis://..."},
    "sessions": {"BACKEND": "...Redis...", "LOCATION": "redis://..."},
    "fast": {"BACKEND": "...LocMem..."},
}

cache = caches["sessions"]

low-level cache API

from django.core.cache import cache

cache.set("key", "value", timeout=300)    # 5분 (기본 300)
cache.set("key", "value", timeout=None)    # 영구
cache.get("key")                            # None if 없음
cache.get("key", default="fallback")
cache.add("key", "value")                   # 이미 있으면 무시
cache.get_or_set("key", lambda: expensive())
cache.delete("key")
cache.clear()                                # 전체 (위험)

# 원자적 증감
cache.incr("counter")
cache.decr("counter")

# 다중 키
cache.set_many({"a": 1, "b": 2})
cache.get_many(["a", "b"])
cache.delete_many(["a", "b"])

per-view cache

from django.views.decorators.cache import cache_page

@cache_page(60 * 15)    # 15분
def my_view(request):
    return render(request, "...")

# URL에서
from django.urls import path
urlpatterns = [
    path("posts/", cache_page(60 * 5)(post_list)),
]

요청 URL + 쿼리스트링이 캐시 키. 사용자별로 다른 응답엔 부적합 (모두에게 같은 응답).

vary_on_cookie / vary_on_headers:

from django.views.decorators.vary import vary_on_cookie, vary_on_headers

@cache_page(60 * 15)
@vary_on_cookie    # 쿠키에 따라 다른 캐시 (로그인 등)
def my_view(request):
    ...

@vary_on_headers("Accept-Language")
def view(request):
    ...

per-site cache (모든 페이지)

MIDDLEWARE = [
    "django.middleware.cache.UpdateCacheMiddleware",    # 가장 먼저
    "django.middleware.common.CommonMiddleware",
    "django.middleware.cache.FetchFromCacheMiddleware",  # 가장 나중
    ...
]

CACHE_MIDDLEWARE_SECONDS = 600
CACHE_MIDDLEWARE_KEY_PREFIX = ""
CACHE_MIDDLEWARE_ALIAS = "default"

전체 사이트가 익명 사용자에게 같은 응답이면 OK. 거의 안 씀.

template fragment cache

{% load cache %}

{% cache 600 sidebar request.user.id %}
    ... 비싼 sidebar 렌더링 ...
{% endcache %}

vary 키를 인자로 추가. 600초.

{% cache 600 sidebar request.user.id LANGUAGE_CODE %}

QuerySet cache

def get_top_posts():
    key = "top_posts"
    posts = cache.get(key)
    if posts is None:
        posts = list(Post.objects.order_by("-views")[:10])
        cache.set(key, posts, 300)
    return posts

# 또는 get_or_set
def get_top_posts():
    return cache.get_or_set("top_posts", lambda: list(Post.objects.order_by("-views")[:10]), 300)

ORM 결과를 list로 변환해야 pickle 가능.

캐시 무효화

캐싱의 진짜 어려움은 무효화.

signal 기반

@receiver([post_save, post_delete], sender=Post)
def invalidate(sender, instance, **kwargs):
    cache.delete(f"post:{instance.slug}")
    cache.delete("top_posts")

versioned key

def get_post(slug):
    version = cache.get(f"post:{slug}:version", 0)
    return cache.get_or_set(
        f"post:{slug}:v{version}",
        lambda: Post.objects.get(slug=slug),
    )

@receiver(post_save, sender=Post)
def bump_version(sender, instance, **kwargs):
    cache.incr(f"post:{instance.slug}:version", initial=1)

이전 버전은 TTL로 자연 만료.

tag/group invalidation

django-cachalot 또는 django-cache-utils 같은 라이브러리.

Redis 직접 사용 (고급)

Django cache API는 추상화라 Redis 특화 기능(Sorted Set, Pub/Sub, Stream) 못 씀. 필요 시 redis-py 직접.

import redis

r = redis.from_url(settings.REDIS_URL)
r.zadd("leaderboard", {user_id: score})
r.zrevrange("leaderboard", 0, 10, withscores=True)

Session backend로 cache

SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"

세션을 Redis에 저장. 빠르나 evict 시 로그아웃 위험 → cached_db (DB 백업).

함정

1. cache.set 후 즉시 get → None

cache.set("k", "v", 0)    # timeout 0이면 즉시 만료 (Memcached 일부 버전)
cache.get("k")             # None

timeout=0의 의미가 백엔드마다 다름. 명시적 양수.

2. cache.clear()

모든 키 삭제. 다른 앱과 백엔드 공유 시 위험. 키 prefix 명확히.

CACHES = {"default": {..., "KEY_PREFIX": "myapp:"}}

3. cache thunder herd

캐시 만료 순간 동시 다수 요청이 DB로 → 폭주.

해결:

  • 미리 갱신 (TTL 도달 전)
  • lock 기반 단일 갱신
  • Stale-while-revalidate 패턴

4. 큰 객체 캐싱

cache.set("queryset", list(Post.objects.all()), 300)    # 10만 개면 메모리 폭발

크기 제한.

5. pickle 안 됨

cache.set("func", lambda x: x)    # PicklingError

함수, 클래스 인스턴스 일부는 캐시 불가.

6. cache stampede with get_or_set

cache.get_or_set("k", expensive)    # 만료 직후 동시 호출 → 모두 expensive 호출

add + lock으로 보호.

자주 보는 패턴

conditional view cache

from django.views.decorators.cache import cache_page
from django.views.decorators.vary import vary_on_cookie

@cache_page(60 * 5)
@vary_on_cookie
def article_detail(request, slug):
    ...

template caching for partials

{% cache 600 post_card post.id post.updated_at %}
    {# post 카드 (updated_at 변경 시 자동 무효화) #}
{% endcache %}

vary 키에 updated_at 포함 → 모델 저장 시 새 키, 옛 키는 자연 만료.

Cache layer 추상

def cached(key_fn, ttl=300):
    def decorator(fn):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            key = key_fn(*args, **kwargs)
            value = cache.get(key)
            if value is None:
                value = fn(*args, **kwargs)
                cache.set(key, value, ttl)
            return value
        return wrapper
    return decorator

@cached(lambda slug: f"post:{slug}")
def get_post(slug):
    return Post.objects.get(slug=slug)

직접 만들기보다 django-cacheops, django-cachalot 등 검토.

모니터링

  • 캐시 히트율 (메트릭)
  • 캐시 키 분포 (redis-cli --bigkeys)
  • TTL 분포
  • 메모리 사용량

django-cache-memoize, Sentry, Datadog 등.

관련

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기