
1편에서 다루었던 동시성 이슈(Race Condition)가 데이터의 '정확성'을 지키기 위한 방패였다면, 이번 2편은 데이터의 '속도'를 책임지는 창에 대한 이야기입니다.
Django를 사용하면서 가장 흔하게 겪는 성능 문제인 N+1 문제를 심도 있게 파헤쳐 보겠습니다.
단순히 "쿼리를 줄인다"는 개념을 넘어, Django ORM이 내부적으로 어떻게 SQL을 생성하는지 그 원리를 이해하는 것이 핵심입니다.
"기능은 똑같은데 왜 쿼리가 100방이나 나가죠?" (N+1 문제 완벽 해결)
1. 로컬에선 빨랐는데 배포하니까 느려요
"분명 로컬 개발 환경에서는 페이지가 0.1초 만에 떴거든요? 근데 운영 서버에 데이터 1,000개 정도 넣으니까 목록 조회하는 데 3초가 걸려요."
백엔드 개발자로 일하면서 가장 많이 듣는 성능 이슈 중 하나입니다. 코드를 열어보면 비즈니스 로직은 완벽합니다. 복잡한 연산도 없습니다. 그저 DB에서 데이터를 가져와서 뿌려주는 것뿐입니다.
하지만 Django ORM의 지연 로딩(Lazy Loading) 특성을 이해하지 못하고 코드를 짰다면, 당신의 서버는 지금 이 순간에도 데이터베이스에 수백, 수천 번의 노크를 하고 있을지도 모릅니다.
오늘은 N+1 문제가 무엇인지, 그리고 select_related와 prefetch_related를 언제, 어떻게 사용해야 하는지 실제 코드를 통해 알아보겠습니다.
2. N+1 문제란 무엇인가?
상황을 가정해 봅시다. 블로그 서비스를 만들고 있습니다. 하나의 Article(게시글)은 한 명의 User(작성자)를 가지고, 여러 개의 Comment(댓글)를 가질 수 있습니다.
모델 정의 (Models.py)
from django.db import models
class User(models.Model):
name = models.CharField(max_length=100)
class Article(models.Model):
title = models.CharField(max_length=100)
# User와 1:N 관계 (Forward 참조)
author = models.ForeignKey(User, on_delete=models.CASCADE)
class Comment(models.Model):
content = models.TextField()
# Article과 1:N 관계 (Reverse 참조)
article = models.ForeignKey(Article, on_delete=models.CASCADE, related_name='comments')
재앙의 시작: 아무 생각 없이 조회하기
이제 게시글 목록 API를 만듭니다. 요구사항은 "게시글 제목과 작성자 이름, 그리고 달린 댓글들을 보여달라"는 것입니다.
# views.py (나쁜 예시)
def get_articles():
# 1. 모든 게시글을 가져온다. (쿼리 1방)
articles = Article.objects.all()
results = []
for article in articles:
results.append({
"title": article.title,
# 2. 작성자 이름을 가져온다. (문제 발생 지점 A)
"author": article.author.name,
# 3. 댓글 목록을 가져온다. (문제 발생 지점 B)
"comments": [c.content for c in article.comments.all()]
})
return results
이 코드를 실행하면 어떤 일이 벌어질까요? 만약 게시글이 100개라면
Article.objects.all(): 게시글 전체 조회 (1번)article.author.name: 각 게시글마다 작성자를 찾기 위해 User 테이블 조회 (100번)article.comments.all(): 각 게시글마다 댓글을 찾기 위해 Comment 테이블 조회 (100번)
총 201번(1 + N + N)의 쿼리가 발생합니다. 게시글이 1,000개라면 2,001번이 되겠죠. 이것이 바로 N+1 문제입니다.
(1번의 조회로 N개의 데이터를 가져왔는데, 그 N개를 처리하기 위해 N번의 쿼리가 추가로 발생하는 현상)
3. 해결책 1: select_related (JOIN의 마법)
첫 번째 해결책은 select_related입니다. 이것은 SQL의 JOIN 기능을 사용합니다.
언제 쓰는가?
- 정방향 참조 (Forward):
ForeignKey,OneToOneField - 즉, "이 데이터 하나에 연결된 저 데이터 하나"를 가져올 때 사용합니다. (1:1 또는 N:1)
코드 적용
# views.py (개선된 예시 1)
def get_articles_optimized_step1():
# select_related를 사용하여 author 정보를 JOIN 해서 한 번에 가져옵니다.
articles = Article.objects.select_related('author').all()
results = []
for article in articles:
results.append({
"title": article.title,
# 이미 메모리에 author 객체가 로딩되어 있으므로 DB 조회가 발생하지 않음!
"author": article.author.name,
})
return results
동작 원리
Django는 다음과 같은 SQL을 생성합니다.
SELECT article.id, article.title, user.id, user.name
FROM article
INNER JOIN user ON article.author_id = user.id;
단 1번의 쿼리로 게시글과 작성자 정보를 모두 가져와 파이썬 메모리에 올려둡니다. for문을 돌 때 DB를 다시 찾을 필요가 없습니다.
4. 해결책 2: prefetch_related (Python의 조립)
두 번째 해결책은 prefetch_related입니다. select_related가 JOIN을 쓴다면, 이 녀석은 "쿼리를 두 번 날려서 파이썬 안에서 짝을 맞추는" 방식을 씁니다.
언제 쓰는가?
- 역방향 참조 (Reverse):
ForeignKey의 역참조 (article.comments.all()) - 다대다 관계 (Many-to-Many):
ManyToManyField - 즉, "이 데이터 하나에 연결된 여러 데이터들"을 가져올 때 사용합니다. (1:N 또는 N:M)
SQL JOIN으로 1:N 관계를 가져오면 데이터 뻥튀기(Cartesian Product)가 발생하여 비효율적이기 때문에, Django는 별도의 전략을 취하는 것입니다.
코드 적용
# views.py (개선된 예시 2 - 댓글까지 포함)
def get_articles_optimized_complete():
# select_related와 prefetch_related를 체이닝하여 사용
articles = Article.objects.select_related('author').prefetch_related('comments').all()
results = []
for article in articles:
results.append({
"title": article.title,
"author": article.author.name, # DB 조회 X
"comments": [c.content for c in article.comments.all()] # DB 조회 X
})
return results
동작 원리
Django는 내부적으로 총 2번의 쿼리를 실행합니다.
- Main Query: 모든 게시글을 가져옵니다.
SELECT * FROM article ...; - Prefetch Query: 가져온 게시글들의 ID를 모아(
IN연산자), 관련된 댓글을 한 번에 가져옵니다.SELECT * FROM comment WHERE article_id IN (1, 2, 3, ... 100); - Python Level: Django가 메모리 상에서 Article ID와 Comment의
article_id를 매칭시켜서article.comments캐시에 집어넣습니다.
결과적으로 게시글이 1,000개든 10,000개든, 쿼리는 딱 2번(또는 select_related 포함 1번 + 1번)만 나갑니다.
5. 심화: Prefetch 객체로 필터링하기
많은 분들이 prefetch_related를 쓸 때 겪는 딜레마가 있습니다.
"댓글을 미리 가져오긴 하는데, '삭제된 댓글'은 빼고 가져오고 싶어요."
그냥 prefetch_related('comments')를 하면 모든 댓글을 가져오고, for문 안에서 article.comments.filter(is_deleted=False)를 하면 또다시 쿼리가 발생합니다. (Prefetch 해놓은 데이터를 무시하고 DB에 다시 질의하기 때문입니다.) 이때 사용하는 것이 Prefetch 객체입니다.
from django.db.models import Prefetch
def get_articles_with_active_comments():
# 1. 미리 가져올 쿼리셋(QuerySet)을 정의합니다.
active_comments = Comment.objects.filter(is_deleted=False)
articles = Article.objects.prefetch_related(
# 2. Prefetch 객체를 통해 '어떤 조건으로 가져올지' 명시합니다.
Prefetch('comments', queryset=active_comments, to_attr='active_comments_list')
)
results = []
for article in articles:
# 3. to_attr로 지정한 이름으로 접근하면 추가 쿼리 없이 필터링된 데이터를 씁니다.
for comment in article.active_comments_list:
print(comment.content)
이 기술을 사용하면 복잡한 조건이 걸린 연관 관계 데이터도 N+1 문제 없이 깔끔하게 가져올 수 있습니다.
6. 마치며: ORM을 믿되, 검증하라
Django ORM은 개발자에게 엄청난 생산성을 선물해주지만, 그 대가로 SQL 제어권을 가져갔습니다. 우리가 무심코 적은 점(. ) 하나가 서버를 멈추게 할 수도 있다는 사실을 항상 인지해야 합니다.
오늘의 요약
- N+1 문제: 반복문 안에서 연관 관계 모델을 접근하면 쿼리가 폭발한다.
select_related: 1:1, N:1 관계 (ForeignKey) -> JOIN으로 해결.prefetch_related: 1:N, N:M 관계 (Reverse, M2M) -> 추가 쿼리 + 메모리 병합으로 해결.Prefetch객체: 연관된 데이터를 가져올 때 **조건(filter)**을 걸고 싶을 때 사용.
데이터의 무결성(1편)과 조회 성능(2편)을 잡았다면, 이제는 "대량의 데이터를 쓰고(Write) 수정(Update)하는 성능"을 챙길 차례입니다.
다음 [[실전 Django -database 정복기 3부] 대용량 데이터 처리: "for문 돌리지 말고 Bulk를 믿으세요"] 에서는, 10만 건의 데이터를 저장하는 데 걸리는 시간을 30초에서 0.5초로 단축시키는 bulk_create와 bulk_update의 활용법에 대해 다루겠습니다.
긴 글 읽어주셔서 감사합니다!