[Django] QuerySet 특징 - Lazy evaluation (Lazy evaluation with Django QuerySet)
#1. Lazy evaluation / Lazy loading
장고 ORM의 QuerySet은 기본적으로 'Lazy' 합니다.
Lazy 하다는 것은 장고 objects manager를 통해 '선언'한 쿼리를 바로 '호출'하지 않는다는 것을 의미합니다.
간단한 테스트 예시를 통해 살펴보면,
def test_what_can_be_evaluated(self) -> None:
# When
with self.assertNumQueries(0):
User.objects.all()
User.objects.filter(id=1)
User.objects.order_by('-id')
이 테스트는 실제 호출한 쿼리가 0이 되어야 통과하는 테스트입니다.
문제 없이 통과했습니다.
장고의 QuerySet은 실제로 SQL 쿼리를 '호출'(Hit)해야만 하는 시점에 '필요한 만큼만' 호출합니다.
#2. Why Lazy?
Lazy evaluation은 어플리케이션의 병목현상을 줄이기위한 효율적인 방법으로 잘 알려져 있습니다.
기본적으로 웹 어플리케이션의 성능 저하 이유로 가장 큰 것이 데이터베이스에서 발생한다고 알고 있습니다.
괜히 백엔드 엔지니어들이 쿼리 횟수를 줄이는 것에 집착하는 것이 아닙니다.
user = User.objects.filter(Q(is_superuser=True) & Q(is_staff=True))
.filter(username__icontains='admin')
위 예시처럼 쿼리를 계속해서 연결(chaining)할 때마다 지속적으로 SQL 쿼리를 '호출'(Hit) 한다면 DB 레벨에서의
병목현상등의 심각한 성능저하를 일으키게 됩니다.
이런식으로 chaining 하는 과정에서 실제로 DB를 호출하지 않고 함수에 연결하거나 값을 return 받을 수 있기 때문에 매우 효율적입니다.
#3. When evaluated?
QuerySet은 다음의 경우에 SQL 쿼리를 호출합니다.
- Iteration
- Slicing ("step" 파라미터를 사용할 경우)
- Pickling / Caching
- repr()
- len()
- list()
- bool()
이 밖에도 데이터를 불러오는 시점이 명확한 get(), count(), value(), value_list(), create() 등의 몇몇 ORM 메서드도 lazy하지 않습니다.
#4. QuerySet Caching
QuerySet은 evaluated되는 순간 불필요한 SQL호출을 줄이기 위해 캐싱을 사용합니다.
다음과 같은 경우는 캐싱을 하지 않아 매우 비효율적인 코드입니다.
all_user = User.objects.all() # No evaluation at this point
first_user = all_user[0] # Total NumQuery = 1
all_user = list(all_user) # Total NumQuery = 2
여기서 코드 순서를 다음과 같이 바꾸기만 하면 캐싱이 되어 SQL 호출을 줄일 수 있습니다.
all_user = User.objects.all() # No evaluation at this point
all_user = list(all_user) # Total NumQuery = 1
first_user = all_user[0] # Total NumQuery = 1
따라서 로직을 설계할 때 캐싱을 염두에 두고 쿼리문을 조합하는 것이 중요합니다.
#5. Lazy evaluation의 단점 - N+1 problem
Lazy loading을 기본으로하는 ORM의 대표적인 문제입니다.
N+1 Problem은 한번의 호출로 N개의 쿼리셋을 가져온 뒤 관련 컬럼을 얻기 위해 N개의 모델을 순회할때마다 쿼리를 계속해서 수행하는 매우 비효율적인 SQL 쿼리를 호출하는 문제를 의미합니다.
all_user = User.objects.all()
'''
all_user 호출 쿼리 수행 1번, all_user 안의 컬럼 조회 호출 N번 = N+1 Problem
all_user 쿼리셋 갯수 만큼 계속해서 SQL쿼리를 호출한다. 매우 비효율적
'''
for user in all_user:
posts = user.post_set.count()
위 예시에서 모든 유저데이터의 갯수가 천개, 만개라고 생각하면 끔찍합니다.
장고 ORM에서는 이를 해결하기 위한 수단으로 select_related()와 prefetch_releated() 메서드를 제공합니다.
관련해서 따로 포스팅을 남기고자 합니다.
#6. 정리
- 장고의 QuerySet은 Lazy하다. (선언만 할뿐 실제로 호출하지는 않는다.)
- 필요한 시점에 필요한 정보만 호출한다.
- QuerySey 캐싱을 생각해 API설계를 하는 것이 좋다.
#6. 참고
- 공식문서(https://docs.djangoproject.com/en/4.0/ref/models/querysets/)
- Django ORM (QuerySet) 구조와 원리 그리고 최적화 전략 - 김성렬 - PyCon Korea 2020 (https://youtu.be/EZgLfDrUlrk)