Django QuerySet - 분석 및 특징, 최적화

Hoonkii·2022년 1월 3일
1

Django

목록 보기
2/2

오늘은 Django ORM 중 쿼리셋(Queryset)에 대해서 다루어 보려고 한다.

오늘 포스트는 유튜브 pycon 코리아에서 발표된 내용을 를 기반으로 쿼리셋에 대한 특징, 최적화 등을 다룰 것이다.
발표된 내용이 정말 유익해서, 발표 내용과 내가 분석한 내용들을 토대로 살을 덧붙여 정리해보고자 한다.

Lazy Loading, Eager Loading

  • Lazy Loading

Django ORM에서는 정말 필요할 때, 즉 실제 값이 사용될 때만 DB를 hitting한다. 이걸 Lazy Loading이라고 한다.

아래 코드의 예시를 보자.

users: QuerySet = User.objects.all()
# 현재까지는 아직 sql이 수행되지 않았다.

user_list = list(users)
# 이제 sql이 수행되었다.

즉 쿼리셋은 그 값이 실제로 사용될 때 (Evaluate) DB를 hitting하여 sql을 수행한다.
쿼리셋이 Evaulate 되는 예시는 다음과 같다.

  • iteration
  • slicing
  • repr()
  • len()
  • count()
  • list() 등등

이 특징을 이해하는 것은 매우 중요한데, 그 이유는 우리가 이 Django ORM 쿼리셋에 대한 이해 없이 코드를 짜면 불필요한 쿼리를 발생시켜 성능문제를 야기할 수 있기 때문이다.

아래의 코드 예시를 보자

posts: QuerySet = Post.objects.all()

first_post = posts[0] # slicing
# 여기서는 Limit을 통해 첫 번째 데이터만 DB에서 가져온다.

post_list = list(posts) # list
# 여기서는 select * from posts;로 또 하나의 추가적인 쿼리가 사용되어 DB에서 전체를 가져온다. 

만약 첫 포스트에 대해서 어떤 액션을 취하고 싶고, 그 다음 전체 포스트를 리스트로 변환하여 가져오는 형태로 코드를 짜면 어떻게 될까? 이 코드는 의도한대로 잘 동작하지만, 불필요하게 DB를 두번 hit 하는 문제가 있다.

왜냐면 posts 를 슬라이싱하는 과정에서 SELECT FROM post LIMIT 1 쿼리가 호출되며, 다시 모든 post목록을 얻기 위해 SELECT FROM post 쿼리가 호출되기 때문이다.

쿼리셋의 Lazy Loading 때문에 첫 번째 줄에서 쿼리를 실제 수행하는 것이 아니라, 두 번째 줄에서 실제 Evaluate할 때 쿼리가 실행되기 때문에 발생한 문제이다.

이 특성을 이해하지 못하고 코드를 짜면 불필요한 쿼리가 생성되어 성능 상 좋지 않게 된다.

이를 해결하기 위해서는 first_post 그리고 post_list의 순서를 바꾸어 캐싱(result_cache)을 이용하면 된다.

  • Eager Loading

기본적으로 Django의 QuerySet은 Lazy Loading이다. 즉 Django의 모델에서 외래키로 참조된 객체들은 처음부터 로딩되는 것이 아니라, 그 변수가 참조될 때 로딩이된다. 그러나 join등을 통해 여러 개의 객체들을 DB로 부터 한 번에 메모리로 가져오고 싶을 때가 있다. 이를 수행하려면 Eager Loading을 사용하면 된다.

Eager Loading을 사용하지 않았을 때 발생할 수 있는 문제를 설명해보겠다. 예를 들어, 포스트(게시물)는 단 하나의 카테고리(외래키)를 갖는다고 가정했을 때, 아래와 같은 코드는 심각한 성능 문제를 야기할 수 있다. 첫 번째 줄에서 포스트들의 목록들은 쿼리를 통해 불러왔지만, for loop 안에서 category는 DB에서 불러온 상태가 아니므로, iteration 마다 lazy loading을 통해 불러와질 것이다. 그러면 post의 수(N)만큼 카테고리를 불러오는 쿼리가 호출되는 문제가 발생한다.

posts = Posts.objects.all()

for post in posts:
    post.category

이 문제는 N+1문제이며, Django QuerySet에서는 select_related(), prefetch_related() 라는 인터페이스를 통해 해결할 수 있다. 이 함수들에 대해 다루기 전에 QuerySet의 구조를 먼저 분석해보자.

QuerySet 코드 분석

