Study/django

Django ORM (QuerySet)구조와 원리 그리고 최적화전략 - 정리

bluebamus 2024. 2. 22.

 

 

 발표 자료 : PyCon 2020 Django QuerySet 발표자료 · Issue #7 · KimSoungRyoul/PyConKR2020-DjangoORM · GitHub

PyConKR-2020-.ORM_.pdf
5.39MB

 

 

 발표 자료 github : GitHub - KimSoungRyoul/PyConKR2020-DjangoORM: PyCon 2020 발표자료 포함

 주요 이슈 : Issues · KimSoungRyoul/PyConKR2020-DjangoORM · GitHub

 쿼리셋 연습장 : PyConKR2020-DjangoORM/orm_practice_app/queryset_pratice.py at master · KimSoungRyoul/PyConKR2020-DjangoORM · GitHub

 

 발표 정리 blog : Django ORM (QuerySet)구조와 원리 그리고 최적화전략 - PyCon Korea 2020 발표 정리 (velog.io)

 추천 참고 : Django ORM Cookbook — Django ORM Cookbook 2.0 documentation (agiliq.com)

 개인 blog 추가 정리 1 : Django에서는 QuerySet이 당신을 만듭니다 (1). ORM with Django — 김성렬, Backend… | by Soungryoul Kim | YOGIYO Tech Blog - 요기요 기술블로그

 개인 blog 추가 정리 2 : Django에서는 QuerySet이 당신을 만듭니다 (2). Django with QuerySet 심화  — 김성렬, Backend… | by Soungryoul Kim | YOGIYO Tech Blog - 요기요 기술블로그

 

 1. LazyLoading - QuerySet이 평가(evaluated)되는 시점

   1) 어떨 때 queryset을 평가하는가?

      1. Model.objects.get()

>>> blog = Blog.objects.get(id=1)
(0.000) SELECT "app_blog"."id", "app_blog"."name", "app_blog"."tagline" FROM "app_blog" WHERE "app_blog"."id" = 1 LIMIT 21; args=(1,); alias=default
>>> blog
<Blog: Beatles Blog>
>>> blog = Blog.objects.filter(name="Beatles Blog")
>>> blog
(0.000) SELECT "app_blog"."id", "app_blog"."name", "app_blog"."tagline" FROM "app_blog" WHERE "app_blog"."name" = 'Beatles Blog' LIMIT 21; args=('Beatles Blog',); alias=default
<QuerySet [<Blog: Beatles Blog>]>

 

      2. 반복 (Iteration)

>>> for b in Blog.objects.all():
...     print(b)
... 
(0.000) SELECT "app_blog"."id", "app_blog"."name", "app_blog"."tagline" FROM "app_blog"; args=(); alias=default
Beatles Blog

 

      3. 슬라이싱 (Slicing)

>>> blogs = Blog.objects.all()[::2]
(0.000) SELECT "app_blog"."id", "app_blog"."name", "app_blog"."tagline" FROM "app_blog"; args=(); alias=default

 

      4. Picking / Caching

         - Pickling:

import pickle
from myapp.models import MyModel

# QuerySet 생성 (아직 평가되지 않음)
queryset = MyModel.objects.filter(name='test')

# QuerySet을 Pickle (QuerySet이 여기서 평가됨)
pickled_queryset = pickle.dumps(queryset)

# Pickled QuerySet을 Unpickle
unpickled_queryset = pickle.loads(pickled_queryset)

# Unpickled QuerySet 사용
for obj in unpickled_queryset:
    print(obj.name)
SELECT * FROM myapp_mymodel WHERE name = 'test';

 

         - Caching:

from django.core.cache import cache
from myapp.models import MyModel

# QuerySet 생성 (아직 평가되지 않음)
queryset = MyModel.objects.filter(name='test')

# QuerySet 캐싱 (QuerySet이 여기서 평가됨)
cache.set('my_key', queryset)

