Python/Django

[Django] QuerySet, Manager, Active Record 패턴, django-model-utils의 InheritanceManager들 톺아보기 및 성능 개선기

Kani Kim 2024. 10. 20. 19:17

 

장고의 ORM을 사용하다 보면 다양한 기능이 있지만, 가끔씩 답답해서 무언가 안타까운 경우도 있다. 개인적으로 이번에 업무를 진행하면서 우연히 알게 된 django-model-utils의 Manager를 알게되었고, 그와 동시에 이러한 내용을 정리해 두면 좋을 것 같아서 이번에 한 번 글을 쓰게 되었다.

 

먼저 장고 Models, Fields, Manager, QuerySet 그리고 Backend에 대해 알아보겠다.

 

전체적인 개요 / General Overview

 

0. Active Record Pattern

https://docs.djangoproject.com/ko/5.0/misc/design-philosophies/

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. 내가 겪은 상황

기존 코드의 문제점 및 상황은 다음과 같았다.

  1. model_one.model_foreign_set.order_by("-created_at") 을 통해 해당 모델(이 사례에서는 model_one)의 모든 model_foreign_set의 인스턴스 결과를 조회하는 것이 문제였다.
  2. 그리고 해당 model_foreign을 전부 순회하면서 일일히 회사에서 커스터마이징한 타입 캐스팅을 일일이 순회하는 것이 성능 하락을 원인이 되었다는 것을 추정했다.
    • 여기서 커스텀 타입 캐스팅 함수에 들어갈 때도 고정된 n값의 순회가 한 번 더 일어남
    • 대략 Big O(n*m)~O(n^2) (n ≥ m) 정도의 속도가 나타날 수 있었다. 
  3. 특히 instance를 확인할 때 사용하는 getattr의 경우 소모하는 비용이 큰 것을 확인을 했다.

그래서 나의 경우 애초에 InheritanceManager를 사용하여 커스텀 타입 캐스팅을 사용하는 상황을 없애고자 했다. 생각보다 이 작업은 순조롭게 진행되어서 1초에서 최대 7초까지 걸리던 프로덕션 응답 속도를 40~60%까지 응답속도를 줄이는 것이 가능해 졌다. 

참고 자료

728x90
반응형