Django annotate 집계가 부풀려질 때: JOIN 증폭과 Subquery로 합계 지키기

DJANGO DEEP DIVE · 08 / 20

주문 하나에 상품 라인 3개와 결제 기록 2개가 있다. 상품 합계와 결제 횟수를 한 번의 annotate()로 가져왔더니 합계가 두 배가 되고 횟수는 6이 된다. SQL 오류가 아니라 JOIN된 행의 수가 달라졌기 때문이다.

기준: Django 5.2. OrderLine.order는 lines, Payment.order는 payments 역참조를 가진다. 금액은 DecimalField이며 주문별 단일 통화를 전제로 한다.

독립적인 두 관계를 JOIN하면 행이 곱해진다

from django.db.models import Count, Sum

qs = Order.objects.annotate(
    line_total=Sum("lines__amount"),
    payment_count=Count("payments"),
)

라인 3개 각각이 결제 2개와 결합해 중간 결과가 6행이 될 수 있다. 그러면 각 라인 금액이 두 번 등장한다. Django 공식 집계 문서도 여러 집계를 JOIN으로 결합할 때 잘못된 결과가 나올 수 있음을 설명한다. 집계 결합 문서

작은 데이터로 표를 만들면 문제를 빨리 찾을 수 있다. 라인 금액을 100, 100, 300으로 두고 결제 기록 2개를 넣는다. 올바른 합계는 500인데 JOIN 집계는 1000이 될 수 있다.

distinct는 합계의 만능 해결책이 아니다

Count("payments", distinct=True)는 결제 식별자의 중복을 제거할 수 있다. 하지만 Sum("lines__amount", distinct=True)는 같은 금액을 중복 제거한다. 서로 다른 상품 라인이 우연히 같은 가격이면 정상 금액까지 빠진다. 앞의 100, 100, 300은 400이 된다.

최상위 QuerySet에 .distinct()를 붙이는 것도 집계 전에 생긴 곱셈을 자동 복원하지 않는다. 먼저 집계의 대상 단위가 주문인지, 라인인지, 결제인지 구분해야 한다.

집계별로 상관 서브쿼리를 만든다

from decimal import Decimal
from django.db.models import (
    Count, DecimalField, IntegerField, OuterRef, Subquery, Sum, Value,
)
from django.db.models.functions import Coalesce

money = DecimalField(max_digits=14, decimal_places=2)
line_totals = (
    OrderLine.objects.filter(order_id=OuterRef("pk"))
    .order_by().values("order_id")
    .annotate(total=Sum("amount")).values("total")
)
payment_counts = (
    Payment.objects.filter(order_id=OuterRef("pk"))
    .order_by().values("order_id")
    .annotate(total=Count("pk")).values("total")
)
orders = Order.objects.annotate(
    line_total=Coalesce(
        Subquery(line_totals, output_field=money),
        Value(Decimal("0.00"), output_field=money),
    ),
    payment_count=Coalesce(
        Subquery(payment_counts, output_field=IntegerField()),
        Value(0),
    ),
)

OuterRef("pk")는 바깥 주문을 가리킨다. values("order_id")로 그룹을 정하고 최종 values("total")로 한 컬럼만 반환한다. 해당 주문으로 필터링된 그룹이므로 결과는 최대 한 행이다. 기본 정렬이 집계를 방해하지 않도록 order_by()로 비운다. Subquery에서 집계 사용

관련 행이 없으면 서브쿼리 결과 자체가 없으므로 외부 Coalesce로 0을 정한다. 이때 숫자 0과 Decimal 합계의 타입을 분명히 한다. max_digits는 개별 라인보다 주문 합계가 커질 수 있다는 점을 고려해 정한다.

필터 순서도 결과의 의미를 바꾼다

filter(...).annotate(...)는 필터된 관계를 집계한다. annotate(...).filter(...)는 집계 후 다른 조건으로 대상을 거르는 형태가 될 수 있고 추가 JOIN도 생길 수 있다. 두 연산은 일반적으로 교환 가능하지 않다. annotate와 filter 순서

존재 여부만 필요하면 Exists()를 고려한다. 숫자가 필요하지 않은데 Count를 계산하면 불필요한 작업을 할 수 있다. 반대로 서브쿼리가 언제나 더 빠르다는 보장도 없다. 관련 FK 인덱스와 실제 주문 수로 실행 계획을 확인한다.

회귀 데이터는 일부러 중복 값을 넣는다

  • 가격이 같은 서로 다른 라인 두 개: Sum(distinct=True)의 잘못된 보정을 잡는다.
  • 결제 0개·1개·2개: 관계 수에 따른 증폭을 잡는다.
  • 라인 0개인 주문: NULL과 0의 API 계약을 검증한다.
  • 취소 결제와 정상 결제가 섞인 주문: 필터의 적용 위치를 검증한다.

쿼리 한 개로 처리했다는 사실보다 결과가 업무 정의와 맞는지가 먼저다. 집계가 의심되면 생성 SQL과 중간 JOIN 행의 모양을 함께 보자.

댓글