프로그래밍/Python

🐌 Django 성능의 가장 큰 적: N+1 쿼리 문제 완전 정복

Tiboong 2025. 9. 12. 13:21

"쿼리 하나가 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 등) 사용하는지 확인

🎯 최적화 적용 가이드

  1. ForeignKey, OneToOneField → select_related()
  2. ManyToManyField, 역참조 → prefetch_related()
  3. 조건부 prefetch → Prefetch() 객체 사용
  4. 집계 계산 → annotate() 활용
  5. 복잡한 쿼리 → 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 쿼리 전체 분석 및 해결
  • 데이터베이스 쿼리 튜닝
  • 📊 성능 모니터링 시스템 구축
  • 🎓 팀 개발자 최적화 교육

➡️ Django 성능 최적화 전문가 상담받기

"느린 쿼리 때문에 스트레스 받지 마세요. 전문가가 해결해드립니다!"