장고 중급 #2 ORM 중급 — annotate, aggregate, F/Q, prefetch_related
기초 #3에서 ORM의 첫인사 — Post.objects.filter, get, create 같은 도구를 다졌습니다. 이번 글은 그 위에 본격 도구들을 쌓습니다. 실무에서 거의 매일 쓰는 패턴이고, 이 한 글이 ORM으로 쓰는 코드의 절반을 바꿉니다.
annotate/aggregate— 집계F표현식 — DB 측 연산Q객체 — 복합 조건 (OR/AND/NOT)select_related/prefetch_related— N+1 문제 해결only/defer/values— 가져올 필드 선택- raw SQL 출구 — 마지막 카드
이전 글 #1 CBV에서 본 get_queryset 안에 들어갈 도구들이기도 합니다.
annotate와 aggregate — 집계의 두 얼굴
#
둘 다 집계함수를 쓰지만 결과의 형태가 다릅니다.
annotate | aggregate | |
|---|---|---|
| 결과 | QuerySet (각 행에 새 필드) | dict (전체에 대한 집계값) |
| 구분 | 행 단위 계산 | 전체 단위 계산 |
| 예 | “각 글의 댓글 수” | “전체 글 수, 평균 평점” |
aggregate — 전체에 대한 집계
#
from django.db.models import Count, Avg, Sum, Max, Min
Post.objects.aggregate(Count("id"))
# {'id__count': 1234}
Post.objects.aggregate(
total=Count("id"),
avg_views=Avg("views"),
max_views=Max("views"),
)
# {'total': 1234, 'avg_views': 152.3, 'max_views': 9876}키 이름은 자동 생성되거나 (<field>__<func> 형태), 키워드 인자로 직접 지정할 수 있습니다.
annotate — 행 단위 계산
#
from django.db.models import Count
posts = Post.objects.annotate(comment_count=Count("comments"))
for p in posts:
print(p.title, p.comment_count)**comments**는 Comment 모델의 ForeignKey(Post, related_name="comments")의 역방향 매니저 이름입니다. annotate가 SQL GROUP BY + 집계함수를 자동으로 만들어 각 글마다 댓글 수를 계산합니다.
SELECT post.*, COUNT(comment.id) AS comment_count
FROM post
LEFT OUTER JOIN comment ON comment.post_id = post.id
GROUP BY post.id;annotate + filter 조합 #
Post.objects.annotate(
comment_count=Count("comments")
).filter(comment_count__gte=5)annotate로 만든 가상 필드를 그 지점에서 filter 조건으로 쓸 수 있습니다. SQL의 HAVING으로 변환됩니다.
filter + annotate의 순서 함정 #
# 발행된 댓글만 세고 싶었는데...
Post.objects.filter(comments__published=True).annotate(
cnt=Count("comments")
)
# JOIN 후 cnt가 부풀려질 수 있음 (중복 행)from django.db.models import Q
Post.objects.annotate(
cnt=Count("comments", filter=Q(comments__published=True))
)Count("comments", filter=Q(...))가 집계 시점에 조건을 거는 표준 형태입니다.
F 표현식 — DB 측 연산
#
파이썬으로 값을 가져와 계산하고 다시 저장하는 패턴은 레이스 컨디션을 만듭니다.
post = Post.objects.get(pk=1)
post.views += 1
post.save()
# 두 요청이 동시에 읽고 쓰면 한 번이 사라짐F 객체로 DB 측에서 연산합니다.
from django.db.models import F
Post.objects.filter(pk=1).update(views=F("views") + 1)
# UPDATE post SET views = views + 1 WHERE id = 1;DB가 원자적으로 처리하니 동시성 문제가 없습니다. 다른 활용:
Product.objects.update(price=F("price") * Decimal("1.1"))
# 재고가 주문 수량보다 적은 항목
Order.objects.filter(quantity__gt=F("product__stock"))
# 두 필드 동시 갱신
Account.objects.filter(pk=1).update(
balance=F("balance") - 100,
debit_count=F("debit_count") + 1,
)Q 객체 — 복합 조건
#
filter(...)의 키워드 인자는 모두 AND로 결합됩니다. OR / NOT / 복합 조건이 필요하면 Q 객체.
from django.db.models import Q
Post.objects.filter(
Q(title__icontains="장고") | Q(body__icontains="장고")
)# AND
Post.objects.filter(Q(published=True) & Q(views__gte=100))
# NOT
Post.objects.filter(~Q(category__name="공지"))
# 조합
Post.objects.filter(
Q(published=True) & (Q(featured=True) | Q(views__gte=1000))
)동적 조건 조립 #
검색 조건이 동적일 때 Q가 빛납니다.
def search_posts(query, only_published=True, category=None):
q = Q()
if query:
q &= Q(title__icontains=query) | Q(body__icontains=query)
if only_published:
q &= Q(published=True)
if category:
q &= Q(category=category)
return Post.objects.filter(q)빈 Q()에서 시작해 조건을 누적하는 패턴입니다. CBV의 get_queryset 안에서 잘 어울립니다.
N+1 문제 — 가장 흔한 성능 함정 #
comments = Comment.objects.all()
for c in comments:
print(c.post.title) # 매 반복마다 SELECT 한 번 추가쿼리는 처음 1번 (댓글 전체) + N번 (각 댓글마다 글 조회). 댓글 1000개면 1001 쿼리. 이게 N+1 문제 입니다.
select_related — JOIN으로 한 번에 (FK 정방향)
#
comments = Comment.objects.select_related("post").all()
for c in comments:
print(c.post.title) # 추가 쿼리 0생성되는 SQL:
SELECT comment.*, post.* FROM comment
LEFT OUTER JOIN post ON post.id = comment.post_id;쿼리 한 번에 JOIN으로 가져옵니다. OneToOne, ForeignKey (정방향)에 사용 가능.
prefetch_related — 별도 쿼리로 한 번에 (역방향, M2M)
#
역방향 ForeignKey 나 ManyToMany는 JOIN으로 한 번에 못 가져옵니다 (행 수가 폭발). prefetch_related가 별도 쿼리 + 파이썬 측 매핑으로 풉니다.
posts = Post.objects.all()
for p in posts:
print(len(p.comments.all())) # 매 반복마다 댓글 조회posts = Post.objects.prefetch_related("comments").all()
for p in posts:
print(len(p.comments.all())) # 추가 쿼리 0생성되는 쿼리: SELECT * FROM post + SELECT * FROM comment WHERE post_id IN (...) — 항상 2 쿼리. 글이 1000개여도 그대로 2번.
둘을 섞기 #
posts = (Post.objects
.select_related("author", "category") # FK
.prefetch_related("comments", "tags") # 역방향 / M2M
.all())Prefetch — prefetch의 쿼리 자체를 가공
#
from django.db.models import Prefetch
posts = Post.objects.prefetch_related(
Prefetch(
"comments",
queryset=Comment.objects.filter(published=True).order_by("-created_at"),
to_attr="published_comments",
)
)
for p in posts:
for c in p.published_comments:
print(c.body)Prefetch 객체로 prefetch 되는 쿼리 자체에 필터/정렬을 걸 수 있습니다. to_attr로 별도 속성에 담아 원래 매니저와 충돌도 피합니다.
자세한 N+1 진단 방법(django-debug-toolbar, EXPLAIN, 쿼리 카운트 단언)은 고급 #3 쿼리 최적화에서 다룹니다.
only / defer — 가져올 필드 선택
#
큰 텍스트 필드 (본문 등)가 있으면 목록 페이지에서 굳이 불러오지 않아도 됩니다.
Post.objects.only("id", "title", "slug").all()
# 본문 없이 가벼운 쿼리
Post.objects.defer("body").all()
# defer는 반대 — 명시한 필드를 빼고주의: only 한 객체에서 명시 안 한 필드에 접근하면 자동으로 추가 쿼리가 발생합니다. 의도치 않게 N+1이 될 수 있습니다.
values / values_list — dict / tuple로 받기
#
모델 인스턴스가 아닌 dict 또는 tuple로 받고 싶을 때.
list(Post.objects.values("id", "title")[:3])
# [{'id': 1, 'title': '...'}, {'id': 2, 'title': '...'}, ...]
list(Post.objects.values_list("id", "title")[:3])
# [(1, '...'), (2, '...'), ...]
list(Post.objects.values_list("title", flat=True)[:3])
# ['...', '...', '...'] # flat=True 면 단일 값 리스트API 응답을 가벼운 dict로 만들 때, 또는 id만 모아 다른 쿼리에 쓸 때 유용합니다. 모델 인스턴스를 만들지 않으니 메모리도 적게 듭니다.
서브쿼리 — Subquery, OuterRef, Exists
#
복잡한 조건은 서브쿼리가 답입니다.
from django.db.models import Subquery, OuterRef
latest_comment = Comment.objects.filter(
post=OuterRef("pk")
).order_by("-created_at")
posts = Post.objects.annotate(
latest_comment_at=Subquery(latest_comment.values("created_at")[:1])
)from django.db.models import Exists, OuterRef
has_comment = Comment.objects.filter(post=OuterRef("pk"))
Post.objects.annotate(
has_comment=Exists(has_comment)
).filter(has_comment=True)OuterRef("pk")가 바깥 쿼리의 컬럼을 참조하는 표시입니다.
마지막 카드 — raw SQL #
ORM으로 표현이 너무 복잡해지면 raw SQL 출구가 있습니다.
posts = Post.objects.raw(
"SELECT * FROM blog_post WHERE views > %s",
[100],
)
for p in posts:
print(p.title)from django.db import connection
with connection.cursor() as cur:
cur.execute(
"SELECT category_id, COUNT(*) FROM blog_post GROUP BY category_id"
)
rows = cur.fetchall()원칙: ORM으로 풀 수 있으면 ORM. raw는 정말 복잡하거나 성능이 결정적인 경우에만. 파라미터는 **반드시 placeholder (%s)**로, 문자열 포맷 X (SQL 인젝션 방지).
트랜잭션 — transaction.atomic
#
여러 쓰기 작업이 한 단위로 묶여야 할 때 씁니다.
from django.db import transaction
with transaction.atomic():
order = Order.objects.create(user=user, total=total)
for item in items:
OrderItem.objects.create(order=order, product=item.product, qty=item.qty)
Product.objects.filter(pk=item.product_id).update(
stock=F("stock") - item.qty
)블록 안에서 예외가 나면 모든 쓰기가 롤백됩니다. 데코레이터 형태로도 쓸 수 있는데, @transaction.atomic을 붙이면 함수/메소드 전체가 한 트랜잭션으로 묶입니다. 트랜잭션 후처리(on_commit) 같은 깊은 패턴은 고급 #5에서 다루겠습니다.
QuerySet의 lazy 한 성격 #
QuerySet은 실제로 평가될 때까지 SQL을 보내지 않습니다.
qs = Post.objects.filter(published=True) # 아직 SQL 없음
qs = qs.order_by("-created_at") # 여전히 없음
qs = qs.annotate(cnt=Count("comments")) # 여전히 없음
list(qs) # 여기서 SQL 실행
for p in qs: pass # 또는 여기
print(qs[0]) # 또는 여기체이닝하는 동안은 그저 쿼리를 조립할 뿐. 평가는 반복 / 슬라이싱 / len / bool / list 등에서 일어납니다. 이 성질을 이해하면 불필요한 쿼리를 피할 수 있습니다.
정리 #
이번 글에서 잡은 것:
aggregate(전체 집계 → dict),annotate(행별 집계 → QuerySet)Count(..., filter=Q(...))— 조건부 집계F표현식 — DB 측 연산, 동시성 안전Q객체 — OR/AND/NOT, 동적 조건 조립- N+1 문제와 해결:
select_related(FK 정방향, JOIN),prefetch_related(역방향/M2M, 별도 쿼리) Prefetch(..., queryset=..., to_attr=...)로 prefetch 자체를 가공only/defer— 필드 선택 (단, 미선택 필드 접근 시 추가 쿼리 주의)values/values_list(flat=True)— dict/tuple로 받기Subquery,OuterRef,Exists— 서브쿼리- raw SQL은 마지막 카드, 파라미터는 placeholder
transaction.atomic으로 여러 쓰기를 한 단위로- QuerySet은 lazy — 평가 시점 이해
다음 글(#3 Signals와 Middleware)에서는 모델,뷰의 흐름 바깥에서 일어나는 두 가지 — **Signals (이벤트)**와 **Middleware (요청/응답 파이프라인)**를 다룹니다. 둘 다 강력하지만 함정도 많은 도구입니다.