Published on

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

Authors
  • avatar
    Name
    Hyo814
    Twitter

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]

요약

문제해결
FK/OneToOne 루프 내 접근select_related
M2M / 역참조 루프 내 접근prefetch_related
복합 관계둘 다 조합
prefetch 후 Python 필터링Prefetch(to_attr=...) 또는 리스트 컴프리헨션

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