장고 중급 #2 ORM 중급 — annotate, aggregate, F/Q, prefetch_related

6 분 소요

기초 #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 안에 들어갈 도구들이기도 합니다.

annotateaggregate — 집계의 두 얼굴 #

둘 다 집계함수를 쓰지만 결과의 형태가 다릅니다.

annotateaggregate
결과QuerySet (각 행에 새 필드)dict (전체에 대한 집계값)
구분행 단위 계산전체 단위 계산
“각 글의 댓글 수”“전체 글 수, 평균 평점”

aggregate — 전체에 대한 집계 #

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 — 행 단위 계산 #

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 + 집계함수를 자동으로 만들어 각 글마다 댓글 수를 계산합니다.

생성되는 SQL (단순화)
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 조합 #

댓글 5개 이상인 글
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가 부풀려질 수 있음 (중복 행)
✅ Q와 함께
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 측에서 연산합니다.

✅ F 표현식
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가 원자적으로 처리하니 동시성 문제가 없습니다. 다른 활용:

가격 10% 인상, 두 필드 비교
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 객체.

OR — 제목 또는 본문에 키워드
from django.db.models import Q

Post.objects.filter(
    Q(title__icontains="장고") | Q(body__icontains="장고")
)
AND, NOT
# 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 문제 — 가장 흔한 성능 함정 #

🚫 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 정방향) #

✅ select_related
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별도 쿼리 + 파이썬 측 매핑으로 풉니다.

🚫 N+1 — 글마다 댓글 목록
posts = Post.objects.all()
for p in posts:
    print(len(p.comments.all()))   # 매 반복마다 댓글 조회
✅ prefetch_related
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 — 가져올 필드 선택 #

큰 텍스트 필드 (본문 등)가 있으면 목록 페이지에서 굳이 불러오지 않아도 됩니다.

only — 명시한 필드만
Post.objects.only("id", "title", "slug").all()
# 본문 없이 가벼운 쿼리

Post.objects.defer("body").all()
# defer는 반대 — 명시한 필드를 빼고

주의: only 한 객체에서 명시 안 한 필드에 접근하면 자동으로 추가 쿼리가 발생합니다. 의도치 않게 N+1이 될 수 있습니다.

values / values_list — dict / tuple로 받기 #

모델 인스턴스가 아닌 dict 또는 tuple로 받고 싶을 때.

values
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 출구가 있습니다.

raw — 결과를 모델 인스턴스로
posts = Post.objects.raw(
    "SELECT * FROM blog_post WHERE views > %s",
    [100],
)
for p in posts:
    print(p.title)
connection.cursor() — 완전 자유
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 #

여러 쓰기 작업이 한 단위로 묶여야 할 때 씁니다.

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 (요청/응답 파이프라인)**를 다룹니다. 둘 다 강력하지만 함정도 많은 도구입니다.

X