[Django] select_related, prefetch_related 1차 수정 (계속하여 수정 예정)

Alex of the year 2020 & 2021·2020년 8월 28일
0

Django

목록 보기
5/5
post-thumbnail

이해 다 하고 정리하는 그 날이
언제 올지 모르겠어서 우선 시작하는
select_related vs. prefetch_related 개념 정리 🤯


정참조: ForeignKey(OneToMany), OneToOne / 역참조: OnetoOne

쿼리 실행 시, FK 관계에 있는(==FK로 물림받은, 즉 정참조 관계의) 쿼리셋을 반환.
select_related(==FK테이블)이라는 쿼리를 하나 더 (a single more complex query)쓰지만, 결론적으로 이후의 DB 히트 수를 줄여주는(later use of FK relationships won't require database queries) performance booster의 역할을 한다.

select_related를 사용하지 않는 단순 쿼리와(==plain lookups)
select_related를 사용하는 쿼리의 차이를 명백히 보여주는 예시를 보자.

# 단순 쿼리

e = Entry.objects.get(id=5) 
# id가 5인 Entry테이블의 객체 반환

b = e.blog 
# Entry 테이블의 FK로 물려있는 blog테이블 객체 반환 시, 
# 다시 처음부터 Entry 테이블 DB hit 후 --> Blog 테이블을 타고 넘어가 객체 반환 
# 따라서 DB hit는 총 2회


# select_related 사용 쿼리

e = Entry.objects.select_related('blog').get(id=5)
# id가 5인 Entry테이블의 객체 반환하는 과정에서 FK인 'blog'를 미리 populate함 (==미리 쿼리/캐싱해둠)

b = e.blog
# select_related를 통해 미리 캐싱해둔 데이터에서 접근하므로 
# DB hit는 총 1회

이 select_related는 어떤 객체의 queryset에서도 사용이 가능하다.

from django.utils import timezone

blogs = set()
# 하나의 빈 set을 미리 선언해둔 후 

for e in Entry.objects.filter(pub_date__gt=timezone.now()).select_related('blog'):
    blogs.add(e.blog)

🖐
# Entry object 중 pub_date가 미래인, 즉 이후에 발행될 객체만 blog를 select_related로 미리 캐싱하여 찾는다 (for e in Entry.objects.filter(pub_date__gt=timezone.now()).select_related('blog'): )
# 찾아진 각각의 객체들은 이미 blog를 미리 캐싱해두었으므로 별도의 DB hit 없이 blog 객체로 넘어갈 수 있고 (e.blog)
# 그 blog 객체들을 미리 선언해둔 빈 set에 추가한다. (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())

아래와 같은 models.py를 작성했다고 가정해보자.

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)

🖐🏻1) Person 객체는 hometown 속성을 가지며, 해당 속성은 City 객체를 FK로 물고있다. person 참조 -> city(FK 물림 당함)
🖐2) Book 객체는 author 속성을 가지며, 해당 속성은 Person 객체를 FK로 물고있다. book 참조 -> person(FK 물림 당함)

아래와 같은 형태로 엮여있다.

이 때,
Book.objects.select_related('author__hometown').get(id=4)를 하게되면,
🖐
1) Book 객체를 쿼리로 반환하는 과정에서
2) Book 객체 내 author 속성이 FK로 물고있는 Person 테이블의 hometown 속성을 캐싱하게 되는데, 뿐만 아니라
3) Person 테이블 내 hometown 속성이 FK로 물고있는 City 테이블까지도 한번에 캐싱하는 것이다.

단순 쿼리에 비해 단 한 줄의 코드가 행사하는 영향력의 범위가 넓어진다.
다시 한 번 단순쿼리와 select_related를 사용한 쿼리의 DB hit를 비교해보자.

# select_related 사용 시

b = Book.objects.select_related('author__hometown').get(id=4)
p = b.author         
c = p.hometown    

