Django

[Queryset] select_related와 prefetch_related 살펴보기

r잡초처럼 2023. 1. 13. 17:10

select_related와 prefetch_related를 사용하는 이유는 ORM에서 발생되는 N+1 문제를 해결하기 위해 eager-loading(즉시 호출) 방식을 취하는 두 가지 메서드 사용한다.

N+1 문제란?
Lazy-loading 방식은 실제 데이터를 사용할 때 DB에 히트하게 된다. 이로 인해 참조 관계의 데이터를 가져올 때 문제가 발생한다. 처음 1번의 SQL query를 실행할 때 해당 모델이 갖고 있는 필드만을 가져온다. 순회 시 참조 모델의 데이터는 처음 쿼리에서 가져오지 않았기 때문에 순회 횟수(N번)만큼 참조 관계 모델의 SQL query를 실행한다. 꼭 1번 더 쿼리를 실행하는 것은 아니고 비효율적으로 쿼리 호출 횟수가 생긴다는 의미로 받아들이면 된다.
자세한 내용은 1. 블로그 2.블로그 참조
Eager-loading 이란?
Lazy-loading과 반대되는 개념으로 queyset이 evaluate 될 때 쿼리를 실행하는 게 아닌, queryset 인스턴스를 생성할 때 Join을 걸어 관계되어 있는 모델의 데이터까지 SQL query를 사전에 실행하는 방법이다.

1. select_related() 

사용하는 경우는 다음과 같다.

  1. foreign-key, one-to-one처럼 single-valued 관계에서만 사용이 가능하다. SQL의 JOIN을 사용하는 방법이다.
  2. 1:N 관계의 N(FK의 정참조)이 사용할 수 있다. 
  • 외부 키 관계를 "따라서" 실행할 때 관련 개체 데이터를 추가로 선택하는 Querysets를 반환한다.
# Hits the database.
e = Entry.objects.get(id=5)

# Hits the database again to get the related Blog object.
b = e.blog
  • 아래와 같이 select_related를 실행한다면? DB에 다시 hit 하지 않는다.
# Hits the database.
e = Entry.objects.select_related('blog').get(id=5)

# Doesn't hit the database, because e.blog has been prepopulated
# in the previous query.
b = e.blog
  • select_related()를 개체의 쿼리 집합과 함께 사용할 수 있다.
from django.utils import timezone

# Find all the blogs with entries scheduled to be published in the future.
blogs = set()

for e in Entry.objects.filter(pub_date__gt=timezone.now()).select_related('blog'):
    # Without select_related(), this would make a database query for each
    # loop iteration in order to fetch the related blog for each entry.
    blogs.add(e.blog)
  • filter()와 select_related() 체인의 순서는 중요하지 않다. 
Entry.objects.filter(pub_date__gt=timezone.now()).select_related('blog')
Entry.objects.select_related('blog').filter(pub_date__gt=timezone.now())
  • 외부 키를 쿼리 하는 것과 유사한 방식으로 외부 키를 따를 수 있다. 다음과 같은 모델이 있는 경우
from django.db import models

class City(models.Model):
    # ...
    pass

class Person(models.Model):
    # ...
    hometown = models.ForeignKey(
        City,
        on_delete=models.SET_NULL,
        blank=True,
        null=True,
    )

class Book(models.Model):
    # ...
    author = models.ForeignKey(Person, on_delete=models.CASCADE)

Book.objects.select_related('author__hometown'). get(id=4)의 queryset 호출은 관련 사용자 및 관련 도시를 캐싱한다.

# Hits the database with joins to the author and hometown tables.
b = Book.objects.select_related('author__hometown').get(id=4)
p = b.author         # Doesn't hit the database.
c = p.hometown       # Doesn't hit the database.

# Without select_related()...
b = Book.objects.get(id=4)  # Hits the database.
p = b.author         # Hits the database.
c = p.hometown       # Hits the database.

2. prefetch_related()

사용하는 경우는 다음과 같다.

  1. many-to-many
  2. many-to-one 

지정한 각 조회에 대해 관련 개체를 일괄적으로 자동으로 검색하는 QuerySet을 반환한다. select_related와 비슷한 목적을 가지고 있다. 둘 다 관련 객체에 액세스 하여 발생하는 데이터베이스 쿼리의 홍수를 막기 위해 설계되었지, 전략은 상당히 다르다.

