🌈 프로그래밍/Django

Django DRF에서 N+1 쿼리를 잡은 날 — Datadog에서 Prefetch까지

수구리 2026. 2. 20. 10:29
반응형

TLI (Today I Learned) · 2026-02-20
태그: django drf orm n+1 prefetch datadog


오늘의 작업

/api/v2/stadium-groups/{id}/ API에서 Datadog APM을 통해 N+1 쿼리를 발견했고,
Prefetch로 해결했다. 브랜치 생성 → 코드 수정 → 테스트 작성 → PR까지 한 사이클을 돌았다.


문제 발견 — Datadog APM

최근 배포 직후 Datadog에서 특정 API 트레이스를 확인했다.

trace_id: 1981294581012217384
span: stadium_image 쿼리 × 14회

14개의 stadium_image SELECT가 개별로 날아가고 있었다.
로직을 보면 즉시 이해가 됐다.


AS-IS 코드 — 왜 N+1이 생겼나

# StadiumGroupSerializer
def get_stadiums(self, group):
    queryset = Stadium.objects.filter(group=group, is_open=True).order_by("name")
    serializer = StadiumSerializer(queryset, many=True)
    return serializer.data

# StadiumSerializer
def get_images(self, stadium):
    queryset = (
        StadiumImage.objects.filter(stadium_id=stadium.id)
        .exclude(is_ad=True)
        .order_by("id")
    )
    serializer = StadiumImageSerializer(queryset, many=True)
    return serializer.data

get_stadiums()는 Stadium을 한 번에 조회하지만, get_images()는 각 Stadium마다 개별 쿼리를 날린다.
Stadium이 N개면 image 쿼리는 N번 → N+1 패턴.

DRF의 SerializerMethodField는 Django ORM의 prefetch 캐시를 자동으로 활용하지 않는다.
Queryset에 prefetch_related를 걸어도 get_images() 안에서 직접 StadiumImage.objects.filter(...) 를 호출하면 캐시를 완전히 무시한다.


해결 방향 — to_attr로 캐시 연결

핵심은 두 가지:

  1. get_stadiums()에서 Prefetch로 이미지를 미리 로드하고 to_attr로 커스텀 속성에 저장
  2. get_images()에서 그 속성이 존재하면 DB를 찌르지 않고 반환
# StadiumGroupSerializer
def get_stadiums(self, group):
    from django.db.models import Prefetch

    queryset = (
        Stadium.objects.filter(group=group, is_open=True)
        .prefetch_related(
            Prefetch(
                "stadiumimage_set",
                queryset=StadiumImage.objects.filter(is_ad=False).order_by("id"),
                to_attr="_images_cache",
            )
        )
        .order_by("name")
    )
    return StadiumSerializer(queryset, many=True).data

# StadiumSerializer
def get_images(self, stadium):
    if hasattr(stadium, "_images_cache"):
        return StadiumImageSerializer(stadium._images_cache, many=True).data
    # fallback — 다른 호출부에서 prefetch 없이 쓸 경우
    queryset = (
        StadiumImage.objects.filter(stadium_id=stadium.id)
        .exclude(is_ad=True)
        .order_by("id")
    )
    return StadiumImageSerializer(queryset, many=True).data

to_attr="_images_cache"를 사용하면 Django가 prefetch 결과를 stadium._images_cache 리스트에 담아준다.
hasattr 체크를 통해 캐시가 없는 호출 경로(다른 ViewSet 등)는 기존 방식으로 폴백된다. 하위 호환성을 챙기는 부분.


Prefetch queryset에서 is_ad=False 필터를 걸어야 하는 이유

처음에는 Prefetch 없이 stadiumimage_set을 전부 로드하고 get_images()에서 필터링할까 생각했다.
하지만 그렇게 하면 is_ad=True 이미지까지 메모리에 올라오고 Python 단에서 필터링하게 된다.

DB에서 불필요한 행을 가져오는 것보다 Prefetch queryset에 필터를 포함해서 정확한 데이터만 가져오는 게 낫다.
그래서 Prefetch("stadiumimage_set", queryset=StadiumImage.objects.filter(is_ad=False).order_by("id"), ...) 로 처음부터 광고 이미지를 제외했다.


테스트 — 어떻게 검증했나

이전에 같은 패턴으로 작성한 test_stadium_group_team_query_optimization.py가 있었고,
그 방식을 참고해서 새 테스트 파일을 작성했다.

테스트를 작성하면서 겪은 삽질:

삽질 1 — StadiumGroupSerializer 전체로 쿼리 수를 검증하면 안 된다

처음에는 StadiumGroupSerializer(sg).data를 실행하고 stadium_image 쿼리 수를 샀다.
그런데 StadiumGroupSerializer에는 stadium_cover 프로퍼티가 있고,
이 프로퍼티는 내부적으로 Stadium.cover()를 호출해서 is_thumbnail 조회 + fallback 조회로 image 쿼리가 최대 2번 더 발생한다.

즉, stadium_image 쿼리 수가 1이 아니라 3이 되어서 어설션이 실패했다.
해결: StadiumSerializer만 직접 prefetch 쿼리셋과 함께 호출해서 get_stadiums() 경로만 격리해서 검증.

삽질 2 — SQLite 환경에서 FK 무결성 오류

IntegrityError: FOREIGN KEY constraint failed

Stadium.cash_itemdefault=6이 설정되어 있는데, 테스트 DB에는 CashItem(pk=6)이 없었다.
MySQL은 teardown 시점에 FK를 검사하지만 SQLite는 좀 더 엄격하게 테스트 중에 오류를 낸다.
setUp에서 CashItem.objects.create(pk=6, item_price=0)을 명시적으로 생성해서 해결.

최종 테스트 결과

Ran 2 tests in 0.022s
OK

Total queries: 2, stadium_image queries: 1

CaptureQueriesContext로 실제 발생한 쿼리를 출력하고,
stadium_image 키워드를 포함한 쿼리가 정확히 1회인지 (IN 절 포함) 검증했다.


결과

항목 이전 이후
stadium_image 쿼리 수 14회 (N개 면 × 개별 SELECT) 1회 (IN 쿼리)
쿼리 방식 WHERE stadium_id = ? × N WHERE stadium_id IN (?, ?, ...) × 1
하위호환성 fallback 유지 (다른 호출부 영향 없음)

오늘 배운 것

1. DRF SerializerMethodField + prefetch_related는 자동으로 연결되지 않는다.
Querysetprefetch_related를 걸어도 SerializerMethodField 안에서 새 QuerySet을 만들면 캐시를 무시한다.
to_attr로 명명된 속성을 만들고, 시리얼라이저에서 hasattr로 체크해야 연결된다.

2. to_attr의 의미.
Prefetch("stadiumimage_set", to_attr="_images_cache")를 쓰면 Django는 prefetch 결과를 stadium.stadiumimage_set Manager가 아니라 stadium._images_cache 리스트에 담는다. 이미 평가된(evaluated) Python 리스트이므로 접근 시 DB를 찌르지 않는다.

3. Datadog APM으로 N+1을 발견하는 흐름.
트레이스에서 동일한 테이블에 대한 반복 쿼리가 보이면 N+1을 의심해야 한다.
이번에는 stadium_image 14개가 보였고, Stadium 수(14면)와 정확히 일치했다.

4. 테스트에서 다른 serializer 프로퍼티의 사이드이펙트를 격리해야 한다.
상위 serializer 전체로 쿼리 수를 재면 다른 필드에서 발생하는 쿼리까지 포함된다.
특정 SerializerMethodField만 격리해서 테스트하는 게 정확하다.


참고

반응형