Django Model - 모델의 유연한 코딩 model Manager & Mixin

정현우·2023년 3월 30일
5

Django Basic to Advanced

목록 보기
25/37
post-thumbnail

[ 글의 목적: django에서 코드 반복을 줄이고 재사용성 증대, 생산성과 유지보수 이득을 가져가기 위한 설계 중 하나인 mixin, manager 활용법 제대로 알기 ]

Django Model Manager & Mixin

django model을 사용하면서 "공통된 queryset 호출", "특정 filter 조건에 model 대상 특정 비즈니스 로직의 반복" 을 경험하게 된다. 그럴때마다 admin 로직 또는 api 로직에서 매번 반복하면 코드의 재사용성이 저하된다. 코드 재사용성과 유지보수 향상에 많은 도움이 되는 manager & mixin 활용에 대해 알아보자.

우선 등장하게 될 예제는 https://github.com/Nuung/django-all-about 레포 기준으로 진행한다.

Manager

  • Django model은 모두 default manager이 있고 object라는 attribute에 binding 되어 있다. 즉, objects = models.Manager() 이 모든 모델에 기본적으로 선언이 되어 있다는 것이다. 그래서 queryset handling할때 Model.objects.filter ... 와 같이 접근해야 한다. 물론 objects 이름을 apple 이런식으로 바꿔 Model.apple... 과 같이 접근할 수 있다. 이런식으로 어쎄신 코딩이 가능하다.

  • 하지만 objects라는 default manager를 계속 사용하는게 협업에 있어서 유리하다. 핵심은 default manager가 이미 존재하고, 이미 objects 라는 Attribute로 존재한다는 것이다.

사용 예시

  • 흔히 특정 모델에 대한 table 전체 관점의 queryset, 특히 통계에 대한 queryset을 구성할때 많이 사용한다. status 또는 active 라는 choice 값을 가지는 model이 많을 것이다. User 를 예시로 살펴보자.
class User(models.Model):
	...
    active = models.BooleanField(
    	default=True
	    verbose_name="활성화 여부",
        help_text="유저의 현재 활성화 상태입니다.",
    )
  • django admin을 생각한다면, verbose_namehelp_text 를 작성하는 센스,, 우리는 user model 대상으로 User.object.filter(active=True) 와 같은 queryset을 구성할 수 있다.

  • 이후 특정 비즈니스 로직에서, 예를 들면 active 유저 대상으로 (1) 광고 메일 집행, (2) 특정 batch 돌릴때 user model filtering을 위해, (3) 다른 비즈니스 로직에서 user 를 filtering할 때 등 다양한 로직이 있을 수 있다. 이럴때 마다 User.object.filter(active=True) 를 쓰고 또 and를 위해 Q import 하고, 게다가 최적화를 위한 prefetch 등을 고려하다보면 한 쿼리셋을 만드는 코드가 엄청 heavy 해질 수 있다.

  • 이때 Custom Manager를 만들면 간단해진다. 일단 default manager는 models.Manager를 통해 상속받고 오버라이딩할 수 있다.

# 1번 방법
class ActiveManager(models.Manager):
	def actives(self):
    	return self.get_queryset().filter(active=True)

class User(models.Model):
	...
    objects = ActiveManager()


# 2번 방법
class ActiveManager(models.Manager):
	def get_queryset(self):
    	return super().get_queryset().filter(active=True)

class User(models.Model):
	...
    objects = models.Manager()
    actives = ActiveManager()
  • 1번 방법 으로는 User.objects.actives() 로, 2번 방법 으로는 User.actives.all() 로 사용할 수 있다. 2번 방법 이 다른 모델에서도 재사용가능한 manager를 만드는데 유리하다고 볼 수 있다. 하지만 해당 방법에서 주의할 점이 꼭 있다.

  • get_queryset method 는 무조건 query_set을 return 해야한다. 그리고 Manager를 2개 사용하기때문에 어떤 것을 default 로 사용할지 정하는게 굉장히 중요하다!

  • 그리고 ActiveManager는 active를 사용하는 어떤 모델에서든 사용이 가능해진다. 다른 예제를 하나만 더 살펴보자!

class PublishedManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(published_date__lte=timezone.now()).order_by('-published_date')

class Post(models.Model):
	...
    published_date = models.DateTimeField(blank=True, null=True)

    objects = models.Manager()
    published = PublishedManager()

    def publish(self):
        self.published_date = timezone.now()
        self.save()
  • 위 예시에서는 new_post = Post(...) 을 하고 new_post.save() 또는 new_post.publish() 를 통해 바로 create 할 수 있다. 전자의 경우 published_date 는 비어있게 되어 있다.

  • 이 점을 활용해서 manager를 통해 Post.objects.all()Post.published.all() 로 2가지 all queryset을 활용할 수 있다. 쉽고 간편하게 날짜를, 속성 및 상태를 필터링한 queryset를 가져올 수 있다.

