본문으로 건너뛰기
김신건의 로그

[Django] Aggregation: annotate, aggregate, F, Q, Window

· 수정 · 📖 약 1분 · 291자/단어 #django #orm #aggregation #query
django annotate, django aggregate, Q objects, F expressions, Window functions

정의

Django ORM의 annotate / aggregate / F / Q / Window는 SQL의 GROUP BY, 집계 함수, 동적 조건, 컬럼 참조, Window 함수를 Python으로 표현. N+1 회피 + DB에서 무거운 계산 위임.

aggregate (전체)

from django.db.models import Count, Sum, Avg, Max, Min, F

stats = Post.objects.aggregate(
    total=Count("id"),
    avg_views=Avg("views"),
    max_views=Max("views"),
    total_views=Sum("views"),
)
# {"total": 100, "avg_views": 42.5, "max_views": 1000, "total_views": 4250}

stats["avg_views"]    # dict

annotate (group by)

# 각 작성자별 글 수
from django.db.models import Count

authors = User.objects.annotate(post_count=Count("posts"))
for a in authors:
    print(a.username, a.post_count)

# 정렬 + 필터
top = User.objects.annotate(post_count=Count("posts")).filter(post_count__gt=10).order_by("-post_count")

내부적으로 SQL GROUP BY user.id + COUNT(post.id). ORM이 자동 처리.

여러 annotation 동시 (함정)

# WRONG: 결과가 곱해짐
User.objects.annotate(
    post_count=Count("posts"),
    comment_count=Count("comments"),
)
# JOIN posts × JOIN comments → cartesian product

해결 1: distinct

User.objects.annotate(
    post_count=Count("posts", distinct=True),
    comment_count=Count("comments", distinct=True),
)

해결 2: Subquery (더 정확)

from django.db.models import OuterRef, Subquery, Count

post_count = Post.objects.filter(author=OuterRef("pk")).order_by().values("author").annotate(c=Count("*")).values("c")
comment_count = Comment.objects.filter(author=OuterRef("pk")).order_by().values("author").annotate(c=Count("*")).values("c")

User.objects.annotate(
    post_count=Subquery(post_count),
    comment_count=Subquery(comment_count),
)

F 표현식 (컬럼 참조)

from django.db.models import F

# 조회수 1 증가 (원자적)
Post.objects.filter(id=1).update(views=F("views") + 1)

# 두 컬럼 비교
Post.objects.filter(updated_at__gt=F("created_at"))

# 컬럼 간 연산
Post.objects.annotate(net_score=F("likes") - F("dislikes"))

# 다른 테이블 컬럼
Post.objects.filter(author__points__gt=F("min_points_required"))

Q 객체 (복합 조건)

from django.db.models import Q

# OR
Post.objects.filter(Q(is_published=True) | Q(is_featured=True))

# AND + NOT
Post.objects.filter(Q(views__gt=100) & ~Q(author=current_user))

# 동적
q = Q()
if search:
    q &= Q(title__icontains=search) | Q(body__icontains=search)
if author_id:
    q &= Q(author_id=author_id)
posts = Post.objects.filter(q)

Conditional (Case/When)

from django.db.models import Case, When, Value, IntegerField, CharField

Post.objects.annotate(
    priority=Case(
        When(is_featured=True, then=Value(1)),
        When(views__gt=1000, then=Value(2)),
        When(views__gt=100, then=Value(3)),
        default=Value(4),
        output_field=IntegerField(),
    )
).order_by("priority")
# 라벨링
status_label=Case(
    When(status="draft", then=Value("초안")),
    When(status="published", then=Value("발행")),
    default=Value("기타"),
    output_field=CharField(),
)

Subquery / OuterRef

from django.db.models import Subquery, OuterRef

# 각 작성자의 최신 글 제목
latest_post = Post.objects.filter(
    author=OuterRef("pk")
).order_by("-created_at").values("title")[:1]