# b에서 한번에 Person, City 테이블 캐싱 시 
# b에서만 DB hit 한 번, p, c에서는 DB hit X. 
# 총 DB hit 1회


# 단순 쿼리 사용 시

b = Book.objects.get(id=4) 
p = b.author         
c = p.hometown       

# b에서 Book 객체만 쿼리하며 DB hit 1회
# p에서 다시 한 번 DB hit 1회 추가
# c에서 다시 또 한 번 DB hit 1회 추가
# 총 DB hit 3회

이렇게 DB 히트 수에서 차이가 나는 것은 select_related를 사용한 쿼리는 단순 쿼리와 달리
미리 author(Person)와 hometown(City) 테이블을 🍎SQL에서 Join하여🍎 DB를 히트하여 캐싱하기 때문이다.

이러한 select_related는 🖐OneToMany와 OneToOneField에서 사용이 가능하다. OneToOneField는 역참조 관계도 가능하다. 만일 models.py 작성 시, related_name을 작성했다면 테이블의 field name보다는 정의한 related_named으로 참조하는 것이 좋다.

🖐모든 related objects에 대하여, 혹은 이름은 모르겠는 related objects를 한 번의 lookup으로 미리 캐싱해두고 싶을 때는 select_related()로 사용할 수도 있다. 정확한 인자값을 전달하지 않아도 모든 non-null FK에 대해 적용되는 것이다. (It is possible to call select_related() with no arguments.) 단, nullable한 FK에는 적용이 될 수 없다.
(select_related()는 쿼리를 더욱 복잡하게 만들거나 내가 원하는 데이터보다 더 많은 데이터를 리턴할 확률이 있어(==정확성이 떨어짐) 권장되지는 않는다.)

select_related를 통해 캐싱해둔 모든 쿼리셋을 clear할 때는
queryset.select_related(None)을 입력해준다.

또한 여러 개의 select_related를 할 때에는
select_related('foo'), select_related('bar')도 가능하지만
한 번에 select_related('foo', 'bar')도 가능하다.



정참조: ManyToMany / 역참조: ForeignKey, ManyToMany

select_related와 근본적인 목적은 비슷하다. 실행되는 쿼리의 수를 줄이는 것.
하지만 그 방식은 조금 다르다.

select_related works by creating an SQL join and including the fields of the related object in the SELECT statement. For this reason, select_related gets the related objects in the same database query. However, to avoid the much larger result set that would result from joining across a ‘many’ relationship, select_related is limited to single-valued relationships - foreign key(one-to-many) and one-to-one.

prefetch_related, on the other hand, does a separate lookup for each relationship, and does the ‘joining’ in Python. This allows it to prefetch many-to-many and many-to-one objects, which cannot be done using select_related, in addition to the foreign key and one-to-one relationships that are supported by select_related. It also supports prefetching of GenericRelation and GenericForeignKey, however, it must be restricted to a homogeneous set of results. For example, prefetching objects referenced by a GenericForeignKey is only supported if the query is restricted to one ContentType.

결국 prefetch_related는
1) 각각의 관계에 대해 따로 쿼리를 찾고 (SQL문에서의 join X)
2) python에서 join을 수행한다
3) select_related만으로 찾을 수 없는 ManyToMany 혹은 ManyToOne 관계의 객체까지 찾을 수 있게 해준다

이러한 models.py를 작성했다고 가정해보자.

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()),
        )

한 피자가 여러개의 토핑을 가질 수 있고
한 토핑은 여러개의 피자에 들어갈 수 있으므로
피자와 토핑은 MTM 관계에 있다. 이를 Pizza 테이블의 toppings 속성에 표현했다.

그리고 ORM 실행

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

잘 나오는듯 싶지만 이 ORM에는 치명적인 문제가 있다. 뭐냐하면
Pizza.__str__()는 매번 원하는 정보를 가져오기 위해 self.toppings.all()을 query해야하고 그 말은 결국, Pizza.objects.all()은 Pizza object를 가져오기 위해 topping 테이블을 매번 매 객체마다 hit한다는 뜻이 된다. 매우 비효율적인 쿼리를 수행하게 되는 것이다.

