[Re:Django] 11. QuerySet Method (5)

Magit·2020년 5월 6일
2

Django

목록 보기
11/13

jupiny님의 select_related와 prefetch_related

prefetch_related()select_related()와 마찬가지로 하나의 QuerySet을 가져올 때, 미리 related objects 들까지 다 불러와주는 함수이다. 비록 query를 복잡하게 만들긴 하지만, 그렇게 불러온 data들은 모두 cache에 남아있게 되므로 DB에 다시 접근해야 하는 수고를 덜어줄 수 있다. 두 가지 모두 DB에 접근하는 수를 줄려서 성능을 향상시켜주지만, 방식에는 차이가 있다.

prefetch_related()는 foreign-key , one-to-one 뿐만 아니라 many-to-many , many-to-one 등 모든 relationships에서 사용 가능하다.

from django.db import models
 
 
class Country(models.Model):
 
    name = models.CharField(
        max_length=10,
    )
 
    def __str__(self):
        return self.name
 
 
class Person(models.Model):
 
    city = models.ForeignKey(City)
     
    name = models.CharField(
        max_length=10,
    )
 
    def __str__(self):
        return self.name
         
         
class Pet(models.Model):
 
    person = models.ForeignKey(Person)
     
    name = models.CharField(
        max_length=10,
    )
 
    def __str__(self):
        return self.name

class Language(models.Model):
 
    person_set = models.ManyToManyField(Person)
 
    name = models.CharField(
        max_length=10,
    )
 
    def __str__(self):
        return self.name

Person 모델과 N:M 관계를 갖는 Language라는 모델이 있다.
여기서 Person의 모든 instance들과 그 instance들의 language_set 들을 아래와 같이 모두 출력해야 한다고 가정하자.

Tom: Python Ruby
Peter: Python Node.js Java
John: Java C++ php

두 가지 방법을 사용해서 출력해보자.

# No use prefetch_related()
 
people = Person.objects.all()
for person in people:
    print(person.name+" : ", end="")
    for language in person.language_set.all():
        print(language.name+" ", end="")
    print("")
# Use prefetch_related()
 
people = Person.objects.all().prefetch_related('language_set')
for person in people:
    print(person.name+" : ", end="")
    for language in person.language_set.all():
        print(language.name+" ", end="")
    print("")

첫번째 경우에는 Person.objects.all() 안에 있는 person마다 person.language_set.all() 이라는 query가 실행이 된다. 즉, person이 100개 있다면 person.language_set.all() 이라는 query가 DB로 100번 날아간다는 의미이다.

하지만 두번째 경우에서처럼 prefetch_related 를 쓰게 되면, Person.objects.all() 라는 query가 동일하게 실행됨과 동시에 self.language_set.all() 이라는 query가 별도로 실행돼 받아온 data들이 cache에 저장되게 된다. 그래서 person의 수 만큼 person.language_set.all()이 실행되더라도 DB에 접근하지 않고 cache에서 찾아서 쓰게 된다. 따라서 결과적으로 2개의 query만으로 아까와 똑같은 결과를 내게 된다.

언뜻 보면 모든 relationship에서 사용할 수 있는 prefetch_related 가 더 좋아보이고 prefetch_related 로 할 수 있는 것을 굳이 select_related를 사용해야되나 싶다.

하지만 두 함수가 동작하는 방식에 중요한 차이점이 있다. prefetch_related는 원래 main query 실행된 후 별도의 query를 따로 실행하게 된다. 반면에 select_related 은 하나의 query만으로 related objects들을 다 갖고오게된다.

Pet.objects.prefetch_related('person') # 2 queries

Pet.objects.select_related('person') # 1 query

즉, 완벽하게 동일한 결과라도 prefetch_related 를 쓰느냐, select_related를 쓰느냐에 따라 query의 수가 달라진다.

적절히 사용하는 예를 알아보자.

Pet.objects.prefetch_related('person__language_set')

위 코드는 prefetch_related 만을 사용했고, 결과적으로 3개의 query가 순차적으로 실행되었다.
1. Pet의 모든 instance를 가져오기 위한 query
2. 그 Pet instace들의 person을 가져오기 위한 query
3. 그 person들의 language_set을 가져오기 위한 query

여기에 select_related를 사용하면 query를 더 줄일 수 있다.

