SW개발/Django

[Django]Django select_related() 와 prefetch_related()

이번 포스팅에서는 Queryset의 심화 부분인 select_related() prefetch_related()에 대하여 알아볼 것이다.

먼저 장고에서는 Queryset이 어떻게 구현 되어있는지 알아볼 필요가 있다. Queryset은 크게 1개의 메인 쿼리와 0~N 개의 서브 쿼리들로 구성이 되어있다. 

 

django.db.models.query.py

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 = None

    @property
    def query(self):
        if self._deferred_filter:
            negate, args, kwargs = self._deferred_filter
            self._filter_or_exclude_inplace(negate, args, kwargs)
            self._deferred_filter = None
        return self._query

실제 장고의 Queryset 구현체가 해당 파일 안에 존재하고 있다. 여기에 있는 모든 부분을 알 필요는 없으니 중요한 부분만을 다룰 것이다. 

  • query : 1개의 메인 쿼리로 부를 수 있음
  • result_cache : SQL의 수행 결과가 저장되는 부분(캐싱), 저장된 데이터가 없을 경우 새로운 SQL문 호출함
  • prefetch_related_lookups : prefetch_relate() 부분에 선언된 값들을 저장함, 추가 쿼리셋이라고 부름
  • iterable_class : SQL의 결과값을 python의 어떤 자료구조로 받을 지에 대한 부분, values(), values_list()를 통해 바뀜

쿼리셋의 구현체 중 위의 요소들만 알아도 앞으로 나올 부분에 대한 이해는 충분할 것이다.

 

Select_related 와 Prefetch_related 란?

select_related, prefetch_related 는 하나의 쿼리셋을 가져올 때 연관되어 있는 objects들을 미리 불러오게(Eager Loading) 하는 함수이다. JOIN문을 사용하기 때문에 호출되는 SQL문이 복잡해질 수 있지만, 이렇게 불러온 데이터들은 앞서 알아본 result_cache 라는 부분에 cache 되기 때문에 결과적으로 중복 호출을 방지할 수 있다.

 

이렇게 두 함수 모두 DB에 액세스(connection) 하는 횟수를 줄여주므로 Performance를 향상시킬 수 있다. 하지만 두 함수의 SQL문이 호출되는 방식에는 차이가 있다.

 

select_related() VS prefetch_related()
  • Select_related()는 JOIN을 통해 데이터를 즉시 가져오는(Eager Loading) 방법 (SQL단계에서의 JOIN)
  • Pretfetech_related()는 추가 쿼리를 통해 데이터를 즉시 가져오는 방법 (추가 쿼리 발생, JOIN은 파이썬 level에서 이루어짐)

즉, 이 두 방법의 차이는 추가쿼리가 발생하느냐 아니냐로 구분지을 수 있다. 이 부분 역시 개념적으로만 접근하면 이해가 어렵기 때문에 실제 모델을 가지고 호출되는 SQL문을 보면서 알아볼 것이다. 모델은 아래와 같다.

 

models.py

from django.db import models
 
class Blog(models.Model):
    name = models.CharField(max_length=50)


class Category(models.Model):
    blog = models.ForeignKey(Blog, on_delete=models.CASCADE)
    name = models.CharField(max_length=50)


class Post(models.Model):
    category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True)
    title = models.CharField(max_length=50)
    content = models.CharField(max_length=500)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

이전 포스팅의 모델과 필드명이 명확하지 않다는 생각이 들어 다음과 같이 변경하여 진행하였다. 간단하게 모델에 대한 설명을 붙이자면 'leffe' 라는 블로그가 존재할 때, leffe 블로그 안에는 N개의 카테고리가 존재하고, 카테고리 안에는 N개의 포스트가 존재하는 구조이다.

 


select_related()의 사용

select_related는 1:1의 관계에서 사용할 수 있고, 1:N의 관계에서 N이 사용할 수 있다. 즉, 정방향 참조(Post 모델에서 Category 모델의 정보를 찾으려고 할 때)에서의 JOIN에 유리하게 사용된다.

 

다음의 예시는 Post에서 Category가 무엇인지를 알고 싶을 때 필요한 2가지의 ORM 이다. 이를 통해 select_related()를 사용하는 이유를 알아볼 것이다.

 

select_related() 사용 X

