2022년 7월 5일에 Django 공식 문서를 읽으며 적은 QuerySet 메모를 Django 6.0 문서 기준으로 다시 정리했다. 핵심은 QuerySet을 만드는 것과 database query가 실제로 평가되는 시점을 구분하는 것이다.
Manager와 QuerySet
model을 정의하면 기본적으로 objects manager를 통해 QuerySet API에 접근한다.
from django.db import models
class Entry(models.Model):
headline = models.CharField(max_length=200)
views = models.PositiveIntegerField(default=0)
target_views = models.PositiveIntegerField(default=0)
published_at = models.DateTimeField()
manager는 model class에서 접근한다.
entries = Entry.objects.all()
recent = Entry.objects.filter(published_at__year=2026)
각 filter()와 exclude() 호출은 원본을 변경하기보다 새 QuerySet을 반환하므로 condition을 단계적으로 조합할 수 있다.
visible = Entry.objects.filter(published_at__year=2026)
popular = visible.filter(views__gte=1_000)
Lazy Evaluation은 언제 끝나나
QuerySet은 보통 생성·chaining만으로 바로 database를 조회하지 않는다. 다음과 같은 동작에서 평가될 수 있다.
- sync 또는 async iteration
list(queryset),len(queryset),bool(queryset)repr(queryset)- step을 포함한 slicing
- pickling
queryset = Entry.objects.filter(views__gte=100) # 보통 아직 미평가
for entry in queryset: # 여기서 평가
print(entry.headline)
한 번 정상적으로 전체 평가된 QuerySet은 result cache를 재사용할 수 있다. 그러나 indexing이나 일부 slicing이 전체 cache를 채워 줄 것이라고 기대하면 안 된다. 큰 result를 한 번만 순회하면서 cache를 피하려면 목적에 맞게 iterator()를 검토한다.
for entry in Entry.objects.all().iterator(chunk_size=2_000):
consume(entry)
exists()는 존재 여부만 알고 싶을 때 유용하다. 다만 바로 뒤에서 같은 QuerySet 전체를 읽을 예정이라면 exists() query와 본 query가 각각 실행될 수 있어 총 query 수가 늘 수 있다.
get()과 filter()의 차이
get()은 조건에 맞는 object가 정확히 하나일 때 사용한다.
entry = Entry.objects.get(pk=42)
- 0개면
Entry.DoesNotExist - 2개 이상이면
Entry.MultipleObjectsReturned
filter()는 0개 이상을 표현하는 QuerySet을 돌려준다.
entries = Entry.objects.filter(headline__icontains="django")
first = entries.first() # 없으면 None
filter(...)[0]은 결과가 없으면 IndexError를 낸다. “하나이거나 없음”이라는 의도라면 first(), “반드시 정확히 하나”라면 unique constraint와 get()처럼 contract가 드러나는 API를 선택한다.
Slicing이 만드는 Query
step 없는 slice는 보통 LIMIT과 OFFSET을 표현하는 새 QuerySet을 만든다.
first_five = Entry.objects.order_by("id")[:5]
six_to_ten = Entry.objects.order_by("id")[5:10]
step을 넣으면 database result를 평가한 뒤 Python list로 반환한다.
every_second = Entry.objects.order_by("id")[:10:2]
negative indexing은 QuerySet에서 지원되지 않는다. 또한 slicing한 QuerySet에 추가 filter나 ordering을 붙이는 것은 SQL 의미가 모호해 제한될 수 있다. query 조합을 끝낸 뒤 마지막에 slice하는 편이 이해하기 쉽다.
offset이 매우 커지면 database가 앞 row를 건너뛰는 비용이 커질 수 있다. API pagination에서는 stable ordering과 keyset pagination 가능성도 함께 본다. SQL 단계의 ORDER BY와 LIMIT은 MySQL SELECT 기초 학습노트로 연결된다.
Field Lookup과 관계 조회
lookup은 field__lookuptype=value 형태다.
Entry.objects.filter(headline__icontains="queryset")
Entry.objects.filter(views__gte=1_000)
Entry.objects.exclude(headline__startswith="Draft")
존재하지 않는 field나 허용되지 않은 lookup을 쓰면 query 구성 과정에서 FieldError 등이 발생할 수 있다. 이를 무조건 TypeError라고 보면 안 된다.
foreign key field가 author라면 author_id로 raw key value를 비교할 수 있다.
Entry.objects.filter(author_id=42)
related object를 반복 접근할 때 생기는 N+1 query는 관계 종류에 맞춰 select_related()와 prefetch_related()를 검토한다. 필요하면 QuerySet.explain()으로 database execution plan을 확인하고, index가 실제 조회 pattern에 맞는지도 본다. index와 scan의 기초는 DB가 데이터를 찾는 방법에서 이어서 볼 수 있다.
create()와 save()
objects.create()는 object를 만들고 database에 저장하는 convenience method다.
from django.utils import timezone
entry = Entry.objects.create(
headline="QuerySet 복습",
views=0,
published_at=timezone.now(),
)
반면 constructor와 save()를 나누면 저장 전에 Python object를 다룰 수 있다.
entry = Entry(
headline="QuerySet 복습",
views=0,
published_at=timezone.now(),
)
entry.save()
create() 뒤에 변경 없이 save()를 한 번 더 호출할 필요는 없다. 다시 저장하면 별도의 write가 발생할 수 있다. 여러 row를 갱신할 때 QuerySet.update()는 model instance의 save()를 호출하는 것과 lifecycle·signal 동작이 같지 않으므로 선택 이유를 구분해야 한다.
F Expression은 값을 Database에서 계산한다
F()는 현재 field 값을 Python으로 먼저 가져오지 않고 query 안에서 참조한다.
from django.db.models import F
Entry.objects.filter(pk=42).update(views=F("views") + 1)
read-modify-write를 Python에서 나누는 것보다 concurrent update에서 lost update 가능성을 줄이는 데 도움이 된다. 그렇다고 모든 business invariant가 자동으로 안전해지는 것은 아니다. 여러 row·table에 걸친 규칙은 transaction과 constraint를 함께 설계한다.
field끼리 비교할 수도 있다.
Entry.objects.filter(views__gt=F("target_views"))
F() assignment를 model instance에 넣고 save()하면 expression이 instance에 남아 이후 save에서 다시 적용될 수 있다. 값이 필요한 시점에는 refresh_from_db()로 database 값을 다시 읽는 습관이 안전하다.
Query 수를 예측하는 점검표
- QuerySet을 만든 지점과 평가되는 지점을 구분한다.
get,first,exists,count,len이 각각 어떤 contract와 query를 만드는지 본다.- loop 안에서 related object를 읽어 N+1이 생기지 않는지 확인한다.
- stable ordering 없이 slicing·pagination하지 않는다.
str(queryset.query)와explain()으로 예상 SQL과 plan을 확인한다.- raw SQL이 필요하면 parameter binding을 사용하고 ORM의 escaping을 문자열 조합으로 우회하지 않는다.
처음 적어 둔 “QuerySet은 요청하기 전까지 지연 처리된다”는 방향은 맞았다. 이번에는 어떤 operation이 평가를 일으키며, 그 선택이 query 수와 memory에 어떤 차이를 만드는지까지 연결했다.
참고 자료
'배움과 성장 > 백엔드·데이터' 카테고리의 다른 글
| SQL 학습 메모: SARGable 조건·LEFT JOIN·GROUP BY 순서 이해하기 (0) | 2022.07.15 |
|---|---|
| SQL 학습 메모: NULL 안전한 안티 조인과 날짜 범위 조건 (0) | 2022.07.14 |
| 데이터베이스 키와 정규화 학습 메모: 자연키·대리키·복합키 선택 기준 (0) | 2022.07.05 |
| Spring MVC HTTP 요청·응답 학습노트: Query·Form·JSON과 Servlet API (0) | 2022.05.10 |
| MySQL 8.4 쿼리 문법: ALL·INSERT SELECT·CASE·NULL 처리 (0) | 2022.05.10 |
댓글