Home > Python > Django
최초 작성 : 2016-10-10 / 최종 수정 : 2017-09-25

장고 모델 행동(Django Model Behaviors) By Kevin Stone


본 글은 http://blog.kevinastone.com/django-model-behaviors.html 의 글을 번역한 것입니다.

간단하고 깔끔한 튜토리얼 정도가 아닌, 복잡성이 큰 장고 프로젝트에선 어떻게 모델들을 잘 관리하도록 구성할까요? 10여개에서 100여개의 모델들, 수많은 뷰와 템플릿, 그리고 테스트들에 대해서 이야기 해봅시다.

역자주) 본문에서 상황에 맞게 behaviors(행동, 행위)를 번역하거나 영어 그대로 사용하였습니다.

구성모델 행위 (Compositional Model Behaviors)

구성모델 패턴은 각 기능별로 구성요소를 쪼개서 여러분이 모델의 복잡성을 관리 할 수 있게 해줍니다.

거대 모델(Fat Models)의 장점

  • 캡슐화(Encapsulation)
  • 단일 경로(Single Path)
  • 관심사 분리 (MVC)

유지보수 비용을 고려한다면

  • 중복 제거(DRY)
  • 가독성(Readability)
  • 재사용성(Reusability)
  • 단일 책임 원칙(Single Responsibility Principle)
  • 테스트 용이성(Testability)

모델 behaviors 예제

기존의 모델

class BlogPost(models.Model):
    title = models.CharField(max_length=255)
    body = models.TextField()
    slug = models.SlugField()
    author = models.ForeignKey(User, related_name='posts')
    create_date = models.DateTimeField(auto_now_add=True)
    modified_date = models.DateTimeField(auto_now=True)
    publish_date = models.DateTimeField(null=True)

각각의 행위들을 분리한다

Behaviors 패턴의 목적은 핵심에 있는 모델들을 재사용 가능한 mixin으로 분리합니다. 모델 필드보단, 의도된 비즈니스 로직을 캡슐화할 더 높은 수준의 추상화를 만듭니다.

from .behaviors import Authorable, Permalinkable, Timestampable, Publishable


class BlogPost(Authorable, Permalinkable, Timestampable, Publishable, models.Model):
    title = models.CharField(max_length=255)
    body = models.TextField()

재사용 가능한 behaviors

class Authorable(models.Model):
    author = models.ForeignKey(User)

    class Meta:
        abstract = True


class Permalinkable(models.Model):
    slug = models.SlugField()

    class Meta:
        abstract = True


class Publishable(models.Model):
    publish_date = models.DateTimeField(null=True)

    class Meta:
        abstract = True


class Timestampable(models.Model):
    create_date = models.DateTimeField(auto_now_add=True)
    modified_date = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True

필드(field, 멤버변수)보다 많은 모델들

우리가 처음으로 잘라낸 공통된 behaviors는 공통 필드 뿐이었습니다. 하지만 필드외에 다른 것들은 어떻게 추상화 할까요?

  • 프로퍼티
  • 커스텀 메소드
  • 메소드 오버로드 (save() 등)
  • Validation
  • 쿼리셋 (QuerySets)

모델 메소드 확인

이제 비즈니스 로직들이 캡슐화 된 거대모델(fat model)도 살펴봅시다.

class BlogPost(models.Model):
    ...

    @property
    def is_published(self):
        from django.utils import timezone
        return self.publish_date < timezone.now()

    @models.permalink
    def get_absolute_url(self):
        return ('blog-post', (), {
            "slug": self.slug,
        })

    def pre_save(self, instance, add):
        from django.utils.text import slugify
        if not instance.slug:
            instance.slug = slugify(self.title)

메소드가 있는 Behaviors

사실, 같은 기능을 하는 메소드들은 일반화시켜서 behaviors 모델안으로 추출이 가능합니다.

class Permalinkable(models.Model):
    slug = models.SlugField()

    class Meta:
        abstract = True

    def get_url_kwargs(self, **kwargs):
        kwargs.update(getattr(self, 'url_kwargs', {}))
        return kwargs

    @models.permalink
    def get_absolute_url(self):
        url_kwargs = self.get_url_kwargs(slug=self.slug)
        return (self.url_name, (), url_kwargs)

    def pre_save(self, instance, add):
        from django.utils.text import slugify
        if not instance.slug:
            instance.slug = slugify(self.slug_source)


class Publishable(models.Model):
    publish_date = models.DateTimeField(null=True)

    class Meta:
        abstract = True

    objects = PassThroughManager.for_queryset_class(PublishableQuerySet)()

    def publish_on(self, date=None):
        from django.utils import timezone
        if not date:
            date = timezone.now()
        self.publish_date = date
        self.save()

    @property
    def is_published(self):
        from django.utils import timezone
        return self.publish_date < timezone.now()