class QuerySet:
    """Represent a lazy database lookup for a set of objects."""

    def __init__(self, model=None, query=None, using=None, hints=None):
        self.model = model
        self._db = using
        self._hints = hints or {}
        self._query = query or sql.Query(self.model)
        self._result_cache = None
        self._sticky_filter = False
        self._for_write = False
        self._prefetch_related_lookups = ()
        self._prefetch_done = False
        self._known_related_objects = {}  # {rel_field: {pk: rel_obj}}
        self._iterable_class = ModelIterable
        self._fields = None
        self._defer_next_filter = False
        self._deferred_filter = Non

쿼리셋은 여러 개의 필드를 가지는데 그 중 주요한 필드는 다음과 같다.

  • _query
    _query는 생성자의 query 혹은 django.db.models.sql 패키지의 Query(model)을 할당 받는다. 이 sql 패키지의 Query 모델의 코드는 아래 코드와 같으며, 하나의 SQL 쿼리를 의미한다. 즉 모델 객체 목록을 불러오기 위한 쿼리셋에서는 하나의 메인 SQL 쿼리가 존재하는 것이다.
class Query(BaseExpression):
    """A single SQL query."""

    alias_prefix = 'T'
    subq_aliases = frozenset([alias_prefix])

    compiler = 'SQLCompiler'

    def __init__(self, model, where=WhereNode, alias_cols=True):
        self.model = model
        self.alias_refcount = {}
        # alias_map is the most important data structure regarding joins.
        # It's used for recording which joins exist in the query and what
        # types they are. The key is the alias of the joined table (possibly
        # the table name) and the value is a Join-like object (see
        # sql.datastructures.Join for more information).
        self.alias_map = {}
        # Whether to provide alias to columns during reference resolving.
        self.alias_cols = alias_cols
        # Sometimes the query contains references to aliases in outer queries (as
        # a result of split_exclude). Correct alias quoting needs to know these
        # aliases too.
        # Map external tables to whether they are aliased.
        self.external_aliases = {}
        self.table_map = {}     # Maps table names to list of aliases.
        self.default_cols = True
        self.default_ordering = True

잘 생각을 해보면 우리가 어떤 모델 목록을 불러오기 위해 코드를 짜면 다음과 같이 짤 것이다.

Model.objects.filter(name="hoonki")

그러면 우리가 불러오고자 하는 모델에 대한 메인 쿼리가 있어야 할 것이다 그래서 이 _query 변수는 메인 모델을 불러오기 위한 단일 메인 쿼리를 의미한다고 보면 된다.

  • _result_cache
    QuerySet이 _query 변수를 통해 실제 SQL을 실행하고 전달받은 결과 값을 캐싱하는 변수이다. 아래 코드를 보면 _fetch_all()함수를 통해 result_cache값이 세팅된다. _fetch_all()함수는 queryset이 evaluate되는 시점 즉 실제 SQL이 실행될 때 호출되는 함수이다.
def _fetch_all(self):
    if self._result_cache is None:
        self._result_cache = list(self._iterable_class(self))
    if self._prefetch_related_lookups and not self._prefetch_done:
        self._prefetch_related_objects()
  • _prefetch_related_lookups = ()
    _prefetch_related_lookups는 추가 쿼리 셋이 담기는 필드이다. 예를 들어 어떤 하위 객체를 SQL의 where in절을 통해 별도의 쿼리 셋으로 불러온다면, 해당 값은 _prefetch_related_lookups 변수에 저장된다. 일반적으로 Eager Loading을 지원하기 위해 ORM이 쓰는 Approach는 조인을 하거나, 역참조의 경우 메인 모델의 pk 목록들을 토대로 서브 모델에서 select * from sub where main_id in (1, 2, 3) 와 같은 쿼리를 통해 불러오는 방식이 있다. _prefetch_related_lookups는 두 번째 경우를 지원하기 위해 사용되는 변수라고 보면 된다.
    _prefetch_related_lookups의 경우 0~N개의 쿼리를 포함할 수 있다고 생각하면 된다.

자 그러면 다시 돌아와서, select_related(), prefetch_related()가 어떻게 사용되고, 어떻게 N+1문제를 해결할 수 있는지 살펴보자.

사용 예시코드이다.

Posts.objects.filter(author="hoonki").select_related('정방향_참조_필드').prefetch_related('역방향 참조 필드')

select_related()

  • 조인을 통해서 데이터를 가져온다.
  • 역방향 참조 모델의 경우 select_related()를 사용할 수 없다.
  • inner join, left outer join을 통해 하위 모델의 데이터를 가져오는데, 이를 판단하는 것은 연관된 모델의 외래키 nullable 옵션이다.

select_related()의 경우 조인을 통해 데이터를 가져오기 때문에, 아까 QuerySet분석에서 설명한 메인쿼리 (_query) 의 SQL 문에 포함되어 실행된다.

prefetch_related()

  • 개별 추가 쿼리를 통해 데이터를 가져온다.
  • 정방향 참조, 역방향 참조 모델 둘다 prefetch_related()를 사용할 수 있다. (다만 정방향 모델 참조의 경우 select_related가 권장된다.)

