Django에 DDD가 적용되었거나, 다중 어플리케이션으로 분할하여 시스템을 구성하면, Read 와 Update의 위치에 대해 고민하게 됩니다. 다양한 도메인/어플리케이션이 서로 영향을 주는 복잡한 비즈니스 로직을 가지고 있는 경우, 이러한 문제는 더욱 심화됩니다.

이러한 상황에서, 모델에 대한 인터페이스가 여러 도메인에서 작성되어 이용되고 있다면, 도메인 별 관리에 어려움을 겪게됩니다. Django에서는 이러한 문제를 보다 편리하게 관리할 수 있도록 Signal 이라는 기능을 제공합니다.

Signal?

Django Document에서의 Signal

https://docs.djangoproject.com/en/2.2/topics/signals/

공식 문서에서는 이러한 상황을 아래와 같이 설명합니다.

which helps allow decoupled applications get notified when actions occur elsewhere in the framework.

실제 적용되는 상황을 상상하며, 아래와 같이 두 도메인을 가정하고 사용법을 확인해보겠습니다. Book이라는 도메인/어플리케이션을 도서 상품으로 가정하고, 결제와 관련된 Purchase를 예시 도메인으로 가정하겠습니다. 일반적인 경우 위와 같은 상황에서는 서로 독립된 도메인/어플리케이션으로 구성됩니다.

Book > models.py

from django.db import models

class Book(models.Model):
    inventory = models.IntegerField(default=0)
    ...

Purchase > models.py

from django.db import models

...


class PurchaseHistory(models.Model):
    book = models.ForeignKey('book.Book', related_name='purchase_history')
    price = models.IntegerField(default=0)
    ...

Purchase > views.py

...

class PurchaseView(TemplateView):
    ...

    def post(self, request, *args, **kwargs):
        # ??


...

일반적인 쇼핑몰과 같은 구조를 상상하면, 구매 페이지는 Book 도메인/어플리케이션의 하위 요소이기 보다는 Purchase 도메인/어플리케이션의 하위 요소로써 view Class 가 설계됩니다. 이 과정에서 PurchaseHistory Instance 하나가 생성되면, Book의 inventory(재고)는 1 감소하게 됩니다.

위 상황이, decoupled application 간에 동시성을 가진 이벤트가 발생하는 상황입니다. 쉽게 생각해서 아래와 같이 코드를 작성할 수 있습니다.

Purchase > views.py

class PurchaseView(TemplateView):
    ...
    def post(self, request, *args, **kwargs):
        ...
        instance = form.save()
        book = instance.book
        book.inventory -= 1
        book.save()
        ...

이러한 코드는 이상적인 DDD를 구성하기에 아쉬운 코드입니다. Book 도메인에 대한 Update가 Purchase 도메인에서 이루어지기 때문입니다. 어느 정도 수준에서 도메인간의 독립성을 유지할 것인가에 따라 문제가 되지 않을 수 있지만, Django에서는 이 코드가 껄끄러운 많은 사람들을 위해 Signal을 제공합니다.

Django의 signal은 크게 2가지를 이해하면 간단하게 활용할 수 있습니다.

  1. Sender
  2. Receiver (with connect())

Sender

Django signal의 sender 는 이벤트가 발생했을 때, 해당 이벤트가 발생했음을 시스템에 알려주는 주체입니다. 기본적으로 Django에 내장된 모델 관련 signal이 있고, 이러한 signal 들은 Model 클래스를 sender로 하여 작동합니다.

from django.db.models.signals import pre_save
from django.dispatch import receiver
from myapp.models import MyModel


@receiver(pre_save, sender=MyModel)
def my_handler(sender, **kwargs):
    ...

signal을 직접 생성할 수도, 내장 signal을 활용할 수도 있지만, 이번 글에서는 내장 signal을 활용하는 방법을 위주로 확인하고, 다음 글에서 signal을 직접 만들어 사용하는 과정을 보려고 합니다.

위 예제에 보이는 pre_save 는 Django에서 제공하는 signal 중 하나로, sender class에 해당하는 모델의 instance가 저장되기 전에 이벤트가 발생합니다. 비슷한 signal로 역시 내장 signal인 post_save가 있는데, 해당 signal은 instance가 저장된 이후 이벤트가 발생합니다.

Django의 Signal이 재미있는 특징이 sender에서 보여지는데, Django의 model Signal은 이벤트를 발생시키는 영역이 아닌, 이벤트를 받는 영역만 설정하면 이벤트 수집이 진행된다는 점입니다. (즉, 이미 Model Class는 event를 Django 시스템 안에 발송하고 있는 중입니다. 우리가 receiver를 하지 않아도, signal은 계속 발송되고 있습니다.)

직접 만드는 signal은 이벤트를 발생하는 타이밍에 signal이 직접 호출되어야 합니다.

Receiver

위 예제에서 보이는 @receiver()가 무엇인지 정확히 알아보면, 장고의 framework 내에서 발생되고 있는 event들 중 receiver와 연결된(connect) 이벤트가 있으면 실행되는 함수입니다. receiver는 반드시 sender 와 keyword argument를 받아야 하는 조건이 있고, signal이 정의될때 argument를 확장할 수 있습니다.

