장고 중급 #3 Signals와 Middleware

6 분 소요

장고에는 모델,뷰의 정상 흐름 바깥에서 일어나는 코드를 끼워넣는 도구가 두 종류 있습니다.

  • Signals — “어떤 일이 일어났을 때” 반응하는 이벤트 시스템
  • Middleware — 모든 요청/응답이 거쳐가는 파이프라인

둘 다 강력하지만 남용하면 디버깅 지옥으로 가는 도구입니다. 이번 글은 사용법과 함께 언제 쓰지 말아야 하는지도 같이 봅니다.

#1 CBV, #2 ORM 중급에서 본 도구들이 “정상 흐름 안의 도구” 였다면, 이번 글은 그 흐름을 가로지르는 도구입니다.

Signals — 이벤트 시스템 #

장고의 시그널은 발신자 (sender) → 수신자 (receiver) 패턴입니다. 한 곳에서 “이 일이 일어났다” 를 발신하면, 등록된 수신자들이 비동기처럼 (실은 동기) 호출됩니다.

빌트인 시그널 — 자주 쓰는 것들 #

시그널발신 시점자주 쓰는 경우
pre_save모델 save() 직전슬러그 자동 생성, 정규화
post_savesave() 직후캐시 무효화, 알림
pre_deletedelete() 직전외부 자원 정리
post_deletedelete() 직후캐시 무효화, 로그
m2m_changedM2M 관계 변경권한,통계 갱신
request_started / request_finished요청 시작/끝글로벌 로깅
user_logged_in / user_logged_out인증 이벤트마지막 로그인 시각 갱신

수신자 등록 — @receiver #

blog/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils.text import slugify

from .models import Post

@receiver(post_save, sender=Post)
def post_post_save(sender, instance, created, **kwargs):
    if created:
        # 새로 만든 글에 대해서만
        send_notification_to_subscribers(instance)

@receiver(signal, sender=Model) 데코레이터로 함수를 시그널에 묶습니다. 핸들러 시그니처:

  • sender — 발신자 클래스 (위 예에선 Post)
  • instance — 저장된 모델 인스턴스
  • createdTrue 면 새로 만든 것, False 면 업데이트
  • **kwargs — 시그널마다 추가 인자가 다를 수 있어 항상 **kwargs로 받음

apps.pyready()에서 import #

시그널 모듈은 앱 로딩 시점에 한 번 import 돼야 등록됩니다. apps.py가 그 일을 합니다.

blog/apps.py
from django.apps import AppConfig

class BlogConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "blog"

    def ready(self):
        from . import signals    # 등록 트리거

ready() 안에서 import 하는 이유는 순환 import와 앱 로딩 순서 이슈를 피하기 위해서입니다. 모델이 다 로드된 시점에 시그널이 등록됩니다.

직접 만든 시그널 #

blog/signals.py — 커스텀 시그널
import django.dispatch

post_published = django.dispatch.Signal()

# 발신
@receiver(post_published)
def on_published(sender, post, **kwargs):
    print(f"발행: {post.title}")
발신 — 뷰나 매니저 안에서
from .signals import post_published

class PostUpdateView(UpdateView):
    def form_valid(self, form):
        response = super().form_valid(form)
        if self.object.published:
            post_published.send(sender=self.__class__, post=self.object)
        return response

Signal()로 만든 객체에 .send(sender, **kwargs)로 발신합니다.

pre_save — 슬러그 자동 생성 #

자주 쓰는 패턴
@receiver(pre_save, sender=Post)
def post_pre_save(sender, instance, **kwargs):
    if not instance.slug:
        instance.slug = slugify(instance.title)

저장 직전에 슬러그를 채워줍니다. 다만 이런 경우는 보통 모델의 save() 오버라이드가 더 적절합니다. 다음 절에서 설명하겠습니다.

시그널의 함정 — 언제 쓰지 말 것 #

시그널은 너무 멀리 있는 부수효과를 만들기 쉽습니다.

🚫 디버깅 지옥의 시작
# 어딘가에 흩어진 수신자들...
@receiver(post_save, sender=Order)
def send_email(...): ...

@receiver(post_save, sender=Order)
def update_stats(...): ...

@receiver(post_save, sender=Order)
def call_external_api(...): ...