# 캐시에서 QuerySet 가져오기
cached_queryset = cache.get('my_key')

# 캐시된 QuerySet 사용
for obj in cached_queryset:
    print(obj.name)
SELECT * FROM myapp_mymodel WHERE name = 'test';

 

      5. repr()

>>> blogs = repr(Blog.objects.all())
(0.000) SELECT "app_blog"."id", "app_blog"."name", "app_blog"."tagline" FROM "app_blog" LIMIT 21; args=(); alias=default

 

      6. len(), list(), bool()

>>> blogs = len(Blog.objects.all())
(0.000) SELECT "app_blog"."id", "app_blog"."name", "app_blog"."tagline" FROM "app_blog"; args=(); alias=default
>>> blogs = list(Blog.objects.all())
(0.000) SELECT "app_blog"."id", "app_blog"."name", "app_blog"."tagline" FROM "app_blog"; args=(); alias=default
>>> if Blog.objects.filter(name="Rim's Blog"):
...     print("This is Rim's blog!")
... 
(0.000) SELECT "app_blog"."id", "app_blog"."name", "app_blog"."tagline" FROM "app_blog" WHERE "app_blog"."name" = 'Rim''s Blog'; args=("Rim's Blog",); alias=default

 

   2) QuerySet 캐싱을 재사용 하는 방법

      - 캐싱을 적용했을 때

blogs = Blog.objects.all() # 데이터베이스 쿼리

for blog in blogs: # 캐시 사용  
 print(blog.name)

for blog in blogs: # 캐시 사용
 print(blog.tagline)
(0.000) SELECT "blog_blog"."id", "blog_blog"."name", "blog_blog"."tagline" FROM "blog_blog"; args=(); alias=default
Beatles Blog
Rim's Blog
All the latest Beatles news.
This is the Rim's blog.

 

      - QuerySet이 캐시되지 않을 때

         - slice나 index로 queryset을 제한하면 캐시가 되지 않는다.

blogs = Blog.objects.all()

print(blogs[0]) # 데이터베이스 쿼리
print(blogs[0]) # 데이터베이스 다시 쿼리
(0.000) SELECT "blog_blog"."id", "blog_blog"."name", "blog_blog"."tagline" FROM "blog_blog" LIMIT 1; args=(); alias=default
Beatles Blog
(0.000) SELECT "blog_blog"."id", "blog_blog"."name", "blog_blog"."tagline" FROM "blog_blog" LIMIT 1; args=(); alias=default
Beatles Blog

 

 2. EagerLoading (Reslove N+1 Problem)

     - ORM은 게으르기 때문에 기본적으로 Lazy Loading 전략을 택한다. 하지만 Eager Loading 을 이용하면 즉시 데이터를 가져올 수 있다.

      - 아래 코드는 N+1 문제를 발생시킨다.

class ExampleView(View):
    def get(self, request):
        entrys = Entry.objects.all()

        blog_name_list = [entry.blog for entry in entrys]

        return HttpResponse(blog_name_list)
(0.000) SELECT "blog_entry"."id", "blog_entry"."blog_id", "blog_entry"."headline", "blog_entry"."body_text", "blog_entry"."pub_date", "blog_entry"."mod_date", "blog_entry"."number_of_comments", "blog_entry"."number_of_pingbacks", "blog_entry"."rating" FROM "blog_entry"; args=(); alias=default
(0.000) SELECT "blog_blog"."id", "blog_blog"."name", "blog_blog"."tagline" FROM "blog_blog" WHERE "blog_blog"."id" = 2 LIMIT 21; args=(2,); alias=default
(0.000) SELECT "blog_blog"."id", "blog_blog"."name", "blog_blog"."tagline" FROM "blog_blog" WHERE "blog_blog"."id" = 2 LIMIT 21; args=(2,); alias=default

 

      - select_related() & prefetch_related()

         - 역방향참조 모델은 select_related()에 줄 수 없다. 

         - 정방향참조 모델은 select_related() prefetch_related()에 옵션으로 둘다 가능하지만, 특별한 이유가 없다고 한다면 select_related를 권장한다 (추가 쿼리가 없는 것이 성능상 더 좋다)

         - prefetch_related는 'product_set'과 같은 역참조 필드를 사용할 수 있지만 select_related는 사용할 수 없다.

 

   1) select_related()

       - select_related는 join문을 통해서 데이터를 즉시 로딩한다. 위에서 수행한 N+1 Problem이 발생한 코드를 select_related 를 통해서 추가 쿼리가 발생하지 않도록 수정을 할 수 있다.

       - Django ORM에서 select_related 메소드를 사용하면, 기본적으로 LEFT OUTER JOIN이 생성된다. 이는 연관된 객체가 없는 경우에도 주 쿼리셋의 객체를 반환하기 위함이다.

       - Inner / Outer 여부는 QuerySet 조건절 변경에 따라 Join 옵셥이 변할 수 있다.

