- Published on
Django N+1 쿼리 문제와 해결법
- Authors

- Name
- Hyo814
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 %}
해결 방법
select_related — ForeignKey / OneToOneField
JOIN으로 한 번에 가져옵니다.
# ✅ JOIN으로 한 번에 조회
datasets = Dataset.objects.select_related('catalog', 'scheme').all()
for dataset in datasets:
print(dataset.catalog.name) # 추가 쿼리 없음
여러 단계의 외래 키도 가능합니다:
Post.objects.select_related('author__profile')
prefetch_related — ManyToManyField / 역참조
별도 쿼리를 미리 실행하고 파이썬에서 조합합니다.
# ✅ 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, OneToOneField | select_related | SQL JOIN (단일 쿼리) |
| ManyToManyField | prefetch_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_related 후 필터링 금지
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를 개발 환경에 항상 켜두는 것만으로도 많은 문제를 미리 잡을 수 있습니다.