>>> post = Post.objects.all()
>>> for p in post:
...     p.category.name
... 
SELECT "mysite2_post"."id",
       "mysite2_post"."category_id",
       "mysite2_post"."title",
       "mysite2_post"."content",
       "mysite2_post"."created_at",
       "mysite2_post"."updated_at"
  FROM "mysite2_post"
Execution time: 0.000784s [Database: default]
SELECT "mysite2_category"."id",
       "mysite2_category"."blog_id",
       "mysite2_category"."name"
  FROM "mysite2_category"
 WHERE "mysite2_category"."id" = 5
 LIMIT 21
Execution time: 0.000676s [Database: default]

... 생략

 

select_related() 사용 O

>>> post = Post.objects.select_related('category').all()
>>> for p in post:
...     p.category.name
... 
SELECT "mysite2_post"."id",
       "mysite2_post"."category_id",
       "mysite2_post"."title",
       "mysite2_post"."content",
       "mysite2_post"."created_at",
       "mysite2_post"."updated_at",
       "mysite2_category"."id",
       "mysite2_category"."blog_id",
       "mysite2_category"."name"
  FROM "mysite2_post"
  LEFT OUTER JOIN "mysite2_category"
    ON ("mysite2_post"."category_id" = "mysite2_category"."id")
Execution time: 0.007924s [Database: default]
'programming category'
'programming category'
'programming category'
'programming category'
'programming category'
>>> 

 

위의 두 가지의 코드만 보더라도 SQL문이 호출되는 횟수에 큰 차이가 있다는 것을 파악할 수 있다. 이렇게 되는 이유는 미리 얽혀있는 관계에 대한 데이터를 가져오느냐에 대한 차이로 발생하는 것인데, 생성되는 SQL문을 유심히 살펴보면 select_related('정방향 참조 필드') 함수를 통해 category에 대한 정보를 미리(Eager Loading) 불러오는 것을 볼 수 있다.

 

또 한가지 유의할 점은, model의 category 필드에서 on_delete=models.SET_NULL(null=True) 옵션을 걸었기에 ORM에서 LEFT OUTER JOIN 구문이 생성되었다. 만약, models.CASCADE 옵션을 걸었다면 INNER JOIN 으로 수행됬을 것이다.

 

즉, select_related()는 정참조의 JOIN을 통해 Eager Loading 하는 함수라고 할 수 있다.

 


prefetch_related()의 사용

prefetch_related는 반대로 1:N의 관계에서 1이 사용할 수 있고, M:N의 관계에서 사용할 수 있다. 즉, 역방향 참조(Category 모델에서 Post 모델의 정보를 찾으려고 할 때)에 유리하게 사용된다.

 

이번에는 django 라는 이름을 가진 Category 에는 어떠한 제목을 가진 Post들이 존재하는지를 알고 싶어할 때의 예제를 통해 prefetch_related()를 알아볼 것이다.

 

prefetch_related 사용 O

>>> category = Category.objects.prefetch_related('post_set').get(name='django')
SELECT "mysite2_category"."id",
       "mysite2_category"."blog_id",
       "mysite2_category"."name"
  FROM "mysite2_category"
 WHERE "mysite2_category"."name" = 'django'
 LIMIT 21
Execution time: 0.000285s [Database: default]
SELECT "mysite2_post"."id",
       "mysite2_post"."category_id",
       "mysite2_post"."title",
       "mysite2_post"."content",
       "mysite2_post"."created_at",
       "mysite2_post"."updated_at"
  FROM "mysite2_post"
 WHERE "mysite2_post"."category_id" IN (6)
Execution time: 0.000296s [Database: default]
>>> for c in category.post_set.all():
...     c.title
... 
'django 1'
'django 2'
'django 3'
>>> 

위의 ORM을 통해 django라는 카테고리에는 django 1, 2, 3과 같은 제목의 post가 들어있는 것을 확인할 수 있다.

 

여기서 유심히 살펴봐야할 점은 Select 쿼리가 두 번 수행되었다는 점인데, 첫 번째의 쿼리는 category의 정보를 위한 쿼리이고 두 번째 쿼리는 prefetch_related('역방향 필드 참조', ...) 에 작성한 필드에 대한 추가 쿼리이다. 즉, 필드 값으로 N개를 줄 경우 각 필드값에 대한 N개의 추가 쿼리가 발생하게 되는 것이다.

 

select_related 와의 가장 큰 차이점은 추가 쿼리가 발생한다는 것이고, 또 다른 차이점은 발생된 추가 쿼리를 파이썬 단계에서 JOIN을 통해 결과를 만들어낸다는 점이다.

 

 추가 쿼리셋을 제어하는 방법 - Prefetch()

