김성렬 님의 "Django ORM QuerySet 구조와 원리 그리고 최적화 전략강의"를 듣고 정리한 글입니다.
정말 필요한 시점에 SQL을 호출한다.
users: QuerySet = User.objects.all()
orders: QuerySet = Order.objects.all()
user_list = list(users)
(예시1)
Django ORM은 정말 필요한 만큼만 호출한다
이러한 특성 때문에, ORM이 비효율적으로 동작하기도 함.
users: QuerySet = User.objects.all()
first_user: User = users[0]
user_list: list(User) = list(users)
(예시2)
ORM이 필요한 시점에 필요한 만큼만 데이터를 가져오기 때문에 캐싱된 쿼리셋을 재사용 해야 한다.
users: QuerySet = User.objects.all()
user_list: list(User) = list(users) # 모든 유저 정보가 캐싱 된다.
first_user: User = users[0]
(예시3)
예시2
에서 순서를 바꾸어 first_user를 먼저 호출하는 것이 아니라 모든 User를 QuerySet에서 먼저 호출한다.QuerySet은 기본적으로 Lazy Loading이라는 전략을 택하는데, 가끔 SQL로 한 번에 많은 데이터를 끌어와야 할 때가 있다. 이를 ORM에서는 Eager Loading
이라고 한다.
→ QuetySet에서 이를 지원하기 위해 select_related
, prefetch_related
메소드를 제공한다.
users: QuerySet = User.objects.all()
for user in users:
user.userinfo
(예제4: N+1 Problem이 발생하는 예제)
LazyLoading
이기 때문에 모든 user의 정보를 SQL로 한 번에 가져왔다고 하더라도 userinfo 테이블에 있는 정보는 QuerySet을 선언한 시점에서 당장 필요하지 않기 때문에 데이터를 가져오지 않게 된다.for문이 도는 시점에 users의 모든 정보를 가져오기 위해 SQL이 호출 된다.
그 다음 for문 안에서 user에 해당하는 userinfo를 QuerySet에서 찾는데, QuerySet은 해당 정보를 들고 있지 않다.
그러면 QuerySet은 해당 정보를 찾아 SQL을 한 번 더 호출하게 된다.
⇒ 따라서, SQL이 n번 동안 계속해서 호출 되며, N+1개의 SQL 호출이 발생해 N+1 problem
에 해당한다.
N+1 problem
은 ORM에서 Lazy-Loading에 의해 발생하는 대표적인 문제이다.Eager-Loading
전략을 택하도록 QuerySet에 옵션을 줘야 한다.QuerySet은 한 개의 Query와 0 또는 N개의 추가 QuerySet으로 구성되어 있다.
# Query()는 개발자가 정의한 QuerySet을 읽어서 실제 SQL을 생성해 주는 구현체
from django.db.model.sql import Query
class QuerySet:
# 강의에서 김성렬 님은 이를 메인쿼리라 명명하였다.
query: Query = Query()
# SQL의 수행 결과 저장 및 재사용(QuerySet Cache)
# QuerySet 재호출시 해당 프로퍼티에 저장된 데이터가 없으면 SQL을 호출해 데이터를 가져온다.
_result_cache: list[Dict[Any, Any]] = dict()
# 추가 QuerySet이 될 타겟들 저장
_prefetch_related_lookups: Tuple(str) = ()
# SQL 결과값을 파이썬이 어떤 자료구조로 반환 받을 지 선언하는 프로퍼티
# 이 값은 직접 수정하지 않으며 QuerySet.values() 또는 .values_list()를 사용해 변환시킨다.
_iterable_class = ModelIterable
(예시4)
_result_cache
_result_cache
에 QuerySet이 가져온 캐싱하는 데이터들을 저장해 놓는다._result_cache
에 원하는 데이터가 없다면 그 때 SQL을 호출한다.prefetch_related_lookups
_iterable_class
values
옵션을 주면 dict
형태로 반환한다.values_list
옵션을 주면 tuple list
형태로 데이터 값을 반환한다.
select_related
는 join을 통해 데이터를 즉시 로딩하는 방식을 가지고 있으며 정방향 참조 필드이다.prefetch_related
는 추가 쿼리를 수행해(쿼리를 하나 더 호출) 데이터를 즉시 가져오는 방식으로 역방향 참조 필드이다.
정방향/역방향 참조필드
주문과 상품이 N : M(다대다) 관계라고 가정해 보자.(회원 : 주문 = 1 : N)
order_list = (
Order.objects.select_related("order_owner")
.filter(order_owner__username="username4")
.prefetch_related("product_set_included_order")
)
(예시5)
select_related
옵션을 주어 User의 정보를 조인하라는 옵션을 준다.prefetch_related
를 이용해 추가 쿼리를 통해 상품의 정보를 전부 다 끌어올 수 있다.select_related
에 옵션을 줄 수가 없다.select_related()
에 추가할 수 없다.prefetch_related
에 옵션으로 줄 수는 있지만 ‘product_set’이라는 필드를 select_related
에는 줄 수가 없다.select_related
에는 줄 수가 없지만 정방향 참조 모델은 select_related
와 prefetch_related
모두에 옵션으로 줄 수 있다.prefetch_related()는 추가 쿼리셋이다.
QuerySet은 한 개의 쿼리와 N개의 쿼리셋으로 이루어져 있다.
prefetch_related
는 새로운 쿼리셋이고 그 안에 선언한 개수만큼 쿼리가 추가적으로 더 호출된다.
# 1번
queryset = AModel.objects.prefetch_related("b_model_set", "c_models")
# 2번
from django.db.models import Prefetch
queryset = AModel.objects.prefetch_related(
Prefetch(to_attr="b_model_set", queryset=BModel.objects.filter(is_deleted=False)),
Prefetch(to_attr="c_models", queryset=CModel.objects.all()),
)
# SQL
select * from a_model;
select * from b_model where id in (~~~) and is_deleted is False;
select * from c_model where id in (~~~);
(예시6)
prefetch_related
안에 선언한 개수 만큼 추가적으로 SQL이 실행 된다.order_product = OrderedProduct.objects.select_related(
"related_order", "related_product"
).filter(related_order=4)
(연습1)
OrderedProduct.objects.filter(
product_cnt__lt=30, related_order__description="주문의 상세내용입니다."
).prefetch_related(
Prefetch("related_order", queryset=Order.objects.select_related("mileage").all())
)
(연습2)
prefetch_related()
옵션에 의해 1개의 추가 쿼리가 더 발생 한다.Prefetch
함수를 통해 쿼리셋을 재선언 해 주었는데, 재선언된 쿼리셋은 select_related()
옵션을 주었다.select_related
가 붙어 있는 것이다.OrderedProduct.objects.filter(product_cnt__gt=23).prefetch_related(
Prefetch(
"related_product__product_owned_company",
queryset=Company.objects.filter(name__contains="comanpy_name"),
)
)
(연습3)
prefetch_related()
에 2개의 옵션이 추가된 셈이다.name__contains
조건으로 재선언을 해서 WHERE 절을 걸었으므로 SQL 쿼리에서는 LIKE
문이 붙게 된다.OrderedProduct.objects.filter(product_cnt__gt=23).prefetch_related(
Prefetch(
"related_product", queryset=Product.objects.filter(price__isnull=False)
)
Prefetch(
"related_product__product_owned_company",
queryset=Company.objects.filter(name__contains="comanpy_name"),
)
)
김성렬님이 자주 사용하시는 테스트 케이스로 소개하신 것이 CaptureQueriesContext
이다.
강의 내용에 따르면, N+1 문제로 인한 크리티컬한 성능 이슈만 체크하고 싶을 때 사용하기에 유용하다고 한다.
꿀팁인 것 같다.
아래와 같은 예시를 강의에서 확인할 수 있었다.
from django.test.utils import CaptureQueriesContext
from rest_framework.test import APIClient
def test_check_n_plus_1_problem():
from django.db import connection
with CaptureQueriesContext(connection) as expected_num_queries:
APIClient.get(path='/restaurants/")
# 주문이 두 개 더 추가된 이후 API에서 발생하는 SQL Count
Order.objects.create(
total_pricee=1000,
)
Order.objects.create(
total_pricee=5000,
)
with CaptureQueriesContext(connection) as checked_num_queries:
APIClient.get(path='/restaurants/")
# 이제 주문이 두 개 더 발생했다고 SQL이 2개 더 생성되었는지 여부를 확인한다.
# 주문이 N개 생성되었다고 해서 SQL이 N개 더 생성되면 안된다!
# 즉, 아래의 두 쿼리셋의 길이가 같아야 한다.
assert len(checked_num_queries) == len(expected_num_queries)
company_qs = Company.objects.prefetch_related("product_set").filter(
name="company_name1", product__name__isnull=False
)
(예시7)
filter()
는 한 개의 쿼리에 해당하는 내용들을 제어한다.prefetch_related()
는 추가 쿼리셋에 있는 내용들을 제어한다.product__name__isnull=False
라는 조건절을 검색하기 위해서 필연적으로 product를 조인할 수밖에 없다.prefetch_related
라는 옵션을 줬기 때문에 product가 한 번 더 쿼리를 생성하게 된다.prefetch_related()
옵션 제거QuerySet이 알아서 JOIN으로 SQL을 풀어 준다.
즉, 쿼리 한 줄로 데이터를 전부 끌어올 수 있다.
company_qs = Company.objects.filter(
name="company_name1", product__name__isnull=False
)
(예시7-1)
Prefetch()
에 제공추가 쿼리에 where문이 추가 된다.
company_qs = Company.objects.filter(name="company_name1").prefetch_related(
"product_set",
Prefetch(queryset=Product.objects.filter(product__name__isnull=False)),
)
(예시7-2)
김성렬님이 개인적으로 추천하는 작성 순서이다.
prefetch_related
가 filter 앞에 있는 것은 피하는 것이 좋다는 것이다.company_list = list(Company.objects.prefetch_related("product_set").all())
company = company_list[0]
company.product_set.all() # SQL이 추가 발생하지 않음(이미 Eager Loading 했기 때문)
company.product_set.filter(name="불닭볶음") # SQL이 추가 발생
# SQL을 추가로 발생시키지 않기 위한 방법 - list comprehension
fire_noodle_product_list = [
product for product in company.product_set.all() if product.name == "불닭볶음"
]
(예시8)
company_list[0]
에서 쿼리가 추가적으로 나가지 않는다.compnay.product_set.all()
에서도 쿼리가 추가적으로 나가지 않는다.prefetch_related()
를 통해서 product_set
을 이미 Eager-Loading으로 한 번에 다 끌어왔기 때문이다.company.product_set.filter(name='불닭볶음')
에서는 쿼리가 추가적으로 발생한다.company.product_set.all()
을 호출해서 그 안에 있는 로직에서 파이썬 list comprehension으로 찾아 주는 방식을 사용해야 한다.가끔 원하는 SQL을 위해 QuerySet을 완전 포기할 때가 있다.
즉, Django connection에서 cursor.execute() 식으로 로직을 사용할 때가 있다.
강의에서는 이 방법 보다는 RawQuerySet
을 추천한다.
from django.db.models.query import QuerySet, RawQuerySet
raw_queryset: RawQuerySet = Model.objects.raw("select * from model where ~~")
queryset: QuerySet = Model.objects.filter(~~)
(예시9)
RawQuerySet
과 쿼리셋은 크게 다르지는 않다.RawQuerySet
또한 아직 ORM의 제어권 안쪽에 있는 구현체이다.raw()
를 선언하면 RawQuerySet이 된다.sql.Query()
를 사용하는 반면, RawQuerySet은 장고에서 제공하는 것을 사용하지 않고 사용자가 직접 제공해준 Raw SQL을 가져다가 그대로 사용한다.# 쿼리셋
class QuerySet:
def __init__(self, model=None, query=None, using=None, hints=None):
...
self._query = query or sql.Query(self.model)
...
# RawQuerySet
class RawQuerySet:
def __init__(self, raw_query, model=None, query=None, params=None, translations=None,
using=None, hints=None):
...
self._query = query or sql.RawQuery(sql=raw_query, using=self.db, params=params)
...
(예시9-1)RawQuerySet
은 QuerySet의 또다른 유형이기 때문에 prefetch_related()
를 사용할 수 있다.from django.db.models.query import RawQuerySet
from django.db.models.query import QuerySet
# 로우쿼리셋
order_queryset: RawQuerySet = Order.objects.raw(
raw_query="""
SELECT *
FROM "orm_practice_app_order"
INNER JOIN "orm_practice_app_user"
ON ("orm_pratice_app_order"."order_owner_id" = "orm_practice_app_user"."id")
WHERE "orm_pratice_app_user"."username" = %(username_param1)%
"""
, params=("username_param1": "username4")
).prefetch_related("product_set_included_order")
# 쿼리셋
order_queryset: QuerySet = (
Order.objects.select_related("order_owner")
.filter(order_owner__username="username4")
.prefetch_related("product_set__included_order")
)
(예시9-2).select_related() # 메인 쿼리에 JOIN 옵션을 주는 메소드
FilteredRelation() # ON절 제어 옵션 - Join이 안되므로 당연히 사용 불가
.annotate() # 메인쿼리에 AS 옵션을 주는 메소드
.order_by() # 메인 쿼리에 order by 옵션 주는 메소드
.extra() # 메인 쿼리에 sql을 추가 반영하는 메소드
[:10]...[:2]... # 메인 쿼리에 limit 옵션을 걸 수 없다.
일반적으로 서브쿼리는 슬로우 쿼리를 많이 야기한다(심한 경우 서비스 자체가 느려질 수도 있다). 따라서, 서브쿼리를 의도하고 사용하는 케이스가 생각보다 별로 없는데 가끔씩 쿼리셋이 개발자의 의도와 다르게 혹은 예상치 못하게 서브쿼리를 수행할 때가 있다.
django orm에 서브쿼리 옵션이 있긴 하지만 이 옵션을 주지 않았을 때도 가끔씩 발생할 수 있다.
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)
(예시10)
company_queryset
이 아직 쿼리셋이며 실행되지 않았기 때문에 product_queryset
안에 조건절로 들어갔을 때도 쿼리셋 상태이다.list() 안에 넣는 방법이 그 중 하나이다. list() 옵션으로 QuerySet을 바로 수행하면 company_queryset
은 쿼리셋이 아니게 된다.
company_queryset: QuerySet = list(Company.objects.filter(id__lte=20).values_list("id", flat=True)
)
product_queryset: QuerySet = Product.objects.filter(product_owned_company__id__in=company_queryset)
normal_joined_queryset = Order.objects.filter(description__isnull=False, product_set_included_order__name='asd')
(예시11)
exclude()
는 not 옵션, filter()
는 바른 옵션으로 주로 사용한다.exclude()
옵션에서 서브쿼리가 발생할 때가 있다.exclude()
로 옮겨 주기만 했는데 서브쿼리가 발생한다.normal_joined_queryset = Order.objects.filter(description__isnull=False).exclude(product_set_included_order__name='asd')
exclude
에 넣어서 사용하면 서브쿼리가 발생한다.~Q
옵션(Q는 'OR' 조건에 사용한다. 따라서 아래의 경우 ~인 것 또는 ~아닌 것을 의미한다)을 주어도 여전히 서브쿼리가 발생한다.normal_joined_queryset = Order.objects.filter(Q(description__isnull=False), ~Q(product_set_included_order__name='asd'))
select_related
옵션을 추가해 강제로 JOIN을 유도해도 여전히 서브쿼리가 발생한다.prefetch_related(Prefetch())
를 사용해서 따로 제약을 주는 방법을 대체할 수 있다.정방향 참조의 경우 위의 역방향 참조에서와는 달리 .exclude()
절을 사용할 경우 의도한 대로 JOIN을 수행한다. 이를 참고하면 서브쿼리를 발생시키지 않을 수 있을 것이다.
normal_joined_queryset = Order.objects.filter(description__isnull=False).exclude(order_owner__userinfo__tel_num='010-0000-0000')
(예시12)
values_list()
의 경우 flat=True
옵션과 named=True
옵션이 있다.
# ModelIterable
result: List[Model] = Model.objects.all()
.only() # 지정한 필드만 조회
.defer() # 지정한 필드 제외하고 조회
# ValuesIterable
result: List[Dict[str, Any]] = Model.objects.values()
# ValuesListIterable
result: List[Tuple[str, Any]] = Model.objects.values_list()
# FlatValuesListIterable
result: List[Any] = Model.objects.values_list('pk', flat=True)
# NamedValuesListIterable: django에서 제공하는 Raw라는 객체에 데이터를 담아 리턴
result: List[Raw] = Model.objects.values_list(named=True)
(예시13)
named=True
옵션은 2.X에 들어와서 생긴 옵션이다.namedtuple
이다.values()
, values_list()
를 사용하면 eager-loading 옵션들을 전부 무시하는 특성이 있다. 따라서, 해당 QuerySet에 주어진 select_related()
, prefetch_related()
옵션들을 전부 무시한다.
gg = list(Product.objects.select_related('product_owned_company').filter(id=1).values())
(예시 14)
select_related
JOIN을 하지 않으며 select_related
를 무시한다.gg = list(Product.objects.select_related('product_owned_company').filter(id=1).values(product_owned_company)
values()
와 values_list()
는 DB의 row 단위로 데이터를 반환하기 때문이다.select_related()
와 prefetch_related()
는 DB Row 단위로 데이터를 조회하는 values()
, values_list()
에서는 무의미한 옵션인 것이다.너무 좋은 강의였고, 나한테 꼭 필요한 강의였다!
참고
Django ORM (QuerySet)구조와 원리 그리고 최적화전략 - 김성렬 - PyCon Korea 2020