Order.objects.create(...) 한 줄을 적었는데 어디서 무엇이 일어나는지 코드 한 줄만 봐선 알 수 없습니다. 시그널이 여러 파일에 흩어져 있기 때문입니다.

대안 1 — 모델 메소드 #

✅ 명시적 모델 메소드
class Post(models.Model):
    ...
    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.title)
        super().save(*args, **kwargs)

    def publish(self):
        self.published = True
        self.published_at = timezone.now()
        self.save()
        notify_subscribers(self)

post.publish() 한 줄을 보면 무슨 일이 일어나는지 그 메소드만 보면 됩니다. 시그널보다 추적이 쉽습니다.

대안 2 — 매니저 / 서비스 함수 #

✅ 서비스 함수
def publish_post(post: Post) -> None:
    post.published = True
    post.published_at = timezone.now()
    post.save()
    notify_subscribers(post)
    invalidate_homepage_cache()

도메인 로직을 명시적인 함수에 모읍니다. 테스트도 쉽고, 호출 경로도 분명.

시그널이 정말 어울리는 경우 #

경우시그널 OK?이유
다른 앱이 발신하는 모델 (장고 빌트인 User 등)에 후크그 모델을 수정 못 함
외부 라이브러리 모델에 후크같은 이유
같은 앱 안의 부수효과모델 메소드/서비스가 명시적
트랜잭션 안의 순수 영속화 작업일관성을 시그널이 보장

원칙: 본인 코드를 자기 손으로 고칠 수 있다면 시그널보다 명시적인 호출이 답. 시그널은 “남의 모델에 후크 거는” 도구로 보세요.

트랜잭션과 시그널을 같이 쓰는 패턴 (transaction.on_commit, post_save + atomic)은 고급 #5에서 자세히 다루겠습니다.

Middleware — 요청/응답 파이프라인 #

미들웨어는 모든 요청과 응답이 거쳐가는 파이프라인 입니다. 인증, 세션, CSRF 같은 전역 관심사가 여기에 삽니다.

동작 모델 #

요청 흐름
브라우저
SecurityMiddleware
SessionMiddleware
CommonMiddleware
CsrfViewMiddleware
AuthenticationMiddleware
MessageMiddleware
View                    ← 여기서 응답 생성
MessageMiddleware
... (역순으로 다시 거쳐감)
브라우저

들어갈 때는 위에서 아래로, 나갈 때는 아래에서 위로. 양파 껍질처럼 감쌉니다.

settings.py에 등록 #

settings.py
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

순서가 중요합니다. 예를 들어 AuthenticationMiddlewareSessionMiddleware보다 뒤에 와야 합니다 (세션을 읽어 사용자를 인증하니까).

빌트인 핵심 미들웨어 #

