# Django ORM: 쿼리를 최적화해 최고의 성능 끌어내기 > Django ORM 쿼리 최적화에 관한 완전한 가이드입니다. select_related, prefetch_related, 인덱스, N+1 문제 분석과 고성능 애플리케이션을 위한 고급 기법을 다룹니다. - Published: 2026-02-15 - Updated: 2026-04-28 - Author: SharpSkill - Tags: django, django orm, python, 데이터베이스 최적화, 성능 - Reading time: 14 min --- Django ORM은 데이터베이스와의 상호작용을 우아하게 추상화하지만, 그 단순함이 심각한 성능 문제를 가릴 수 있습니다. 최적화가 부족한 Django 애플리케이션은 한 번이면 충분한 상황에서 수백 개의 쿼리를 만들기도 합니다. 이 글에서는 그러한 문제를 식별하고 해결하기 위한 핵심 기법들을 살펴봅니다. > **최적화의 황금률** > > 최적화 전에 반드시 측정이 필요합니다. 개발 환경에서 django-debug-toolbar를 사용하면 생성되는 모든 SQL 쿼리를 시각화하고 병목 지점을 빠르게 찾을 수 있습니다. ## N+1 문제 이해하기 N+1 문제는 ORM에서 가장 흔한 함정입니다. 첫 번째 쿼리로 N개의 객체를 가져온 뒤, 각 객체의 관계 정보를 얻기 위해 N개의 추가 쿼리가 실행되며 발생합니다. 이런 쿼리 폭증은 성능을 크게 떨어뜨립니다. ```python # models.py from django.db import models class Author(models.Model): """책의 저자를 나타내는 모델입니다.""" name = models.CharField(max_length=200) email = models.EmailField(unique=True) bio = models.TextField(blank=True) def __str__(self): return self.name class Book(models.Model): """저자와 함께 책을 나타내는 모델입니다.""" title = models.CharField(max_length=300) author = models.ForeignKey( Author, on_delete=models.CASCADE, related_name='books' ) published_date = models.DateField() isbn = models.CharField(max_length=13, unique=True) def __str__(self): return self.title ``` 이 단순한 모델로 N+1 문제를 구체적으로 보여드리겠습니다. ```python # views.py - 문제 예시 def list_books_bad(request): """❌ 이 뷰는 N+1 쿼리를 발생시킵니다.""" books = Book.objects.all() # 책 조회용 쿼리 1개 for book in books: # book.author에 접근할 때마다 SQL 쿼리가 실행됩니다 print(f"{book.title} - {book.author.name}") # 책이 100권이라면 = SQL 쿼리 101개! return render(request, 'books/list.html', {'books': books}) ``` 이 코드는 무해해 보이지만 책 한 권당 저자를 가져오는 쿼리가 발생합니다. ## select_related로 N+1 해결하기 `select_related` 메서드는 SQL JOIN을 수행하여 관련 데이터를 단일 쿼리로 가져옵니다. ForeignKey와 OneToOneField 관계에서 사용할 수 있습니다. ```python # views.py - select_related로 최적화한 예시 def list_books_optimized(request): """✅ 이 뷰는 JOIN이 포함된 단일 쿼리를 만듭니다.""" # select_related는 SQL JOIN을 수행합니다 books = Book.objects.select_related('author').all() for book in books: # 추가 쿼리는 없습니다: 저자가 이미 로드되어 있음 print(f"{book.title} - {book.author.name}") # 합계: 책 수와 무관하게 SQL 쿼리는 단 1개 return render(request, 'books/list.html', {'books': books}) ``` 생성된 SQL 쿼리는 LEFT OUTER JOIN을 이용해 책과 저자를 함께 가져옵니다. ```python # 중첩 관계를 위해 select_related 체이닝하기 # models.py class Publisher(models.Model): name = models.CharField(max_length=200) country = models.CharField(max_length=100) class Book(models.Model): title = models.CharField(max_length=300) author = models.ForeignKey(Author, on_delete=models.CASCADE) publisher = models.ForeignKey(Publisher, on_delete=models.CASCADE) # views.py def list_books_with_details(request): """책, 저자, 출판사를 한 번의 쿼리로 가져옵니다.""" books = Book.objects.select_related( 'author', # Author로의 ForeignKey 'publisher' # Publisher로의 ForeignKey ).all() return render(request, 'books/list.html', {'books': books}) ``` 여러 관계를 `select_related`에 함께 나열하면 동시에 최적화할 수 있습니다. ## prefetch_related로 ManyToMany 관계 최적화하기 ManyToMany 관계나 역방향 관계(다른 쪽에서 본 ForeignKey)에서는 `prefetch_related`가 거대한 JOIN 대신 별도의 최적화된 쿼리를 실행합니다. ```python # models.py class Tag(models.Model): """책을 분류하기 위한 태그입니다.""" name = models.CharField(max_length=50, unique=True) def __str__(self): return self.name class Book(models.Model): title = models.CharField(max_length=300) author = models.ForeignKey(Author, on_delete=models.CASCADE) tags = models.ManyToManyField(Tag, related_name='books') ``` Book과 Tag 사이의 ManyToMany 관계는 `prefetch_related`로 효과적으로 최적화해야 합니다. ```python # views.py - ManyToMany 최적화 def list_books_with_tags(request): """✅ 책과 태그를 효율적으로 가져옵니다.""" books = Book.objects.prefetch_related('tags').all() for book in books: # 태그가 미리 로드되었으므로 추가 쿼리 없음 tag_names = [tag.name for tag in book.tags.all()] print(f"{book.title}: {', '.join(tag_names)}") # 합계: 양과 상관없이 2개의 쿼리(책 + 태그) return render(request, 'books/list.html', {'books': books}) ``` `prefetch_related`는 태그를 위한 별도 쿼리를 실행하고, 결합은 Python에서 수행합니다. > **select_related와 prefetch_related** > > ForeignKey와 OneToOne에는 `select_related`(SQL JOIN)를 사용합니다. ManyToMany와 역방향 관계에는 `prefetch_related`(별도 쿼리)를 활용합니다. 동일한 QuerySet에서 두 메서드를 결합할 수도 있습니다. ## Prefetch 객체로 사전 로딩 맞춤화하기 `Prefetch` 객체는 사전 로딩되는 데이터에 대해 세밀한 제어를 제공합니다. 필터링, 정렬, 결과 수 제한까지 가능합니다. ```python # views.py from django.db.models import Prefetch def list_authors_with_recent_books(request): """저자를 최근 출간된 책과 함께 가져옵니다.""" # 사용자 정의 Prefetch: 2025년 이후의 책만 recent_books_prefetch = Prefetch( 'books', queryset=Book.objects.filter( published_date__year__gte=2025 ).order_by('-published_date'), to_attr='recent_books' # 사용자 정의 속성에 저장 ) authors = Author.objects.prefetch_related( recent_books_prefetch ).all() for author in authors: # 사용자 정의 속성으로 접근 for book in author.recent_books: print(f"{author.name}: {book.title}") return render(request, 'authors/list.html', {'authors': authors}) ``` `to_attr` 속성은 결과를 일반 매니저가 아닌 Python 리스트에 저장합니다. ```python # 고급 조합: select_related + Prefetch def list_authors_complete(request): """다단계 최적화의 종합 예시입니다.""" authors = Author.objects.prefetch_related( Prefetch( 'books', queryset=Book.objects.select_related( 'publisher' # 각 책의 출판사도 최적화 ).prefetch_related( 'tags' # 각 책의 태그도 사전 로딩 ).filter(published_date__year=2026) ) ).all() return render(request, 'authors/complete.html', {'authors': authors}) ``` 이 방식은 복잡한 데이터 구조에서 쿼리 수를 크게 줄여 줍니다. ## only()와 defer()로 컬럼 제한하기 Django는 기본적으로 테이블의 모든 컬럼을 가져옵니다. 필드가 많거나 큰 필드를 가진 모델에서는 컬럼을 제한하면 성능이 향상됩니다. ```python # views.py def list_books_minimal(request): """필요한 컬럼만 가져옵니다.""" # only()는 가져올 컬럼을 지정합니다 books = Book.objects.only( 'id', 'title', 'published_date' ).select_related('author') # 주의: 포함되지 않은 필드에 접근하면 추가 쿼리가 발생합니다 for book in books: print(book.title) # OK, 포함됨 # print(book.isbn) # 추가 쿼리를 유발함 return render(request, 'books/list.html', {'books': books}) ``` `only()` 메서드는 지정된 컬럼만 로드하는 "deferred" 객체를 만듭니다. ```python # defer()로 특정 컬럼 제외하기 def list_authors_without_bio(request): """드물게 사용되는 큰 필드를 제외합니다.""" # defer()는 지정된 컬럼을 제외합니다 authors = Author.objects.defer( 'bio' # TextField는 로드되지 않습니다 ).all() for author in authors: print(author.name) # OK print(author.email) # OK # author.bio는 필요할 때 로드됩니다 return render(request, 'authors/list.html', {'authors': authors}) ``` `defer()`는 `only()`의 반대입니다: 지정된 컬럼은 처음 로드되지 않습니다. ## values()와 values_list()로 최적화하기 전체 모델 객체가 아닌 일부 값만 필요할 때는 `values()`와 `values_list()`가 더 가벼운 딕셔너리나 튜플을 반환합니다. ```python # views.py def get_book_titles(request): """제목만 리스트로 가져옵니다.""" # values_list는 튜플을 반환합니다 titles = Book.objects.values_list('title', flat=True) # 결과: ['책 1', '책 2', ...] # values는 딕셔너리를 반환합니다 book_data = Book.objects.values('title', 'published_date') # 결과: [{'title': '책 1', 'published_date': ...}, ...] return render(request, 'books/titles.html', {'titles': titles}) ``` 이 메서드들은 모델 객체 생성을 피해 메모리 사용량을 줄여 줍니다. ```python # 집계와의 결합 from django.db.models import Count, Avg def get_author_statistics(request): """객체를 로드하지 않고 저자별 통계를 계산합니다.""" stats = Author.objects.values('name').annotate( book_count=Count('books'), avg_year=Avg('books__published_date__year') ).order_by('-book_count') # 결과: [{'name': '저자', 'book_count': 5, 'avg_year': 2024}, ...] return render(request, 'authors/stats.html', {'stats': stats}) ``` 어노테이션을 활용하면 계산을 데이터베이스에서 직접 처리할 수 있습니다. ## 인덱스를 만들어 쿼리 가속하기 데이터베이스 인덱스는 검색을 비약적으로 빠르게 만듭니다. Django는 모델 안에서 인덱스를 직접 정의할 수 있게 해 줍니다. ```python # models.py from django.db import models class Book(models.Model): title = models.CharField(max_length=300, db_index=True) author = models.ForeignKey(Author, on_delete=models.CASCADE) published_date = models.DateField() isbn = models.CharField(max_length=13, unique=True) status = models.CharField(max_length=20, default='available') class Meta: # 자주 수행되는 쿼리를 위한 복합 인덱스 indexes = [ # 출간일 기준 인덱스(빈번한 정렬에 사용) models.Index( fields=['published_date'], name='book_pub_date_idx' ), # 저자 + 상태 필터링을 위한 복합 인덱스 models.Index( fields=['author', 'status'], name='book_author_status_idx' ), # 부분 인덱스: 이용 가능한 책에 한정 models.Index( fields=['published_date'], name='book_available_idx', condition=models.Q(status='available') ), ] # 인덱스를 활용하는 기본 정렬 ordering = ['-published_date'] ``` 이 인덱스들은 해당 컬럼으로 필터링하는 쿼리의 성능을 향상시킵니다. > **인덱스와 쓰기 작업** > > 인덱스는 읽기를 빠르게 하지만 INSERT, UPDATE 같은 쓰기 작업을 약간 느리게 만듭니다. WHERE, ORDER BY, JOIN에서 자주 사용되는 컬럼에만 인덱스를 만드는 것이 좋습니다. ## 필요할 때 원시 SQL 쿼리 사용하기 복잡한 쿼리나 데이터베이스 특화 최적화에는 원시 SQL 쿼리가 완전한 제어권을 제공합니다. ```python # views.py from django.db import connection def get_books_with_raw_sql(request): """특수한 경우를 위한 원시 쿼리""" # 방법 1: raw()로 모델 객체 받기 books = Book.objects.raw(''' SELECT b.*, a.name as author_name FROM library_book b INNER JOIN library_author a ON b.author_id = a.id WHERE b.published_date > %s ORDER BY b.published_date DESC ''', ['2025-01-01']) return render(request, 'books/list.html', {'books': books}) def execute_custom_query(request): """SELECT가 아닌 쿼리를 직접 실행합니다.""" with connection.cursor() as cursor: # 복잡한 집계 쿼리 cursor.execute(''' SELECT a.name, COUNT(b.id) as book_count, AVG(EXTRACT(YEAR FROM b.published_date)) as avg_year FROM library_author a LEFT JOIN library_book b ON b.author_id = a.id GROUP BY a.id, a.name HAVING COUNT(b.id) > 2 ORDER BY book_count DESC ''') results = cursor.fetchall() return render(request, 'stats.html', {'results': results}) ``` 원시 쿼리는 ORM을 거치지 않지만 데이터베이스 간 이식성을 잃습니다. ## django-debug-toolbar로 쿼리 분석하기 django-debug-toolbar 도구는 Django 뷰가 만드는 모든 SQL 쿼리를 시각적으로 보여 줍니다. ```python # settings.py - debug toolbar 구성 INSTALLED_APPS = [ # ... 기타 앱 'debug_toolbar', ] MIDDLEWARE = [ 'debug_toolbar.middleware.DebugToolbarMiddleware', # ... 기타 미들웨어 ] # 로컬 요청에 대해 toolbar 표시 INTERNAL_IPS = ['127.0.0.1'] DEBUG_TOOLBAR_PANELS = [ 'debug_toolbar.panels.sql.SQLPanel', # SQL 쿼리 'debug_toolbar.panels.timer.TimerPanel', # 실행 시간 'debug_toolbar.panels.cache.CachePanel', # 캐시 ] ``` 이 설정은 최적화에 가장 유용한 패널을 활성화합니다. ```python # 개발 환경에서 SQL 쿼리 로깅 # settings.py LOGGING = { 'version': 1, 'handlers': { 'console': { 'class': 'logging.StreamHandler', }, }, 'loggers': { 'django.db.backends': { 'level': 'DEBUG', 'handlers': ['console'], }, }, } ``` 이 로깅은 모든 SQL 쿼리를 콘솔에 출력하여 N+1 문제를 발견하는 데 도움이 됩니다. ## QuerySet 캐싱하기 자주 조회되지만 거의 바뀌지 않는 데이터는 캐시를 사용해 반복 쿼리를 피할 수 있습니다. ```python # views.py from django.core.cache import cache from django.views.decorators.cache import cache_page def list_featured_books(request): """추천 도서를 캐시와 함께 가져옵니다.""" cache_key = 'featured_books_list' # 캐시에서 먼저 조회 시도 books = cache.get(cache_key) if books is None: # 캐시 미스: 쿼리 실행 books = list( Book.objects.select_related('author') .filter(featured=True) .order_by('-published_date')[:10] ) # 5분간 캐시에 저장 cache.set(cache_key, books, timeout=300) return render(request, 'books/featured.html', {'books': books}) # 뷰 전체를 캐시하는 데코레이터 @cache_page(60 * 15) # 15분 캐시 def list_all_tags(request): """모든 태그(거의 변하지 않는 데이터)를 보여 줍니다.""" tags = Tag.objects.annotate( book_count=Count('books') ).order_by('-book_count') return render(request, 'tags/list.html', {'tags': tags}) ``` 캐시는 정적인 데이터에 대한 데이터베이스 부하를 줄여 줍니다. ## 결론 Django ORM 쿼리 최적화는 몇 가지 기본 원칙에 기반합니다. ✅ **최적화 전에 측정**: django-debug-toolbar로 실제 문제를 식별합니다 ✅ **N+1 문제 제거**: ForeignKey에는 `select_related`, ManyToMany에는 `prefetch_related` ✅ **데이터 제한**: `only()`, `defer()`, `values()`로 필요한 정보만 로드합니다 ✅ **현명한 인덱싱**: 자주 필터링하거나 정렬하는 컬럼에 인덱스를 만듭니다 ✅ **전략적인 캐싱**: 거의 변하지 않는 데이터는 Django 캐시의 이점을 누립니다 ✅ **원시 쿼리**: 데이터베이스 특화 최적화가 필요할 때의 마지막 수단입니다 이 기법들을 체계적으로 적용하면 느린 Django 애플리케이션도 대용량 데이터를 처리할 수 있는 효율적인 시스템으로 탈바꿈합니다. Django ORM은 그 미묘한 부분까지 익혔을 때 강력한 도구로 자리매김합니다. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ko/blog/django/django-orm-optimizing-queries