장고 고급 #5 Signals 깊이와 트랜잭션 후 처리
중급 #3 Signals와 Middleware에서 시그널의 기본을 다졌다면, 이제 운영에서 시그널을 안전하게 쓰기 위한 도구상자를 정리합니다. 핵심 키워드는 **transaction.on_commit**과 절제.
시그널의 위험 — 왜 “절제” 인가 #
시그널은 강력하지만, 큰 프로젝트에서 가장 자주 비난받는 기능이기도 합니다. 이유:
- 흐름이 안 보임.
User.save()한 줄 뒤에 무엇이 일어나는지 코드를 따라가도 안 보임 — 시그널 핸들러가 다른 파일에 있어서. - 순서가 모호. 같은 시그널에 여러 receiver가 붙으면 등록 순서대로 호출되지만, 등록 자체가 import 순서에 의존.
- 트랜잭션과 미묘하게 어긋남.
post_save가 트랜잭션 안에서 실행됨 — 이게 외부 호출이면 큰 문제. - 테스트가 새는 추상화. 다른 테스트의 시그널 등록이 영향을 줘 깜빡임 (flaky)의 원인.
장고 자신의 권장은 “가능하면 명시적 호출, 시그널은 정말 분리가 필요한 경우만”. 좋은 사용처:
- 다른 앱이 우리 모델의 변경을 알아야 할 때 (
auth.User의 변경에 우리 앱이 반응) - 캐시 무효화
- 검색 인덱싱 (Elasticsearch, Algolia 등)
나쁜 사용처:
- 같은 앱 안의 명시적 후처리 (그냥
save다음 줄에 적자) - 결제, 잔액 같은 비즈니스 로직의 핵심 (시그널이 누락되면 데이터가 깨짐)
트랜잭션 — 짧은 복습 #
장고 view는 기본적으로 AUTOCOMMIT 모드. 명시적으로 묶으려면:
from django.db import transaction
@transaction.atomic
def transfer(from_id, to_id, amount):
src = Account.objects.select_for_update().get(pk=from_id)
dst = Account.objects.select_for_update().get(pk=to_id)
src.balance -= amount
dst.balance += amount
src.save()
dst.save()블록 안의 모든 SQL이 한 트랜잭션. 예외가 나면 자동 rollback.
ATOMIC_REQUESTS
#
DATABASES = {
"default": {
"ENGINE": "...",
"ATOMIC_REQUESTS": True,
...
}
}이걸 켜면 모든 view가 자동으로 트랜잭션으로 감쌉니다. 5xx 응답이면 rollback. 편하지만:
- 트랜잭션이 길어져 락 시간 증가
- view 안에서 외부 API 호출이 있으면 그동안 트랜잭션 잡음
큰 트래픽 사이트는 명시적 atomic이 더 정밀합니다.
컨텍스트 매니저 + savepoint #
@transaction.atomic
def outer():
Order.objects.create(...)
try:
with transaction.atomic(): # 내부 — savepoint로 동작
risky_step()
except SomeError:
# outer 트랜잭션은 살아 있음
log_failure()
Order.objects.filter(...).update(status="processed")내부 atomic은 savepoint — 부분 rollback이 가능합니다. 외부 트랜잭션은 영향 안 받음.
with transaction.atomic(savepoint=False):
...극히 드문 경우입니다. 보통 기본 (savepoint=True)으로 충분.
transaction.on_commit — 후처리의 표준
#
가장 중요한 도구. 트랜잭션이 성공적으로 commit 된 후에 실행할 콜백을 등록합니다.
from django.db import transaction
@transaction.atomic
def create_order(user, items):
order = Order.objects.create(user=user, total=0)
for item in items:
OrderItem.objects.create(order=order, ...)
transaction.on_commit(lambda: send_confirmation_email(order.id))
return order흐름:
- order 생성, items 생성
- commit 됨
- 그제서야
send_confirmation_email실행
만약 트랜잭션이 rollback 되면? 콜백은 호출되지 않습니다. 정확히 우리가 원하는 동작입니다.
왜 이게 필수인가 #
@transaction.atomic
def create_order(user, items):
order = Order.objects.create(user=user, total=0)
send_confirmation_email(order.id) # ← 위험
raise SomeError()이메일은 보내졌는데 트랜잭션은 rollback. 존재하지 않는 주문에 대한 이메일이 사용자에게 갑니다. 비슷한 패턴이 결제, 알림, 외부 인덱싱 등 모든 경우에서 같은 위험을 만듭니다.
on_commit이 정확히 이 문제를 막습니다.
시그널 안에서 #
post_save 시그널은 트랜잭션 안에서 호출됩니다. 외부 호출은 반드시 on_commit으로 미루세요.
from django.db import transaction
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Order
@receiver(post_save, sender=Order)
def index_order(sender, instance, created, **kwargs):
if not created:
return
transaction.on_commit(lambda: search_index.add(instance.id))search_index.add가 commit 후에만 실행. rollback 되면 인덱스에도 안 들어갑니다.
Celery와의 결합 #
비동기 작업 큐를 쓴다면:
@receiver(post_save, sender=Order)
def queue_processing(sender, instance, created, **kwargs):
if not created:
return
transaction.on_commit(lambda: process_order_task.delay(instance.id))여기서 on_commit이 결정적. 그렇지 않으면 Celery 워커가 받아서 DB를 조회했을 때 아직 commit 전이라 주문이 보이지 않을 수 있습니다. 미묘한 race condition의 흔한 원인. 자세한 Celery 통합은 DRF #4에서.
체이닝 / 여러 콜백 #
여러 번 호출 가능. 등록 순서대로 실행됩니다.
@transaction.atomic
def f():
...
transaction.on_commit(callback_a)
transaction.on_commit(callback_b)
transaction.on_commit(callback_c)
# commit 후 a → b → c 순트랜잭션 밖에서 부르면 #
on_commit을 트랜잭션 밖에서 부르면 즉시 실행됩니다 (이미 “commit 된 것” 으로 간주). 그래서 라이브러리/유틸 함수에 안전하게 넣을 수 있습니다 — 호출자가 atomic으로 감쌌든 아니든.
시그널 — 깊이 있는 사용 #
dispatch_uid — 중복 등록 방지
#
@receiver(post_save, sender=Order, dispatch_uid="order_index_handler")
def index_order(...):
...dispatch_uid가 유일한 ID. 같은 ID로 두 번 등록되면 무시됩니다. 모듈이 두 번 import 되어도 핸들러가 두 번 등록되는 일 없음.
apps.py에서 등록
#
시그널 모듈이 자동 import 되도록 권장 패턴.
from django.apps import AppConfig
class MyAppConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "myapp"
def ready(self):
from . import signals # noqadefault_app_config = "myapp.apps.MyAppConfig" # Django 3.2+ 는 자동 감지ready() 안에서 import만 하면 데코레이터가 등록을 자동.
⚠ ready() 안에서 DB 조회는 금지. migration도중에 호출될 수 있습니다.
자주 쓰는 시그널 #
| 시그널 | 발생 시점 |
|---|---|
pre_save / post_save | save() 전/후 |
pre_delete / post_delete | delete() 전/후 |
pre_migrate / post_migrate | migrate 명령 전/후 |
pre_init / post_init | 모델 인스턴스 생성 |
m2m_changed | M2M 관계 변경 (add/remove/clear) |
request_started / request_finished | 요청 시작/끝 |
got_request_exception | view 안에서 예외 발생 |
user_logged_in / user_logged_out / user_login_failed | 인증 |
m2m_changed — 의외로 까다로움
#
from django.db.models.signals import m2m_changed
@receiver(m2m_changed, sender=Post.tags.through)
def on_tags_changed(sender, instance, action, pk_set, **kwargs):
if action in {"post_add", "post_remove", "post_clear"}:
cache.delete(f"post_tags:{instance.id}")action 값:
pre_add,post_addpre_remove,post_removepre_clear,post_clear
대부분 post_*만 보면 됩니다. pre_* + post_* 둘 다에 같은 핸들러를 붙이면 두 번 호출되는 흔한 실수가 있습니다.
Custom signal — 우리 앱의 시그널 정의 #
내장 시그널만 있는 게 아닙니다. 우리 도메인 이벤트를 시그널로.
from django.dispatch import Signal
order_paid = Signal() # providing_args는 5.x에서 제거됨, 그냥 docstring으로
order_cancelled = Signal()from .signals import order_paid
def mark_paid(order):
order.status = "paid"
order.save()
order_paid.send(sender=Order, order=order, paid_at=timezone.now())from django.dispatch import receiver
from myapp.signals import order_paid
@receiver(order_paid)
def reward_user(sender, order, paid_at, **kwargs):
transaction.on_commit(lambda: give_points(order.user_id, 100))
@receiver(order_paid)
def notify_seller(sender, order, **kwargs):
transaction.on_commit(lambda: notify_seller_task.delay(order.seller_id))여러 모듈이 같은 이벤트에 반응하는 경우에 어울립니다. 다만 위에서 본 “흐름이 안 보임” 단점이 그대로 적용 — 명시적 함수 호출이 가능하면 그게 더 단순.
send vs send_robust
#
order_paid.send(sender=Order, order=order) # 한 receiver 라도 예외 → 전파
order_paid.send_robust(sender=Order, order=order) # 예외를 받아 로그만, 다른 receiver는 계속send_robust는 결과로 **(receiver, response_or_exception)**의 리스트를 반환. 응답을 검사하려면 이쪽.
이메일 알림처럼 한 receiver가 죽어도 다른 게 돌아야 하는 경우는 send_robust. 트랜잭션의 일부라면 send (예외로 rollback 시키고 싶음).
시그널 테스트 #
시그널 비활성화 — mute_signals
#
factory_boy의 도구가 표준입니다.
pip install factory_boyfrom factory.django import mute_signals
from django.db.models.signals import post_save
@mute_signals(post_save)
def test_create_without_signals():
user = User.objects.create(email="a@b.com")
# post_save 핸들러가 호출되지 않음데이터 시드, 픽스처 로딩 등 부수효과를 원치 않는 경우에 결정적. 클래스 데코레이터로도 사용 가능.
시그널 직접 disconnect / reconnect #
from django.test import TestCase
from django.db.models.signals import post_save
from myapp.signals import index_order
class MyTest(TestCase):
def setUp(self):
post_save.disconnect(index_order, sender=Order)
def tearDown(self):
post_save.connect(index_order, sender=Order)mute_signals가 더 깔끔하지만, 특정 receiver만 빼고 싶으면 직접.
시그널이 호출됐는지 검증 #
from unittest.mock import MagicMock
def test_signal_fires():
handler = MagicMock()
order_paid.connect(handler, sender=Order)
try:
mark_paid(order)
handler.assert_called_once()
finally:
order_paid.disconnect(handler, sender=Order)bulk_*와 시그널
#
#3에서 본 bulk_create, bulk_update, update, delete (queryset의)는 시그널을 발생시키지 않습니다. 큰 함정.
Order.objects.filter(status="pending").update(status="cancelled")
# post_save 호출 안 됨!데이터 마이그레이션 같은 경우에서 의도적으로 시그널을 우회하는 건 OK. 하지만 평소 코드에서 시그널이 핵심 로직이라면 bulk_* 사용 시 직접 후처리를 코드에서 해줘야 합니다.
ids = list(qs.values_list("id", flat=True))
Order.objects.filter(id__in=ids).update(status="cancelled")
for order_id in ids:
transaction.on_commit(lambda i=order_id: post_cancel_hook(i))lambda i=order_id의 기본 인자 트릭에 주의 — 안 그러면 모든 lambda가 마지막 order_id를 캡처합니다. 클로저 함정.
자주 만나는 함정 #
1) post_save 안에서 instance.save() 다시 호출
#
무한 재귀. update_fields로 끊거나, pre_save에서 처리하거나, Model.objects.filter(pk=instance.pk).update(...) (시그널 안 일으킴).
2) signal 안의 외부 API 호출이 트랜잭션을 묶음 #
위에서 본 그것. 반드시 on_commit으로.
3) m2m_changed가 두 번 호출
#
pre_*와 post_* 둘 다 받으면 두 번. 하나만.
4) 다른 앱의 시그널 등록 누락 #
apps.py의 ready()가 import를 트리거하지 않으면 시그널이 등록 안 됩니다. 정적 분석 도구가 못 잡는 흔한 회귀.
5) async view에서 시그널 핸들러 #
핸들러가 동기인데 비동기 컨텍스트에서 호출됩니다. 내부적으로 sync_to_async가 적용되긴 하지만, 핸들러가 무거우면 이벤트 루프를 막을 수 있습니다. 비동기 view가 메인이라면 시그널 대신 명시적 호출 + await가 더 정직.
정리 #
이번 글에서 잡은 것:
- 시그널은 강력하지만 “흐름이 안 보임” 단점 — 절제 권장
@transaction.atomic의 쓰임,ATOMIC_REQUESTS의 트레이드오프- 중첩
atomic= savepoint, 부분 rollback가능 transaction.on_commit(callback)— 시그널/atomic 안에서 외부 호출의 표준dispatch_uid로 중복 등록 방지,apps.py의ready()에서 importm2m_changed의 action 종류와 두 번 호출 함정- Custom
Signal()— 도메인 이벤트,sendvssend_robust - 테스트:
mute_signals, disconnect/reconnect, MagicMock으로 호출 검증 bulk_*/ querysetupdate/delete는 시그널 발생 안 함 — 명시 후처리- Celery와 결합: 반드시
on_commit안에서task.delay
다음 글(#6 Django Channels — WebSocket)에서는 #1 ASGI 위에 WebSocket을 얹습니다. AsyncWebsocketConsumer, group broadcast, HTTP view에서 push, daphne 배포까지 — 실시간 양방향 통신을 구현하는 방법을 다룹니다.