Django ORM (QuerySet)구조와 원리 그리고 최적화전략 - 정리
발표 자료 : PyCon 2020 Django QuerySet 발표자료 · Issue #7 · KimSoungRyoul/PyConKR2020-DjangoORM · GitHub
발표 자료 github : GitHub - KimSoungRyoul/PyConKR2020-DjangoORM: PyCon 2020 발표자료 포함
주요 이슈 : Issues · 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://www.youtube.com/watch?v=1tI6ectLjy4
'Study > django' 카테고리의 다른 글
[UDEMY]Django DRF Project: eCommerce RESTful API 강의 평가 (0) | 2024.03.01 |
---|---|
[UDEMY]Django DRF Project: eCommerce RESTful API 강의 정리 (0) | 2024.03.01 |
[udemy] Build a Backend REST API with Python & Django - Advanced 학습 정리 (1) | 2024.01.03 |
SQL을 이해하고 사용하는 Django ORM, 신동현 - PyCon Korea 2022 - 학습 정리 (0) | 2023.12.30 |
DB를 먼저 설계하고 django와 연동하기 (6) - 함수와 lock 사용하기 (0) | 2023.12.14 |
댓글