장고 고급 #4 캐싱 — per-view / template fragment / low-level

7 분 소요

#3 쿼리 최적화쿼리를 줄이는 길이었다면, 캐싱은 계산 결과를 저장해 다음에 안 만드는 길입니다. 둘은 보완 관계입니다. 캐시 히트율이 높은 경우는 ORM 최적화 효과가 더 크고, 미스 시에도 빨라야 캐시가 의미 있습니다.

장고는 여러 레벨의 캐싱을 다 제공합니다. 가는 것부터 굵은 것까지:

레벨데코레이터/도구쓰임
Low-levelcache.set/get임의 객체
템플릿 조각{% cache %}사이드바, 헤더 등
Per-view@cache_page전체 뷰 응답
Per-site미들웨어사이트 전체
조건부condition (ETag)클라이언트 측 캐시

캐시 백엔드 #

settings.CACHES에 정의합니다.

locmem — 기본 (개발용) #

settings.py
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
        "LOCATION": "unique-per-process",
    }
}

프로세스 단위 메모리. 워커가 여러 개면 워커마다 캐시가 따로 — 운영에서는 부적절. 개발/테스트용입니다.

Redis — 표준 운영 백엔드 #

장고 5.x부터 빌트인 백엔드.

빌트인 Redis
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379/1",
    }
}

더 풍부한 기능 (분산 락 등)이 필요하면 **django-redis**가 사실상 표준.

설치
pip install django-redis
django-redis 설정
CACHES = {
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379/1",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
            "PARSER_CLASS": "redis.connection._HiredisParser",
            "CONNECTION_POOL_KWARGS": {"max_connections": 50},
            "SOCKET_CONNECT_TIMEOUT": 5,
            "SOCKET_TIMEOUT": 5,
            "IGNORE_EXCEPTIONS": True,   # Redis 다운 시 예외 대신 None
        },
        "KEY_PREFIX": "myproject",
    }
}
DJANGO_REDIS_IGNORE_EXCEPTIONS = True

IGNORE_EXCEPTIONS=True가 운영에서 결정적 — Redis가 일시적으로 죽었을 때 사이트 전체가 500으로 가지 않고 캐시 미스로 fallback.

Memcached #

memcached
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
        "LOCATION": "127.0.0.1:11211",
    }
}

가장 가볍고 빠릅니다. 단, TTL 기반의 단순 KV만 — 분산 락, sorted set, pub/sub 같은 게 필요하면 Redis.

DB / 파일 #

DB 캐시
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.db.DatabaseCache",
        "LOCATION": "my_cache_table",
    }
}

설치 후 python manage.py createcachetable 한 번. 별다른 인프라 없이 캐싱이 필요하면. 다만 DB 부하가 늘어 운영 권장은 아님.

filebased.FileBasedCache도 같은 쓰임입니다.

Per-view 캐시 — @cache_page #

가장 간편한 캐싱. 응답 전체를 통째로.

views.py
from django.views.decorators.cache import cache_page

@cache_page(60 * 15)   # 15분
def article_list(request):
    articles = Article.objects.published().order_by("-created_at")[:50]
    return render(request, "articles/list.html", {"articles": articles})

내부 동작:

  • 캐시 키: (요청 메소드, URL, 쿼리스트링, Accept-Language, ...)의 해시
  • 미스: view 실행 → 응답 캐시 → 반환
  • 히트: view 안 거치고 응답만 반환 (DB도 안 봄)

GET / HEAD 요청만 캐시. POST/PUT/DELETE는 무시.

클래스 기반 뷰 #

CBV
from django.utils.decorators import method_decorator
from django.views.generic import ListView

@method_decorator(cache_page(60 * 15), name="dispatch")
class ArticleListView(ListView):
    model = Article

또는 URL 등록 지점에서:

urls.py에서
from django.views.decorators.cache import cache_page

urlpatterns = [
    path("articles/", cache_page(60 * 15)(ArticleListView.as_view())),
]

사용자별로 다른 캐시 #

