- 발행일
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 %}
해결 방법
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]
다만 리스트 컴프리헨션은 이미 전부 당겨온 뒤에 거르는 임시방편입니다. 정석은 Prefetch(queryset=Concept.objects.filter(public=True))처럼 필터를 prefetch 단계로 내리는 것 — 아래 ③에서 다루는 그 Prefetch 객체예요. 관계 행은 많은데 살아남는 게 적다면, 컴프리헨션 방식은 그 낭비를 그대로 안고 갑니다.
실무에서 만난 세 가지 — 이 글의 조언이 안 통했던 곳
위 내용은 "N+1을 없애는 법"인데, 실제로 적용해보니 없애는 것보다 어디에 붙일지 정하는 게 어려웠습니다.
① prefetch_related가 화면을 죽인 케이스 — 관계가 없는데도 통과한다
최적화라고 믿고 있던 prefetch_related("metadata")가 카탈로그 다운로드 기능 네 개를 못 쓰게 만들었습니다. Dataset에 metadata라는 관계가 아예 없었거든요.
이 글은 내내 "관계를 미리 당겨오면 쿼리가 준다" 고만 말하는데, 인자가 실재하는 관계인지 언제 확인되는지는 한 줄도 안 적어놨습니다. 여기가 함정이었어요.
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를 개발 환경에 항상 켜두는 것만으로도 많은 문제를 미리 잡을 수 있습니다.