ForiengKey(null=True) 이면 Outer join
ForiengKey(null=False) 이면 Inner join
# INNER JOIN
# ORM
books = Book.objects.select_related('author')

# 생성되는 쿼리
"""
SELECT book.*, author.* 
FROM book 
INNER JOIN author ON book.author_id = author.id;
"""
# 이 쿼리는 Book과 Author 테이블을 INNER JOIN하고, 
# 그 결과를 사용하여 모든 Book 객체와 관련된 Author 객체를 가져옵니다.


# LEFT OUTER JOIN (LEFT JOIN):
# ORM
# books = Book.objects.select_related('author').all()
books = Book.objects.all().select_related('author')

# 생성되는 쿼리
"""
SELECT book.*, author.* 
FROM book 
LEFT OUTER JOIN author ON book.author_id = author.id;
"""
# 이 쿼리는 Book 테이블에 있는 모든 레코드를 반환하고, 
# 관련된 Author 레코드가 있으면 그 정보도 함께 반환합니다.
# ORM
order_product = OrderedProduct.objects.select_related('related_order', 'related_product').filter(related_order=4)

# 실행되는 쿼리
"""
SELECT orderedproduct.*, related_order.*, related_product.* 
FROM orderedproduct 
INNER JOIN related_order ON orderedproduct.related_order_id = related_order.id 
INNER JOIN related_product ON orderedproduct.related_product_id = related_product.id 
WHERE related_order.id = 4;
"""
이 쿼리는 `OrderedProduct` 테이블, `RelatedOrder` 테이블, `RelatedProduct` 테이블을 INNER JOIN하고, 그 결과를 사용하여 `related_order`의 id가 4인 `OrderedProduct` 객체와 관련된 `RelatedOrder` 객체와 `RelatedProduct` 객체를 가져옵니다. 이렇게 하면 각 `OrderedProduct` 객체에 대해 별도의 쿼리를 실행하여 관련된 `RelatedOrder`와 `RelatedProduct`를 가져올 필요가 없으므로, 데이터베이스에 대한 쿼리 수가 크게 줄어들게 됩니다.

 

   2) prefetch_related()

      - prefetch_related를 사용하면 하나의 추가 쿼리문이 더 날아가게된다.

      - prefetch_related 는 추가 쿼리를 통해서 참조하고 있는 테이블의 정보를 전부 가져오게 된다.

queryset = (AModel.objects.prefetch_related("b_model_set","c_models"))

# prefetch 사용
from django.db.models import Prefetch

# ORM
b_queryset = BModel.objects.filter(is_deleted=False)
c_queryset = CModel.objects.all()
queryset = AModel.objects.prefetch_related(
    Prefetch("b_model_set", queryset=b_queryset),
    Prefetch("c_models", queryset=c_queryset)
)

# 실행되는 쿼리
"""
SELECT * FROM a_model;

SELECT * 
FROM b_model 
WHERE is_deleted = False AND a_model_id IN (...);  # a_model 테이블의 모든 id

SELECT * 
FROM c_model 
WHERE a_model_id IN (...);  # a_model 테이블의 모든 id
"""
from myapp.models import Company, Product