User.objects.annotate(latest_title=Subquery(latest_post))
# EXISTS
from django.db.models import Exists

has_comments = Comment.objects.filter(post=OuterRef("pk"))
Post.objects.annotate(has_comments=Exists(has_comments))

Window 함수 (3.2+)

from django.db.models import Window, F
from django.db.models.functions import Rank, DenseRank, RowNumber, Lag, Lead, FirstValue, LastValue

# 각 작성자별로 글 순위
Post.objects.annotate(
    rank=Window(
        expression=Rank(),
        partition_by=[F("author")],
        order_by=F("views").desc(),
    )
).filter(rank__lte=3)    # 작성자별 top 3

# 이전 글
Post.objects.annotate(
    prev_id=Window(
        expression=Lag("id"),
        partition_by=[F("author")],
        order_by=F("created_at"),
    )
)

# 누적 합
Post.objects.annotate(
    running_total=Window(
        expression=Sum("views"),
        order_by=F("created_at"),
    )
)

DB에서 직접 계산. Python 루프보다 매우 빠름.

values + annotate (GROUP BY 명시)

# 작성자별 통계
Post.objects.values("author__username").annotate(
    total=Count("id"),
    avg_views=Avg("views"),
).order_by("-total")
# [{"author__username": "alice", "total": 50, "avg_views": 100.0}, ...]

명시적으로 그룹화할 컬럼 지정.

자주 보는 패턴

Pagination + Count 최적화

posts = Post.objects.all()
total = posts.count()    # SELECT COUNT(*)
page = posts[offset:offset + limit]

큰 테이블에서 COUNT가 느림. estimate 또는 cursor pagination.

통계 dashboard

from django.db.models.functions import TruncMonth, TruncDate

# 월별 가입자 수
User.objects.annotate(
    month=TruncMonth("date_joined")
).values("month").annotate(
    count=Count("id")
).order_by("month")

# 일별
User.objects.annotate(day=TruncDate("date_joined")).values("day").annotate(count=Count("id"))

Conditional aggregation

from django.db.models import Sum, Q

Post.objects.aggregate(
    published_views=Sum("views", filter=Q(is_published=True)),
    draft_views=Sum("views", filter=Q(is_published=False)),
)

3.0+ filter 인자 지원.

Median / Percentile

기본 ORM에 없지만 raw 또는 percentile_cont (PG):

from django.db.models import F
from django.db.models.functions import PercentileCont

# PG 8.4+
posts = Post.objects.aggregate(
    median=PercentileCont(0.5).within_group(F("views"))
)

또는 Subquery + LIMIT/OFFSET.

비싼 annotation 캐싱

@cached_property
def total_views(self):
    return self.posts.aggregate(t=Sum("views"))["t"] or 0

또는 denormalize:

class User(models.Model):
    total_views = models.PositiveIntegerField(default=0)

# signal로 동기화
@receiver(post_save, sender=Post)
def update_total(sender, instance, **kwargs):
    user = instance.author
    user.total_views = user.posts.aggregate(t=Sum("views"))["t"] or 0
    user.save()

함정

1. JOIN 결과 곱

위에서 설명. 여러 reverse FK를 동시 Count 시 cartesian. distinct 또는 Subquery.

2. annotate 순서

Post.objects.annotate(a=Count("comments")).filter(a__gt=5).annotate(b=Count("tags"))
# a는 모든 댓글, b는 a 필터 후 태그

순서에 따라 결과 다름.

3. F 표현식과 Python 연산

post.views += 1
post.save()
# 동시 다수 요청 → race condition
Post.objects.filter(id=post.id).update(views=F("views") + 1)
# DB 레벨 atomic
post.refresh_from_db()

4. Subquery output_field

Subquery(post_count, output_field=IntegerField())

명시 권장. 추론 실패 시 에러.

5. count() vs len(qs)

qs.count()     # SQL COUNT
len(qs)        # 모두 로드 후 len

count로 분기.

이 개념을 다룬 위키 페이지 (2)

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기