Pet.objects.select_related('person').prefetch_related('person__language_set')

select_related 에서 이미 person에 대한 data까지 모두 갖고왔으므로, prefetch_related 에서 person은 cache를 통해 갖고오고, laguage_set만 DB에서 fetch해오면 된다. 따라서 총 2개의 query가 실행된다.

  1. Pet의 모든 instance와 그 intance의 person을 가져오는 query
  2. 그 person들의 language_set을 가져오기 위한 query

결론

many-to-many , many-to-one 과 같은 relationships에서는 prefetch_related 를 사용해야되지만, foreign-key , one-to-one 와 같은 single-valued relationships이 있는 곳에서는 최대한 select_related 를 사용하여 query 수를 줄여주는 것이 효과적이다.


django - QuerySet Api reference
(뭐야 왜이리 어려워..)

지정된 각 조회에 대해 단일 배치에서 관련 객체를 자동으로 검색하는 QuerySet을 반환한다. select_related와 비슷하다. 둘 다 관련 객체에 액세스하여 발생하는 DB 쿼리의 유출을 막기 위해 설계되었지만, 전략은 완전히 다르다.

select_related()는 SQL join을 생성하고 SELECT 문에 관련 객체의 필드를 포함시켜 작동한다. 그렇기에 select_related()는 동일한 DB 쿼리에서 관련 객체를 갖고온다. 그러나 많은 관계가 join하여 발생하는 큰 결과셋을 피하기 위해 select_related() 는 ForeignKey와 OneToOne 관계로 제한되어있다.

prefetch_related() 는 각 관계에 대해 별도로 조회하고 파이썬에서 joining을 수행한다. 이를 통해 select_related 에서는 할 수 없는 Many-To-Many, Many-To-One 객체도 사용 가능하다. 또한 GenericRelation, GenericForeignKey도 지원하지만, 동일한 결과 집합으로 제한되어있다.

# 예를 위한 모델
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()),
        )
