최근 사내 스터디에서 「Django QuerySet의 구조와 원리」를 주제로 발표를 했다. 이 발표를 하게 된 이유는, 최근 values에서 prefetch filter가 안 먹히는 버그를 두 번이나 겪었기 때문이다. 한 번은 product에서 발생하진 않았지만, 다른 한 번은 product에서 발생해서 심각한 버그(…)를 만들어버렸다.
취업 준비를 하면서 꼭 나중에 제대로 참고해서 공부해야지 하는 영상이 있었다. 물론, 아예 안 본것은 아니었다. 다만 그때는 이해할 수 없었던 Django의 여러 ORM들이었지만, 현업에서 직접 경험해보고 난 후 다시 참고하니 그 때는 안보였던 것들이 보이고 훨씬 기억에 많이 남는 것 같다.
Django ORM (QuerySet)구조와 원리 그리고 최적화전략, 김성렬, Pycon Korea 2020
본 발표 영상을 보고 정리한 글입니다.
(3–2) Caching — QuerySet 캐싱을 재사용하기
(3–4) Eager Loading, 즉시로딩 : N+1 problem
(4–2) Prefetch_related()는 추가 쿼리셋이다
(4–3) Prefetch_related()와 filter는 완전 별개다
(4–4) QuerySet 캐시를 재활용하지 못하는 QuerySet 호출
(4–5–1) QuerySet안에 QuerySet이 있을 경우
(4–5–2) 서브쿼리의 발생 조건 — exclude() 조건절의 함정
(4–6) Eager Loading(select_related, prefetch_related)을 무시하는 QuerySet의 반환타입
1. ORM이란?
Object Relational Mapping의 약자인 ORM은 객체-관계 매핑의 줄임말이다. 여기서 Object, 즉 객체는 객체지향프로그래밍(OOP)에서 쓰이는 개념이며, Relational은 관계형 데이터베이스(RDBMS)를 의미한다. 이를 적용하면 객체와 관계형 데이터베이스를 자동으로 연결(매핑)해준다는 의미이다.
그래서 객체와 데이터베이스를 연결해준다는 것까지는 알겠는데, 어떻게 어떤 방식으로 연결을 해줄 수 있는 것일까? 객체 지향 프로그래밍에서는 클래스를 사용하고, 관계형 데이터베이스는 테이블을 사용한다. 따라서 두 모델의 타입이 달라 직접적으로 두 모델을 연결하는 것은 불가능하다. 이 때, 두 모델 사이의 중간다리가 되어주는 것이 바로 SQL이다.
후에 살펴보겠지만, Django에서 data model을 생성하면, Django는 개체를 생성, 검색, 업데이트, 삭제할 수 있는 데이터베이스 추상화 API를 자동으로 제공한다. 데이터베이스 추상화 API인 QuerySet은 SQL의 질의인 Query들로 이루어져 있다.
2. ORM의 장단점
장점
- SQL Query가 아닌 직관적인 코드로 데이터를 조작 가능
- 선언문, 할당, 종료 같은 부수적인 코드 ↓
(데이터베이스 연결을 연습하면서 작성했던 source code, Github)
- 각종 객체에 대한 코드를 별도로 작성하여 가독성 ↑
- 구현방법과 자료형 타입 등이 DB에 종속적이지 않음
- 높은 재사용성
단점
- 성능적인 이슈(N+1 problem, 때때로 아주 비효율적인 Query를 만들기도 함
- ORM만으로 복잡한 SQL문을 생성하기 어려움
3. ORM을 통해 알아보는 QuerySet의 특징
(3–1) Lazy Loading, 지연로딩
ORM은 다음 세가지의 특징을 가지고 있다.
- 정말 필요한 시점에 SQL을 호출한다.
- 정말 필요해야만 SQL을 호출한다.
- 정말 필요한 만큼만 호출한다.
- 정말 필요한 시점에 SQL을 호출한다.
users: QuerySet = User.objects.all()
orders: QuerySet = Order.objects.all()
users와 orders를 만드는 순간에 ORM이 호출되어 DB로부터 데이터를 가져올 것 같지만, users
와 orders
Queryset은 그에 해당하는 SQL Query만 저장되어 있을 뿐 실제 데이터를 가지고 있지 않다.
실제 데이터를 가져오기 위해서는 ORM을 호출해야한다.
user_list = list(users)
로, users의 값을 list로 불러오면 이 때서야 SQL이 호출되어 DB로부터 데이터를 가져온다.
- 정말 필요해야만 SQL을 호출한다. + 정말 필요한 만큼만 호출한다
이 특성들 때문에 때때로 Queryset이 비효율적으로 작동하기도 한다.
users: QuerySet = User.objects.all()
User = users[0] # SQL 호출
user_list: list(User) = list(users) # SQL 다시 호출
User = User[0]
시 LIMIT 1 옵션을 걸고 SQL을 호출했으나, 그 뒤에 users를 list로 묶을 시 모든 user 목록을 얻기 위해 이전에 LIMIT 1 옵션을 걸고 SQL을 호출했었다는 점은 무시하고 다시 SQL을 호출한다.
(우리는 전체 user를 한번에 불러온다면 SQL을 한번에 데이터를 가져올 수 있다는 것을 알 수 있지만, ORM은 이를 모른다)
(3–2) Caching — QuerySet 캐싱을 재사용하기
앞선 예시에서 다음과 같이 호출 순서만 바꿔도, Caching하여 QuerySet을 재활용할 수 있다.
users: QuerySet = User.objects.all()
user_list: list(User) = list(users) # SQL을 통해 모든 유저 정보를 저장하고
first_user: User = users[0] # 0번째 사용자를 캐시에서 가져오게 된다.
(3–3) Eager Loading, 즉시로딩 : N+1 problem
ORM의 대표적인 문제점이 바로 N+1 problem이다. N + 1 Problem이란, ORM의 Lazy-Loading에 의해서 발생하는 대표적인 문제이다.
# N+1 Problem
users: QuerySet = User.objects.all()
for user in users:
user.userinfo
User
와 UserInfo
는 1:1의 관계이고, users
에 모든 User의 정보를 가져왔더라도, QuerySet을 선언한 시점에서는 필요하지 않아 SQL을 호출하지 않는다.
For문이 돌면서 SQL이 호출되는데, for문안에서 user
에는 userinfo
라는 정보가 존재하지 않는다.( User
QuerySet이므로 ) 따라서 QuerySet은 userinfo
의 정보를 찾기 위해 SQL문을 한번 더 호출하게 된다. 따라서 SQL이 n번 동안 계속해서 호출되며, N번( userinfo
) + 1번( user
) 의 SQL이 호출이 발생된다.
이런 상황에서는 Lazy-Loading이 아니라 Eager-Loading이라는 전략을 통해 관련된 데이터를 한번에 가져올 수 있도록 QuerySet에 옵션을 주어야 한다.
=> select_related
, prefetch_related
메소드로 즉시 로딩(Eager-Loading)
4. QuerySet 상세
(4–1) QuerySet의 구성요소
QuerySet은 한 개의 Query와 N개의 추가 QuerySet으로 구성되어 있다.
# 실제 Django source code를 간단히 정리한 것
# 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–2) Prefetch_related()는 추가 쿼리셋이다
앞서 QuerySet은 한 개의 Query와 N개의 추가 QuerySet으로 구성되어 있다고 했다.
Django에서 사용하는 QuerySet 구문들을 다음과 같은 식으로 반영된다.
select_related
, filter
, annotate
, order_by
은 메인 쿼리(1개의 쿼리)로 반영되는데에 반해, prefetch_related
는 추가 쿼리셋에 반영된다. 또한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
# 1번
select * from a_model;
# 2번
select * from b_model where id in (~~~) and is_deleted is False;
select * from c_model where id in (~~~);
(4–3) Prefetch_related()와 filter는 완전 별개다
앞선 내용을 바탕으로 prefetch_related
와 filter
를 다시 생각해보자.
prefetch_related
: 추가 쿼리셋filter
: 메인 쿼리
(잘못된 예시)
company_qs = Company.objects.prefetch_related("product_set").filter( name="company_name1", product__name__isnull=False
)
company_qs를 살펴보면, 메인 쿼리에서 product_name__isnull=False
라는 조건절을 검색하기 위해서 product
가 Join될 것이고, 그 후 prefetch_related
옵션의 존재 때문에 추가 쿼리셋을 통해 불필요하게 한 번 더 쿼리가 생성된다.
( 해결방법)
prefetch_related
제거
company_qs = Company.objects.filter(
name="company_name1", product__name__isnull=False
)
2. filter 조건을 Prefetch
에 제공
( Prefetch
이용 시 : 추가 쿼리에 where문 추가)
ompany_qs = Company.objects.filter(name="company_name1").prefetch_related(
"product_set",
Prefetch(queryset=Product.objects.filter(product__name__isnull=False)),
)
- 김성렬님은 개인적으로 SQL의 순서와 가장 유사하게 QuerySet을 작성하기 위해서,
annotate
,select_related
,filter
,prefetch_related
순서대로 QuerySet을 작성하는 것을 추천한다. - 어렵다면,
prefetch_related
가filter
앞에 있는 것은 피하자. 하나의 쿼리에서 작동하는 것처럼 보이기 쉬워 실수하기 쉽기 때문이다.
(4–4) QuerySet 캐시를 재활용하지 못하는 QuerySet 호출
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 == "불닭볶음면"
]
company.product_set.filter(name='불닭볶음면')
에서는 QuerySet의 캐시를 재사용하지 않고 SQL을 호출하게 된다. SQL을 추가적으로 발생시키지 않으려면 company.product_set.all()
을 호출해서 그 안에 있는 로직에서 파이썬 list comprehension으로 찾아 주는 방식을 사용해야 한다.
(4–5) 서브쿼리의 발생 조건
(4–5–1) QuerySet안에 QuerySet이 있을 경우
서브쿼리는 슬로우 쿼리를 많이 야기하므로 성능상의 이슈가 있을 수도 있다. 의도하고 서브쿼리를 쓰는 경우는 없지만, QuerySet에서 의도하지 않았지만 서브쿼리가 야기되는 경우들이 있다.
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)
다음과 같이 QuerySet안에 QuerySet이 들어있으면 서브쿼리가 발생할 수 있다. company_queryset
은 product_queryset
안에 들어가있을 때도, 실행되지 않아 queryset으로 남아있다.
이 경우 즉시 수행되도록, list()로 만들어 문제를 해결할 수 있다.
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)
(4–5–2) 서브쿼리의 발생 조건 — exclude() 조건절의 함정
normal_joined_queryset =
Order.objects.filter(description__isnull=False,
product_set_included_order__name='asd'
# (역방향)
normal_joined_queryset =
Order.objects.filter(description__isnull=False).exclude
(product_set_included_order__name=‘asd')
# (정방향)
normal_joined_queryset =
Order.objects.filter(description__isnull=False).exclude
(order_owner__userinfo__tel_num='010-0000-0000')
역방향 참조모델에서는 filter 조건에 넣어서 사용할때는 JOIN, exclude에 넣으면 서브쿼리가 발생된다. 김성렬님은 아마도 장고에서 의도하는 버그성 동작일 것 같다고 하셨다. 역방향 참조모델에서 exclude는 조심해서 사용해야 한다.
(4–6) Eager Loading(select_related, prefetch_related)을 무시하는 QuerySet의 반환타입
이번 스터디를 준비하게 된 직접적인 원인과 관련이 있는 사항이다.
values
, values_list
는 Eager-Loading 옵션들을 전부 무시하는 특성이 있다고 한다.
list(Product.objects.select_related('product_owned_company').filter(id=1).values())
이 경우 추가쿼리를 무시하고 메인 쿼리에 inner 조인해서 가져온다.
values
, values_list
는 DB의 row 단위로 데이터를 반환한다. 이 말은 즉, 객체와 관계지향 간에 매핑이 일어나지 않는다. values
와 values_list
에서 외래 테이블은 외래 모델이 아니라 외래키 값 자체를 반환하게 된다.
values
, values_list
를 이용하지 않고 해당 queryset을 serializer에서 보여줄 때는 외래키 관계 매핑을 serializer에서 해주므로 해당사항이 없다.
(필자는 Pandas DataFrame을 이용하여 값을 계산하므로, values
, values_list
옵션을 이용할 수 밖에 없었다. Prefetch
를 이용하여 역방향 관계의 데이터들을 filter하였는데, values
를 사용하여 filter가 먹히지 않아 심각한 버그가 발생하였다.
이 경우 Prefetch
가 아니라 filter문을 통해 ORM이 알아서 join할 수 있게
결론
ORM은 SQL을 이용하지 않고도, 개발 속도를 빠르게 해주지만
결국 서비스와 로직이 복잡해짐에 따라 ORM을 잘 쓰기 위해서는 SQL도 잘 알아야 한다.