TLI (Today I Learned) · 2026-02-20
태그:djangodrformn+1prefetchdatadog
오늘의 작업
/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로 캐시 연결
핵심은 두 가지:
get_stadiums()에서Prefetch로 이미지를 미리 로드하고to_attr로 커스텀 속성에 저장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_item에 default=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는 자동으로 연결되지 않는다.Queryset에 prefetch_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만 격리해서 테스트하는 게 정확하다.
참고
- Django 공식 문서: Prefetch objects
'🌈 프로그래밍 > Django' 카테고리의 다른 글
| PyCharm Django에서 Django 콘솔 구성하기 (0) | 2024.11.19 |
|---|---|
| 파이참 django 프로젝트 소스 디렉터리 설정 (0) | 2024.09.26 |
| 테스트 자동화 하기 (0) | 2024.04.29 |
| unmanaged table에 대한 django test 하는 방법 (0) | 2023.09.23 |
| djongo를 설치하는 과정에서 발견한 오류들 (0) | 2023.09.17 |