@cache_page만으로는 모든 사용자에게 같은 응답을 반환합니다. 로그인 사용자별로 다르면 위험 — 다른 사용자의 페이지가 보일 수 있습니다.

✅ Vary: Cookie
from django.views.decorators.vary import vary_on_cookie
from django.views.decorators.cache import cache_page

@cache_page(60 * 5)
@vary_on_cookie
def my_dashboard(request):
    ...

vary_on_cookie쿠키별로 다른 캐시 키를 만듭니다. 다만 사용자가 많으면 캐시 적중률이 낮아져 효율이 떨어집니다. 사용자별 페이지는 보통 fragment 캐싱이 더 어울립니다.

Per-site 미들웨어 캐시 #

사이트 전체를 캐시하고 싶을 때.

settings.py
MIDDLEWARE = [
    "django.middleware.cache.UpdateCacheMiddleware",   # ← 가장 위
    "django.middleware.common.CommonMiddleware",
    ...
    "django.middleware.cache.FetchFromCacheMiddleware",  # ← 가장 아래
]

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

순서가 중요 — UpdateCacheMiddleware가 위 (응답 시점), FetchFromCacheMiddleware가 아래 (요청 시점).

거의 정적인 마케팅 사이트, 블로그 등에 어울립니다. 동적 사이트에는 view/fragment 단위 캐시가 더 정밀합니다.

Template fragment 캐시 #

뷰의 일부만 캐시. 사이드바, 헤더, 메뉴, 인기글 같은 경우입니다.

templates/_sidebar.html
{% load cache %}

{% cache 500 sidebar request.user.username %}
    <aside>
        <h3>인기 글</h3>
        {% for post in popular_posts %}
            <a href="{{ post.url }}">{{ post.title }}</a>
        {% endfor %}
    </aside>
{% endcache %}

문법: {% cache <초> <키 이름> [추가 키 인자...] %}

추가 키 인자는 캐시 분기 기준. request.user.username을 넣으면 사용자별로 다른 캐시. LANGUAGE_CODE를 넣으면 언어별로.

여러 키
{% cache 600 article_card article.id article.updated_at user.is_authenticated %}
   ...
{% endcache %}

article.updated_at을 키에 넣는 게 수동 무효화 회피의 흔한 트릭 — 게시물이 바뀌면 키가 자동으로 달라져 새 캐시가 만들어집니다. 명시적 invalidate가 필요 없습니다.

Low-level 캐시 API #

세 줄 요약:

기본
from django.core.cache import cache

cache.set("key", value, timeout=300)   # 5분
value = cache.get("key", default=None)
cache.delete("key")

자주 쓰는 메소드 #

set/get/delete + alpha
cache.set("a", 1, timeout=60)
cache.add("a", 2)                  # 이미 있으면 안 함 (set-if-not-exists)
cache.get_or_set("a", lambda: compute(), timeout=60)   # 없으면 만들고 set

cache.set_many({"x": 1, "y": 2}, timeout=60)
cache.get_many(["x", "y"])         # {"x": 1, "y": 2}
cache.delete_many(["x", "y"])

cache.incr("counter", 1)            # 원자적 증가
cache.decr("counter", 1)

cache.has_key("a")                  # bool
cache.clear()                       # 모두 (위험)

cache.set("a", 1, timeout=None)     # 무제한
cache.set("a", 1, timeout=0)        # 즉시 만료 (= 안 캐시)

get_or_set이 가장 자주 쓰는 패턴.

실용 — 비싼 계산을 캐시
def get_dashboard_stats(user_id):
    return cache.get_or_set(
        f"dashboard_stats:{user_id}",
        lambda: compute_dashboard_stats(user_id),
        timeout=300,
    )

여러 캐시 백엔드 같이 #

settings.py — 여러 alias
CACHES = {
    "default": {"BACKEND": "...redis...", "LOCATION": "redis://r1:6379/1"},
    "sessions": {"BACKEND": "...redis...", "LOCATION": "redis://r1:6379/2"},
    "long": {"BACKEND": "...redis...", "LOCATION": "redis://r2:6379/0"},
}
alias 지정
from django.core.cache import caches

