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

[Django] Signals: 모델 이벤트 hook

· 수정 · 📖 약 2분 · 749자/단어 #django #signal #event #hook
django signal, post_save, pre_save, signal receiver, @receiver, AppConfig.ready

정의

Django signal은 발신자(sender)가 특정 이벤트를 알리면 등록된 수신자(receiver)들이 호출되는 옵저버 패턴. 모델 저장/삭제, 요청 시작/종료, 마이그레이션 적용 등에 hook을 걸 수 있다. 결합을 줄이지만 흐름이 비명시적이라 남용은 디버깅 난이도를 키운다.

빌트인 signal

신호발생 시점
pre_save모델 저장 전
post_save저장 후
pre_delete삭제 전
post_delete삭제 후
m2m_changedM2M 관계 변경
pre_migrate / post_migrate마이그레이션 전후
request_started / request_finished요청 사이클
got_request_exception요청 처리 중 예외
user_logged_in / user_logged_out / user_login_failed인증
setting_changed테스트에서 settings 변경
template_rendered템플릿 렌더링 (테스트 한정)

기본 사용

# blog/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Post

@receiver(post_save, sender=Post)
def post_saved(sender, instance, created, **kwargs):
    if created:
        log.info(f"new post: {instance.title}")
    else:
        log.info(f"updated: {instance.title}")

@receiver 데코레이터로 연결. 또는 명시적:

def my_handler(sender, instance, **kwargs): ...

post_save.connect(my_handler, sender=Post)

해제:

post_save.disconnect(my_handler, sender=Post)

AppConfig.ready()에 등록

signal이 로드되도록 앱 시작 시 import.

# blog/apps.py
from django.apps import AppConfig

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

    def ready(self):
        import blog.signals    # signal 등록
# blog/__init__.py
default_app_config = "blog.apps.BlogConfig"     # 3.2 미만만 필요

AppConfig.ready()가 신호 import의 표준 위치.

post_save 시그니처

@receiver(post_save, sender=Post)
def handler(sender, instance, created, raw, using, update_fields, **kwargs):
    # sender: 모델 클래스 (Post)
    # instance: 저장된 인스턴스
    # created: True면 새로 생성, False면 업데이트
    # raw: loaddata 등 raw save 시 True
    # using: DB alias
    # update_fields: update_fields= 인자로 저장 시 set
    ...

raw=True일 때 시그널 안에서 DB 접근하면 fixture 로드 깨질 수 있어 보통 early return:

if raw:
    return

자주 보는 패턴

Profile 자동 생성

from django.contrib.auth import get_user_model
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Profile

User = get_user_model()

@receiver(post_save, sender=User)
def create_profile(sender, instance, created, **kwargs):
    if created:
        Profile.objects.create(user=instance)

slug 자동 생성

from django.utils.text import slugify

@receiver(pre_save, sender=Post)
def fill_slug(sender, instance, **kwargs):
    if not instance.slug:
        instance.slug = slugify(instance.title)

save() 오버라이드와 비교: signal은 외부에서 import만 하면 동작. 단 명시성이 떨어짐.

캐시 무효화

from django.core.cache import cache

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

검색 인덱스 갱신

@receiver(post_save, sender=Post)
def reindex_search(sender, instance, **kwargs):
    if instance.is_published:
        search_engine.index(instance)

비동기로 (Celery):

@receiver(post_save, sender=Post)
def reindex_search(sender, instance, **kwargs):
    from .tasks import reindex_post
    reindex_post.delay(instance.id)

M2M 추적

from django.db.models.signals import m2m_changed

@receiver(m2m_changed, sender=Post.tags.through)
def tags_changed(sender, instance, action, reverse, model, pk_set, **kwargs):
    # action: 'pre_add', 'post_add', 'pre_remove', 'post_remove', 'pre_clear', 'post_clear'
    if action == "post_add":
        log.info(f"tags added to post {instance.id}: {pk_set}")

커스텀 signal

# signals.py
from django.dispatch import Signal

order_completed = Signal()    # providing_args는 deprecated, 그냥 보내고 받으면 됨

# 발신
order_completed.send(sender=Order, instance=order, total=order.total)

# 수신
@receiver(order_completed)
def send_confirmation_email(sender, instance, total, **kwargs):
    send_mail(...)

특정 도메인 이벤트 (주문 완료, 결제 처리, 알림 발송 등)를 signal로 디커플링.

signal 등록 위치

핸들러 함수는 어디에 둬도 OK지만 반드시 import되어야 receiver가 등록된다.

권장 구조:

  • blog/signals.py: 핸들러 함수
  • blog/apps.pyAppConfig.ready()에서 import blog.signals

함정

1. signals.py를 import 안 함

핸들러 코드가 있어도 import 안 되면 등록 안 됨. → AppConfig.ready()에서 명시.

2. update(), bulk_create() 신호 안 발생

Post.objects.filter(...).update(views=F("views") + 1)    # post_save 안 발생
Post.objects.bulk_create([...])                           # 안 발생 (대부분 DB)

이런 우회 시 신호 의존 로직 깨짐. 명시적 처리 필요.

3. signal 안에서 같은 모델 저장 → 무한 루프

@receiver(post_save, sender=Post)
def update_summary(sender, instance, **kwargs):
    instance.summary = ...
    instance.save()    # post_save 다시 호출! 무한 루프

해결:

  • update_fields로 분기
  • pre_save에서 처리 (save 전이라 추가 save 불필요)
  • 플래그/lock
  • queryset.update()로 시그널 우회

4. signal로 인한 N+1

@receiver(post_save, sender=Comment)
def update_post_count(sender, instance, **kwargs):
    instance.post.comment_count = instance.post.comments.count()
    instance.post.save()

100개 comment bulk 처리 → 100번 post 업데이트. 배치 후 한 번에 처리하는 게 나음.

5. 너무 많은 signal

10+ signal이 한 모델 save에 걸려 있으면 디버깅·테스트 어려움. 명시적 메서드(Post.publish())나 service layer가 더 추적 가능.

signal vs alternatives

패턴장점단점
signal디커플링흐름 비명시
save() 오버라이드명시적모델 결합
service layer명시적, 테스트 쉬움보일러플레이트
Celery task비동기, 격리인프라 필요

규칙: 핵심 비즈니스 로직은 service/모델 메서드. 부수효과(로깅, 알림, 캐시 무효화)는 signal 적합.

post_migrate 활용

마이그레이션 후 데이터 초기화.

from django.db.models.signals import post_migrate
from django.dispatch import receiver

@receiver(post_migrate)
def create_default_groups(sender, **kwargs):
    if sender.name != "myapp":
        return
    from django.contrib.auth.models import Group
    for name in ["Editors", "Viewers"]:
        Group.objects.get_or_create(name=name)

테스트 시 signal disable

from django.test import TestCase
from django.db.models.signals import post_save

class MyTest(TestCase):
    def setUp(self):
        post_save.disconnect(my_handler, sender=Post)

    def tearDown(self):
        post_save.connect(my_handler, sender=Post)

또는 factory_boy@factory.django.mute_signals(post_save).

💬 댓글

사이트 검색 / 명령어

검색

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