# ORM
company_queryset = Company.objects.filter(name='companny_name1').prefetch_related('product_set')

# 실행되는 쿼리
"""
SELECT * 
FROM company 
WHERE name = 'companny_name1';

SELECT * 
FROM product 
WHERE company_id IN (...);  # 첫 번째 쿼리에서 가져온 company의 모든 id
"""
queryset = OrderedProduct.objects.filter(
    product_cnt__lt=30,
    related_order__descriptions='주문의 상세내용입니다'
).prefetch_related(
    Prefetch('related_order', queryset=Order.objects.select_related('mileage').all())
)


SELECT *
FROM ordered_product
INNER JOIN `order` ON ordered_product.related_order_id = `order`.id
WHERE ordered_product.product_cnt < 30
AND `order`.descriptions = '주문의 상세내용입니다'

SELECT *
FROM `order`
LEFT OUTER JOIN mileage ON `order`.mileage_id = mileage.id
WHERE `order`.id IN (...)
orderedproduct.objects.filter(product_cnt__gt=23).prefetch_related(
prefetch('related_product__product_owned_company',
queryset=company.objects.filter(name__contains='company_name')))


SELECT "orderedproduct"."id", "orderedproduct"."product_cnt"
FROM "orderedproduct"
WHERE "orderedproduct"."product_cnt" > 23

SELECT "relatedproduct"."id", "relatedproduct"."product_owned_company_id"
FROM "relatedproduct"
WHERE "relatedproduct"."id" IN (SELECT U0."related_product_id" FROM "orderedproduct" U0 WHERE U0."product_cnt" > 23)

SELECT "productownedcompany"."id", "productownedcompany"."name"
FROM "productownedcompany"
WHERE "productownedcompany"."id" IN (SELECT U0."product_owned_company_id" FROM "relatedproduct" U0 WHERE U0."id" IN (SELECT U1."related_product_id" FROM "orderedproduct" U1 WHERE U1."product_cnt" > 23))
AND "productownedcompany"."name" LIKE '%company_name%'

 

   3) sql preformance를 커버하는 Testcase

      - 장고 공식 문서에서는 assertNumQueries()를 소개하지만, 매회 테스트마다 실행되는 갯수를 체크해줘야 하는 불편함이 있다.(SQL 갯수)

      - 제안하는 테스트 코드

from django.test.utils import CaptureQueriesContext
from rest_framework.test import APIClient

def test_check_n_plus_1_problem():
	from django.db import connection
    
    # given : 주문이 2개더 추가되기전 api에서 발생하는 sql count (expected_num_queries)
    with CaptureQueriesContext(connection) as expected_num_queries:
    	APIClient().get(path="restaurants/")
        
	# when : 주문이 2개 더 추가된 이후 api에서 발생하는 sql count (checked_num_queries)
    Order.objects.create(
    	total_price=9800,
        commect="1) 주문데이터가 n개 생성되었다고 sql이 n개 더 생성되면 안된다."
    )
    Order.objects.create(
    	total_price=8800,
        comment="2) 주문데이터가 n개 생성되었다고 sql이 n개 더 생성되면 안된다.
        )
    
    with CaptureQueriesContext(connection) as checked_num_queries:
    	APIClient().get(path="/restaurants/")
        
    # Then : 주문이 2개더 추가된다고 동일한 API에서 SQL이 추가발생되면 안된다.
    assert len(checked_num_queries.captured_queries) == len(expected_num_queries.captured_queries)

   

   4) 개인적으로 추천하는 queryset 작성 순서

      - annotate -> select_related -> filter, Q, exclud -> only, defer(필요시에만) -> preferch_related(prefetch)

 

   5) queryset 캐시를 재활용하지 못하는 queryset 호출