caches["long"].set("annual_report", data, timeout=86400 * 7)

캐시 키 디자인 — 무효화 전략 #

캐시는 두 가지 어려운 일이 있다고들 합니다. 네이밍, 캐시 무효화. 그리고 off-by-one.

1) TTL만으로 — 가장 간단 #

짧은 TTL
cache.set("popular_posts", qs, timeout=60)

데이터가 좀 오래되어도 되는 경우입니다. 간단함이 큰 미덕. 60초 stale을 받아들일 수 있다면 95% 의 캐시 문제가 풀립니다.

2) 키에 버전을 넣기 — 자동 무효화 #

updated_at 또는 version
key = f"article:{article.id}:{int(article.updated_at.timestamp())}"
cache.set(key, rendered, timeout=86400 * 7)

updated_at이 바뀌면 키가 달라지니 자동으로 새 캐시. 옛 캐시는 TTL로 자연 만료.

3) 명시적 delete — 가장 정확하지만 어려움 #

signal로 무효화
from django.db.models.signals import post_save
from django.dispatch import receiver

@receiver(post_save, sender=Article)
def invalidate_article_cache(sender, instance, **kwargs):
    cache.delete(f"article:{instance.id}")
    cache.delete("article_list:popular")   # 영향받는 키들

문제는 모든 영향 키를 알아야 함. 큰 프로젝트에서는 곧 누락이 생깁니다. 가능하면 1번이나 2번으로 풀고, 명시적 delete는 최소한으로.

4) 패턴 삭제 — django-redis의 강점 #

pattern delete
from django_redis import get_redis_connection

cache.delete_pattern("article:*")   # django-redis만 지원

빌트인 Redis 백엔드는 delete_pattern이 없습니다. 패턴 무효화가 필요하면 django-redis.

Cache-Control / ETag — 클라이언트 측 캐시 #

서버가 결과를 만든 뒤에도 브라우저/CDN이 다시 안 묻게 하면 더 빠릅니다.

cache_control — Cache-Control 헤더 #

브라우저 캐시
from django.views.decorators.cache import cache_control

@cache_control(public=True, max_age=3600)
def static_ish_view(request):
    ...

응답에 Cache-Control: public, max-age=3600이 붙고, 브라우저/CDN이 1시간 동안 다시 안 묻습니다.

비공개
@cache_control(private=True, max_age=300)
def user_view(request):
    ...

private공유 캐시 (CDN)는 캐시 금지, 브라우저만 OK.

condition — ETag / Last-Modified #

ETag
from django.views.decorators.http import condition

def article_etag(request, pk):
    return Article.objects.filter(pk=pk).values_list("updated_at", flat=True).first()

def article_last_modified(request, pk):
    return Article.objects.filter(pk=pk).values_list("updated_at", flat=True).first()

@condition(etag_func=article_etag, last_modified_func=article_last_modified)
def article_detail(request, pk):
    article = Article.objects.get(pk=pk)
    return render(request, "article_detail.html", {"article": article})

흐름:

  1. 클라이언트가 If-None-Match: <etag> 또는 If-Modified-Since: <date> 헤더로 요청
  2. 장고가 etag_func / last_modified_func을 부르고 비교
  3. 같으면 **304 Not Modified**만 응답 (본문 안 보냄)
  4. 다르면 view 실행

etag_func이 가벼워야 의미 있습니다. updated_at 한 컬럼 조회 정도면 OK.

캐시 stampede #

캐시가 동시에 만료되어 모든 요청이 한꺼번에 미스 → 같은 비싼 계산을 동시에 N 번. 이게 stampede.

단순 락 #

django-redis의 lock
from django_redis import get_redis_connection

def get_expensive(key):
    val = cache.get(key)
    if val is not None:
        return val

    redis = get_redis_connection("default")
    lock = redis.lock(f"lock:{key}", timeout=10)
    if lock.acquire(blocking=True, blocking_timeout=5):
        try:
            val = cache.get(key)   # 더블 체크
            if val is not None:
                return val
            val = compute_expensive()
            cache.set(key, val, timeout=300)
            return val
        finally:
            lock.release()

    # 락 못 잡았으면 stale 반환 또는 짧게 대기 후 재시도
    return cache.get(key) or compute_expensive()