# run
>>> Pizza.objects.all()
["Hawaiian (ham, pineapple)", "Seafood (prawns, smoked salmon)"...

위 코드의 문제는 Pizza.__str__()self.toppings.all() 을 요청할 때 마다 DB를 쿼리하기 때문에 Pizza.objects.all() 은 Toppings 테이블에서 쿼리를 실행한다.

# prefetch_related를 사용하면 두 개의 쿼리로 줄일 수 있다.
>>> Pizza.objects.all().prefetch_related('toppings')

이건 각 Pizza마다 self.toppings.all() 을 의미한다. 이 self.toppings.all() 은 호출할 때마다 DB로 이동하지 않고 단일 조회로 채워진 미리 설정된 QuerySet캐시에서 해당 항목을 찾는다.

즉, 모든 관련 toppings은 단일 쿼리로 갖고와서 관련 결과들로 미리 채워진 캐시가 있는 QuerySet을 만드는데 사용되고, 이 QuerySet은 self.toppings.all() 호출에 사용된다.

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

반복 가능한 모델 인스턴스의 경우 prefetch_related_objects() 를 사용하여 해당 인스턴스에서 관련 속성을 미리 준비할 수 있다.

그 후에 기본 QuerySet의 결과 캐시와 지정된 모든 관련 객체를 메모리에 완전히 불러온다. 일반적으로는 DB에서 쿼리가 실행된 후에도 필요한 모든 객체를 메모리에 로드하지 않는다.

QuerySet에서 다른 DB 쿼리를 암시하는 후속 체인 메소드는 이전의 캐시된 결과를 무시하고 새로운 DB 쿼리를 사용하여 데이터를 검색한다.

>>> pizzas = Pizza.objects.prefetch_related('toppings')
>>> [list(pizza.toppings.filter(spicy=True)) for pizza in pizzas]

위 코드는 pizza.toppings.all()가 prefetch 되었지만 아무런 도움이 되지 않는다. prefetch_related('toppings')pizza.toppings.all() 을 암시하지만 pizza.topping.filter() 와는 다른 쿼리이다. 그래서 위 코드에서는 사용하지 않는 DB 쿼리를 수행하기 때문에 prefetch가 도움이 되지 않을 뿐더러, 성능까지 저하된다.

관련 관리자에서 DB 변경 메소드(add(), remove(), clear(), set())을 호출하면 prefetch된 캐시가 지워진다.

일반 조인 구문을 사용하여 관련 필드의 관련 필드를 수행할 수도 있다.

# 위 모델 코드에 추가 모델
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')

Restaurant에 속한 모든 Pizza와 해당 Pizza에 속하는 모든 Topping이 prefetch되는데, 이는 총 3개의 DB 쿼리가 발생한다. (Restaurant, Pizza, Topping)

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

이건 각 Restaurant 마다 최고의 Pizza와 최고의 Pizza를 위한 모든 Topping을 갖고온다. 이 역시 3개의 DB 쿼리가 발생한다.

물론, select_related 를 사용하여 쿼리를 둘로 줄일 수 있다.

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

prefetch는 기본쿼리 후에 실행되므로 best_pizza 객체가 이미 패치되었음을 감지하고 다시 패치하지 않는다.

prefetch_related 호출을 연결하면 프리패치된 조회가 누적된다. 이 동작을 지우려면 None을 매개변수로 전달해야 한다.

>>> non_prefetched = qs.prefetch_related(None)

prefetch_related를 사용시 유의할 점은 쿼리로 생성된 객체가 의도와 상관없이 관계가 있는 다른 객체간에 공유 될 수 있다는 것이다. 이건 일반적으로 외래키 관계에서 발생하는데, 이 동작이 문제가 되지 않는다면 메모리와 CPU 시간을 모두 절약한다.

GenericForeignKey는 여러 테이블의 데이터를 참조할 수 있기 때문에 모든 항목에 대해 하나의 쿼리가 아니라 참조된 테이블 당 하나의 쿼리가 필요하다. 관계된 행을 가져오지 못한 경우 ContentType 테이블에 추가 쿼리가 있을 수 있다.

대부분 prefetch_related는 SQL IN 연산자를 사용한다. 이건 큰 QuerySet의 경우 DB에 따라 쿼리 구분 분석이나 실행시 큰 IN 이 생성되어 성능 이슈가 발생할 수 있음을 의미한다.

iterator()를 사용하면 prefetch_related()는 무시된다.

Prefetch를 사용하면 프리패치 객체를 추가로 제어할 수 있다.

>>> from django.db.models import Prefetch
# 가장 단순한 Prefetch 사용으로, 기본 문자열 검색과 동일하다.
>>> Restaurant.objects.prefetch_related(Prefetch('pizzas__toppings'))

# 선택적 queryset 인자를 사용하여 cunstom 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으로 동일한 관계를 여러번 프리패치 할 수 있다.
>>> vegetarian_pizzas = Pizza.objects.filter(vegetarian=True)
>>> Restaurant.objects.prefetch_related(
...     Prefetch('pizzas', to_attr='menu'),
...     Prefetch('pizzas', queryset=vegetarian_pizzas, to_attr='vegetarian_menu'))

# 사용자 정의된 to_attr으로 작성된 조회는 다른 조회에서 같이 계속 순회가 가능하다.
>>> vegetarian_pizzas = Pizza.objects.filter(vegetarian=True)
>>> Restaurant.objects.prefetch_related(
...     Prefetch('pizzas', queryset=vegetarian_pizzas, to_attr='vegetarian_menu'),
...     'vegetarian_menu__toppings')

# 프리 패치 결과를 필터링 할때는 to_attr을 사용하는 것이 좋다.
>>>
>>> # 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()

사용자 지정 프리패치는 ForeignKey나 OneToOneField와 같은 단일 관계에서도 동작한다. 이런 관계는 일반적으로 select_related()를 사용하지만, 사용자 정의 QuerySet을 사용하여 프리패치 하는 것이 더 유용한 경우도 많다.

  • 관련 모델에서 추가 프리패치를 수행하는 QuerySet을 사용하려는 경우
  • 관련 객체의 일부만 프리패치하려는 경우
  • deferred fields처럼 성능 최적화된 기술을 사용하려는 경우
>>> queryset = Pizza.objects.only('name')
>>>
>>> restaurants = Restaurant.objects.prefetch_related(
...     Prefetch('best_pizza', queryset=queryset))
profile
이제 막 배우기 시작한 개발자입니다.

1개의 댓글

comment-user-thumbnail
2021년 1월 10일

좋은 글 공유해 주셔서 감사합니다!

답글 달기