select_related는 SQL JOIN을 생성하고 SELECT 문에 관련 개체의 필드를 포함하여 동작한다. select_related는 동일한 데이터베이스 쿼리에서 관련 개체를 가져온다. 그러나 '많은(many-to-many)' 관계에 걸쳐 결합함으로써 발생하는 훨씬 더 큰 결과 집합을 피하기 위해 select_related는 단일 값 관계(one-to-one, one-to-many:FK)로 제한한다.

반면에 prefetch_related는 각 관계에 대해 별도의 조회를 수행하고 Python에서 'Joining'을 실행한다. 이를 통해 select_related에서 수행할 수 없는 many-to-manymany-to-one 개체를 미리 가져올 수 있다. 또한 추가 쿼리가 발생한다. 

 

예를 들면,

from django.db import models

class Topping(models.Model):
    name = models.CharField(max_length=30)

class Pizza(models.Model):
    name = models.CharField(max_length=50)
    toppings = models.ManyToManyField(Topping)

    def __str__(self):
        return "%s (%s)" % (
            self.name,
            ", ".join(topping.name for topping in self.toppings.all()),
        )

실행하면,

>>> Pizza.objects.all()
["Hawaiian (ham, pineapple)", "Seafood (prawns, smoked salmon)"...

이것의 문제는 Pizza.__str__()이 self.topings.all()을 요청할 때 데이터베이스를 쿼리해야 하므로 Pizza.objects.all()은 Pizza QuerySet의 모든 항목에 대해 Toping 테이블에서 쿼리를 실행한다.

prefetch_related를 사용하여 쿼리를 두 개로 줄일 수 있다.

>>> Pizza.objects.prefetch_related('toppings')

이것은 각 피자에 대한 self.tops.all()을 의미하며, 이제 매번 self.tops.all()이 호출될 때마다 항목을 위해 데이터베이스로 이동하는 대신 단일 쿼리에 채워져 있는 사전 추출된 쿼리 세트 캐시에서 항목을 찾을 수 있다. 즉, 모든 관련 토핑은 단일 쿼리로 가져와 관련 결과의 캐시가 미리 채워진 queryset를 만드는 데 사용된다. 이러한 queryset는 self.tops.all() 호출에 사용된다.

prefetch_related()의 추가 쿼리는 QuerySet이 평가되기 시작하고 기본 쿼리가 실행된 후에 실행된.

 

일반 조인 구문을 사용하여 관련 필드의 관련 필드를 수행할 수도 있습니다. 위의 예에 추가 모델이 있다고 가정하자.

class Restaurant(models.Model):
    pizzas = models.ManyToManyField(Pizza, related_name='restaurants')
    best_pizza = models.ForeignKey(Pizza, related_name='championed_by', on_delete=models.CASCADE)

다음의 경우는 모두 사용 가능하다.

>>> Restaurant.objects.prefetch_related('pizzas__toppings')

이렇게 하면 레스토랑에 속한 모든 피자와 해당 피자에 속한 모든 토핑을 미리 가져온다. 그러면 총 3개의 데이터베이스 쿼리가 생성된다. 하나는 레스토랑, 하나는 피자, 다른 하나는 토핑이다.

>>> Restaurant.objects.prefetch_related('best_pizza__toppings')

이건 최고의 피자와 각 레스토랑의 최고의 피자에 대한 모든 토핑을 가져올 것이다. 이것은 3개의 데이터베이스 쿼리에서 수행된다. 하나는 레스토랑용, 하나는 최고의 피자, 다른 하나는 토핑이다.

 

쿼리 수를 2개로 줄이기 위해 select_related를 사용하여 best_pizza의 관계를 가져올 수도 있다.

>>> Restaurant.objects.select_related('best_pizza').prefetch_related('best_pizza__toppings')

prefetch는 기본 쿼리(select_related에서 필요한 조인 포함) 이후에 실행되므로 best_pizza 객체가 이미 fetch 되었음을 감지할 수 있으며, 다시 fetch 하지 않는다. 

 

또한 Prefetch 개체를 사용하여 prefetch 작업을 추가로 제어할 수 있다. 가장 간단한 형태로 Prefetch는 전통적인 문자열 기반 조회와 동일하다.

>>> from django.db.models import Prefetch
>>> Restaurant.objects.prefetch_related(Prefetch('pizzas__toppings'))

queryset인수를 사용하여 사용자 지정 queryset를 제공할 수 있다. 이 명령을 사용하여 queryset의 기본 순서를 변경할 수 있다.

>>> Restaurant.objects.prefetch_related(
...     Prefetch('pizzas__toppings', queryset=Toppings.objects.order_by('name')))

또는 select_related()를 호출하여 쿼리 수를 더욱 줄일 수 있다.

>>> Pizza.objects.prefetch_related(
...     Prefetch('restaurants', queryset=Restaurant.objects.select_related('best_pizza')))

필터링된 결과를 관련 관리자의 캐시에 저장하는 것보다 덜 모호하므로 사전 추출 결과를 필터링할 때 to_attr을 사용하는 것이 좋다.

>>> queryset = Pizza.objects.filter(vegetarian=True)
>>>
>>> # Recommended:
>>> restaurants = Restaurant.objects.prefetch_related(
...     Prefetch('pizzas', queryset=queryset, to_attr='vegetarian_pizzas'))
>>> vegetarian_pizzas = restaurants[0].vegetarian_pizzas
>>>
>>> # Not recommended:
>>> restaurants = Restaurant.objects.prefetch_related(
...     Prefetch('pizzas', queryset=queryset))
>>> vegetarian_pizzas = restaurants[0].pizzas.all()

Prefetch에 대한 자세한 정보는 이곳을 참고하자.


prefetch_related에 관한 정리는 다음에 다시 해봐야겠다. 이번 포스팅으로 명확하게는 이해가 되지않았다.


참고

1. https://docs.djangoproject.com/en/4.1/ref/models/querysets/#select-related

 

Django

The web framework for perfectionists with deadlines.

docs.djangoproject.com

2. https://scoutapm.com/blog/django-and-the-n1-queries-problem

 

Django and the N+1 Queries Problem | Scout APM Blog

Django and the N+1 Queries Problem Adam Johnson  on August 13, 2020 The N+1 Queries Problem is a perennial database performance issue. It affects many ORM’s and custom SQL code, and Django’s ORM is not immune either. In this post, we’ll examine what

scoutapm.com

3. https://dentuniverse.tistory.com/entry/DjangoPythonQuerySet-N1-prblem-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0

 

[Django][Python]QuerySet N+1 prblem 해결하기

해당 내용의 원문은 [링크] 이곳에서 발췌해왔습니다😀 이전에 글에서 언급했던 N+1문제를 해결하는 방법에 대해서 적으려고한다. N+1문제에 대해선 아래 링크에 적어두었고, 해당 링크 하단쯤

dentuniverse.tistory.com

4. https://cocook.tistory.com/52

 

[Django] 장고 쿼리셋 파헤치기(Eager Loading)

아래 내용은 김성렬님의 2020 Pycon-Korea Django ORM (QuerySet)구조와 원리 그리고 최적화전략을 정리해둔 내용입니다. https://www.youtube.com/watch?v=EZgLfDrUlrk 장고는 ORM(Object Relational Mapping)을 이용해 데이터

cocook.tistory.com

5. https://velog.io/@keywookim/We.TIL-29-prefetchrelated%EC%99%80-toattr%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-%ED%9A%A8%EA%B3%BC%EC%A0%81%EC%9D%B8-%EC%97%AD%EC%B0%B8%EC%A1%B0-%EB%B0%A9%EB%B2%95

 

We.TIL 29 : prefetch_related와 to_attr을 활용한 효과적인 역참조 방법

<장고 공식문서를 참조 : 미숙한 번역 주의>프리페치란 지정해놓은 관계된 객체의 특정열을 하나로 미리 묶어 쿼리셋으로 반환하는 기능이다.본질적으로 프리페치는 select_related와 동일한 목적

velog.io

6.https://docs.djangoproject.com/en/4.1/ref/models/querysets/#django.db.models.Prefetch

 

Django

The web framework for perfectionists with deadlines.

docs.djangoproject.com