Django ORM의 select_related와 prefetch_related 완전 분석: N+1 문제 해결의 핵심
Django ORM의 select_related와 prefetch_related 완전 분석
Django ORM을 사용하다 보면 성능 최적화의 핵심이 되는 두 가지 메서드를 만나게 됩니다: select_related와 prefetch_related. 겉보기엔 비슷해 보이지만, 이 둘은 완전히 다른 방식으로 동작하며 각각의 적절한 사용 시나리오가 있습니다.
이 포스트에서는 단순히 사용법을 다루는 것을 넘어서, 내부적으로 어떤 SQL이 생성되는지, 메모리에서 어떻게 데이터가 처리되는지, 그리고 언제 어떤 것을 사용해야 하는지를 실제 SQL 쿼리와 함께 깊이 있게 분석해보겠습니다.
🎯 학습 목표
- N+1 쿼리 문제의 본질적 이해
- select_related의 내부 동작 원리와 JOIN 전략
- prefetch_related의 독특한 Python 레벨 처리 방식
- 실제 SQL 쿼리 비교를 통한 성능 차이 분석
- 복잡한 관계에서의 최적화 전략
- 실무에서 마주치는 다양한 시나리오별 해결책
📊 1. 기본 모델 설정 및 N+1 문제 이해
먼저 실습을 위한 모델을 설정하고, N+1 문제가 무엇인지 구체적으로 살펴보겠습니다.
1.1 실습용 모델 정의
# models.py
from django.db import models
class Author(models.Model):
"""작가 모델"""
name = models.CharField(max_length=100)
email = models.EmailField()
birth_date = models.DateField()
country = models.ForeignKey('Country', on_delete=models.CASCADE)
def __str__(self):
return self.name
class Country(models.Model):
"""국가 모델"""
name = models.CharField(max_length=50)
code = models.CharField(max_length=2, unique=True)
def __str__(self):
return self.name
class Publisher(models.Model):
"""출판사 모델"""
name = models.CharField(max_length=100)
founded_year = models.IntegerField()
country = models.ForeignKey(Country, on_delete=models.CASCADE)
def __str__(self):
return self.name
class Book(models.Model):
"""도서 모델"""
title = models.CharField(max_length=200)
isbn = models.CharField(max_length=13, unique=True)
publication_date = models.DateField()
price = models.DecimalField(max_digits=10, decimal_places=2)
# ForeignKey 관계
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name='books')
publisher = models.ForeignKey(Publisher, on_delete=models.CASCADE, related_name='books')
def __str__(self):
return self.title
class Review(models.Model):
"""리뷰 모델"""
book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name='reviews')
reviewer_name = models.CharField(max_length=100)
rating = models.IntegerField(choices=[(i, i) for i in range(1, 6)])
comment = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.book.title} - {self.rating}점"
class Tag(models.Model):
"""태그 모델"""
name = models.CharField(max_length=50, unique=True)
def __str__(self):
return self.name
class BookTag(models.Model):
"""도서-태그 중간 테이블 (ManyToMany 관계)"""
book = models.ForeignKey(Book, on_delete=models.CASCADE)
tag = models.ForeignKey(Tag, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('book', 'tag')
# ManyToMany 관계 추가
Book.add_to_class('tags', models.ManyToManyField(Tag, through=BookTag, related_name='books'))
1.2 N+1 문제의 실체
N+1 문제란 하나의 메인 쿼리 실행 후, 연관된 데이터를 가져오기 위해 N번의 추가 쿼리가 실행되는 문제입니다. 실제 예시를 통해 살펴보겠습니다.
# 문제가 되는 코드
def get_books_with_authors_bad():
"""N+1 문제가 발생하는 코드"""
books = Book.objects.all() # 1번의 쿼리
result = []
for book in books: # N번의 반복
result.append({
'title': book.title,
'author_name': book.author.name, # 각 반복마다 쿼리 실행!
'author_country': book.author.country.name # 또 다른 쿼리!
})
return result
이 코드가 실행될 때 생성되는 SQL을 살펴보겠습니다:
-- 1. 초기 Book 조회 (1번의 쿼리)
SELECT "myapp_book"."id",
"myapp_book"."title",
"myapp_book"."isbn",
"myapp_book"."publication_date",
"myapp_book"."price",
"myapp_book"."author_id",
"myapp_book"."publisher_id"
FROM "myapp_book";
-- 2. 각 Book마다 Author 조회 (N번의 쿼리)
SELECT "myapp_author"."id",
"myapp_author"."name",
"myapp_author"."email",
"myapp_author"."birth_date",
"myapp_author"."country_id"
FROM "myapp_author"
WHERE "myapp_author"."id" = 1;
SELECT "myapp_author"."id",
"myapp_author"."name",
"myapp_author"."email",
"myapp_author"."birth_date",
"myapp_author"."country_id"
FROM "myapp_author"
WHERE "myapp_author"."id" = 2;
-- ... (도서 수만큼 반복)
-- 3. 각 Author마다 Country 조회 (또 다른 N번의 쿼리)
SELECT "myapp_country"."id",
"myapp_country"."name",
"myapp_country"."code"
FROM "myapp_country"
WHERE "myapp_country"."id" = 1;
SELECT "myapp_country"."id",
"myapp_country"."name",
"myapp_country"."code"
FROM "myapp_country"
WHERE "myapp_country"."id" = 2;
-- ... (작가 수만큼 반복)
결과: 도서가 100권, 작가가 50명이라면 1 + 100 + 50 = 151번의 쿼리가 실행됩니다!
1.3 Django의 Lazy Loading 메커니즘
이런 문제가 발생하는 이유는 Django ORM의 Lazy Loading 때문입니다:
# Django ORM의 Lazy Loading 동작 과정
# 1. 쿼리셋 생성 (아직 DB 접근 안함)
books = Book.objects.all()
print("쿼리셋 생성됨, 아직 DB 접근 안함")
# 2. 첫 번째 데이터 접근 시 쿼리 실행
first_book = books[0] # 이 시점에 SELECT 쿼리 실행
print("Book 테이블 쿼리 실행됨")
# 3. 연관 관계 접근 시 추가 쿼리
author_name = first_book.author.name # Author 테이블 쿼리 실행
print("Author 테이블 쿼리 실행됨")
# 4. 중첩된 연관 관계 접근 시 또 다른 쿼리
country_name = first_book.author.country.name # Country 테이블 쿼리 실행
print("Country 테이블 쿼리 실행됨")
이제 이 문제를 어떻게 해결할 수 있는지, select_related와 prefetch_related의 동작 원리를 통해 알아보겠습니다.
🔗 2. select_related 심화 분석: JOIN의 마법
select_related는 Django ORM에서 Forward ForeignKey와 OneToOne 관계에 대해 SQL JOIN을 사용하여 연관된 데이터를 한 번에 가져오는 메서드입니다.
2.1 select_related의 내부 동작 원리
# select_related 사용 예시
def get_books_with_authors_optimized():
"""select_related를 사용한 최적화된 코드"""
books = Book.objects.select_related('author', 'author__country', 'publisher').all()
result = []
for book in books:
result.append({
'title': book.title,
'author_name': book.author.name, # 추가 쿼리 없음!
'author_country': book.author.country.name, # 추가 쿼리 없음!
'publisher_name': book.publisher.name # 추가 쿼리 없음!
})
return result
이 코드가 생성하는 SQL을 분석해보겠습니다:
-- select_related 사용 시 생성되는 단일 쿼리
SELECT
"myapp_book"."id",
"myapp_book"."title",
"myapp_book"."isbn",
"myapp_book"."publication_date",
"myapp_book"."price",
"myapp_book"."author_id",
"myapp_book"."publisher_id",
-- Author 테이블의 모든 필드
"myapp_author"."id",
"myapp_author"."name",
"myapp_author"."email",
"myapp_author"."birth_date",
"myapp_author"."country_id",
-- Country 테이블의 모든 필드
"myapp_country"."id",
"myapp_country"."name",
"myapp_country"."code",
-- Publisher 테이블의 모든 필드
"myapp_publisher"."id",
"myapp_publisher"."name",
"myapp_publisher"."founded_year",
"myapp_publisher"."country_id"
FROM "myapp_book"
-- INNER JOIN으로 Author 테이블 연결
INNER JOIN "myapp_author"
ON ("myapp_book"."author_id" = "myapp_author"."id")
-- INNER JOIN으로 Country 테이블 연결 (author__country)
INNER JOIN "myapp_country"
ON ("myapp_author"."country_id" = "myapp_country"."id")
-- INNER JOIN으로 Publisher 테이블 연결
INNER JOIN "myapp_publisher"
ON ("myapp_book"."publisher_id" = "myapp_publisher"."id");
결과: N+1 문제가 있던 151번의 쿼리가 단 1번의 쿼리로 해결됩니다!
2.2 select_related의 메모리 처리 방식
select_related가 어떻게 메모리에서 객체를 구성하는지 살펴보겠습니다:
# Django 내부적으로 일어나는 과정 (의사코드)
def select_related_internal_process():
"""select_related 내부 처리 과정"""
# 1. SQL 실행 결과 (JOIN된 플랫한 데이터)
raw_data = [
{
'book_id': 1, 'book_title': 'Django 마스터하기',
'author_id': 1, 'author_name': '김개발', 'author_country_id': 1,
'country_id': 1, 'country_name': '대한민국',
'publisher_id': 1, 'publisher_name': '테크북스'
},
{
'book_id': 2, 'book_title': 'Python 완전정복',
'author_id': 2, 'author_name': '박파이썬', 'author_country_id': 2,
'country_id': 2, 'country_name': '미국',
'publisher_id': 2, 'publisher_name': 'O\'Reilly'
},
# ... 더 많은 데이터
]
# 2. Django ORM이 객체로 재구성
books = []
for row in raw_data:
# Country 객체 생성
country = Country(
id=row['country_id'],
name=row['country_name'],
code=row['country_code']
)
# Author 객체 생성 (Country 객체 연결)
author = Author(
id=row['author_id'],
name=row['author_name'],
country=country # 이미 로드된 객체 연결
)
# Publisher 객체 생성
publisher = Publisher(
id=row['publisher_id'],
name=row['publisher_name']
)
# Book 객체 생성 (모든 연관 객체들 연결)
book = Book(
id=row['book_id'],
title=row['book_title'],
author=author, # 이미 로드된 객체 연결
publisher=publisher # 이미 로드된 객체 연결
)
books.append(book)
return books
2.3 select_related 성능 비교 실험
실제 성능 차이를 측정해보겠습니다:
import time
from django.db import connection
from django.test.utils import override_settings
def performance_comparison():
"""성능 비교 실험"""
# 테스트 데이터 생성 (1000권의 책)
setup_test_data(1000)
# 1. N+1 문제가 있는 코드
start_time = time.time()
connection.queries_log.clear()
books = Book.objects.all()
for book in books:
author_name = book.author.name
country_name = book.author.country.name
n_plus_1_time = time.time() - start_time
n_plus_1_queries = len(connection.queries)
# 2. select_related 사용 코드
start_time = time.time()
connection.queries_log.clear()
books = Book.objects.select_related('author', 'author__country').all()
for book in books:
author_name = book.author.name
country_name = book.author.country.name
optimized_time = time.time() - start_time
optimized_queries = len(connection.queries)
print(f"N+1 문제: {n_plus_1_queries}개 쿼리, {n_plus_1_time:.3f}초")
print(f"select_related: {optimized_queries}개 쿼리, {optimized_time:.3f}초")
print(f"성능 향상: {n_plus_1_time/optimized_time:.1f}배 빨라짐")
# 실행 결과 예시:
# N+1 문제: 2001개 쿼리, 2.847초
# select_related: 1개 쿼리, 0.043초
# 성능 향상: 66.2배 빨라짐
2.4 select_related 사용 시 주의사항
2.4.1 과도한 JOIN의 문제
# 잘못된 사용 예시 - 과도한 JOIN
def bad_select_related():
"""너무 많은 테이블을 JOIN하는 잘못된 예시"""
books = Book.objects.select_related(
'author',
'author__country',
'publisher',
'publisher__country',
'author__books', # 역참조는 select_related로 불가능!
# ... 더 많은 관계들
).all()
# 생성되는 SQL이 매우 복잡해지고 느려질 수 있음
2.4.2 메모리 사용량 증가
def memory_usage_analysis():
"""select_related 메모리 사용량 분석"""
# Case 1: select_related 없이
books_simple = Book.objects.all()
# 메모리: Book 객체들만 로드
# Case 2: select_related 사용
books_with_related = Book.objects.select_related(
'author', 'author__country', 'publisher'
).all()
# 메모리: Book + Author + Country + Publisher 객체들 모두 로드
# 트레이드오프: 메모리 사용량 증가 vs 쿼리 수 감소
2.4.3 NULL 값과 INNER JOIN 문제
# 잠재적 문제 상황
def null_handling_issue():
"""NULL 값이 있는 경우의 문제"""
# 만약 일부 Author의 country가 NULL이라면?
books = Book.objects.select_related('author', 'author__country').all()
# INNER JOIN으로 인해 country가 NULL인 Author를 가진 Book은 결과에서 제외됨!
# 해결책: LEFT JOIN 사용 (Django는 자동으로 처리)
실제로 Django는 이 문제를 해결하기 위해 LEFT JOIN을 사용합니다:
-- Django가 실제로 생성하는 SQL (LEFT JOIN 사용)
SELECT ...
FROM "myapp_book"
LEFT OUTER JOIN "myapp_author"
ON ("myapp_book"."author_id" = "myapp_author"."id")
LEFT OUTER JOIN "myapp_country"
ON ("myapp_author"."country_id" = "myapp_country"."id");
🔄 3. prefetch_related 심화 분석: Python의 힘
prefetch_related는 select_related와 완전히 다른 접근 방식을 사용합니다. SQL JOIN 대신 별도의 쿼리들을 실행한 후 Python에서 관계를 연결하는 독특한 방식으로 동작합니다.
3.1 prefetch_related가 필요한 상황
select_related는 Forward ForeignKey에만 사용할 수 있지만, prefetch_related는 다음과 같은 관계에서 사용됩니다:
# prefetch_related를 사용해야 하는 관계들
# 1. Reverse ForeignKey (1:N 관계의 역참조)
authors = Author.objects.prefetch_related('books').all()
# 2. ManyToMany 관계
books = Book.objects.prefetch_related('tags').all()
# 3. Forward ForeignKey (select_related 대신 사용 가능)
books = Book.objects.prefetch_related('author').all()
# 4. 복잡한 중첩 관계
authors = Author.objects.prefetch_related('books__reviews').all()
3.2 prefetch_related의 내부 동작 원리
가장 핵심적인 부분입니다. prefetch_related가 어떻게 동작하는지 단계별로 분석해보겠습니다:
# 예시: 작가와 그들의 모든 책을 가져오기
def get_authors_with_books():
"""작가와 책들을 prefetch_related로 가져오기"""
authors = Author.objects.prefetch_related('books').all()
for author in authors:
print(f"작가: {author.name}")
for book in author.books.all(): # 추가 쿼리 없음!
print(f" - {book.title}")
이 코드가 실행될 때 Django 내부에서 일어나는 과정:
3.2.1 1단계: 메인 쿼리 실행
-- 1단계: Author 테이블에서 기본 데이터 조회
SELECT "myapp_author"."id",
"myapp_author"."name",
"myapp_author"."email",
"myapp_author"."birth_date",
"myapp_author"."country_id"
FROM "myapp_author";
3.2.2 2단계: 관련 데이터 일괄 조회
-- 2단계: 조회된 작가들의 ID를 사용해 관련 책들을 일괄 조회
SELECT "myapp_book"."id",
"myapp_book"."title",
"myapp_book"."isbn",
"myapp_book"."publication_date",
"myapp_book"."price",
"myapp_book"."author_id",
"myapp_book"."publisher_id"
FROM "myapp_book"
WHERE "myapp_book"."author_id" IN (1, 2, 3, 4, 5, ...); -- 모든 작가 ID
3.2.3 3단계: Python에서 관계 매핑
# Django 내부 처리 과정 (의사코드)
def prefetch_related_internal_process():
"""prefetch_related 내부 처리 과정"""
# 1단계: 메인 객체들 조회
authors = [
Author(id=1, name='김개발'),
Author(id=2, name='박파이썬'),
Author(id=3, name='이자바'),
# ...
]
# 2단계: 관련 객체들 조회
books = [
Book(id=1, title='Django 입문', author_id=1),
Book(id=2, title='Django 고급', author_id=1),
Book(id=3, title='Python 기초', author_id=2),
Book(id=4, title='Python 심화', author_id=2),
Book(id=5, title='Java 완전정복', author_id=3),
# ...
]
# 3단계: Python에서 관계 매핑 (핵심!)
author_books_map = {}
for book in books:
if book.author_id not in author_books_map:
author_books_map[book.author_id] = []
author_books_map[book.author_id].append(book)
# 4단계: Author 객체에 books 캐시 설정
for author in authors:
author._prefetched_objects_cache = {
'books': author_books_map.get(author.id, [])
}
return authors
3.3 prefetch_related vs N+1 문제 성능 비교
실제 성능 차이를 측정해보겠습니다:
def prefetch_performance_comparison():
"""prefetch_related 성능 비교"""
# 1. N+1 문제가 있는 코드
start_time = time.time()
connection.queries_log.clear()
authors = Author.objects.all() # 1번 쿼리
for author in authors: # N번 반복
books = author.books.all() # 각 작가마다 쿼리 실행!
for book in books:
title = book.title
n_plus_1_time = time.time() - start_time
n_plus_1_queries = len(connection.queries)
# 2. prefetch_related 사용 코드
start_time = time.time()
connection.queries_log.clear()
authors = Author.objects.prefetch_related('books').all() # 2번 쿼리
for author in authors:
books = author.books.all() # 캐시에서 가져옴, 추가 쿼리 없음!
for book in books:
title = book.title
prefetch_time = time.time() - start_time
prefetch_queries = len(connection.queries)
print(f"N+1 문제: {n_plus_1_queries}개 쿼리, {n_plus_1_time:.3f}초")
print(f"prefetch_related: {prefetch_queries}개 쿼리, {prefetch_time:.3f}초")
# 실행 결과 예시 (작가 100명, 책 1000권):
# N+1 문제: 101개 쿼리, 1.234초
# prefetch_related: 2개 쿼리, 0.087초
3.4 복잡한 prefetch_related 사용법
3.4.1 중첩된 prefetch
def nested_prefetch_example():
"""중첩된 prefetch 예시"""
# 작가 -> 책 -> 리뷰를 한 번에 가져오기
authors = Author.objects.prefetch_related(
'books', # 1단계: books 테이블 조회
'books__reviews' # 2단계: reviews 테이블 조회
).all()
for author in authors:
print(f"작가: {author.name}")
for book in author.books.all():
print(f" 책: {book.title}")
for review in book.reviews.all(): # 추가 쿼리 없음!
print(f" 리뷰: {review.rating}점 - {review.comment}")
생성되는 SQL:
-- 1단계: Authors 조회
SELECT * FROM "myapp_author";
-- 2단계: Books 조회
SELECT * FROM "myapp_book"
WHERE "myapp_book"."author_id" IN (1, 2, 3, ...);
-- 3단계: Reviews 조회
SELECT * FROM "myapp_review"
WHERE "myapp_review"."book_id" IN (1, 2, 3, 4, 5, ...);
3.4.2 조건부 prefetch with Prefetch 객체
from django.db.models import Prefetch
def conditional_prefetch_example():
"""조건부 prefetch 예시"""
# 평점 4점 이상의 리뷰만 prefetch
high_rated_reviews = Prefetch(
'reviews',
queryset=Review.objects.filter(rating__gte=4).select_related('book'),
to_attr='high_rated_reviews' # 별도 속성에 저장
)
books = Book.objects.prefetch_related(high_rated_reviews).all()
for book in books:
print(f"책: {book.title}")
# 기본 reviews는 그대로 사용 가능 (별도 쿼리 실행됨)
all_reviews = book.reviews.all()
# 고평점 리뷰는 캐시된 데이터 사용
for review in book.high_rated_reviews: # 추가 쿼리 없음!
print(f" 고평점 리뷰: {review.rating}점")
3.4.3 ManyToMany 관계 prefetch
def manytomany_prefetch_example():
"""ManyToMany 관계 prefetch 예시"""
# 책과 태그들을 함께 가져오기
books = Book.objects.prefetch_related('tags').all()
for book in books:
print(f"책: {book.title}")
for tag in book.tags.all(): # 추가 쿼리 없음!
print(f" 태그: {tag.name}")
생성되는 SQL:
-- 1단계: Books 조회
SELECT * FROM "myapp_book";
-- 2단계: 중간 테이블과 Tags 조회 (JOIN 사용)
SELECT "myapp_tag"."id",
"myapp_tag"."name",
"myapp_booktag"."book_id"
FROM "myapp_tag"
INNER JOIN "myapp_booktag"
ON ("myapp_tag"."id" = "myapp_booktag"."tag_id")
WHERE "myapp_booktag"."book_id" IN (1, 2, 3, ...);
3.5 prefetch_related의 메모리 처리 방식
prefetch_related는 select_related와 다른 메모리 사용 패턴을 보입니다:
def memory_usage_analysis():
"""prefetch_related 메모리 사용 분석"""
# select_related: 모든 관련 데이터를 하나의 큰 결과셋으로 메모리에 로드
books_select = Book.objects.select_related('author', 'publisher').all()
# 메모리: [Book+Author+Publisher, Book+Author+Publisher, ...]
# prefetch_related: 별도의 결과셋들을 메모리에 로드
authors_prefetch = Author.objects.prefetch_related('books').all()
# 메모리:
# - Authors: [Author1, Author2, Author3, ...]
# - Books: [Book1, Book2, Book3, ...] (별도 저장)
# - 관계 매핑: {author_id: [book_list]}
⚖️ 4. select_related vs prefetch_related: 언제 무엇을 사용할까?
두 메서드의 차이점을 이해했으니, 이제 실제 상황에서 언제 어떤 것을 사용해야 하는지 구체적인 기준을 알아보겠습니다.
4.1 기술적 차이점 요약
| 구분 | select_related | prefetch_related |
|---|---|---|
| 사용 가능한 관계 | ForeignKey, OneToOne (Forward) | 모든 관계 (Reverse FK, M2M 포함) |
| SQL 패턴 | 단일 쿼리 + JOIN | 최소 2개의 별도 쿼리 |
| 메모리 사용 | 하나의 큰 결과셋 | 여러 개의 별도 결과셋 |
| 데이터 처리 | DB에서 JOIN 수행 | Python에서 관계 매핑 |
| NULL 값 처리 | LEFT JOIN으로 자동 처리 | 별도 처리 불필요 |
4.2 성능 특성 비교
4.2.1 쿼리 복잡도에 따른 성능
def performance_comparison_detailed():
"""상세한 성능 비교"""
# 시나리오 1: 간단한 1:1 관계 (Book -> Author)
# select_related가 유리
# 방법 1: select_related
books_sr = Book.objects.select_related('author').all()
# SQL: 1개의 JOIN 쿼리
# 메모리: 효율적
# 방법 2: prefetch_related
books_pr = Book.objects.prefetch_related('author').all()
# SQL: 2개의 별도 쿼리
# 메모리: 약간 비효율적
print("간단한 관계에서는 select_related가 더 효율적")
# 시나리오 2: 복잡한 1:N 관계 (Author -> Books)
# prefetch_related만 가능
authors = Author.objects.prefetch_related('books').all()
# select_related로는 불가능!
# 시나리오 3: 매우 많은 JOIN이 필요한 경우
# prefetch_related가 유리할 수 있음
# select_related: 복잡한 JOIN으로 느려질 수 있음
books_complex_sr = Book.objects.select_related(
'author__country',
'publisher__country',
# ... 더 많은 관계
).all()
# prefetch_related: 여러 간단한 쿼리로 분산
books_complex_pr = Book.objects.prefetch_related(
'author__country',
'publisher__country',
# ... 더 많은 관계
).all()
4.2.2 데이터 크기에 따른 선택
def data_size_considerations():
"""데이터 크기에 따른 고려사항"""
# Case 1: 작은 데이터셋 (책 100권, 작가 10명)
# select_related 추천: JOIN 오버헤드가 적고 메모리 효율적
small_dataset = Book.objects.select_related('author').all()
# Case 2: 큰 데이터셋 (책 10만권, 작가 1만명)
# 관계에 따라 다름:
# 2-1. 1:1 관계라면 여전히 select_related
large_books_11 = Book.objects.select_related('author').all()
# 2-2. 1:N 관계라면 prefetch_related (데이터 중복 방지)
large_authors_1n = Author.objects.prefetch_related('books').all()
# select_related를 1:N에 사용하면 데이터 중복으로 메모리 낭비
# (각 책 정보가 작가 수만큼 중복됨)
4.3 실제 사용 시나리오별 가이드
4.3.1 블로그 시스템 예시
# 블로그 포스트와 작성자 정보 표시
def blog_post_list():
"""블로그 포스트 목록 - select_related 사용"""
posts = Post.objects.select_related('author', 'category').all()
for post in posts:
print(f"{post.title} by {post.author.name} in {post.category.name}")
# 왜 select_related?
# - 1:1 관계 (Post -> Author, Post -> Category)
# - 모든 포스트에 대해 작성자/카테고리 정보가 필요
# - 단일 쿼리로 효율적 처리 가능
def blog_author_detail():
"""작성자 상세 페이지 - prefetch_related 사용"""
author = Author.objects.prefetch_related(
'posts',
'posts__comments'
).get(id=author_id)
print(f"작성자: {author.name}")
for post in author.posts.all():
print(f" 포스트: {post.title}")
for comment in post.comments.all():
print(f" 댓글: {comment.content}")
# 왜 prefetch_related?
# - 1:N 관계 (Author -> Posts, Post -> Comments)
# - 데이터 중복 방지
# - 중첩된 관계 처리 가능
4.3.2 전자상거래 시스템 예시
def ecommerce_product_list():
"""상품 목록 - select_related 사용"""
products = Product.objects.select_related(
'category',
'brand',
'supplier'
).all()
for product in products:
print(f"{product.name} - {product.category.name} by {product.brand.name}")
# 단일 쿼리로 모든 기본 정보 로드
def ecommerce_order_detail():
"""주문 상세 - 복합 사용"""
order = Order.objects.select_related('customer', 'shipping_address').prefetch_related(
'items',
'items__product',
'items__product__category'
).get(id=order_id)
print(f"주문자: {order.customer.name}")
print(f"배송지: {order.shipping_address.full_address}")
for item in order.items.all():
product = item.product
print(f"상품: {product.name} ({product.category.name}) - 수량: {item.quantity}")
# select_related: 1:1 관계 (Order -> Customer, Order -> Address)
# prefetch_related: 1:N 관계 (Order -> Items -> Products)
4.4 혼합 사용 전략
실제 프로젝트에서는 두 방법을 적절히 조합해야 합니다:
def mixed_strategy_example():
"""select_related와 prefetch_related 혼합 사용"""
# 복잡한 요구사항:
# - 책 목록에서 각 책의 작가, 출판사, 리뷰들을 모두 표시
# - 작가와 출판사의 국가 정보도 필요
books = Book.objects.select_related(
'author', # Book -> Author (1:1, JOIN 효율적)
'author__country', # Author -> Country (1:1, JOIN 효율적)
'publisher', # Book -> Publisher (1:1, JOIN 효율적)
'publisher__country' # Publisher -> Country (1:1, JOIN 효율적)
).prefetch_related(
'reviews', # Book -> Reviews (1:N, JOIN 비효율적)
'tags' # Book -> Tags (M:M, JOIN 불가능)
).all()
for book in books:
# select_related로 가져온 데이터 (추가 쿼리 없음)
print(f"책: {book.title}")
print(f"작가: {book.author.name} ({book.author.country.name})")
print(f"출판사: {book.publisher.name} ({book.publisher.country.name})")
# prefetch_related로 가져온 데이터 (추가 쿼리 없음)
print("리뷰:")
for review in book.reviews.all():
print(f" - {review.rating}점: {review.comment}")
print("태그:")
for tag in book.tags.all():
print(f" - {tag.name}")
생성되는 SQL 분석:
-- 1. select_related 부분: 단일 복합 쿼리
SELECT
book.*, author.*, author_country.*,
publisher.*, publisher_country.*
FROM book
LEFT JOIN author ON book.author_id = author.id
LEFT JOIN country author_country ON author.country_id = author_country.id
LEFT JOIN publisher ON book.publisher_id = publisher.id
LEFT JOIN country publisher_country ON publisher.country_id = publisher_country.id;
-- 2. prefetch_related 부분: 별도 쿼리들
SELECT * FROM review WHERE book_id IN (1,2,3,...);
SELECT tag.*, booktag.book_id
FROM tag
JOIN booktag ON tag.id = booktag.tag_id
WHERE booktag.book_id IN (1,2,3,...);
총 3개의 쿼리로 모든 데이터 획득! (N+1 없이)
4.5 선택 기준 요약
def selection_criteria():
"""선택 기준 요약"""
# select_related를 사용하는 경우:
criteria_for_select_related = [
"ForeignKey 또는 OneToOne 관계 (Forward)",
"1:1 관계에서 거의 항상 관련 데이터가 필요한 경우",
"관련 테이블의 크기가 크지 않은 경우",
"JOIN 연산이 복잡하지 않은 경우 (테이블 3-4개 이하)",
"메모리 사용량을 최소화하고 싶은 경우"
]
# prefetch_related를 사용하는 경우:
criteria_for_prefetch_related = [
"Reverse ForeignKey (1:N 관계의 역참조)",
"ManyToMany 관계",
"1:N 관계에서 N이 큰 경우 (데이터 중복 방지)",
"복잡한 중첩 관계가 있는 경우",
"조건부 필터링이 필요한 경우 (Prefetch 객체 사용)",
"선택적으로 관련 데이터가 필요한 경우"
]
# 혼합 사용하는 경우:
criteria_for_mixed_usage = [
"복잡한 도메인 모델에서 다양한 관계가 섞여 있는 경우",
"성능 최적화가 중요한 API 엔드포인트",
"관계의 성격에 따라 최적의 방법을 각각 적용하고 싶은 경우"
]
🚀 5. 고급 최적화 기법과 실무 팁
이제 기본 사용법을 넘어서 실무에서 마주치는 복잡한 상황들과 고급 최적화 기법들을 알아보겠습니다.
5.1 only()와 defer()를 활용한 필드 레벨 최적화
def field_level_optimization():
"""필드 레벨 최적화 기법"""
# 문제 상황: 대용량 텍스트 필드가 포함된 모델
class Article(models.Model):
title = models.CharField(max_length=200)
summary = models.TextField()
content = models.TextField() # 매우 큰 필드
author = models.ForeignKey(Author, on_delete=models.CASCADE)
category = models.ForeignKey(Category, on_delete=models.CASCADE)
# 나쁜 예: 모든 필드를 가져옴 (content 때문에 느림)
articles_bad = Article.objects.select_related('author', 'category').all()
# 좋은 예: 필요한 필드만 선택
articles_optimized = Article.objects.select_related(
'author', 'category'
).only(
'title', 'summary', # Article 필드
'author__name', # Author 필드
'category__name' # Category 필드
).all()
# 생성되는 SQL
"""
SELECT
article.id, article.title, article.summary,
author.id, author.name,
category.id, category.name
FROM article
LEFT JOIN author ON article.author_id = author.id
LEFT JOIN category ON article.category_id = category.id;
"""
# 메모리와 네트워크 트래픽이 크게 감소함
5.2 Prefetch 객체를 활용한 고급 prefetch_related
from django.db.models import Prefetch, Q
def advanced_prefetch_techniques():
"""고급 Prefetch 기법들"""
# 기법 1: 조건부 prefetch
recent_reviews = Prefetch(
'reviews',
queryset=Review.objects.filter(
created_at__gte=timezone.now() - timedelta(days=30)
).select_related('reviewer'),
to_attr='recent_reviews'
)
books = Book.objects.prefetch_related(recent_reviews).all()
for book in books:
# 최근 30일 리뷰만 캐시됨
for review in book.recent_reviews:
print(f"최근 리뷰: {review.rating}점")
# 기법 2: 정렬된 prefetch
top_reviews = Prefetch(
'reviews',
queryset=Review.objects.filter(rating__gte=4).order_by('-rating', '-created_at'),
to_attr='top_reviews'
)
# 기법 3: 중첩된 조건부 prefetch
authors_with_popular_books = Prefetch(
'books',
queryset=Book.objects.filter(
reviews__rating__avg__gte=4.0
).prefetch_related(
Prefetch(
'reviews',
queryset=Review.objects.filter(rating__gte=4).order_by('-created_at'),
to_attr='good_reviews'
)
),
to_attr='popular_books'
)
authors = Author.objects.prefetch_related(authors_with_popular_books).all()
5.3 쿼리 최적화 디버깅 및 모니터링
5.3.1 쿼리 로깅 및 분석
import logging
from django.db import connection
from django.conf import settings
def query_debugging_tools():
"""쿼리 디버깅 도구들"""
# 1. 개발 환경에서 쿼리 로깅 설정
if settings.DEBUG:
# settings.py에 추가
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
},
},
'loggers': {
'django.db.backends': {
'handlers': ['console'],
'level': 'DEBUG',
},
},
}
# 2. 수동 쿼리 카운트 체크
def count_queries(func):
"""쿼리 개수를 측정하는 데코레이터"""
def wrapper(*args, **kwargs):
initial_count = len(connection.queries)
result = func(*args, **kwargs)
final_count = len(connection.queries)
print(f"{func.__name__} executed {final_count - initial_count} queries")
return result
return wrapper
# 3. 쿼리 실행 시간 측정
@count_queries
def test_optimization():
books = Book.objects.select_related('author').prefetch_related('reviews').all()
for book in books:
author_name = book.author.name
review_count = book.reviews.count()
return books
# 4. 실제 SQL 쿼리 확인
def print_queries():
for query in connection.queries:
print(f"Time: {query['time']}")
print(f"SQL: {query['sql']}")
print("-" * 50)
5.3.2 성능 프로파일링
import cProfile
import time
from django.test.utils import override_settings
def performance_profiling():
"""성능 프로파일링 기법"""
# 1. 기본 성능 측정
def measure_performance(func, *args, **kwargs):
start_time = time.time()
start_queries = len(connection.queries)
result = func(*args, **kwargs)
end_time = time.time()
end_queries = len(connection.queries)
return {
'result': result,
'execution_time': end_time - start_time,
'query_count': end_queries - start_queries,
'queries': connection.queries[start_queries:end_queries]
}
# 2. A/B 테스트 방식 비교
def compare_optimization_strategies():
"""최적화 전략 비교"""
# 전략 A: select_related만 사용
def strategy_a():
return list(Book.objects.select_related('author', 'publisher').all())
# 전략 B: prefetch_related만 사용
def strategy_b():
return list(Book.objects.prefetch_related('author', 'publisher').all())
# 전략 C: 혼합 사용
def strategy_c():
return list(Book.objects.select_related('author').prefetch_related('reviews').all())
results = {}
for name, strategy in [('A', strategy_a), ('B', strategy_b), ('C', strategy_c)]:
results[name] = measure_performance(strategy)
print(f"Strategy {name}: {results[name]['query_count']} queries, "
f"{results[name]['execution_time']:.3f}s")
return results
5.4 대용량 데이터 처리 최적화
5.4.1 청크 단위 처리
def bulk_data_optimization():
"""대용량 데이터 최적화 기법"""
# 1. iterator() 사용으로 메모리 최적화
def process_large_dataset():
"""대용량 데이터셋 처리"""
# 나쁜 예: 모든 데이터를 메모리에 로드
all_books = Book.objects.select_related('author').all()
for book in all_books: # 100만 권이면 OOM 발생 가능
process_book(book)
# 좋은 예: iterator() 사용
for book in Book.objects.select_related('author').iterator(chunk_size=1000):
process_book(book) # 1000개씩 처리하며 메모리 절약
# 2. 페이지네이션과 함께 사용
def paginated_processing():
"""페이지네이션 처리"""
page_size = 1000
offset = 0
while True:
books = Book.objects.select_related('author').prefetch_related('reviews')[
offset:offset + page_size
]
if not books:
break
for book in books:
process_book(book)
offset += page_size
# 3. 배치 처리 최적화
def batch_processing():
"""배치 처리 최적화"""
# ID 기반 청크 처리 (OFFSET 대신 WHERE 사용)
last_id = 0
batch_size = 1000
while True:
books = Book.objects.filter(
id__gt=last_id
).select_related('author').order_by('id')[:batch_size]
if not books:
break
# 배치 단위로 prefetch 수행
book_ids = [book.id for book in books]
reviews = Review.objects.filter(book_id__in=book_ids).select_related('book')
# 메모리에서 관계 매핑
review_map = {}
for review in reviews:
if review.book_id not in review_map:
review_map[review.book_id] = []
review_map[review.book_id].append(review)
# 처리
for book in books:
book._cached_reviews = review_map.get(book.id, [])
process_book(book)
last_id = books[-1].id
5.4.2 캐싱 전략
from django.core.cache import cache
import hashlib
def caching_strategies():
"""캐싱 전략들"""
# 1. 쿼리 결과 캐싱
def cached_expensive_query():
"""비싼 쿼리 결과 캐싱"""
cache_key = "popular_books_with_authors"
cached_result = cache.get(cache_key)
if cached_result is None:
# 복잡한 쿼리 실행
cached_result = list(
Book.objects.select_related('author', 'publisher')
.prefetch_related('reviews')
.filter(reviews__rating__avg__gte=4.0)
.annotate(avg_rating=Avg('reviews__rating'))
.order_by('-avg_rating')[:100]
)
# 1시간 캐싱
cache.set(cache_key, cached_result, 3600)
return cached_result
# 2. 조건부 캐싱
def conditional_caching(author_id, filters=None):
"""조건부 캐싱"""
# 캐시 키 생성 (파라미터 기반)
cache_data = f"author_{author_id}_{filters}"
cache_key = hashlib.md5(cache_data.encode()).hexdigest()
cached_books = cache.get(cache_key)
if cached_books is None:
query = Book.objects.filter(author_id=author_id).select_related('author')
if filters:
if filters.get('min_rating'):
query = query.filter(reviews__rating__avg__gte=filters['min_rating'])
if filters.get('category'):
query = query.filter(category=filters['category'])
cached_books = list(query.prefetch_related('reviews'))
cache.set(cache_key, cached_books, 1800) # 30분 캐싱
return cached_books
# 3. 관계 데이터 개별 캐싱
def relationship_caching():
"""관계 데이터 개별 캐싱"""
def get_author_books(author_id):
cache_key = f"author_books_{author_id}"
books = cache.get(cache_key)
if books is None:
books = list(
Book.objects.filter(author_id=author_id)
.select_related('publisher')
.prefetch_related('reviews')
)
cache.set(cache_key, books, 1800)
return books
# 작가별로 개별 캐싱하여 유연성 확보
authors = Author.objects.all()
for author in authors:
author._cached_books = get_author_books(author.id)
5.5 실무 체크리스트
def production_checklist():
"""실무 체크리스트"""
optimization_checklist = {
"개발 단계": [
"모든 API 엔드포인트에서 N+1 문제 확인",
"django-debug-toolbar로 쿼리 수 모니터링",
"복잡한 관계는 select_related vs prefetch_related 성능 비교",
"only()/defer()로 불필요한 필드 제외 검토"
],
"테스트 단계": [
"실제 데이터 크기로 성능 테스트",
"메모리 사용량 프로파일링",
"동시 접속자 수를 고려한 부하 테스트",
"DB 커넥션 풀 크기 최적화"
],
"배포 단계": [
"쿼리 로깅 레벨 조정 (DEBUG 비활성화)",
"APM 도구로 실시간 쿼리 모니터링 설정",
"Redis 등 캐시 레이어 구성",
"DB 인덱스 최적화 확인"
],
"운영 단계": [
"주기적인 슬로우 쿼리 분석",
"데이터 증가에 따른 성능 변화 모니터링",
"캐시 히트율 및 무효화 전략 최적화",
"쿼리 패턴 변화 추적"
]
}
return optimization_checklist
# 성능 측정 유틸리티
class PerformanceMonitor:
"""성능 모니터링 클래스"""
def __init__(self):
self.query_count = 0
self.start_time = None
def __enter__(self):
self.query_count = len(connection.queries)
self.start_time = time.time()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
end_time = time.time()
end_query_count = len(connection.queries)
print(f"실행 시간: {end_time - self.start_time:.3f}초")
print(f"실행 쿼리: {end_query_count - self.query_count}개")
if end_query_count - self.query_count > 10:
print("⚠️ 쿼리 수가 많습니다. 최적화를 검토해보세요.")
# 사용 예시
def usage_example():
"""사용 예시"""
with PerformanceMonitor():
# 최적화할 코드 작성
books = Book.objects.select_related('author').prefetch_related('reviews').all()
for book in books:
print(f"{book.title} by {book.author.name}")
for review in book.reviews.all():
print(f" 리뷰: {review.rating}점")
📚 결론 및 핵심 요약
Django ORM의 select_related와 prefetch_related는 단순한 성능 최적화 도구를 넘어서, 데이터베이스와 Python 애플리케이션 간의 효율적인 데이터 교환을 위한 핵심 메커니즘입니다.
🎯 핵심 원리 요약
- select_related: SQL JOIN을 통한 단일 쿼리 최적화
- Forward ForeignKey, OneToOne 관계에 적합
- 메모리 효율적이지만 복잡한 JOIN 시 성능 저하 가능
- prefetch_related: Python 레벨 관계 매핑을 통한 다중 쿼리 최적화
- Reverse ForeignKey, ManyToMany 관계에 필수
- 데이터 중복 방지 및 복잡한 중첩 관계 처리 가능
🔧 실무 적용 가이드
- 간단한 관계: select_related 우선 고려
- 복잡한 관계: prefetch_related 또는 혼합 사용
- 대용량 데이터: iterator(), 캐싱, 페이지네이션 병행
- 성능 모니터링: 지속적인 쿼리 분석 및 최적화
이러한 최적화 기법들을 마스터하면 Django 애플리케이션의 성능을 극적으로 향상시킬 수 있으며, 확장 가능한 웹 서비스를 구축하는 데 큰 도움이 될 것입니다.
Django ORM의 깊은 이해를 통해 여러분의 애플리케이션이 더욱 효율적이고 빠르게 동작하길 바랍니다! 🚀
🏷️ 태그
Django ORM select_related prefetch_related N+1문제 성능최적화 SQL 데이터베이스 Python 웹개발