prefetch_related를 이용하면 이런 상황을 단 두 번의 DB hit로 끝낼 수 있다.

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

This implies a self.toppings.all() for each Pizza; now each time self.toppings.all() is called, instead of having to go to the database for the items, it will find them in a prefetched QuerySet cache that was populated in a single query.

That is, all the relevant toppings will have been fetched in a single query, and used to make QuerySets that have a pre-filled cache of the relevant results; these QuerySets are then used in the self.toppings.all() calls.

🖐이제는 각각의 토핑에 대해 매번 DB를 히트하는 것이 아니라, prefetch하여 캐싱된 toppings쿼리셋 내에서 값을 리턴하는 것이다.

하지만, 연결된 다른 DB를 추적해야하는 경우에 헛된 prefetch는 도움이 되지 않기도 한다.

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

🖐이 경우 prefetched하게 캐싱된 데이타는 Pizza.objects.all()이다. 하지만 아래 for문에서 찾고 싶은 값은 Pizza.objects.filter()에 해당하는 전혀 다른 쿼리이다. 이런 경우는 미리 캐싱해둔 쿼리가 도움이 되지 않는다. 심지어 쿼리 수행에 해를 끼치기도 한다. 이런 경우에 각별히 주의하여 prefetch_related를 사용해야 한다.

Pizza와 Topping 테이블만 있던 기존 모델에 아래의 테이블을 추가해본다.

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 테이블의 pizzas 속성과 best_pizza 속성 둘다 모두 Pizza 테이블을 참조하고 있다는 것이다.
이럴 경우에는 참조하는 Pizza 클래스 인스턴스에서 어떤 명칭으로 이 Restaurant 테이블의 해당 속성들(pizzas, best_pizza)호출할 것인지 서로 헷갈리지 않도록 related_named을 반드시 지정해주어야 한다. 그리하여 pizzas에는 'restaurants'가, best_pizza에는 'championed_by'가 related_name으로 붙어있는 것이다. (이 related_name을 아무렇게나 설정하면 차후에 상당히 곤란한 일이 발생할 수 있으니 반드시 관계를 알아볼 수 있고, 최대한 직관적이게 정하도록 한다.)

그리고 이 때
>>> Restaurant.objects.prefetch_related('pizzas__toppings') 라는 쿼리를 수행 시,
Restaurant과 연결된 모든 Pizza를, 그리고 Pizza에 연결된 모든 Topping을 prefetch하게 된다. 총 세 번의 쿼리를 수행하는 것이다. (This will result in a total of 3 database queries - one for the restaurants, one for the pizzas, and one for the toppings.)

>>> Restaurant.objects.prefetch_related('best_pizza__toppings')
이 쿼리 역시 위와 비슷한 과정이기에 세 번의 쿼리로 진행된다. 다만 이 경우, Restaurant과 best_pizza는 일대다 FK 관계에 있기 때문에 select_related를 이용하여 아래처럼 같은 과정을 진행할 수도 있다.

>>> Restaurant.objects.select_related('best_pizza').prefetch_related('best_pizza__toppings')
이렇게 select_related를 이용할 경우 쿼리 수는 2번으로 줄어든다.
(best_pizza를 SQL로 join하여 Restaurant를 쿼리할 때 한 번, 그 이후 prefetch_related를 이용하여 toppings 쿼리 수행 시 한 번.)



reference:
https://velog.io/@brighten_the_way/Django%EC%99%80-Reverse-relations%EA%B3%BC-Relatedname (related_name 마스터 빛소헌님)
https://docs.djangoproject.com/en/3.1/ref/models/querysets/#select-related (장고 공식문서)

profile
Backend 개발 학습 아카이빙 블로그입니다. (현재는 작성하지 않습니다.)

0개의 댓글