구현체와 연결하기

이제 behaviors으로 추출했으니, 구현체에 상속시켜줌으로써 구현체가 완벽하게 동작할 수 있도록 해줍시다.

from .behaviors import Authorable, Permalinkable, Timestampable, Publishable


class BlogPost(Authorable, Permalinkable, Timestampable, Publishable, models.Model):
    title = models.CharField(max_length=255)
    body = models.TextField()

    url_name = "blog-post"

    @property
    def slug_source(self):
        return self.title

이름짓는 방법

동사 + able 형태의 패턴을 사용하세요. 접미사로 able을 사용한다면 behaviors 임을 알아차릴 수 있습니다. 이 방법은 현재 이미 사용하고 있는 단어들과 섞이는 것도 막을 수 있습니다.(OptionallyGenericRelateable같은 일반적이지 않은 영어를 쓰면 어떡하지에 대한 걱정은 하지마세요.)

커스텀 쿼리셋 체이닝(Custom QuerySet Chaining)

우리 모두 체인 쿼리셋 메소드는 잘 알고 있습니다. 하지만 커스텀 매니저 메소드도 잘 알고 계신가요? Author(username1)와 Published(publish_date가 과거의 시간인) 포스트를 찾아봅시다.

캡슐화가 없는 쿼리셋

from django.utils import timezone
from .models import BlogPost

>>> BlogPost.objects.filter(author__username='username1') \
.filter(publish_date__lte=timezone.now())

커스텀 매니저

Author와 Published 필터 메소드를 커스텀 매니저에 만들어 봅시다.

class BlogPostManager(models.Manager):

    def published(self):
        from django.utils import timezone
        return self.filter(publish_date__lte=timezone.now())

    def authored_by(self, author):
        return self.filter(author__username=author)


class BlogPost(models.Model):
    ...

    objects = BlogPostManager()
>>> published_posts = BlogPost.objects.published()
>>> posts_by_author = BlogPost.objects.authored_by('username1')

커스텀 매니저의 필터는 어떻게 체이닝하죠?

만들었던 필터들을 체이닝 하고 싶으면 어떻게 할까요?

>>> BlogPost.objects.authored_by('username1').published()
AttributeError: 'QuerySet' object has no attribute 'published'

>>> type(Blogpost.objects.authored_by('username1'))
<class 'django.db.models.query.QuerySet'>

해결방법: 커스텀 쿼리셋

django-model-utils의 PassthroughManager를 이용해서 커스텀 매니저의 메소드를 체이닝할 수 있습니다.

from model_utils.managers import PassThroughManager

class PublishableQuerySet(models.query.QuerySet):
    def published(self):
        from django.utils import timezone
        return self.filter(publish_date__lte=timezone.now())


class AuthorableQuerySet(models.query.QuerySet):
    def authored_by(self, author):
        return self.filter(author__username=author)

class BlogPostQuerySet(AuthorableQuerySet, PublishableQuerySet):
    pass


class BlogPost(Authorable, Permalinkable, Timestampable, Publishable, models.Model):
    ...

    objects = PassThroughManager.for_queryset_class(BlogPostQuerySet)()

이제 여러개의 behaviors를 상속받아서 커스텀 메소드 체인이 가능합니다.

>>> author_public_posts = BlogPost.objects.authored_by('username1').published()

>>> type(Blogpost.objects.authored_by('username1'))
<class 'example.queryset.BlogPostQuerySet'>

분리된 비즈니스 로직

다음 중 어떤게 읽기 쉽고 유지보수 하기도 쉬워 보이시나요?

BlogPost.objects.filter(author__username='username1').filter(publish_date__lte=timezone.now())
BlogPost.objects.authored_by('username1').published()

Behaviors를 테스트하기

모델에 적합한 Behaivors 테스트를 만들어봅시다.

모델에 적용했을 때와 동일한 장점들

  • 중복 제거(DRY)
  • 가독성(Readability)
  • 재사용성(Reusability)
  • 단일 책임 원칙(Single Responsibility Principle)

유닛테스트 예제

우리는 behaviors를 검증할 재사용 가능한 테스트 컴포넌트들을 만들 수 있습니다. 테스트 믹스인 목록은 각 모델이 맡은 역할에 대한 문서가 됩니다.

기존의 테스트

from django.test import TestCase

from .models import BlogPost


class BlogPostTestCase(TestCase):
    def test_published_blogpost(self):
        from django.utils import timezone
        blogpost = BlogPost.objects.create(publish_date=timezone.now())
        self.assertTrue(blogpost.is_published)
        self.assertIn(blogpost, BlogPost.objects.published())