Default 우선 순위

  • 여기서 조금 더 깊게 들어가보면, (django 4.0버전 이상기준) django.db.models.base.pyModelBasenew_class.add_to_class("_meta", Options(meta, app_label)) 로 model안의 meta가 정의되는 것을 볼 수 있다. 그리고 _default_manager property가 정의되어 있는 것을 확인할 수 있다. 이 역시 어디서 가져오느냐! meta 에서 가져온다!
@property
def _default_manager(cls):
	return cls._meta.default_manager
  • 그리고 그 meta는 Options 이라는 class를 사용하는데 django.db.models.options.py 에 정의되어 있다. 바로 여기서! 아래 method를 찾을 수 있다!
@cached_property
def default_manager(self):
    default_manager_name = self.default_manager_name
    if not default_manager_name and not self.local_managers:
        # Get the first parent's default_manager_name if there's one.
        for parent in self.model.mro()[1:]:
            if hasattr(parent, "_meta"):
                default_manager_name = parent._meta.default_manager_name
                break

    if default_manager_name:
        try:
            return self.managers_map[default_manager_name]
        except KeyError:
            raise ValueError(
                "%s has no manager named %r"
                % (
                    self.object_name,
                    default_manager_name,
                )
            )

    if self.managers:
        return self.managers[0]
  • 여기서 다중 manager를 사용할때 어떤 manager가 우선순위를 가지고 정의되는 것을 확인할 수 있다.
  1. [1순위] 는 직접 model의 class Meta 하위에 default_manager_name 을 정의했을 때!

  2. [2순위] 는 부모, 다중 상속의 경우 가장 나중(가장 왼쪽)의 default_manager_name를 따르는 것!

  3. [3순위] 는 1, 2순위 모두 없고, model 내에 정의된 managers 들의 arrays 중 첫 번째, 즉 코드상 가장 먼저, 가장 위에 manager를 정의한게 default가 된다.

  • 다중 manager를 사용할 땐 default를 꼭 바꾸지 않고 제대로, 기존 로직에 방해되지 않게 적용해야한다. default를 나중에 갑자기 바꿧을때 후폭풍은...

Mixin

python에서 다중상속을 지원하고, 그런 점을 활용해 "기능적 확장"을 목표로 mixin pattern이라는 형태를 사용한다. java에 비교하자면 interface + abstractc class 느낌인데, 오히려 abstractc에 가깝다. 이는 사실 모든 OOP에 나타나는 현상이긴 한데, python이 순수 객체 지향에다가 손쉽게 다중상속이 되니 두드러지게 나타나는 것 뿐이다.

  • django를 사용하다보면 model이 가지는 method가 필요하기 마련이다. 그러나 한 모델에 해당 메소드를 계속해서 추가해가다 보니 model이 너무 뚱뚱해진다. 개인적으로 django가 model에 너무 의존적이고 결집도가 높다는 의견, 그렇기 때문에 불편하다는 의견이 이 현상에서 나오지 않나 싶다.

  • 그리고 model간 사용하는 비슷한 성격의 method가 눈에 보인다. 모두 흩어져있으니 영향 범위 파악도 어렵고 유지보수도 어렵다. 이런 부분을 Mixin 으로 빼내는 것이다. Model 은 그 자체 모델정의로 살려두면서 다양한 기능 추가를 Mixin 상속을 통해 꽤할수 있다.

사용 예시

from django.db import models
from django.utils.text import slugify

