- 발행일
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로 전 프로젝트 인자를 대조한 과정은 별도 글2026-07-20없는 관계를 prefetch하고 있었다 — 다운로드 4종을 죽인 한 단어카탈로그 PNG 다운로드가 500을 뱉어서 들어갔는데, 원인은 성능 최적화라고 믿고 있던 prefetch_related의 인자 하나였습니다. Dataset에 없는 관계명이라 QuerySet 평가 시점에 AttributeError가 났고, 지연 평가 때문에 선언부가 아니라 버튼 클릭에서 터졌어요. 같은 파일 안에서 따옴표 표기가 갈려 grep이 한쪽만 잡은 이야기와, AST로 인자 478건을 전수 대조한 기록입니다.에 정리했습니다.
② 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() # ❌ 다시 쿼리
이름을 바꾸는 옵션이 접근 경로까지 바꾸는데, 그걸 모르면 최적화를 넣고도 효과가 없는 상태가 됩니다.
실제 적용 사례
이 내용을 실제 화면에 적용한 기록은 메타클래스 리스트가 점점 느려진 이유2026-05-26메타클래스 리스트가 점점 느려진 이유 — N+1 잡고 prefetch 정리한 회고항목 수가 늘면서 점점 느려지던 메타클래스 리스트 페이지의 쿼리를 분석하고, select_related/prefetch_related로 정리하면서 의식한 점들을 기록합니다.에 따로 정리했습니다. 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를 개발 환경에 항상 켜두는 것만으로도 많은 문제를 미리 잡을 수 있습니다.