[Django] QuerySet, Manager, Active Record 패턴, django-model-utils의 InheritanceManager들 톺아보기 및 성능 개선기
장고의 ORM을 사용하다 보면 다양한 기능이 있지만, 가끔씩 답답해서 무언가 안타까운 경우도 있다. 개인적으로 이번에 업무를 진행하면서 우연히 알게 된 django-model-utils의 Manager를 알게되었고, 그와 동시에 이러한 내용을 정리해 두면 좋을 것 같아서 이번에 한 번 글을 쓰게 되었다.
먼저 장고 Models, Fields, Manager, QuerySet 그리고 Backend에 대해 알아보겠다.
전체적인 개요 / General Overview
0. Active Record Pattern
Active Record란?
- Software Architecture중의 한 가지 패턴→ RDBMS를 그대로 반영하는 패턴 (비즈니스 로직과 데이터의 영속성을 하나의 객체에 담아 일체감 있게 통합하는 것)
- 즉, 하나의 객체, 혹은 데이터, 모델이 자신의 영속성까지 책임(혹은 인지)을 겪는 상황이라고 볼 수 있다.
- 대표적으로 Ruby on Rails가 이에 해당하고, Django의 경우 공식 문서에서 Active Record의 철학을 따른다고만 하고 있다.
- 프로그램 속 각각의 객체가 데이터베이스 테이블의 row에 해당한다는 것.
- 그 객체의 속성들은 각각 column에 해당한다.
ORM(Object-Relational Mapping)이란?
- 프로그램 속 각각의 객체를 데이터베이스에 매핑한 것.
- SQL과 데이터 베이스의 사소한 디자인에서 캡슐화(혹은 추상화)해서 데이터베이스의 데이터를 마치 객체처럼 사용하는 것.
Active Record Pattern의 장점
- 간단하다. 객체 하나에 모든 CRUD를 달성할 수 있게 해준다.
- 가볍게 쓰기 좋다. 즉, 단순하다. 이는 곧 속도로 이어지며, MVP 모델 혹은 급하게 무언가 만들어야 하는 경우에 빛을 발한다
Active Record Pattern의 단점
- 하지만 하나의 클래스 혹은 객체가 모든 데이터베이스와 관련된 로직을 가지고 있다. 심지어 검증 로직 까지.
- 특정 영속성 메커니즘과 강력하게 묶인다. 강하게 결합(tight coupling)된다는 단점이 있다.
- 즉, 하나를 바꾸면 다른 곳에 영향이 가 다 바꿔야 한다는 소리이다.
- RPY(Repeat Your Self, 공통 로직 반복)를 요구하게 된다.
1. Models과 Fields에 대해서
Models의 경우, 위에서 말한 Active Record Pattern을 실행하는 대표적인 패턴. 이 때 객체가 데이터 베이스의 row를 감싼다. 이 때 객체는 데이터와 행동(CRUD)을 모두 들고 있으며 Model Manager를 통해 데이터베이스에 지속된다. ORM설계에서 모델은 Fields에 정의된 쿼리 결과를 파이썬 객체로 연결(map)시키는 메타데이터를 포함한다. QuerySet은 이 map을 이용해서 작업을 수행하고 데이터가 객체의 속성에 완벽히 매핑된 모델의 인스턴스를 반환한다.
⇒ 즉, 모델은 데이터베이스의 데이터를 어떻게 변환시킬 지에 대한 규칙을 가지고 있고, 그리고 그 규칙을 기반으로 변환시켜주는 것은 QuerySet이 수행한다.
⇒ 이 때, Fields는 그 자체로 의미가 없다. 단순히 어떤 타입에 대해 어떻게 규칙을 실행할지에 대해 명시를 해두는 곳이라 생각하면 편한다. (FK의 경우에는 어떻게 연결할지 등의 의미)
2. Manager에 대해서
- Django Docs에서 설명에 따르면 매니저는 장고 모델에 데이터베이스 쿼리 작업을 제공하는 인터페이스이다.
- 이는 Table Data Gateway처럼 작동한다.
- 여기서 Table Data Gateway란, 객체가 마치 데이터베이스 테이블과 연결된 게이트웨이처럼 작동하는 것을 의미.
- 즉, 실제 데이터를 가져오는 것과, 그 데이터를 사용하는 것을 분리하는 것
- 기본적으로 ‘objects’도 매니저이고, 이는 모델 클래스에 자동 추가 된다.
* django.db.models.manager
기본적으로 4개의 클래스가 정의되어 있다. (BaseManager, Manager, ManagerDescriptor, EmptyManager)
class BaseManager:
# To retain order, track each time a Manager instance is created.
creation_counter = 0
# Set to True for the 'objects' managers that are automatically created.
auto_created = False
#: If set to True the manager will be serialized into migrations and will
#: thus be available in e.g. RunPython operations.
use_in_migrations = False
...
--------
class Manager(BaseManager.from_queryset(QuerySet)):
pass
--------
class ManagerDescriptor:
def __init__(self, manager):
self.manager = manager
...
--------
class EmptyManager(Manager):
def __init__(self, model):
super().__init__()
self.model = model
def get_queryset(self):
return super().get_queryset().none()
* BaseManager
- 이 BaseManager를 상속한 매니저에서 반환하는 쿼리셋에 자신이 원하는 쿼리 메서드를 추가할 수 있다.
- 다음과 같은 코드를 작성할 수 있다.
class PersonQuerySet(models.QuerySet):
def authors(self):
return self.filter(role="A")
def editors(self):
return self.filter(role="E")
class PersonManager(models.Manager):
def get_queryset(self):
return PersonQuerySet(self.model, using=self._db)
def authors(self):
return self.get_queryset().authors()
def editors(self):
return self.get_queryset().editors()
class Person(models.Model):
first_name = models.CharField(max_length=50)
last_name = models.CharField(max_length=50)
role = models.CharField(
max_length=1, choices=[("A", _("Author")), ("E", _("Editor"))]
)
people = PersonManager()
authors = Person.people.authors()
* BaseManager
- 디폴트 값으로, ‘objects’로 사용되는 클래스.
- BaseManager.from_queryset(QuerySet)의 반환 값을 상속받는다.
def from_queryset(cls, queryset_class, class_name=None):
if class_name is None:
class_name = "%sFrom%s" % (cls.__name__, queryset_class.__name__)
return type(
class_name,
(cls,),
{
"_queryset_class": queryset_class,
**cls._get_queryset_methods(queryset_class),
},
)
3. QuerySet에 대해서
Queryset은 파이썬 객체를 이용해 쿼리를 만들 수 있게 해준다.
- 그리고 각기 다른 Backend를 사용하여 이러한 Query들을 Raw SQL 쿼리로 변경 시켜주는 작업을 한다.
- Lazy Load 패턴을 사용한다.
- Lazy Load를 간단하게 설명하면 실제 쿼리(SQL Query)는 서버 혹은 다양한 애플리케이션이 실제로 결과를 원할 때에 실행되는 것이다.
- 즉, 데이터베이스로부터 가져온 데이터의 집합이라고 생각해도 좋다.
→ 그렇다면 Manager와 QuerySet의 관계는 무엇인가?
Manager는 우리가 만든 Model 을 위한 데이터베이스의 실행방식을 정의한 인터페이스라고 보면 좋다.
- 이를 위해 장고는 기본적으로, 만약 커스텀 매니저를 정의하고 있지 않다면, objects를 속성을 가지고 있다.
- 그리고 이 Manager는 같은 모델에서 여러개가 정의될 수도 있다.
- 이 커스텀 매니저는 무조건 QuerySet에 국한되어 있지 않다.
코드 예시
from datetime import date, timedelta
from django.db import models
from django.db.models.functions import Concat
class PersonManager(models.Manager):
def with_extra_fields(self):
return self.annotate(
full_name=Concat("first_name", models.Value(" "), "last_name"),
)
def experienced(self):
return self.filter(
join_date__lt=date.today() - timedelta(days=1000)
)
class PersonAnalyticsManager(models.Manager):
def number_of_unique_names(self):
return self.aggregate(
count=models.Count("last_name", distinct=True),
)["count"]
class Person(models.Model):
first_name = models.CharField(max_length=255)
last_name = models.CharField(max_length=255)
join_date = models.DateField(default=date.today)
# 기본이 되는 매니저
objects = PersonManager()
# 추가적으로 정의한 매니저
analytics = PersonAnalyticsManager()
def __str__(self):
return f"{self.first_name} {self.last_name}"
위의 코드를 실행하면 다음과 같이 된다.
>>> from .models import Person
>>> Person.objects.with_extra_fields().order_by("full_name")
<QuerySet [<Person: Catherine Smith>, <Person: Joe Doe>, <Person: Omega Smith>]>
>>> Person.objects.experienced()
<PersonQuerySet [<Person: Joe Doe>]>
>>> Person.analytics.number_of_unique_names()
2
주의 할 점이 있다면, 매니저 끼리는 Chaning, 즉, 서로가 서로에게 무관한 존재이다.
>>> Person.objects.experienced().with_extra_fields()
...
AttributeError: 'QuerySet' object has no attribute 'with_extra_fields'
만약 이러한 두 매니저를 같이 쓰고 싶다면 as_manager()를 쓸 때가 온 것이다.
from datetime import date, timedelta
from django.db import models
from django.db.models.functions import Concat
class PersonQuerySet(models.QuerySet): # 커스텀 쿼리셋으로 변경
def with_extra_fields(self):
return self.annotate(
full_name=Concat("first_name", models.Value(" "), "last_name"),
)
def experienced(self):
return self.filter(
join_date__lt=date.today() - timedelta(days=1000)
)
def number_of_unique_names(self):
return self.aggregate(
count=models.Count("last_name", distinct=True),
)["count"]
class Person(models.Model):
first_name = models.CharField(max_length=255)
last_name = models.CharField(max_length=255)
join_date = models.DateField(default=date.today)
# 매니저로 쓰인 쿼리셋
objects = PersonQuerySet.as_manager()
def __str__(self):
return f"{self.first_name} {self.last_name}"
>>> Person.objects.experienced()
<PersonQuerySet [<Person: Joe Doe>]>
>>> Person.objects.experienced().with_extra_fields().get().full_name
'Joe Doe'
>>> Person.objects.experienced().number_of_unique_names()
1
>>> Person.objects.number_of_unique_names()
2
4. django-model-utils의 Manager중 InheritanceManager 알아보기
현재 기준으로 Django Model Utils에는 총 5개의 Model Manager가 존재한다. InheritanceManager, JoinQueryset, QueryManager, SoftDeletableManager 그리고 Mixins이다.
4-1. InheritanceManager
만약 다음과 같은 Model을 설계했다고 해보자.
class Student(models.Model):
first_name = models.CharField(max_length=50)
last_name = models.CharField(max_length=50)
school_name = models.CharField(max_length=50)
major_name = models.CharField(max_length=50)
class PhDStudent(Student):
professor_name = models.CharField(max_length=50)
paper_name = models.CharField(max_length=50)
class UndergraduateStudent(Student):
pass
위의 모델을 돌려 마이그레이션을 하면 정확히 3개의 테이블이 생길 것이다. 여기서 Student Table은 PhDStudent와 UndergraduateStudent가 상속하고 있으며, 이를 통해 Student Table은 포괄적인 테이블이 될 것이다. 만약 이를 django shell을 통해 적용해 보면 다음과 같은 행동을 볼 수 있다.
>>> s = Student( )
>>> s.first_name = 'Kim'
>>> s.second_name = 'Kani'
>>> s.major_name = 'CS'
>>> s.school_name = 'SKKU'
>>> s.paper_name = "Attention Is All You Need"
# 에러가 난다.Student에는 없고 PhDStudent에만 있기 때문이다.
>>> s.save()
# 위의 에러를 제외하면 정상적으로 잘 저장된다.
>>> p = PhDStudent()
>>> p.paper_name = "Attention Is All You Need"
>>> p.school_name = 'SKKU' # 부모의 필드까지 모두 가지고 있기 때문에 정상적으로 작동한다
(...생략...)
>>> p.save()
실제 DB에는 어떻게 저장되어 있을까? 위의 코드를 쉘에서 실행하면 Student에는 두개의 데이터가 그리고 PhDStudnet에는 하나의 데이터가 적재된다. 그렇다면 부모에서 자식 모델에게 접근이 될까? 당연히 된다.
>>> s = Student.objects.get(paper_name="Attention Is All You Need")
>>> s.phdstudnet # 이 때 s는 PhDStudent클래스라 보면 된다.
>>> s.phdstudnet.paper_name
근데 여기서 사소한 불편함이 있다. Student를 상속하는 테이블 및 클래스가 많아져서 일일이 하나하나 내가 원하는 Class인지 검증하면서 찾아야 하는 불편함이 있다. 즉 일일이 하나하나 클래스의 attribute를 찾으면서 검증해야한다. 이런 부분을 해결하기 위해 있는 것이 바로 django-model-utils의 InheritanceManager이다.
from model_utils.managers import InheritanceManager
class Student(models.Model):
first_name = models.CharField(max_length=50)
last_name = models.CharField(max_length=50)
school_name = models.CharField(max_length=50)
major_name = models.CharField(max_length=50)
objects = InheritanceManager()
위와 같이 objects에다 InheritacneManager()를 해주면 된다. 만약 이를 실제 코드에 적용시키면 다음과 같이 활용이 가능하다.
specific_students = Student.objects.filter(name='kim').select_subclasses()
for student in specific_students:
# "student" 는 자동으로 PhDStudent, UndergraduateStudent, Student중 하나의 인스턴스로 바뀐다.
Student.objects.filter(name='kim').instance_of(PhDStudent, UndergraduateStudent)
#위의 경우처럼 특정 인스턴스를 지정할 수 있다.
5. 내가 겪은 상황
기존 코드의 문제점 및 상황은 다음과 같았다.
- model_one.model_foreign_set.order_by("-created_at") 을 통해 해당 모델(이 사례에서는 model_one)의 모든 model_foreign_set의 인스턴스 결과를 조회하는 것이 문제였다.
- 그리고 해당 model_foreign을 전부 순회하면서 일일히 회사에서 커스터마이징한 타입 캐스팅을 일일이 순회하는 것이 성능 하락을 원인이 되었다는 것을 추정했다.
- 여기서 커스텀 타입 캐스팅 함수에 들어갈 때도 고정된 n값의 순회가 한 번 더 일어남
- 대략 Big O(n*m)~O(n^2) (n ≥ m) 정도의 속도가 나타날 수 있었다.
- 특히 instance를 확인할 때 사용하는 getattr의 경우 소모하는 비용이 큰 것을 확인을 했다.
그래서 나의 경우 애초에 InheritanceManager를 사용하여 커스텀 타입 캐스팅을 사용하는 상황을 없애고자 했다. 생각보다 이 작업은 순조롭게 진행되어서 1초에서 최대 7초까지 걸리던 프로덕션 응답 속도를 40~60%까지 응답속도를 줄이는 것이 가능해 졌다.
참고 자료
- https://medium.com/@shiiyan/active-record-pattern-vs-repository-pattern-making-the-right-choice-f36d8deece94
- https://softwareengineering.stackexchange.com/questions/70291/what-are-the-drawbacks-to-the-activerecord-pattern
- https://medium.com/@benjaminchamwb/object-relational-mapping-orm-vs-active-record-navigating-database-interaction-patterns-fe5c45a2aaa3
- https://stackoverflow.com/questions/1404405/what-is-the-purpose-of-active-records
- https://jairvercosa.medium.com/manger-vs-query-sets-in-django-e9af7ed744e0
- https://stackoverflow.com/questions/29798125/when-should-i-use-a-custom-manager-versus-a-custom-queryset-in-django
- https://medium.com/bunkerkids/django-model-manager-8b5d8b3b539b
- https://fly.io/django-beats/organizing-database-queries-managers-vs-querysets/