Django ORM의 집계(Aggregate)와 그룹화(Annotate) 개념
Django ORM에서 데이터를 통계내거나 그룹별로 묶어 계산할 때는 주로 aggregate()와 annotate() 메서드를 사용합니다. 이 두 메서드는 비슷해 보이지만 반환하는 데이터 구조와 SQL 매핑 방식에서 명확한 차이가 있습니다.
- aggregate(): 전체 쿼리셋에 대한 단일 집계 값을 계산하며, 결과를 딕셔너리(Dictionary) 형태로 반환합니다. SQL의 집계 함수와 유사하게 동작합니다.
- annotate(): 쿼리셋의 각 객체(또는 그룹)에 대한 계산 결과를 추가하여 QuerySet을 반환합니다. 주로
values()와 결합하여 SQL의GROUP BY절을 구현하는 데 사용됩니다.
기본 집계 함수 사용 및 커스텀 키 설정
집계 연산을 수행하려면 django.db.models에서 Avg, Max, Min, Count, Sum 등의 함수를 임포트해야 합니다.
from django.db.models import Avg, Count, Max, Min, Sum
# 전체 게시글의 평균 조회수 계산 (기본 키 사용)
overall_stats = Article.objects.aggregate(Avg('view_count'))
# 결과: {'view_count__avg': 1540.5}
# 반환될 딕셔너리의 키(Key) 값을 직접 지정
custom_stats = Article.objects.aggregate(mean_views=Avg('view_count'))
# 결과: {'mean_views': 1540.5}
집계 결과로 Decimal 타입이 반환되는 경우, 이를 문자열이나 정수로 변환하여 활용할 수 있습니다.
total_stats = Article.objects.aggregate(total_views=Sum('view_count'))
# Decimal 값을 문자열로 변환
print(total_stats['total_views'].to_eng_string())
그룹화(Group By) 쿼리와 values()의 역할
ORM에서 values() 메서드는 두 가지 역할을 수행합니다. 단순 호출 시 특정 필드만 추출하는 SELECT 절의 역할을 하지만, annotate()와 결합되면 GROUP BY 절의 기준 필드를 지정하는 역할로 동작합니다.
따라서 Article.objects.all().annotate(...)와 같이 작성하면 기본 키(PK)를 기준으로 그룹화되어 각 레코드마다 개별 계산이 수행되므로, 의도한 통계 결과를 얻기 어렵습니다. 반드시 그룹화할 필드를 values()로 먼저 지정해야 합니다.
# 카테고리별 평균 조회수 통계 (GROUP BY category_id)
category_stats = Article.objects.values('category_id').annotate(avg_views=Avg('view_count'))
# 결과: <QuerySet [{'category_id': 1, 'avg_views': 1200.0}, {'category_id': 2, 'avg_views': 850.5}]>
filter()의 위치에 따른 WHERE와 HAVING 매핑
Django ORM에서 filter() 메서드가 호출되는 위치에 따라 생성되는 SQL 쿼리의 조건절이 달라집니다.
- annotate() 이전의 filter(): 그룹화 이전의 개별 레코드에 대한 조건으로, SQL의 WHERE 절에 매핑됩니다.
- annotate() 이후의 filter(): 그룹화 및 집계 계산 이후의 결과에 대한 조건으로, SQL의 HAVING 절에 매핑됩니다.
# WHERE: 조회수가 100 이상인 게시글만 대상으로 그룹화
# HAVING: 그룹화 후 작성자 수가 2명 이상인 카테고리만 필터링
complex_query = Article.objects.values('category_id') \
.filter(view_count__gte=100) \
.annotate(writer_count=Count('writers__id')) \
.filter(writer_count__gte=2) \
.values('category_id', 'writer_count')
다중 테이블 조인 및 복합 그룹화 쿼리
연관 관계가 있는 다른 테이블의 데이터를 기준으로 집계할 때는 이중 언더스코어(__)를 사용하여 역참조 또는 정참조 필드를 지정합니다.
1. 카테고리별 게시글 수 통계
특정 기준 테이블(카테고리)을 기반으로 연관 테이블(게시글)의 데이터를 집계할 때는 기준 테이블의 고유한 필드(PK 등)로 그룹화하는 것이 데이터 중복을 방지하는 좋은 방법입니다.
# 카테고리 이름과 해당 카테고리에 속한 게시글 수 조회
cat_articles = Category.objects.values('id') \
.annotate(article_count=Count('article__title')) \
.values('name', 'article_count')
# 결과: <QuerySet [{'name': 'IT', 'article_count': 15}, {'name': '일상', 'article_count': 8}]>
2. 작성자별 최대 조회수 확인
다대다(M2M) 또는 일대다(1:N) 관계에서 조인이 발생하더라도, 기준 모델의 PK로 그룹화하면 정확한 집계를 수행할 수 있습니다.
# 작성자 이름과 해당 작성자가 작성한 게시글 중 가장 높은 조회수
writer_max_views = Writer.objects.values('id') \
.annotate(max_views=Max('article__view_count')) \
.values('name', 'max_views')
# 결과: <QuerySet [{'name': 'Alice', 'max_views': 5400}, {'name': 'Bob', 'max_views': 3200}]>
3. 게시글별 공동 작성자 수 집계
정방향 참조 필드를 활용하여 현재 테이블 기준으로 연관된 데이터의 개수를 셀 수 있습니다.
# 게시글 제목과 해당 게시글의 공동 작성자 수 (작성자가 2명 이상인 경우만)
article_writers = Article.objects.values('id') \
.annotate(co_writer_count=Count('writers__id')) \
.filter(co_writer_count__gte=2) \
.values('title', 'co_writer_count')
# 결과: <QuerySet [{'title': 'Django ORM 가이드', 'co_writer_count': 3}]>