"쿼리 하나가 1000개로 늘어나는 마법같은 일"
새로 입사한 주니어 개발자가 자랑스럽게 코드를 보여줍니다.
"선배님, 게시판 목록 페이지 완성했어요! 깔끔하게 잘 나오죠?"
로컬에서는 잘 돌아갔는데, 프로덕션에 배포하자마자 사이트가 거북이처럼 느려졌습니다.
로그를ㅇ3확인해보니... 쿼리가 3,247개나 실행되고 있었습니다.
원인은 바로 N+1 쿼리 문제였습니다.
27년간 수많은 성능 최적화를 해오면서, Django 성능 문제의 80%가 이 N+1 쿼리 때문이라고 확신합니다. 오늘은 이 골치 아픈 문제를 완전히 해결해보겠습니다.
💥 N+1 쿼리 문제란?
간단한 예시로 이해하기
모델 구조:
# models.py
class Author(models.Model):
name = models.CharField(max_length=100)
email = models.EmailField()
def __str__(self):
return self.name
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, on_delete=models.CASCADE)
published_date = models.DateField()
def __str__(self):
return self.title
문제가 되는 코드:
# views.py - 잘못된 방법
def book_list(request):
books = Book.objects.all() # 쿼리 1개 실행
book_data = []
for book in books: # 각 book마다 추가 쿼리 실행!
book_data.append({
'title': book.title,
'author_name': book.author.name, # 🚨 여기서 추가 쿼리!
'author_email': book.author.email, # 🚨 이미 가져온 author지만 또 쿼리!
})
return render(request, 'books.html', {'books': book_data})
실제 실행되는 SQL:
-- 1. 책 목록 가져오기 (1개 쿼리)
SELECT * FROM books;
-- 2. 각 책마다 작가 정보 가져오기 (N개 쿼리)
SELECT * FROM authors WHERE id = 1; -- 첫 번째 책의 작가
SELECT * FROM authors WHERE id = 2; -- 두 번째 책의 작가
SELECT * FROM authors WHERE id = 1; -- 세 번째 책의 작가 (같은 작가라도 또 쿼리!)
-- ... 책 개수만큼 반복
결과: 책이 1000권이면 1 + 1000 = 1001개 쿼리 실행!
📊 실제 성능 비교 테스트
테스트 환경 설정
# 테스트 데이터 생성
from django.core.management.base import BaseCommand
from books.models import Author, Book
class Command(BaseCommand):
def handle(self, *args, **options):
# 작가 100명 생성
authors = []
for i in range(100):
author = Author.objects.create(
name=f'Author {i}',
email=f'author{i}@example.com'
)
authors.append(author)
# 책 1000권 생성 (한 작가당 평균 10권)
for i in range(1000):
Book.objects.create(
title=f'Book {i}',
author=authors[i % 100], # 작가들을 순환하며 할당
published_date='2024-01-01'
)
성능 측정 코드
import time
from django.db import connection
from django.test import TestCase
class PerformanceTest(TestCase):
def test_n_plus_1_problem(self):
# 1. N+1 쿼리 (문제 있는 코드)
start_time = time.time()
query_count_before = len(connection.queries)
books = Book.objects.all()
book_data = []
for book in books:
book_data.append({
'title': book.title,
'author_name': book.author.name, # N+1 발생!
})
end_time = time.time()
query_count_after = len(connection.queries)
print(f"❌ N+1 쿼리:")
print(f" 실행 시간: {end_time - start_time:.2f}초")
print(f" 쿼리 개수: {query_count_after - query_count_before}개")
# 2. select_related 사용 (올바른 방법)
start_time = time.time()
query_count_before = len(connection.queries)
books = Book.objects.select_related('author').all()
book_data = []
for book in books:
book_data.append({
'title': book.title,
'author_name': book.author.name, # 추가 쿼리 없음!
})
end_time = time.time()
query_count_after = len(connection.queries)
print(f"✅ select_related:")
print(f" 실행 시간: {end_time - start_time:.2f}초")
print(f" 쿼리 개수: {query_count_after - query_count_before}개")
실제 테스트 결과 (책 1000권 기준):
❌ N+1 쿼리:
실행 시간: 2.45초
쿼리 개수: 1001개
✅ select_related:
실행 시간: 0.08초
쿼리 개수: 1개
성능 향상: 30배 빠름! 🚀
🛠️ 해결책 1: select_related() - ForeignKey, OneToOneField용
기본 사용법
# ❌ 잘못된 방법
def book_list_bad(request):
books = Book.objects.all()
return render(request, 'books.html', {'books': books})
# ✅ 올바른 방법
def book_list_good(request):
books = Book.objects.select_related('author').all()
return render(request, 'books.html', {'books': books})
템플릿에서 사용:
<!-- books.html -->
{% for book in books %}
<div class="book">
<h3>{{ book.title }}</h3>
<p>작가: {{ book.author.name }}</p> <!-- 추가 쿼리 없음! -->
<p>이메일: {{ book.author.email }}</p>
</div>
{% endfor %}
복잡한 관계에서의 select_related
# 모델 구조 확장
class Publisher(models.Model):
name = models.CharField(max_length=100)
country = models.CharField(max_length=50)
class Author(models.Model):
name = models.CharField(max_length=100)
publisher = models.ForeignKey(Publisher, on_delete=models.CASCADE)
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, on_delete=models.CASCADE)
# 연쇄 관계까지 한 번에 가져오기
def book_list_with_publisher(request):
books = Book.objects.select_related(
'author', # book -> author
'author__publisher' # author -> publisher
).all()
return render(request, 'books_detail.html', {'books': books})
템플릿:
{% for book in books %}
<div>
<h3>{{ book.title }}</h3>
<p>작가: {{ book.author.name }}</p>
<p>출판사: {{ book.author.publisher.name }}</p> <!-- 추가 쿼리 없음! -->
<p>국가: {{ book.author.publisher.country }}</p>
</div>
{% endfor %}
🛠️ 해결책 2: prefetch_related() - ManyToManyField, 역참조용
ManyToManyField 최적화
# 모델에 다대다 관계 추가
class Category(models.Model):
name = models.CharField(max_length=50)
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, on_delete=models.CASCADE)
categories = models.ManyToManyField(Category) # 다대다 관계
# ❌ N+1 문제 발생
def book_categories_bad(request):
books = Book.objects.all()
book_data = []
for book in books:
categories = [cat.name for cat in book.categories.all()] # 🚨 각 책마다 쿼리!
book_data.append({
'title': book.title,
'categories': categories
})
return render(request, 'books.html', {'book_data': book_data})
# ✅ prefetch_related로 해결
def book_categories_good(request):
books = Book.objects.prefetch_related('categories').all()
return render(request, 'books.html', {'books': books})
템플릿에서 사용:
{% for book in books %}
<div>
<h3>{{ book.title }}</h3>
<p>카테고리:
{% for category in book.categories.all %}
{{ category.name }}{% if not forloop.last %}, {% endif %}
{% endfor %}
</p>
</div>
{% endfor %}
역참조(Reverse ForeignKey) 최적화
# 작가별 책 목록을 보여주는 경우
def author_books(request):
# ❌ 각 작가마다 책 목록 쿼리 실행
authors = Author.objects.all()
# ✅ 모든 관련 책을 미리 가져옴
authors = Author.objects.prefetch_related('book_set').all()
return render(request, 'authors.html', {'authors': authors})
템플릿:
{% for author in authors %}
<div>
<h3>{{ author.name }}</h3>
<ul>
{% for book in author.book_set.all %} <!-- 추가 쿼리 없음! -->
<li>{{ book.title }}</li>
{% endfor %}
</ul>
</div>
{% endfor %}
🚀 고급 최적화 테크닉
1. Prefetch 객체로 세밀한 제어
from django.db.models import Prefetch
def recent_books_by_author(request):
# 각 작가의 최근 3권만 가져오기
recent_books = Prefetch(
'book_set',
queryset=Book.objects.filter(
published_date__gte='2023-01-01'
).order_by('-published_date')[:3]
)
authors = Author.objects.prefetch_related(recent_books).all()
return render(request, 'authors_recent.html', {'authors': authors})
2. select_related + prefetch_related 조합
def optimized_book_list(request):
books = Book.objects.select_related(
'author', # ForeignKey
'author__publisher' # 연쇄 ForeignKey
).prefetch_related(
'categories', # ManyToMany
'reviews' # 역참조
).all()
return render(request, 'books_full.html', {'books': books})
3. 조건부 최적화
def conditional_optimization(request, category_id=None):
books = Book.objects.select_related('author')
if category_id:
# 카테고리별 필터링 시에만 categories prefetch
books = books.filter(categories__id=category_id).prefetch_related('categories')
return render(request, 'books.html', {'books': books})
🔍 N+1 쿼리 찾아내는 디버깅 방법
1. Django Debug Toolbar 활용
# settings.py
if DEBUG:
INSTALLED_APPS += ['debug_toolbar']
MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware']
DEBUG_TOOLBAR_CONFIG = {
'SHOW_TOOLBAR_CALLBACK': lambda request: True,
}
2. 쿼리 로깅으로 모니터링
# settings.py - 개발 환경에서만!
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
},
},
'loggers': {
'django.db.backends': {
'handlers': ['console'],
'level': 'DEBUG',
'propagate': False,
},
},
}
3. 커스텀 미들웨어로 쿼리 카운트 확인
# middleware.py
from django.db import connection
import time
class QueryCountMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
query_count_before = len(connection.queries)
start_time = time.time()
response = self.get_response(request)
end_time = time.time()
query_count_after = len(connection.queries)
query_count = query_count_after - query_count_before
response_time = end_time - start_time
# 의심스러운 경우 로그 출력
if query_count > 10 or response_time > 1.0:
print(f"🚨 [{request.path}] "
f"쿼리: {query_count}개, "
f"응답시간: {response_time:.2f}초")
return response
📈 실전 최적화 사례
사례 1: 게시판 + 댓글 시스템
# 기존 코드 (N+1 문제)
def post_list_bad(request):
posts = Post.objects.all()
post_data = []
for post in posts:
comment_count = post.comments.count() # 🚨 각 게시글마다 쿼리!
latest_comment = post.comments.order_by('-created_at').first() # 🚨 또 쿼리!
post_data.append({
'post': post,
'comment_count': comment_count,
'latest_comment': latest_comment
})
return render(request, 'posts.html', {'post_data': post_data})
# 최적화된 코드
from django.db.models import Count, Prefetch
def post_list_optimized(request):
latest_comment = Prefetch(
'comments',
queryset=Comment.objects.order_by('-created_at')[:1],
to_attr='latest_comment_list'
)
posts = Post.objects.select_related('author').prefetch_related(
latest_comment
).annotate(
comment_count=Count('comments') # 집계도 한 번에!
).all()
return render(request, 'posts.html', {'posts': posts})
템플릿:
{% for post in posts %}
<article>
<h2>{{ post.title }}</h2>
<p>작성자: {{ post.author.name }}</p>
<p>댓글: {{ post.comment_count }}개</p>
{% if post.latest_comment_list %}
<p>최신 댓글: {{ post.latest_comment_list.0.content }}</p>
{% endif %}
</article>
{% endfor %}
사례 2: 전자상거래 상품 목록
def product_list_optimized(request):
products = Product.objects.select_related(
'category',
'manufacturer'
).prefetch_related(
'images',
Prefetch(
'reviews',
queryset=Review.objects.select_related('user')[:5],
to_attr='recent_reviews'
)
).annotate(
avg_rating=Avg('reviews__rating'),
review_count=Count('reviews')
).all()
return render(request, 'products.html', {'products': products})
⚡ 성능 최적화 체크리스트
🔍 코드 리뷰 시 확인사항
# 이런 패턴이 보이면 N+1 의심!
for item in items:
related_data = item.foreign_key.some_field # 🚨
many_to_many = item.many_to_many_field.all() # 🚨
reverse_fk = item.reverse_relation.count() # 🚨
✅ 최적화 전 체크리스트
- [ ] 템플릿에서 관계 필드 사용하는지 확인
- [ ] 반복문 안에서 DB 접근하는지 확인
- [ ] ManyToMany 필드 사용하는지 확인
- [ ] 역참조(reverse) 관계 사용하는지 확인
- [ ] 집계 함수(count, avg 등) 사용하는지 확인
🎯 최적화 적용 가이드
- ForeignKey, OneToOneField → select_related()
- ManyToManyField, 역참조 → prefetch_related()
- 조건부 prefetch → Prefetch() 객체 사용
- 집계 계산 → annotate() 활용
- 복잡한 쿼리 → raw() 또는 extra() 고려
🚨 주의사항과 함정
1. 과도한 최적화 주의
# ❌ 불필요한 데이터까지 가져오는 경우
books = Book.objects.select_related(
'author',
'author__publisher',
'author__publisher__country', # 사용하지 않는 데이터
'editor', # 템플릿에서 안 씀
'category' # 마찬가지
).all()
# ✅ 필요한 것만 가져오기
books = Book.objects.select_related('author').all()
2. prefetch_related 순서 주의
# ❌ 잘못된 순서 - 의도와 다른 결과
Book.objects.filter(author__name='김작가').prefetch_related('author')
# ✅ 올바른 순서
Book.objects.prefetch_related('author').filter(author__name='김작가')
3. 메모리 사용량 고려
# 대량 데이터 처리 시 메모리 주의
def process_all_books():
# ❌ 10만권이면 메모리 부족 가능
books = Book.objects.select_related('author').all()
# ✅ 배치 처리
for books_batch in Book.objects.select_related('author').iterator(chunk_size=1000):
process_batch(books_batch)
🎯 결론: N+1 쿼리는 예방이 최선
N+1 쿼리 문제는:
- 🐌 성능 저하: 응답속도 10배 이상 차이
- 💸 비용 증가: DB 서버 부하, 클라우드 비용 상승
- 😤 사용자 이탈: 느린 페이지로 인한 UX 악화
- 🔥 서버 다운: 트래픽 증가 시 DB 커넥션 고갈
기억하세요:
- ✅ select_related(): ForeignKey, OneToOneField
- ✅ prefetch_related(): ManyToManyField, 역참조
- ✅ 개발 단계에서부터 쿼리 모니터링
- ✅ 코드 리뷰에 성능 체크 포함
💬 Django ORM 최적화가 어려우신가요?
복잡한 관계의 모델에서 어떻게 최적화해야 할지 모르겠다고요?
27년 경력의 시니어 개발자가 여러분의 Django 프로젝트 성능을 직접 최적화해드립니다.
- 🔍 N+1 쿼리 전체 분석 및 해결
- ⚡ 데이터베이스 쿼리 튜닝
- 📊 성능 모니터링 시스템 구축
- 🎓 팀 개발자 최적화 교육
"느린 쿼리 때문에 스트레스 받지 마세요. 전문가가 해결해드립니다!"
'프로그래밍 > Python' 카테고리의 다른 글
| 🎯 Django ORM의 숨겨진 함정: 불필요한 데이터까지 가져오는 비효율적인 쿼리들 (0) | 2025.09.17 |
|---|---|
| 🐌 Django에서 쿼리가 느린 진짜 이유: 인덱스 누락 문제 해결하기 (0) | 2025.09.16 |
| 🚨 Django 프로덕션에서 DEBUG=True? 당신의 서비스가 위험합니다! (0) | 2025.09.11 |
| Django 개발자라면 한 번은 겪어봤을 그 악몽: 순환 참조(Circular Import) 문제 (0) | 2025.09.10 |
| [Python] 로또 번호를 맞춰 볼까? #2 (0) | 2024.07.22 |