발행일

Django N+1 쿼리 문제와 해결법

Django N+1 쿼리 문제와 해결법

Django ORM은 편리하지만 잘못 사용하면 N+1 쿼리 문제가 발생합니다. 이 글에서는 N+1이 왜 생기는지, 어떻게 찾고, 어떻게 고치는지 정리합니다.


N+1 문제란?

데이터 목록을 조회(1번 쿼리)한 뒤, 각 항목의 연관 데이터를 가져오기 위해 N번 추가 쿼리가 발생하는 현상입니다.

# ❌ N+1 발생
datasets = Dataset.objects.all()  # 1번 쿼리

for dataset in datasets:
    print(dataset.catalog.name)   # N번 쿼리 (각 dataset마다 catalog 조회)

Dataset이 100개라면 총 101번의 쿼리가 실행됩니다. 효율성 문제이지 오류는 아니지만, 데이터가 많아질수록 성능 저하가 심각해집니다.


발생 패턴

패턴 1: ForeignKey / OneToOneField 접근

# ❌ 루프 안에서 외래 키 접근
for post in Post.objects.all():
    print(post.author.name)  # author를 매번 SELECT

패턴 2: 역참조(Reverse FK) 접근

# ❌ 역참조도 마찬가지
for author in Author.objects.all():
    print(author.post_set.count())  # post를 매번 SELECT

패턴 3: ManyToManyField 접근

# ❌ M2M 관계
for dataset in Dataset.objects.all():
    for concept in dataset.concepts.all():  # 매번 SELECT
        print(concept.name)

패턴 4: 템플릿에서도 동일