미들웨어하는 일
SecurityMiddlewareHTTPS 리다이렉트, HSTS, X-Content-Type-Options 등
SessionMiddlewarerequest.session 활성화 (#5)
CommonMiddlewareURL 정규화 (슬래시 추가), 통계 헤더
CsrfViewMiddlewareCSRF 토큰 검증
AuthenticationMiddlewarerequest.user 활성화 (#4)
MessageMiddlewareflash 메시지 (#5)
XFrameOptionsMiddlewareclickjacking 방어 (X-Frame-Options)

빌트인은 끄지 않는 게 보통 답입니다. 보안에 직결되는 것들이 많습니다.

미들웨어 작성 — 클래스 형태 #

myapp/middleware.py
import time
import logging

logger = logging.getLogger(__name__)

class TimingMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        start = time.perf_counter()
        response = self.get_response(request)
        elapsed = (time.perf_counter() - start) * 1000
        response["X-Render-Time"] = f"{elapsed:.1f}ms"
        logger.info(f"{request.method} {request.path} {elapsed:.1f}ms")
        return response

구조:

  • __init__(self, get_response) — 앱 시작 시 한 번만 호출. get_response는 다음 미들웨어 (또는 뷰) 호출용
  • __call__(self, request) — 요청마다 호출
    • self.get_response(request) 앞에 코드 → 요청 들어갈 때
    • self.get_response(request) 뒤에 코드 → 응답 나갈 때
settings.py에 추가
MIDDLEWARE = [
    ...
    "myapp.middleware.TimingMiddleware",
]

후크 메소드 — process_view, process_exception, process_template_response #

추가 후크가 필요하면 메소드를 더 정의합니다.

확장 미들웨어
class AuditMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        return self.get_response(request)

    def process_view(self, request, view_func, view_args, view_kwargs):
        # 뷰 함수가 호출되기 직전. None을 반환하면 정상 진행
        request._view_func_name = view_func.__name__

    def process_exception(self, request, exception):
        # 뷰에서 처리 안 된 예외가 났을 때
        logger.error(f"미처리 예외: {exception}", exc_info=True)
        # None 반환 → 다른 핸들러에 위임 / HttpResponse → 응답으로 사용

    def process_template_response(self, request, response):
        # TemplateResponse 일 때 렌더 직전
        return response

미들웨어로 흔히 푸는 경우 #

  • 요청 ID 발급 — 분산 추적용 X-Request-ID 헤더 부여
  • 다국어/타임존Accept-Language 보고 활성 언어 결정
  • 유지보수 모드 — 특정 경로 외엔 503 응답
  • 간단한 rate limit — 캐시 + IP 기반 (큰 트래픽엔 별도 도구)
  • 공통 응답 헤더 — 보안 헤더, CORS 등

미들웨어 vs 데코레이터 vs 시그널 — 언제 무엇을 #

세 도구의 쓰임을 헷갈리기 쉽습니다.

도구쓰임
데코레이터 (또는 Mixin)특정 뷰들에만 거는 가로지르는 관심사 (예: @login_required)
미들웨어모든 요청에 거는 글로벌 관심사 (예: 요청 ID, 보안 헤더)
시그널모델,인증 같은 도메인 이벤트 후크 (자기 코드면 명시 호출이 우선)

판단 기준:

  1. 특정 뷰만 → 데코레이터 / Mixin
  2. 모든 요청 → 미들웨어
  3. 모델 저장 후처리 → 같은 앱이면 메소드/서비스, 외부 모델이면 시그널

작은 실전 예 — 활성 사용자 마지막 활동 시각 #

myapp/middleware.py
from django.utils import timezone

class LastSeenMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)
        if request.user.is_authenticated:
            User = request.user.__class__
            User.objects.filter(pk=request.user.pk).update(
                last_seen_at=timezone.now()
            )
        return response

User.objects.filter(...).update(...)사용자 객체를 다시 save 하지 않고 업데이트합니다. (#2의 패턴.) 매 요청마다 부담을 줄이려면 5 분에 한 번만 갱신 같은 throttle을 추가하세요.

비동기 미들웨어 짧게 #

장고 4.0+ 부터 미들웨어를 비동기로도 작성할 수 있습니다. 본격적인 비동기 뷰는 고급 #1에서 다루지만 미들웨어는 동기/비동기 양쪽 호환을 만들 수 있습니다.

동기/비동기 양쪽 지원
from asgiref.sync import iscoroutinefunction

class HybridMiddleware:
    sync_capable = True
    async_capable = True

    def __init__(self, get_response):
        self.get_response = get_response
        self.async_mode = iscoroutinefunction(get_response)

    def __call__(self, request):
        if self.async_mode:
            return self.__acall__(request)
        return self.get_response(request)

    async def __acall__(self, request):
        return await self.get_response(request)

상세 패턴은 고급 #1에서.

정리 #

이번 글에서 잡은 것:

  • Signals — 발신자/수신자 이벤트 시스템
  • 빌트인 시그널: pre_save, post_save, pre_delete, post_delete, m2m_changed, 인증 이벤트
  • @receiver(signal, sender=Model)로 등록, apps.pyready()에서 import
  • 시그널 함정 — 너무 멀리 있는 부수효과, 디버깅 어려움. 자기 모델이면 메소드/서비스가 우선
  • Middleware — 요청/응답 파이프라인, 양파 껍질 흐름
  • 빌트인 핵심 (Security, Session, Csrf, Authentication, Message)
  • 미들웨어 형태: __init__(get_response), __call__(request), 추가로 process_view/process_exception
  • 세 도구의 쓰임: 특정 뷰 → 데코레이터/Mixin, 모든 요청 → 미들웨어, 도메인 이벤트 → 시그널 (자기 코드면 명시 호출이 우선)

다음 글(#4 사용자/권한)에서는 기초 #7의 빌트인 인증 위에 커스텀 user model, permission, group을 쌓습니다. 프로젝트 시작 시점에 정해야 하는 결정들도 같이 다루겠습니다.

X