- 발행일
Django context processor로 쪽지 미열람 카운트 전역 주입 — 언제 쓰고 언제 쓰지 말아야 하는가
Django context processor로 쪽지 미열람 카운트 전역 주입 — 언제 쓰고 언제 쓰지 말아야 하는가
Django context processor는 편리하지만 함정이 있는 기능입니다. 이번에 쪽지(note) 앱에서 헤더에 미열람 개수 뱃지를 띄우려고 쓰면서 발견한 쓸모와 주의점을 정리합니다.
1. 문제 — 모든 뷰에서 카운트를 넘기고 싶진 않다
요구사항은 단순합니다. 로그인한 사용자의 모든 페이지 헤더에 "쪽지 🔴 3" 같은 뱃지를 띄우고 싶습니다.
가장 순진한 접근:
def board_list(request):
unread_count = Note.objects.filter(
receiver=request.user, is_read=False
).count() if request.user.is_authenticated else 0
return render(request, 'board_list.html', {
'posts': ...,
'unread_count': unread_count,
})
이걸 모든 뷰에 넣는 건 미친 짓입니다. 게시판, 카페 상세, 쪽지함, 마이페이지… 전부 쓸 거니까요. DRY 위반이자, 한 뷰가 빠지면 그 페이지만 뱃지가 사라지는 버그가 생깁니다.
2. context processor — 템플릿 렌더링마다 자동 주입
Django의 context processor는 render()가 호출될 때마다 자동으로 템플릿 컨텍스트에 값을 주입해 주는 함수입니다. 한 군데 등록해 두면 모든 템플릿에서 그 변수를 쓸 수 있습니다.
# note/context_processors.py
from .models import Note
def unread_note_count(request):
if not request.user.is_authenticated:
return {'unread_note_count': 0}
return {
'unread_note_count': Note.objects.filter(
receiver=request.user, is_read=False
).count()
}
등록은 settings.py의 TEMPLATES 설정에 한 줄 추가.
# settings.py
TEMPLATES = [{
...
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'note.context_processors.unread_note_count', # 추가
],
},
}]
템플릿에서는 어디서든 변수로 접근합니다.
{% if user.is_authenticated %}
<a href="{% url 'note:inbox' %}" class="badge-wrap">
쪽지
{% if unread_note_count > 0 %}
<span class="badge bg-danger">{{ unread_note_count }}</span>
{% endif %}
</a>
{% endif %}
뷰 코드는 단 한 줄도 건드리지 않았습니다.
3. 함정 1 — 모든 요청에서 DB 쿼리가 돈다
이게 context processor의 가장 큰 함정입니다. 이미지, 정적 파일을 제외한 거의 모든 요청에서 이 함수가 호출됩니다. 쪽지 카운트라는 단순한 작업이라도, 트래픽이 많으면 부담이 됩니다.
대응:
3.1 비로그인 유저는 빠르게 탈출
이미 했죠. request.user.is_authenticated 체크로 익명 유저에 대해서는 DB를 건드리지 않습니다.
3.2 count() 쿼리가 정말 필요한지 점검
count()는 SELECT COUNT(*) 쿼리가 나갑니다. 한 요청당 한 번이면 감당되지만 거슬린다면 캐시를 씌웁니다.
from django.core.cache import cache
def unread_note_count(request):
if not request.user.is_authenticated:
return {'unread_note_count': 0}
cache_key = f'unread_note_count:{request.user.id}'
count = cache.get(cache_key)
if count is None:
count = Note.objects.filter(
receiver=request.user, is_read=False
).count()
cache.set(cache_key, count, timeout=60)
return {'unread_note_count': count}
쪽지 수신/읽음 처리하는 뷰에서 cache.delete(f'unread_note_count:{user.id}') 로 무효화합니다. 이번 프로젝트에서는 트래픽이 낮아 캐시까지는 안 걸었지만 운영에서는 반드시 검토할 포인트입니다.
4. 함정 2 — 템플릿에 쓰이지 않는 페이지에서도 실행된다
JSON API 응답, 파일 다운로드 뷰처럼 템플릿을 렌더링하지 않는 요청에서도 context processor는 의미가 있는지 살펴봐야 합니다. 다행히 context processor는 render() 호출 시에만 실행되므로, JsonResponse 만 돌리는 뷰에서는 실행되지 않습니다. 하지만 DRF TemplateHTMLRenderer 를 쓰는 경우 돌아갑니다.
정리하면: "이 context processor가 돌아가야 하는 조건" 을 머릿속에 명확히 해두고 등록합니다. 예컨대 쪽지 카운트는 "로그인 + HTML 렌더링" 조건에서만 의미 있고 그 외엔 낭비입니다.
5. 함정 3 — 예외가 나면 모든 페이지가 터진다
context processor에서 DoesNotExist, DB 연결 오류 같은 게 터지면 모든 템플릿 렌더링이 500으로 죽습니다. 단순 카운트 함수라도 방어 코드를 둬야 합니다.
def unread_note_count(request):
try:
if not request.user.is_authenticated:
return {'unread_note_count': 0}
return {
'unread_note_count': Note.objects.filter(
receiver=request.user, is_read=False
).count()
}
except Exception:
# 뱃지 하나 때문에 전체 페이지가 터지면 안 됨
return {'unread_note_count': 0}
이 코드에 logging 을 얹어서 실패 시 기록은 남기되 응답은 죽이지 않는 게 실무에서는 표준입니다.
6. 언제 context processor를 써야 하는가 / 쓰지 말아야 하는가
정리하면 다음 기준으로 판단합니다.
쓰는 게 맞을 때
- 거의 모든 페이지에서 필요한 값 (예: 헤더 뱃지, 사이트 공통 설정)
- 유저 단위로 캐시하기 쉬운 값
- 실패해도 그냥 0/빈값으로 폴백해도 되는 값
쓰지 말아야 할 때
- 특정 페이지 한두 곳에서만 필요한 값 → 해당 뷰의 context에 직접 넣는 게 명시적
- 무거운 쿼리 (JOIN, aggregate, 외부 API 호출) → 모든 요청마다 돌면 병목
- 실패 시 명확히 알려야 하는 값 → 조용히 폴백되면 버그가 숨음
이번 쪽지 카운트는 세 가지 긍정 조건을 전부 만족해서 context processor가 적절했습니다. 반대로 예전에 "모든 페이지에서 현재 사용자의 권한 레벨을 계산해서 주입하자" 같은 걸 context processor로 밀어넣은 적이 있는데, 권한 테이블이 커지면서 N+1 쿼리가 전역적으로 터져서 고생했습니다. 그 값은 뷰에서 필요할 때만 꺼내 쓰는 게 맞았습니다.
7. 대안들 — Middleware, Template tag, HTMX
context processor 외에 같은 목적으로 쓸 수 있는 도구들:
| 방법 | 언제 적합 | 단점 |
|---|---|---|
| Context processor | 모든 HTML 페이지에 공통 값 주입 | 매 렌더링마다 실행 |
Middleware로 request에 붙이기 | 뷰에서도 쓰고 싶을 때 | 템플릿에서 직접 접근 불편 |
| Custom template tag | 특정 영역에서만 계산 | 호출하는 템플릿에서 {% load %} 필요 |
| HTMX/Ajax polling | 실시간성이 필요할 때 | 구현이 더 복잡, 트래픽 증가 |
이번처럼 "헤더에 단순 카운트 뱃지" 라면 context processor가 제일 싸고 단순합니다. 실시간으로 올라가야 한다면 HTMX의 hx-trigger="every 30s" 같은 걸로 별도 엔드포인트를 만드는 게 낫습니다.
8. 나중에 캐시를 실제로 걸어보고 알게 된 것
3.2절에서 "트래픽이 낮아 캐시까지는 안 걸었지만 운영에서는 반드시 검토할 포인트" 라고 넘어갔는데, 다른 프로젝트에서 비슷한 캐시를 실제로 걸어보니 그 뒤에 따라오는 문제가 둘 있었습니다.
① TTL이 곧 "틀린 값을 보여주는 시간"이다
cache.set(cache_key, count, timeout=60)
timeout=60은 최악의 경우 1분 동안 옛 카운트를 보여준다는 뜻입니다. 쪽지가 도착했는데 뱃지가 안 뜨고 다 읽었는데 뱃지가 남아 있어요.
카운트 뱃지야 1분쯤 늦어도 괜찮지만 같은 구조를 다른 값에 쓰면 얘기가 달라집니다. 실제로 오퍼레이션 차단 여부를 TTL 캐시로 두었다가, "방금 차단했는데 왜 아직 통과하지?" 라는 문제를 겪었어요. 보안성 설정이 최대 TTL만큼 늦게 반영되던 겁니다. 그 건은 정책 토글의 캐시 갭2026-06-13Open API 정책 토글의 캐시·만료 상태가 어긋나던 문제를 동기화한 회고관리자가 오퍼레이션을 토글했는데 TTL이 지나야 반영되던 캐시 갭, 그리고 만료된 토큰의 신청 상태가 "승인됨"으로 남던 stale 문제를 정리합니다. 캐시 즉시 무효화와 진입 시점 sync_expired로 "보이는 상태 = 실제 상태"를 맞춘 이야기예요.에 적었습니다.
TTL을 정할 때는 "얼마나 오래 캐시할까"가 아니라 "얼마나 오래 틀려도 괜찮은가"를 물어야 했습니다.
② 무효화를 뷰에 걸면 경로를 빼먹는다
쪽지 수신/읽음 처리하는 뷰에서
cache.delete(...)로 무효화합니다.
여기에 함정이 있습니다. 쪽지를 만드는 경로가 하나가 아니면 전부 챙겨야 해요. 관리자 일괄 발송, 시스템 알림, 관리 커맨드, 벌크 생성 — 하나라도 빠지면 그 경로에서만 뱃지가 안 맞습니다. 그리고 그건 아무 에러 없이 조용히 어긋나요.
무효화를 모델 시그널로 옮기면 경로와 무관해집니다.
@receiver([post_save, post_delete], sender=Note)
def invalidate_unread_count(sender, instance, **kwargs):
cache.delete(f"unread_note_count:{instance.receiver_id}")
다만 시그널도 공짜는 아니라서 벌크 연산(bulk_create)에서는 안 뜬다는 걸 알고 써야 합니다. 이 트레이드오프를 실제로 겪은 기록은 정본과 파생 캐시를 시그널로 붙인 회고2026-07-10고쳤는데 목록은 옛날 값 — 정본과 파생 캐시를 시그널로 다시 붙인 회고인스턴스를 수정해도 목록·다운로드·RDF는 옛 값을 보여줬습니다. 링크 시점에 한 번 복사하고 끝이었거든요. 캐시 제거·조회 시 재계산·쓰기 시 시그널 세 안을 놓고 마지막을 고른 이유와, 감사 로그가 폭발하지 않게 bulk_update로 일부러 시그널을 우회한 트레이드오프를 정리합니다.에 있습니다.
9. 한 가지 자기모순
6절에서 쓰지 말아야 할 때 로 이렇게 적었습니다.
실패 시 명확히 알려야 하는 값 → 조용히 폴백되면 버그가 숨음
그런데 5절의 코드는 정확히 그 조용한 폴백입니다.
except Exception:
return {'unread_note_count': 0}
모든 예외를 삼키고 0을 돌려주니, "쪽지가 없다"와 "조회에 실패했다"가 화면에서 구분되지 않습니다. 사용자는 쪽지가 없다고 믿고요.
카운트 뱃지라는 성격상 이 폴백 자체는 맞는 선택입니다. 문제는 로깅을 "실무에서는 표준"이라고 말만 하고 예제 코드에는 안 넣었다는 거예요. 이 코드가 그대로 복사되면 실패가 어디에도 안 남습니다.
except Exception:
logger.exception("unread_note_count 조회 실패") # 이 한 줄이 있어야 폴백이 완성된다
return {'unread_note_count': 0}
조용히 폴백하려면 조용하지 않은 기록이 짝으로 있어야 합니다. 화면에는 감추되 로그에는 남기는 거죠.
정리
- context processor는 모든 템플릿에 공통 값을 주입하는 가장 단순한 수단
- 하지만 모든 요청에서 실행 된다는 점이 함정
- 예외는 폴백으로 막되 로그는 반드시 남기기. 기록 없는 폴백은 버그를 숨깁니다
- 캐시를 건다면 TTL은 "얼마나 오래 틀려도 되는가" 로 정하고 무효화는 뷰가 아니라 시그널에 거는 편이 경로 누락에 안전합니다
- 적합한 건 "가벼움 + 전역 필요 + 폴백 가능" 세 조건이 맞을 때