첫 요청만 비싼 계산을 하고, 나머지는 그게 끝나길 기다렸다가 캐시에서 읽음.

Pre-compute / 갱신 시간 분산 #

큰 트래픽 사이트는 TTL만료 직전에 백그라운드에서 미리 갱신합니다. Celery beat 같은 도구로. 또는 TTL에 무작위 jitter를 더해 동시 만료를 분산.

jitter
import random

cache.set(key, val, timeout=300 + random.randint(0, 60))

캐시 vs 메모이제이션 vs CDN — 구분 #

메모이제이션 (functools.cache)장고 캐시CDN
범위프로세스워커 간 공유 (Redis)글로벌
속도가장 빠름빠름 (네트워크 1홉)가장 빠름 (지역)
무효화프로세스 재시작API 호출URL/헤더
구분pure 함수 결과사용자별 데이터, 쿼리 결과정적/공개 응답

세 레벨을 같이 씁니다. 정적 응답은 CDN, 사용자별 동적은 장고 캐시, 작은 pure 함수는 lru_cache. DRF #3의 페이지네이션과 결합되는 경우도 있습니다.

캐시 안 하는 경우 #

  • POST/PUT/DELETE
  • 인증 토큰 검증 자체
  • 사용자별로 모든 페이지가 완전히 다른 SaaS 대시보드 — 적중률이 너무 낮음
  • 결제, 잔액 — stale이 위험한 데이터
  • 매우 자주 바뀌는 데이터 — TTL만료보다 변경이 잦음

자주 만나는 함정 #

1) 사용자 페이지에 @cache_page#

위에서 본 그것 — 다른 사용자에게 남의 페이지가 보일 수 있습니다. vary_on_cookie 또는 fragment 캐싱.

2) 큰 객체를 캐시 #

QuerySet 자체가 아니라 list(qs) 또는 직렬화 결과를 캐시. 그리고 너무 큰 건 그냥 안 캐시 하는 게 나을 때도 (직렬화/역직렬화 비용 > 절약).

3) Redis가 죽으면 사이트도 #

IGNORE_EXCEPTIONS=Truecache.get(..., default=None) 패턴.

4) 캐시에 민감 정보 #

토큰, 비밀번호 해시 등은 캐시 금지. 캐시는 재계산 가능한 데이터의 용도입니다.

5) 키 충돌 #

KEY_PREFIX를 환경별로 다르게 (dev, prod) — 같은 Redis를 공유한다면 필수. 또 #7에서 다룰 settings 분리와 같이.

정리 #

이번 글에서 잡은 것:

  • 백엔드: locmem (개발), Redis (운영 표준), Memcached, DB
  • django-redis + IGNORE_EXCEPTIONS=True가 운영 권장
  • Per-view: @cache_page, 사용자별은 vary_on_cookie 또는 fragment
  • Per-site: UpdateCacheMiddleware + FetchFromCacheMiddleware
  • Template fragment: {% cache <초> <키 이름> <분기 인자들> %}
  • Low-level: cache.set/get/delete, get_or_set, incr, set_many, alias
  • 무효화: TTL > 키 버전 (updated_at) > 명시 delete > 패턴 (django-redis)
  • 클라이언트 캐시: cache_control, condition (ETag/Last-Modified)
  • Stampede: lock + double-check, jitter, pre-compute
  • 캐시 vs 메모이제이션 vs CDN — 구분
  • 함정: 사용자별 응답에 cache_page, 큰 객체, Redis 다운, 민감 정보, 키 충돌

다음 글(#5 Signals 깊이와 트랜잭션 후 처리)에서는 중급 #3의 시그널 위에 transaction.on_commit, savepoint, custom signal, Celery와의 결합을 올립니다. “시그널 안에서 외부 시스템을 부를 때 무엇이 무서운가” 에 대한 답이 들어갑니다.

X