class TimestampMixin(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True

class SlugMixin(models.Model):
    slug = models.SlugField(unique=True)

    class Meta:
        abstract = True

    def save(self, *args, **kwargs):
        if hasattr(self, "title") and not self.slug:
            self.slug = slugify(self.title)
        super().save(*args, **kwargs)


class Post(TimestampMixin, SlugMixin):
    title = models.CharField(max_length=100)
    body = models.TextField()

    def __str__(self):
        return self.title
  • Post 라는 model을 위해 abstract 인 model class를 2개나 상속 받았다. 그리고 TimestampMixinSlugMixin 는 어느 모델에서나 상속받을 수 있다. TimestampMixin 에서 사용하는 필드는 이력보관과 업데이트 여부를 위해 통계가 아니고서야 대부분의 table에 포함되는 field이니, 생각보다 굉장히 많이, 자주, 흔히 사용되는 형태다. 아예 models.Model를 상속받는 자체 BaseModel 을 만들고 시작하는 경우도 많다.
class PostStatus(models.TextChoices):
    DEFAULT = "DE", _("Default")
    PUBLIC = "PU", _("Public")
    PRIVATE = "PR", _("Private")


class PublicPostMixin:
    def set_public(self):
        self.status = PostStatus.PUBLIC.value
        self.save()


class PrivatePostMixin:
    def set_private(self):
        self.status = PostStatus.PRIVATE.value
        self.save()


class Post(TimestampMixin, SlugMixin, PublicPostMixin, PrivatePostMixin):
    title = models.CharField(max_length=100)
    status = models.CharField(
        max_length=2,
        choices=PostStatus.choices,
        default=PostStatus.DEFAULT,
    )
    body = models.TextField()

    def __str__(self):
        return self.title
  • 위 모델을 그대로 사용하면서, PostStatus, PublicPostMixin, PrivatePostMixin 3개 추가해서 model을 update 해보자.

  • 우린 PublicPostMixin 으로 status가 PostStatus.PUBLIC 인 친구들 대상으로만 또 따로 class & method를 분리해서 model 자체에 대한 응집도를 낮출 수 있다. public을 대상으로한 다양한 queryset 호출 또는 특별한 로직은 model에서가 아니라 여기 mixin에서만 수정하면 model을 사용하는 모든 곳에서 호출이 가능하다.

>>> t = Post(title="first post test model")
>>> t
<Post: first post test model>
>>> t.save()
>>> Post.objects.get(pk=1)
<Post: first post test model>
>>> t.status
'DE'
>>> t.set_public()
>>> t.status
'PU'
  • 이제 공동 작업을 하기도, 분산 작업을 하기도 편해졌다. 아래와 같이 다양한 커스텀이 가능해 진다.
class PublicPostMixin:
    
    def pre(self):
        if self.status is not PostStatus.PUBLIC:
            return
    
    def set_publics(self):
        self.status = PostStatus.PUBLIC.value
        self.save()

    def find_post_expose(self):
        self.pre()
        ...request...
  • pre라는 method를 통해 set 이외 method는 PostStatus.PUBLIC가 아니면 바로 return하게 하고, find_post_expose 와 같이 아예 NetworkIO를, crawling을 하는 method도 추가할 수 있다. 그럼 특정 instance 기반으로 다양한 호출이 가능해 진다. 여기에 celery 와 같은 async worker를 활요하면 퍼포먼스 향상도 가능하다!

  • 게다가 "@static or @classmethod" 를 추가한다면 더 재미있고 확장가능해 진다!

Model에 property 사용하기

  • python의 @property 에 대해 배경 지식이 있어야 이해가능하다.
class Post(TimestampMixin, SlugMixin, PublicPostMixin, PrivatePostMixin):
	...
    @property
    def is_old(self):
        if datetime.datetime.now().day - self.created_at.day > 1:
            return True
        return False
	...
  • 당연하게, django model에서 python class의 다양한 기능을 활용할 수 있다. 그 중 유용한게 @property 이다. 위와 같이 model에 instance의 특정 필터, 조건값을 property로 만들어 매번 체크해야 했던 사항을 아래와 같이 심플하게 축약 가능하다.
>>> t = Post.objects.get(pk=1)
>>> t.is_old
False

Mixin Library

  • 유용한 django mixin library, django-model-utils 를 통해 model을 위한 다양한 boilerplate를 맛볼 수 있다. djanog가 추구하는 "디자인 철학"에 굉장히 알맞지 않나라는 생각이 든다.

  • 해당 라이브러리의 from model_utils.managers import QueryManager를 통해 아래와 같이 빠르게 model의 property로써 queryset을 구성하는 method를 만들 수 있다.

from django.db import models
from model_utils.managers import QueryManager

class Post(models.Model):
    ...
    published = models.BooleanField()
    pub_date = models.DateField()
    ...

    objects = models.Manager()
    public = QueryManager(published=True).order_by('-pub_date')
  • django의 model의 이런 점을 잘 활용하면 Django의 View, DRF의 RestAPI, 심지어 admin에서까지 재사용가능한 형태를 만들 수 있다. model 자체가 중심이 되어 일관성을 가지게 되는 것이다. 이게 단점이라고 지적하는 의견도 많다.

  • 확실한점은 Django의 Model의 활용도에 따라 Django Project의 "간결성", "재사용성", "유지보수성", "개발의 속도" 가 차이가 난다는 점이다. Django에 대해 다룬다면, 한 번, Django model에 초점을 제대로 맞춰서 전체 project를 구성하는 것도 좋은 경험이 되리라 생각한다.


출처

profile
도메인 중심의 개발, 깊이의 가치를 이해하고 “문제 해결” 에 몰두하는 개발자가 되고싶습니다. 그러기 위해 항상 새로운 것에 도전하고 노력하는 개발자가 되고 싶습니다!

0개의 댓글