이번 글에서는 내장 model signal을 위주로 확인할 예정이니, model signal을 사용하는 상황을 집중적으로 확인해보겠습니다. receiver는 sender 클래스가 발생시킨 signal을 캐치해서, 스스로 함수를 실행합니다. 내장된 Model signal은 signal을 직접 호출하지 않아도, 이미 적용되어 있기 때문에, receiver 코드가 작성되어 있으면 바로 이벤트를 캐치할 수 있습니다.

하지만, 단순히 위 예제 코드처럼 @receiver만 선언한다고 해서 바로 이벤트를 모두 캐치하지는 않습니다. receiver 역시 django framework가 작동되는 과정에서 일종의 등록되는 과정이 필요합니다. 정석적인 1가지 방법과 꼼수 1가지 방법이 있는데 두가지를 모두 확인해보겠습니다.

Book > receivers.py (또는 signals.py)

from django.db.models.signals import pre_save
from django.dispatch import receiver
from my_project.purchase.models import Purchase

@receiver(pre_save, sender=Purchase)
def update_book_inventory(sender, **kwargs):
    book = kwargs['instance'].book
    book.inventory -= 1
    book.save()

첫번째 방법이자 정석적인 방법은 AppConfig에 등록해주는 방법입니다.

Book > apps.py

# Django 1.7 버전 이후에 활용되는 Appconfig
# 1.9 버전 이후부터는 자동 생성되며, 1.7~1.8 버전은 직접 파일을 만들고 아래 코드를 생성해주어야 합니다.

class BookConfig(AppConfig):
    name = 'book'
    verbose_name = u'도서 상품 정보'
    def ready(self):
        from my_project.book.receivers import *
        # signals.py 에 receiver를 작성한 경우 아래 코드
        from my_project.book.signals import *

위 방법을 통해 application(각각의 도메인들)이 초기화 되는 과정에서 receiver들이 signal과 connect 됩니다. 반면, 위 방법은 구 버전 Django를 계속 버전업해오며 사용하던 상황에서는 사용하기가 어려운데, AppConfig를 선언할 경우, settings.py 의 INSTALLED_APP 에서 해당 도메인을 제거해주어야 하기 때문입니다. 기존에 다양한 로직들을 위 설정값을 이용해 사용하고 있거나, Django 버전 대응을 하지 않는 특정 라이브러리로 인해 settings.py 를 수정할 수 없다면 꼼수를 사용할 수 있습니다.

두번째 방법이자 꼼수는 application(각각의 도메인들)이 초기화될 때 models.py 가 인식된다는 점을 활용하는 방법입니다. 과거의 Django 버전들은 models.py 파일을 기반으로 각각의 application이 초기화 되는데에 문제가 있는지 검토 했고, 이 과정에서 여러 application으로 분산되어 있는 application 들의 모델에 중첩 참조같은 문제가 생기는 지 알수 있었습니다.

과거의 이러한 특징은 서로 다른 두 도메인의 models.py 파일이 서로의 파일에 있는 model 을 참조할 때 뜨는 에러 문구로 이해할 수 있습니다. 위와 같은 상황을 만들어 두고 django를 실행하면(circular import가 발생하는 상황), 특정 model을 import 할수 없다는 에러가 발생합니다.

cannot import name MyModel

이 문제가 생기는 이유는 MyModel 이 들어있는 application이 아직 초기화 되지 않은 상황에서(각각의 application 이 초기화 되고 있던 상황) 해당 모델을 import하려고 시도했기 때문에 발생합니다. (이 문제로 인해 circular import가 발생하면, Installed app의 순서를 변경하면 해결되는 경우도 있었습니다. -0-)

Django의 이러한 특징을 살려 models.py 가 읽혀질 때 receiver들도 연결하는 꼼수입니다.

Book > models.py

from django.db import models
...

class Book(models.Model):
    ...

...

from my_project.book.receivers import *

models.py 파일의 맨 마지막에 receivers.py 또는 signals.py 파일을 읽도록 하여 app이 초기화 되는 과정에서 signal이 인식되도록 할 수 있습니다. 다만 이 방법은 단방향성 signal이 발생할 때만 활용이 가능합니다. 위 코드가 각각의 어플리케이션에 들어있는 receivers.py 간에 circular import 로 이어질 수 있기 때문입니다.

요약

간단하게 Django에서 제공하는 Signal에 대해 확인해보았습니다. 비교적 간단한 방식으로 이벤트를 발생시키고 인식할 수 있기 때문에 제대로 활용하면 도메인간의 로직 관리가 수월해집니다. 다만, 제대로 활용하기 위해서는 프로젝트의 모든 도메인이 AppConfig를 보유하고 있는 것이 좋습니다.

  1. Django에는 signal이라는 기능을 통해 (broadcast 같은) 분리된 도메인/어플리케이션 간에 비즈니스 로적을 잘 분리하여 나누어줄 수 있습니다.
  2. 모델의 CRUD와 같은 이벤트에 연결되는 로직들은 Django 내장 signal을 활용해서 간단하게 비즈니스 로직을 분리할 수 있습니다.
  3. signal을 받아 실행되는 receiver들은 AppConfig에 등록되어 앱이 초기화 될 때 함께 시스템에 등록되어야 작동합니다.