company_list = list(Company.objects.prefetch_related("product_set").all()

company = company_list[0]

company.product_set.all() # 여기서는 EagerLoading해서 발생 안함

company.product_set.filter(name="불닭볶음면") # sql이 발생함

```
.all()로 질의하면 result_cache를 재사용하지만
특정 상품을 찾으려 하면 result_cache를 재사용하지 않고 sql로 질의를 하게됨
```

# 해결방법
fire_noodle_product_list = [product for product in company.product_set.all() if product.name=="불닭볶음면"]

   

   6) RawQuerySet은 NativeSQL이 아니다.

      - RawQuerySet과 QuerySet은 메인쿼리를 NativeSQL로 구현한다는 차이점이 있다.

      - 완전 동일하지는 않지만, RawQuerySet은 아직 ORM 제어권 안쪽이다.

from django.db.models.query import QuerySet, RawQuerySet

this_is_raw_queryset: RawQuerySet = Model.objects.raw("select * from~~~)

this_is_queryset: QuerySet = Model.objects.filter(~~~)

     

      - RawQuerySet은 QuerySet의 또다른 유형이기 때문에 prefetch_related(), Prefetch()를 사용할 수 있다.

      - Model.objects.raw()를 사용하면 아래와 같은 메서드들을 사용할 수 없다.

.select_related
FilteredRelation ( filter() )
.annotate()
.order_by()
.extra()
[:10]....[:2]...

  

   7) 서브쿼리 발생조건

      - 서브쿼리 in 서브쿼리 사용시

company_queryset: QuerySet = Company.objects.filter(id__lte=20).values_list("id",flat=True)
product_queryset: QuerySet = Product.objects.filter(product_owned_company__id__in=company_queryset)

# 해결 방법
company_queryset: List[Company] = List(Company.objects.filter(id__lte=20)) # 리스트로 묶어 바로 수행하면 된다.
product_queryset: QuerySet = Product.objects.filter(product_owned_company__id__in=company_queryset)

  

      - exclude() 조건절의 함정 

         - 역방향참조 모델을 filter()절에 넣어서 join을 유도함(의도한 sql이 정상 수행됨)

normal_joined_queryset = Order.objects.filter(descriptions__isnull=False,
                                              product_set_included_order__name="asdfasdf")

 

         - 역방향 참조 모델을 exclude()절에 넣어서 join을 유도 (서브쿼리 발생)

         - ~Q()로 바꿔서 filter()에 넣어서 유도(서브쿼리 발생)

subquery_executed_queryset = Order.objects.filter(descriptions__isnull=False).exclude(
                                              product_set_included_order__name="asdfasdf")
                                              
subquery_executed_queryset = Order.objects.filter(descriptions__isnull=False).~Q(
                                              product_set_included_order__name="asdfasdf")

   

         - 이 문제는 select_related() 옵션을 줘서 join을 유도해도 여전히 발생한다.

         - 이런 경우 join으로 풀리게 유도하는 것이 불가능하다. 차선책으로 prefetch_related(prefetch()) 사용 가능

 

         - 정방향참조 모델은 exclude()절에 넣어서 join을 유도하면 문제가 없이 수행됨

normal_joined_queryset = Order.objects.filter(descriptions__isnull=False).exclude(
                                              product_set_included_order__name="asdfasdf")

 

   8) values() values_list 사용시 주의점

      - select_related(), prefetch_related()과 같은 eagerloading옵션은 무시하고 동작한다.

 

 

   9) where절 조건 순서 고정 방법

      - filter대신 Q를 사용하면 고정이 된다.

 

 

 

 - reference : 

https://medium.com/@hyerimc858/django-queryset-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0-lazy-loading-caching-eager-loading-b2ea7807cfa4

 

📗 Django QuerySet 이해하기 — Lazy Loading, Caching, Eager Loading

💤 ORM은 게으르다 (Lazy Loading)

medium.com

https://www.youtube.com/watch?v=1tI6ectLjy4

 

댓글