Django ORM: 쿼리를 최적화해 최고의 성능 끌어내기

Django ORM 쿼리 최적화에 관한 완전한 가이드입니다. select_related, prefetch_related, 인덱스, N+1 문제 분석과 고성능 애플리케이션을 위한 고급 기법을 다룹니다.

최고의 성능을 위한 Django ORM 쿼리 최적화

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})

이 방식은 복잡한 데이터 구조에서 쿼리 수를 크게 줄여 줍니다.

Django 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

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은 그 미묘한 부분까지 익혔을 때 강력한 도구로 자리매김합니다.

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

태그

#django
#django orm
#python
#데이터베이스 최적화
#성능

공유

관련 기사