{% for dataset in datasets %}
    {{ dataset.catalog.name }}  {# 매번 쿼리 발생 #}
{% endfor %}

해결 방법

JOIN으로 한 번에 가져옵니다.

# ✅ JOIN으로 한 번에 조회
datasets = Dataset.objects.select_related('catalog', 'scheme').all()

for dataset in datasets:
    print(dataset.catalog.name)  # 추가 쿼리 없음

여러 단계의 외래 키도 가능합니다:

Post.objects.select_related('author__profile')

별도 쿼리를 미리 실행하고 파이썬에서 조합합니다.

# ✅ M2M 관계
datasets = Dataset.objects.prefetch_related('concepts').all()

for dataset in datasets:
    for concept in dataset.concepts.all():  # 추가 쿼리 없음
        print(concept.name)

역참조(Reverse FK)도 동일:

Author.objects.prefetch_related('post_set')

언제 무엇을 쓸까?

상황사용 메서드동작 방식
ForeignKey, OneToOneFieldselect_relatedSQL JOIN (단일 쿼리)
ManyToManyFieldprefetch_related별도 쿼리 후 Python 조합
역참조 (related_name)prefetch_related별도 쿼리 후 Python 조합
복잡한 ForeignKey + M2M 혼합둘 다 조합
# ✅ 복합 사용
Dataset.objects.select_related('catalog').prefetch_related('concepts', 'tags')

N+1 찾는 방법

django-debug-toolbar

개발 환경에서 요청당 쿼리 수를 시각적으로 확인할 수 있습니다.

pip install django-debug-toolbar

connection.queries

from django.db import connection, reset_queries

reset_queries()
# ... 코드 실행 ...
print(len(connection.queries))   # 실행된 쿼리 수
for q in connection.queries:
    print(q['sql'])

Prefetch 객체로 세밀한 제어

from django.db.models import Prefetch

# 특정 조건의 prefetch
Dataset.objects.prefetch_related(
    Prefetch(
        'concepts',
        queryset=Concept.objects.filter(public=True),
        to_attr='public_concepts'
    )
)

실수하기 쉬운 케이스

annotate와 혼동

annotate는 집계에 사용하며 N+1과는 별개입니다:

from django.db.models import Count

# 각 author의 post 수 — 단일 쿼리로 처리
Author.objects.annotate(post_count=Count('post'))

prefetch 후 .filter()를 다시 걸면 캐시를 무시하고 새 쿼리가 실행됩니다:

datasets = Dataset.objects.prefetch_related('concepts')

for dataset in datasets:
    # ❌ 캐시 무효화 — 새 쿼리 발생
    active = dataset.concepts.filter(public=True)

    # ✅ Python으로 필터링 (캐시 활용)
    active = [c for c in dataset.concepts.all() if c.public]

다만 리스트 컴프리헨션은 이미 전부 당겨온 뒤에 거르는 임시방편입니다. 정석은 Prefetch(queryset=Concept.objects.filter(public=True))처럼 필터를 prefetch 단계로 내리는 것 — 아래 ③에서 다루는 그 Prefetch 객체예요. 관계 행은 많은데 살아남는 게 적다면, 컴프리헨션 방식은 그 낭비를 그대로 안고 갑니다.


실무에서 만난 세 가지 — 이 글의 조언이 안 통했던 곳

위 내용은 "N+1을 없애는 법"인데, 실제로 적용해보니 없애는 것보다 어디에 붙일지 정하는 게 어려웠습니다.

prefetch_related가 화면을 죽인 케이스 — 관계가 없는데도 통과한다

최적화라고 믿고 있던 prefetch_related("metadata")카탈로그 다운로드 기능 네 개를 못 쓰게 만들었습니다. Datasetmetadata라는 관계가 아예 없었거든요.

이 글은 내내 "관계를 미리 당겨오면 쿼리가 준다" 고만 말하는데, 인자가 실재하는 관계인지 언제 확인되는지는 한 줄도 안 적어놨습니다. 여기가 함정이었어요.

qs = Dataset.objects.prefetch_related("metadata")   # 통과. 아무 일도 안 일어난다
list(qs)                                            # 여기서 터진다
# AttributeError: Cannot find 'metadata' on Dataset object,
#                 'metadata' is an invalid parameter to prefetch_related()

QuerySet은 지연 평가라 선언 시점에는 검증되지 않습니다. 그래서 manage.py check도, 서버 기동도, 마이그레이션도 조용했고, 오타를 낸 파일이 아니라 그 QuerySet을 순회하는 다른 파일에서 스택 트레이스가 시작됐습니다.

select_related/prefetch_related의 문자열 인자는 오타가 런타임까지 살아남는 몇 안 되는 자리입니다. 표에 "M2M이면 prefetch"라고 적을 게 아니라, 그 인자를 무엇이 검증해 주는지를 같이 적었어야 했어요 — 답은 "아무것도, 순회 전까지는"입니다.

원인 추적과 AST로 전 프로젝트 인자를 대조한 과정은 별도 글에 정리했습니다.

Count를 두 개 붙이면 숫자가 부풀어 오른다

"annotate는 N+1과 별개" 라고 짧게 넘어갔는데, 여기에 함정이 하나 더 있습니다.

# 위험: 역방향 관계 두 개를 동시에 Count
Author.objects.annotate(
    post_count=Count('post'),
    comment_count=Count('comment'),   # 둘 다 틀려진다
)

역방향 조인이 둘이면 행이 곱해집니다. 글 3개 × 댓글 4개면 조인 결과가 12행이 되고, Count('post')가 3이 아니라 12를 세요. distinct=True를 주거나 서브쿼리로 빼야 합니다.

distinct=True도 공짜는 아닙니다. 집계 전에 중복 제거가 들어가서 조인 결과가 클수록 그 자체가 비싸져요. 카운트 두 개 정도면 distinct=True로 충분하고, 조인이 크거나 집계가 더 겹치면 Subquery로 각각 따로 세는 쪽 — 이게 갈림 기준입니다.

N+1은 느려서 알아채지만 이건 빠르면서 틀립니다. 쿼리 수만 보고 최적화를 판단하면 이 종류는 절대 안 걸려요.

to_attr을 쓰면 접근 방법도 바뀐다

Prefetch 예제에 함정이 하나 있습니다.

Dataset.objects.prefetch_related(
    Prefetch('concepts', queryset=Concept.objects.filter(public=True), to_attr='public_concepts')
)

to_attr을 주면 결과가 dataset.concepts가 아니라 dataset.public_concepts(리스트) 에 담깁니다. 그대로 dataset.concepts.all()을 쓰면 prefetch를 무시하고 새 쿼리가 나가요.

for dataset in datasets:
    dataset.public_concepts        # ✅ 리스트, 추가 쿼리 없음
    dataset.concepts.all()         # ❌ 다시 쿼리

이름을 바꾸는 옵션이 접근 경로까지 바꾸는데, 그걸 모르면 최적화를 넣고도 효과가 없는 상태가 됩니다.


실제 적용 사례

이 내용을 실제 화면에 적용한 기록은 메타클래스 리스트가 점점 느려진 이유에 따로 정리했습니다. 300건에서 908쿼리가 나오던 목록을 4쿼리로 줄인 과정과, 속성 개수만 필요할 때 prefetch_related 대신 annotate(Count(...))를 고른 이유를 다뤘어요.


요약

문제해결
FK/OneToOne 루프 내 접근select_related
목록에서 M2M / 역참조 루프 내 접근prefetch_related
전량 순회·다운로드 경로iterator() — prefetch와의 병행은 Django 4.1부터 chunk_size 지정 시 가능. 이 프로젝트는 4.2라 "prefetch를 빼야 한다"는 조언은 이제 옛말
개수만 필요annotate(Count(...)) — 단, 두 개 이상이면 distinct=True
복합 관계둘 다 조합
prefetch 후 Python 필터링Prefetch(to_attr=...) — 접근 경로가 바뀌는 것에 주의

최적화는 쿼리셋이 아니라 경로에 붙입니다. 목록에서 옳은 것이 전량 조회에서는 반대일 수 있어요.

N+1은 기능 구현 후 성능 테스트 단계에서 발견되는 경우가 많습니다. django-debug-toolbar를 개발 환경에 항상 켜두는 것만으로도 많은 문제를 미리 잡을 수 있습니다.