Behavior 테스트 믹스인으로 변환

class BehaviorTestCaseMixin(object):
    def get_model(self):
            return getattr(self, 'model')

    def create_instance(self, **kwargs):
        raise NotImplementedError("Implement me")


class PublishableTests(BehaviorTestCaseMixin):
    def test_published_blogpost(self):
        from django.utils import timezone
        obj = self.create_instance(publish_date=timezone.now())
        self.assertTrue(obj.is_published)
        self.assertIn(obj, self.model.objects.published())

변경된 유닛 테스트

from django.test import TestCase

from .models import BlogPost
from .behaviors.tests import PublishableTests


class BlogPostTestCase(PublishableTests, TestCase):
    model = BlogPost

    def create_instance(self, **kwargs):
        return BlogPost.objects.create(**kwargs)

모델을 명시된 테스트들 조합함

class BlogPostTestCase(PublishableTests, AuthorableTests, PermalinkableTests, TimestampableTests, TestCase):
    model = BlogPost

    def create_instance(self, **kwargs):
        return BlogPost.objects.create(**kwargs)

    def test_blog_specific_functionality(self):
        ...

추가적인 모델 테스팅 팁

  • 인스턴스나 픽스쳐를 테스트 하기위해 https://github.com/dnerdy/factory_boy를 사용하세요.
  • 테스트 케이스 상속을 사용해서 다른 시나리오를 검증하세요.
class StaffBlogPostTestCase(PublishableTests, AuthorableTests, PermalinkableTests, TimestampableTests, BaseBlogPostTestCase):
    def setUp(self):
        self.user = StaffUser()

class AuthorizedUserBlogPostTestCase(PublishableTests, AuthorableTests, PermalinkableTests, TimestampableTests, BaseBlogPostTestCase):
    def setUp(self):
        self.user = AuthorizedUser()

(Staff이든 Authorized User이든 예상되는 행동이 같을 때)

재사용성

결국엔 Behavrois 라이브러리를 구축하게 됨

  • Permalinkable
  • Publishable
  • Authorable
  • Timestampable

앱들 간 재사용 가능하며 커뮤니티를 통해서도 공유가 가능

더 많은 예시

  • Moderatable - BooleanField(‘approved’)
  • Scheduleable - (range 쿼리를 사용할 start_date 와 end_date)
  • GenericRelatable (실과 바늘 같은 content_type, object_id, GenericForeignKey)
  • Orderable - PositiveSmallIntegerField(‘position’)

역자주) 본문에서는 triplet(세쌍둥이)를 사용하여서 의역했습니다.

추천하는 앱 레이아웃

  • querysets.py
  • behaviors.py (querysets을 이용)
  • models.py (querysets과 behaviors로 구성됨)
  • factories.py (models을 이용함)
  • tests.py (모든 것을 이용, 큰 앱들을 위해 모듈로 나눔)

전 공유되는 behaviors, model, behavior test 믹스인을 common 앱에 담아서 사용할 때가 많습니다.

한계와 위험

기본적으로 장고 모델 상속에 대해 도전

얕은 추상화

  • 메타 옵션들을 명시적이지 않게 상속하지 마세요(정렬 등)
  • Manager vs Queryset vs Model (약간의 로직 중복)
  • ModelField 옵션들 (default=True vs default=False 을 오가면서 변경)
    종종 커스텀 쿼리셋을 합치거나, 메타 옵션들을 조합하는 등 구성요소들을 다뤄야 합니다.

서드 파티 헬퍼

꼭 해야할 필요가 없다면 바퀴의 재발명을 하지 마세요!

역자주) 바퀴의 재발명이란, 프로그래밍에서 존재하는 기술이 있다면 사용하고 새로 만들지 말라는 격언입니다.

테스터 헬퍼

결론

여기에 사용된 모든 예제 코드들은 Github 프로젝트에서 확인 가능합니다.

번역을 마치며

  • 오역 또는 오탈자에 대해선 댓글 혹은 메일을 남겨주세요.
  • 본 번역은 원저자의 허락을 받지는 못했습니다.(현재 연락을 시도해보고는 있습니다…)
  • 원글에서도 임포트를 함수 내에서 하는 건 안티패턴이 아니냐는 댓글이 달려있습니다.. 저자의 정확한 의도는 잘 모르겠지만 개인적으론 일반적인 코딩 컨벤션을 준수하는 것이 좋다고 생각하는 바이며, 번역한 글에서는 모든 코드에 대해서 전혀 수정하지 않았습니다.

[Views: 2623]