🌈 프로그래밍/TIL

Django ORM aggregate Sum이 NULL을 반환할 때

수구리 2025. 12. 19. 15:33
반응형

오늘 코드 리뷰 받다가 알게 된 내용 정리.

발단

프라임타임 점수 계산 로직에서 이렇게 썼었다:

def calculate_score_for_stadium(self, stadium_id: int) -> int:
    result = (
        FixedMatch.objects.prime_time_weekday()
        .for_stadium(stadium_id)
        .aggregate(total_score=Sum(self._build_score_case_expression()))
    )
    return result["total_score"] or 0  # <- or 0 필요한가?

_build_score_case_expression()에서 이미 default=Value(0)을 설정했는데, or 0이 필요한지 의문이 들었다.

def _build_score_case_expression(self) -> Case:
    when_clauses = [
        When(match_time__hour=hour, then=Value(score))
        for hour, score in self.SCORE_BY_HOUR.items()
    ]
    return Case(
        *when_clauses,
        default=Value(0),  # <- 기본값 0 설정했는데?
        output_field=IntegerField(),
    )

왜 or 0이 필요한가?

Case의 default는 "행(row) 단위"

Casedefault=Value(0)각 행에 대해 조건에 맞지 않을 때 0을 반환한다.

# 3개 행이 있을 때
# row 1: hour=19 → score=1
# row 2: hour=20 → score=2
# row 3: hour=18 → score=0 (default)
# Sum = 3

문제는 "행이 없을 때"

해당 Stadium에 프라임타임 고정매치가 없으면 QuerySet이 비어있다.

# 해당 Stadium에 매칭되는 행이 0개
FixedMatch.objects.prime_time_weekday().for_stadium(stadium_id)
# → 빈 QuerySet

# Sum()을 빈 QuerySet에 적용하면?
.aggregate(total_score=Sum(...))
# → {"total_score": None}  # 0이 아니라 None!

SQL 표준 동작

이건 SQL 표준이다. SUM()을 빈 결과셋에 적용하면 NULL을 반환한다.

-- 결과가 0개인 경우
SELECT SUM(score) FROM fixed_match WHERE 1=0;
-- 결과: NULL (0이 아님)

Django도 이 동작을 그대로 따른다.

해결책: Coalesce 사용

or 0도 동작하지만, Coalesce가 의도를 더 명확히 표현한다.

from django.db.models.functions import Coalesce

def calculate_score_for_stadium(self, stadium_id: int) -> int:
    result = (
        FixedMatch.objects.prime_time_weekday()
        .for_stadium(stadium_id)
        .aggregate(
            total_score=Coalesce(Sum(self._build_score_case_expression()), 0)
        )
    )
    return result["total_score"]  # or 0 불필요

Coalesce vs or 0

방식 처리 위치 장점
or 0 Python 간단함
Coalesce DB 의도 명확, SQL에서 처리

Coalesce는 SQL의 COALESCE() 함수로 변환된다:

SELECT COALESCE(SUM(score), 0) AS total_score FROM ...

결론

  • Sum()은 빈 QuerySet에서 None을 반환함 (SQL 표준)
  • Casedefault는 행 단위로만 적용됨
  • Coalesce로 NULL 처리를 명시적으로 하는 게 좋음
# Before
.aggregate(total_score=Sum(...))
return result["total_score"] or 0

# After
.aggregate(total_score=Coalesce(Sum(...), 0))
return result["total_score"]

참고: 다른 집계 함수들도 마찬가지

Count()만 예외적으로 빈 결과셋에서 0을 반환한다.

# 빈 QuerySet에서
Sum(...)   → None
Avg(...)   → None
Max(...)   → None
Min(...)   → None
Count(...) → 0  # 예외!
반응형