Django QuerySet 평가와 캐시: exists·bool·len·iterator가 만드는 쿼리 차이

DJANGO DEEP DIVE · 06 / 20

쿼리를 줄이려고 exists()를 먼저 호출했는데 전체 요청의 쿼리가 하나 늘었다. 반대로 bool(qs)로 존재만 확인했더니 수만 행을 메모리에 올렸다. 두 현상 모두 QuerySet의 평가 시점과 결과 캐시를 이해하면 설명할 수 있다.

기준: Django 5.2. 예제의 Entry는 실제 프로젝트의 모델로 바꾼다. 측정 구간에서는 관계 필드 접근이나 __str__()의 추가 쿼리가 없는 것으로 가정한다.

QuerySet은 결과 목록이 아니라 조회 계획에서 시작한다

Entry.objects.filter(active=True)를 변수에 넣었다고 즉시 SELECT가 실행되지는 않는다. 반복, list(), len(), bool() 등의 사용이 평가를 유발한다. 한 번 전체 평가된 QuerySet은 결과를 해당 인스턴스의 캐시에 보관한다. QuerySet 평가 문서

qs = Entry.objects.filter(active=True)
rows = list(qs)       # 여기서 조회하고 qs의 결과 캐시를 채운다.
again = list(qs)      # 같은 qs의 캐시를 사용한다.
count = qs.count()   # 이미 전체 캐시가 있으면 그 길이를 사용한다.

이 캐시는 Redis나 Django cache framework가 아니다. 다른 요청, 다른 QuerySet, 같은 조건으로 새로 만든 QuerySet과 공유되지 않는다. 캐시에 담긴 모델도 DB 변경을 자동 반영하지 않는다.

존재만 필요한가, 결국 모든 행이 필요한가

아직 평가하지 않은 QuerySet에 수행 주요 동작 적합한 질문
qs.exists() 존재 확인용 쿼리 하나라도 있는가
qs.count() 집계 쿼리 몇 개인가
bool(qs) 전체 결과 평가 결과를 곧 사용할 것인가
len(qs) 전체 결과 평가 후 길이 결과와 개수가 모두 필요한가
qs.iterator() 소비 결과 캐시 없이 반복 대량 행을 한 번 순회할 것인가

exists()가 항상 더 좋다는 규칙은 없다. 존재 확인 후 반드시 같은 결과를 순회하면 존재 쿼리와 전체 조회가 각각 실행된다. 반대로 전체 결과가 필요하지 않다면 bool()이 불필요한 데이터를 읽을 수 있다. DB 최적화 문서

쿼리 수를 직접 잡아 본다

다음은 Django shell이나 테스트 환경에서 실행할 관찰 코드다. qs를 측정 구간 밖에서 출력하면 repr() 때문에 별도 쿼리가 발생할 수 있으므로 피한다.

from django.db import connection
from django.test.utils import CaptureQueriesContext

qs = Entry.objects.filter(active=True)
with CaptureQueriesContext(connection) as captured:
    has_rows = qs.exists()
    rows = list(qs)
print(len(captured))  # 일반적인 새 QuerySet에서는 2

qs = Entry.objects.filter(active=True)
with CaptureQueriesContext(connection) as captured:
    has_rows = bool(qs)
    rows = list(qs)
print(len(captured))  # 일반적인 새 QuerySet에서는 1

여기서 1이 항상 빠르다는 뜻은 아니다. 응답에 전체 결과가 필요한지, 행의 폭과 수가 얼마인지, Python 객체 생성 비용이 얼마인지도 봐야 한다. 테스트 데이터 10행에서 쿼리 개수만 줄이고 운영의 100만 행 메모리 문제를 만드는 최적화는 실패다.

새 체인은 새 캐시를 만든다

qs.filter(...), qs.order_by(...), qs.all()처럼 새 QuerySet을 만드는 호출은 이미 읽은 Python 목록을 필터링하는 연산이 아니다. 새 SQL을 평가할 수 있다. 특히 캐시를 채운 뒤 qs.all()로 새로 조회하면 DB 변경을 반영할 수 있다.

인덱싱도 조심한다. 평가 전의 qs[0]은 제한 조회를 수행하지만 원본 전체 결과 캐시를 채우는 작업과 같지 않다. 같은 표현을 여러 번 호출하면 반복 조회가 발생할 수 있다. 한 행이 필요하면 그 객체를 변수에 보관하고, 여러 행이 필요하면 사용 범위에 맞춰 한 번 평가한다.

iterator는 메모리 문제의 일부만 해결한다

iterator(chunk_size=1000)는 QuerySet 결과 캐시를 사용하지 않는다. 하지만 DB 드라이버의 버퍼링, 모델의 크기, 사용 코드가 쌓는 리스트, prefetch 대상 데이터는 별도 비용이다. 이미 평가한 QuerySet에 iterator()를 사용하면 기존 캐시를 재사용하지 않고 다시 조회한다.

Django 5.2에서 prefetch_related()와 함께 쓰려면 chunk_size를 지정해야 한다. chunk 크기가 줄면 메모리는 줄 수 있지만 관계 조회 횟수는 늘 수 있다. PostgreSQL의 서버 측 커서와 PgBouncer 설정도 연결 예산 편에서 다룰 별도 제약이다. iterator 문서

최적화 전에는 “이 결과를 언제, 몇 번, 얼마나 많이 쓰는가”를 적는다. 그 답이 있어야 exists, count, 전체 평가, 스트리밍 반복 중 어떤 비용을 지불할지 결정할 수 있다.

댓글