prefetch_related의 필드들에 추가적인 조건을 걸고 싶은 경우가 있을 것이다. 그럴때 사용하는 것이 바로 Prefetch() 객체이다.

그럼 이전에 작성했던 것과 동일한 ORM을 Prefetch()를 이용하여 재 작성해보겠다.

 

Prefetch() 객체의 파라미터
Prefetch(lookup, queryset=None, to_attr=None)

Prefetch()의 기본 파라미터는 위와 같다. lookup의 경우 기존에 prefetch_related()에 작성한 첫 번째 파라미터와 동일하고, queryset의 경우 필요한 옵션들을(filter 등) 포함해 queryset을 재 정의함으로써 lookup에 대한 조건을 부여할 수 있다. 마지막으로 to_attr의 경우에는 prefetching 된 결과 메모리에 저장(cache)하여 재사용 할 수 있게 해주는 옵션이다. 너무 방대한 양의 쿼리가 아니라면 사용하는 것이 퍼포먼스 측면에서의 향상을 가져올 것이다.

 

Prefetch() 객체를 활용하여 재 작성한 쿼리
>>> category = Category.objects.prefetch_related(Prefetch('post_set', to_attr='post_set.set()', 
			queryset=Post.objects.all())).get(name='django')
SELECT "mysite2_category"."id",
       "mysite2_category"."blog_id",
       "mysite2_category"."name"
  FROM "mysite2_category"
 WHERE "mysite2_category"."name" = 'django'
 LIMIT 21
Execution time: 0.000216s [Database: default]
SELECT "mysite2_post"."id",
       "mysite2_post"."category_id",
       "mysite2_post"."title",
       "mysite2_post"."content",
       "mysite2_post"."created_at",
       "mysite2_post"."updated_at"
  FROM "mysite2_post"
 WHERE "mysite2_post"."category_id" IN (6)

위에서 작성한 ORM을 Prefetch() 객체를 이용해 재 작성한 쿼리이다. 실제로 호출되는 SQL문을 살펴보면 이전과 비교해 달라진 것이 없음을 알 수 있다. 

 

추가 쿼리셋의 제어
>>> category = Category.objects.prefetch_related(Prefetch('post_set', to_attr='post_set.set()', 
			queryset=Post.objects.filter(id=1))).get(name='django')

...
동일 부분 생략    
...             
            
  FROM "mysite2_post"
 WHERE ("mysite2_post"."id" = 1 AND "mysite2_post"."category_id" IN (6))
Execution time: 0.000566s [Database: default]

다음과 같이 재 작성한 쿼리셋에 조건을 부여하면 추가 쿼리를 제어할 수 있게 된다.

 


 

정리하자면, select_related(), prefetch_related()는 관계 있는 object를 Eager Loading 하기 위해 사용된다. 다만 1:1, 1:N, N:M 관계 중에 따라 다르게 사용하는 것과, 추가적인 쿼리가 생성되는지에 대한 차이가 있을 뿐이다.

퍼포먼스 측면에서 이야기 하자면 한번에 많은 데이터를 미리 가져오는 것이 유리할 수도 있고, 때로는 작은 양의 데이터를 여러 번 가져오는 것이 유리할 수 도 있다. 이러한 상황을 적절히 판단하여 위의 두 옵션들을 사용해야 할 것이다. 

 

ORM의 특성상 원하는 SQL문을 직접적으로 만들어 낼 수 없기 때문에, 수행하려고 하는 SQL문을 먼저 떠올리는 것보다 필요한 데이터 리스트를 먼저 떠올리면서 ORM을 작성하는 편이 낫다고 생각한다.

다음 포스팅에서는 select_related() 와 prefetch_related() 를 이용한 다양한 예시를 통해 어떻게 SQL문이 호출되는지를 상세하게 알아볼 것이다.

 

지금까지 작성한 코드는 해당 레포지토리의 queryset_2 브랜치에서 확인할 수 있습니다.

github.com/na86421/Django-ORM-Queryset/tree/queryset_2

 

na86421/Django-ORM-Queryset

Django-ORM-Queryset 공부용. Contribute to na86421/Django-ORM-Queryset development by creating an account on GitHub.

github.com

 

공부하면서 정리한 내용이니 틀린 부분이 있을 수 있습니다. 댓글로 남겨주시면 감사하겠습니다.

 

728x90