장고 고급 #4 캐싱 — per-view / template fragment / low-level
#3 쿼리 최적화가 쿼리를 줄이는 길이었다면, 캐싱은 계산 결과를 저장해 다음에 안 만드는 길입니다. 둘은 보완 관계입니다. 캐시 히트율이 높은 경우는 ORM 최적화 효과가 더 크고, 미스 시에도 빨라야 캐시가 의미 있습니다.
장고는 여러 레벨의 캐싱을 다 제공합니다. 가는 것부터 굵은 것까지:
| 레벨 | 데코레이터/도구 | 쓰임 |
|---|---|---|
| Low-level | cache.set/get | 임의 객체 |
| 템플릿 조각 | {% cache %} | 사이드바, 헤더 등 |
| Per-view | @cache_page | 전체 뷰 응답 |
| Per-site | 미들웨어 | 사이트 전체 |
| 조건부 | condition (ETag) | 클라이언트 측 캐시 |
캐시 백엔드 #
settings.CACHES에 정의합니다.
locmem — 기본 (개발용) #
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "unique-per-process",
}
}프로세스 단위 메모리. 워커가 여러 개면 워커마다 캐시가 따로 — 운영에서는 부적절. 개발/테스트용입니다.
Redis — 표준 운영 백엔드 #
장고 5.x부터 빌트인 백엔드.
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/1",
}
}더 풍부한 기능 (분산 락 등)이 필요하면 **django-redis**가 사실상 표준.
pip install django-redisCACHES = {
"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 = TrueIGNORE_EXCEPTIONS=True가 운영에서 결정적 — Redis가 일시적으로 죽었을 때 사이트 전체가 500으로 가지 않고 캐시 미스로 fallback.
Memcached #
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
"LOCATION": "127.0.0.1:11211",
}
}가장 가볍고 빠릅니다. 단, TTL 기반의 단순 KV만 — 분산 락, sorted set, pub/sub 같은 게 필요하면 Redis.
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
#
가장 간편한 캐싱. 응답 전체를 통째로.
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는 무시.
클래스 기반 뷰 #
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 등록 지점에서:
from django.views.decorators.cache import cache_page
urlpatterns = [
path("articles/", cache_page(60 * 15)(ArticleListView.as_view())),
]사용자별로 다른 캐시 #
@cache_page만으로는 모든 사용자에게 같은 응답을 반환합니다. 로그인 사용자별로 다르면 위험 — 다른 사용자의 페이지가 보일 수 있습니다.
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 미들웨어 캐시 #
사이트 전체를 캐시하고 싶을 때.
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 캐시 #
뷰의 일부만 캐시. 사이드바, 헤더, 메뉴, 인기글 같은 경우입니다.
{% 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")자주 쓰는 메소드 #
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,
)여러 캐시 백엔드 같이 #
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"},
}from django.core.cache import caches
caches["long"].set("annual_report", data, timeout=86400 * 7)캐시 키 디자인 — 무효화 전략 #
캐시는 두 가지 어려운 일이 있다고들 합니다. 네이밍, 캐시 무효화. 그리고 off-by-one.
1) TTL만으로 — 가장 간단 #
cache.set("popular_posts", qs, timeout=60)데이터가 좀 오래되어도 되는 경우입니다. 간단함이 큰 미덕. 60초 stale을 받아들일 수 있다면 95% 의 캐시 문제가 풀립니다.
2) 키에 버전을 넣기 — 자동 무효화 #
key = f"article:{article.id}:{int(article.updated_at.timestamp())}"
cache.set(key, rendered, timeout=86400 * 7)updated_at이 바뀌면 키가 달라지니 자동으로 새 캐시. 옛 캐시는 TTL로 자연 만료.
3) 명시적 delete — 가장 정확하지만 어려움 #
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의 강점 #
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
#
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})흐름:
- 클라이언트가
If-None-Match: <etag>또는If-Modified-Since: <date>헤더로 요청 - 장고가
etag_func/last_modified_func을 부르고 비교 - 같으면 **
304 Not Modified**만 응답 (본문 안 보냄) - 다르면 view 실행
etag_func이 가벼워야 의미 있습니다. updated_at 한 컬럼 조회 정도면 OK.
캐시 stampede #
캐시가 동시에 만료되어 모든 요청이 한꺼번에 미스 → 같은 비싼 계산을 동시에 N 번. 이게 stampede.
단순 락 #
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를 더해 동시 만료를 분산.
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=True와 cache.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와의 결합을 올립니다. “시그널 안에서 외부 시스템을 부를 때 무엇이 무서운가” 에 대한 답이 들어갑니다.