Django REST API에서 N+1 쿼리를 찾아 고친 방법

발행: (2026년 5월 23일 PM 08:51 GMT+9)
6 분 소요
원문: Dev.to

출처: Dev.to

성능이 좋은 API라도 결국 같은 조용한 살인자, N+1 쿼리 문제에 직면하게 됩니다. 앱이 크래시가 나거나 오류가 발생하는 것은 아니지만, 데이터가 늘어날수록 모든 리스트 엔드포인트가 살며시 느려집니다 — 그리고 거의 눈에 띄지 않다가 프로덕션에서 Sentry가 경고를 띄울 때 비로소 드러납니다.
오늘은 Sentry가 내 /api/blog-posts/ 엔드포인트에서 이를 잡아냈습니다. 어떤 일이 있었고, 어떻게 세 줄의 코드만으로 해결했는지 정확히 알려드리겠습니다.

N+1 쿼리란, 코드가 N개의 레코드 리스트를 가져온 뒤, 각 레코드마다 연관 데이터를 조회하기 위해 추가 쿼리를 한 번씩 실행하는 상황을 말합니다 — 즉, 평평한 2~3번의 DB 접근 대신 1 + N 번의 접근이 발생합니다.

Django에서는 ORM이 기본적으로 lazy하기 때문에 이 문제가 조용히 발생합니다. 미리 로드되지 않은 모델 인스턴스의 연관 객체에 접근하면 즉시 새로운 SELECT가 실행됩니다. 예를 들어 블로그 포스트 30개를 조회하면, 여러분이 직접 작성하지 않은 30개의 조용한 쿼리가 발생합니다.

문제의 ViewSet

class BlogPostViewSet(viewsets.ReadOnlyModelViewSet):
    queryset = BlogPost.objects.all()
    serializer_class = BlogPostSerializer
    lookup_field = "uid"

문제의 Serializer

class BlogPostSerializer(serializers.ModelSerializer):
    tags = BlogTagSerializer(many=True, read_only=True)
    series = BlogSeriesSerializer(read_only=True)
    ...

문제점을 찾을 수 있나요? BlogPost는 두 개의 관계를 가지고 있습니다.

  • seriesBlogSeries에 대한 ForeignKey
  • tagsBlogTag에 대한 ManyToManyField

DRF가 30개의 포스트 리스트를 직렬화할 때, 각 포스트에 대해 post.seriespost.tags에 접근합니다. eager loading이 없으면 Django는 포스트당 두 개의 추가 쿼리를 실행합니다 — 즉, 30개의 포스트 리스트에 대해 1 + 60개의 쿼리가 발생합니다.

@action(detail=False, methods=["get"])
def featured(self, request):
    queryset = BlogPost.objects.filter(date_published__isnull=False).order_by(
        "-date_published",
    )[:3]

여기서도 BlogPost.objects 호출에 eager loading이 전혀 없습니다.

Django가 제공하는 두 가지 도구

  • select_related()ForeignKeyOneToOne 관계에 사용. SQL JOIN을 수행해 모든 데이터를 한 번에 가져옵니다.
  • prefetch_related()ManyToMany와 역 ForeignKey 관계에 사용. 두 번째 쿼리를 실행하고 결과를 파이썬 메모리에 캐시합니다.

해결 방법

class BlogPostViewSet(viewsets.ReadOnlyModelViewSet):
    queryset = BlogPost.objects.select_related("series").prefetch_related("tags")
    serializer_class = BlogPostSerializer
    lookup_field = "uid"

    @action(detail=False, methods=["get"])
    def featured(self, request):
        queryset = (
            BlogPost.objects.select_related("series")
            .prefetch_related("tags")
            .filter(date_published__isnull=False)
            .order_by("-date_published")[:3]
        )
        serializer = self.get_serializer(queryset, many=True)
        return Response(serializer.data)

이제 30개의 포스트를 조회해도 리스트 엔드포인트는 데이터셋 크기에 관계없이 3개의 쿼리만 수행합니다.

SELECT * FROM core_blogpost ...
SELECT * FROM core_blogseries WHERE id IN (...)
SELECT * FROM core_blogtag INNER JOIN core_blogpost_tags WHERE blogpost_id IN (...)

다른 곳에서도 발견

블로그 엔드포인트를 감사하면서 TestimonialViewSet에서도 같은 패턴을 발견했습니다. 해당 serializer는 project.titleproject.slug에 접근하지만, queryset에 select_related가 없었습니다.

# Before
queryset = Testimonial.objects.all()

# After
queryset = Testimonial.objects.select_related("project")

한 줄만 추가했을 뿐인데, N+1 문제가 사라졌습니다.

체크리스트

항상 다음을 확인하세요:

  • 쿼리셋에 select_related 혹은 prefetch_related가 없는 경우
  • serializer가 연관 필드에 접근하는 경우 (source="relation.field", 중첩 serializer, SerializerMethodField 등)

사전에 N+1을 잡아주는 도구

  • django-debug-toolbar — 브라우저에서 요청당 쿼리 수를 표시
  • nplusone — 테스트 시 N+1 쿼리가 감지되면 예외 발생
  • Sentry Performance — 프로덕션에서 쿼리 트레이스로 감지

마무리

N+1을 가장 빨리 잡을 수 있는 순간은 코드 리뷰입니다. 중첩 serializer를 작성할 때마다 “이 뷰의 queryset이 해당 관계를 eager load하고 있나요?”를 스스로에게 물어보세요.

Django ORM의 lazy evaluation은 버그가 아니라 기능이지만, queryset 레이어에서의 규율이 필요합니다. objects.all()만 사용한 깔끔해 보이는 ViewSet이 실제로는 serializer 한 단계 앞에서 쿼리 폭풍을 숨기고 있을 수 있습니다.

경험 법칙: serializer에서 접근하는 모든 관계는 queryset에 대응되는 select_related 혹은 prefetch_related가 있어야 합니다. ViewSet을 건드리는 모든 PR에 체크리스트 항목으로 추가하세요.

0 조회
Back to Blog

관련 글

더 보기 »

내 스킬

프로젝트를 위한 AI 지시문을 만들고, 설치하고, 관리하세요 — 코딩이 필요 없습니다. CREATE 이름을 정하고, 카테고리를 선택하고, 원하는 것을 설명하세요 — 마법사가 자동으로 구성합니다.