예를 들어, 하나의 포스트와 그와 연관된 댓글들을 prefetch related를 통해 가져온다면,

Posts.objects.prefetch_related("comment_set").all();
--------------------------------------------------------
select * from posts;
select * from comments where id in [posts id];

위와 같이 쿼리가 생성된다. 이 때 prefetch_related_lookup 변수에 새로운 쿼리로 실행된 결과가 저장된다.

select_related(), prefetch_related() 를 적절히 활용하면 Eager Loading을 통해 쿼리를 최적화할 수 있다.

실수하기 좋은 QuerySet 사용

  • prefetch_related(), filter()의 잘못된 사용.

이건 QuerySet모델을 이해하지 못하면 종종 발생할 수 있는 실수이다. 다음의 코드를 보자.

posts = Post.objects.prefetch_related('comment_set').filter(comment__title__isnull=False)

prefetch된 모델에 대해 필터링을 적용하고 싶으면 아래와 같이 코드를 짤 수 있는데, 이는 Django QuerySet 특성 상 join 쿼리, 그리고 추가 쿼리를 둘 다 유발한다.

select * from post inner join on post.id = comment.post_id where comment.title is not null;

select * from comment where comment.post_id in (1, 2, 3)

우리가 실제 쿼리를 짠다고 하면 불필요한 조인을 없애고, 두 번째 where in절에 comment.title이 is not null이라는 조건을 추가할 것이다. 그러면 왜 이런 문제가 발생할까? filter 함수 내부를 보면 그 이유를 알 수 있다.

def filter(self, *args, **kwargs):
        """
        Return a new QuerySet instance with the args ANDed to the existing
        set.
        """
        self._not_support_combined_queries('filter')
        return self._filter_or_exclude(False, args, kwargs)
        
def _filter_or_exclude(self, negate, args, kwargs):
    if args or kwargs:
        assert not self.query.is_sliced, \
            Cannot filter a query once a slice has been taken."

    clone = self._chain()
    if self._defer_next_filter:
        self._defer_next_filter = False
        clone._deferred_filter = negate, args, kwargs
    else:
        clone._filter_or_exclude_inplace(negate, args, kwargs)
    return clone

def _filter_or_exclude_inplace(self, negate, args, kwargs):
    if negate:
        self._query.add_q(~Q(*args, **kwargs))
    else:
        self._query.add_q(Q(*args, **kwargs))

자 보면 filter는 메인 쿼리(_query)를 기반으로 데이터를 필터링한다. 그렇기에 위와 같은 쿼리가 실행되는 것이다. (ORM QuerySet에 대해 제대로 이해하지 못하면 이런 병목이 생길 수 있다.)

이를 개선하려면 어떻게 하면 좋을까? 두 가지로 생각해볼 수 있다.

  1. 만약 comment가 메모리 상 Eager load할 필요가 없다면 prefetch_related를 사용하지 않는다.
  2. Comment를 메모리 상 로드해야 한다면 prefetch_related 안에 조건 절을 삽입한다.

아래와 같이 조건절을 삽입할 수 있고, 다음과 같은 쿼리가 실행된다.

Posts.objects.prefetch_related('comment_set', Prefetch(queryset=Comment.objects.filter(comment__title__isnull=False)
select * from post;

select * from comment where comment.title is not null and comment.post_id in (1, 2, 3)

우리가 의도한 바 대로 이루어졌다!

API 개발 시 쿼리 확인 방법

API 개발할 때 내가 짠 ORM 코드가 어떤 쿼리를 실행하는지 체크할 수 있는 방법이 있어서 소개하려고 한다.

먼저 django shell_plus를 사용하여 print sql 옵션을 주면 내가 짠 ORM 코드의 SQL을 볼 수 있다.

python manage.py shell_plus --print-sql

쉘에 접속해서 내가 API 개발 시 호출하는 ORM 코드를 shell에서 실행해보면, SQL을 미리 볼 수 있어서 유용하다.

근데 이 것만으로는 충분하지 않은 경우가 있었다. 간혹가다가 serializer를 사용할 때 쿼리셋을 잘못 지정하면 인식을 못해서 N+1 문제를 발생하는 경우가 있었다(내 실수이다).

그럴 때는 Django debug toolbar를 이용하면, API를 브라우져를 통해 호출했을 때 실행한 총 SQL 쿼리목록들과 쿼리 실행시간까지 나오기 때문에 위와 같은 문제를 예방할 수 있다.

마치며..

내가 쓰는 프레임워크의 내부 구조를 이해하는 것은 중요한 것 같다. 그래야 삽질도 덜하고, 내가 짜는 코드의 근거를 마련할 수 있다고 생각한다.

profile
개발 공부 내용 정리

0개의 댓글