장고 고급 #3 쿼리 최적화 — N+1, select_related, EXPLAIN, 인덱스
중급 #2 ORM 중급에서 다진 쿼리 위에 본격 최적화를 얹습니다. 운영에서 99% 의 성능 문제는 ORM 사용 패턴에서 옵니다. CPU도, 메모리도 아니고 쿼리 수와 형태.
이번 글의 도구상자:
- N+1 진단과 해결 (
select_related,prefetch_related,Prefetch) - 필드 선택 (
only,defer,values,values_list) - 측정 (debug-toolbar,
connection.queries,.explain()) - 인덱스 (
Meta.indexes,db_index, 멀티컬럼) - 대량 작업 (
bulk_create,bulk_update,update_or_create,iterator) - 동시성 (
select_for_update) - 출구 (raw SQL)
N+1 — 가장 흔한 함정 #
재현 #
class Author(models.Model):
name = models.CharField(max_length=100)
class Post(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="posts")posts = Post.objects.all()[:50]
for post in posts:
print(post.title, post.author.name) # ← 여기서 매번 추가 쿼리쿼리 흐름:
SELECT * FROM post LIMIT 50; -- 1번
SELECT * FROM author WHERE id = ?; -- 50번
-- 총 51번50개 게시물 페이지가 51번 쿼리. 게시물이 1000개면 1001번. 이게 N+1.
select_related — 정방향 FK / OneToOne
#
JOIN 한 번으로 해결.
posts = Post.objects.select_related("author").all()[:50]
for post in posts:
print(post.title, post.author.name)쿼리:
SELECT post.*, author.*
FROM post LEFT OUTER JOIN author ON post.author_id = author.id
LIMIT 50;
-- 1번체인도 됩니다.
Comment.objects.select_related("post__author__profile").all()comment.post.author.profile까지 한 쿼리에 따라옵니다. 다만 JOIN이 늘어나면 결과 행 폭이 커지니 3-4 단계 이상은 신중히.
select_related가 통하는 경우:
- ForeignKey (자기가 가진 쪽)
- OneToOneField (양쪽)
통하지 않는 경우:
- 역방향 ForeignKey (
author.posts) - ManyToMany
이 둘은 prefetch_related의 쓰임입니다.
prefetch_related — 역방향 FK / M2M
#
별도 쿼리 + Python 측 조인.
authors = Author.objects.prefetch_related("posts").all()[:20]
for author in authors:
print(author.name, [p.title for p in author.posts.all()])쿼리 흐름:
SELECT * FROM author LIMIT 20; -- 1번
SELECT * FROM post WHERE author_id IN (1, 2, ..., 20); -- 1번
-- 총 2번장고가 첫 쿼리의 ID 들을 모아 IN 절로 두 번째 쿼리를 던지고, Python에서 매핑합니다. 역방향 컬렉션이 N+1 없이 채워집니다.
| select_related | prefetch_related | |
|---|---|---|
| 방식 | SQL JOIN | 별도 쿼리 + 파이썬 매칭 |
| 쿼리 수 | +0 | +1 (per 관계) |
| 어울리는 경우 | 정방향 FK, 1:1 | 역방향 FK, M2M |
| 결과 폭 | 행이 넓어짐 | 그대로 |
체인도 같이.
Comment.objects.select_related("author").prefetch_related("post__tags")Prefetch 객체 — 커스터마이즈
#
prefetch 되는 쿼리에 추가 조건이나 ordering을 주고 싶을 때.
from django.db.models import Prefetch
authors = Author.objects.prefetch_related(
Prefetch(
"posts",
queryset=Post.objects.filter(published=True).order_by("-created_at"),
to_attr="recent_posts", # author.recent_posts로 접근
)
).all()
for author in authors:
for post in author.recent_posts:
...to_attr로 원래 manager와 분리된 속성에 채울 수 있습니다. author.posts.all()은 그대로 모든 글, author.recent_posts는 필터된 글.
필드 선택 — only, defer, values, values_list
#
모델에 컬럼이 30개 있는데 5개만 쓰면 70% 의 데이터가 낭비입니다.
only — 명시한 필드만 즉시 로드
#
posts = Post.objects.only("id", "title").all()
for p in posts:
print(p.title) # OK, 안 가져온 필드 접근 시 추가 쿼리
print(p.body) # ← 추가 쿼리 발생 (deferred 필드)body같이 큰 텍스트 컬럼이 있고 목록 페이지에서 안 쓰면 큰 효과.
defer — 명시한 필드만 미루기
#
posts = Post.objects.defer("body", "raw_html").all()values, values_list — ORM 객체 안 만들기
#
ORM 객체 인스턴스화 비용까지 빼고 dict / tuple로.
# dict의 리스트
Post.objects.values("id", "title")
# [{"id": 1, "title": "..."}, ...]
# tuple의 리스트
Post.objects.values_list("id", "title")
# [(1, "..."), ...]
# 단일 컬럼 평탄화
ids = list(Post.objects.values_list("id", flat=True))
# [1, 2, 3, ...]ID만 모은다거나, JSON 응답으로 그대로 직렬화한다거나, CSV 익스포트 같은 경우에 결정적.
recent_ids = Post.objects.filter(
created_at__gte=cutoff
).values_list("id", flat=True)
Comment.objects.filter(post_id__in=recent_ids).delete()장고는 recent_ids를 자동으로 서브쿼리로 내려서 한 번의 SQL로 합칩니다.
측정 #
측정 없이 최적화 없다. 장고에는 도구가 잘 갖춰져 있습니다.
django-debug-toolbar
#
개발 환경의 표준.
pip install django-debug-toolbarINSTALLED_APPS = [..., "debug_toolbar"]
MIDDLEWARE = [..., "debug_toolbar.middleware.DebugToolbarMiddleware"]
INTERNAL_IPS = ["127.0.0.1"]from django.conf import settings
from django.urls import include, path
urlpatterns = [...]
if settings.DEBUG:
urlpatterns += [path("__debug__/", include("debug_toolbar.urls"))]페이지 우측 패널에 쿼리 수, 각 쿼리, EXPLAIN, 시간이 다 보입니다. 쿼리 수가 페이지 항목 수에 비례하면 N+1의심.
connection.queries
#
코드 안에서 직접 보고 싶을 때.
from django.db import connection, reset_queries
reset_queries()
posts = list(Post.objects.select_related("author")[:50])
for p in posts:
_ = p.author.name
print(len(connection.queries), "queries")
for q in connection.queries:
print(q["time"], q["sql"][:100])DEBUG=True 또는 CONN_MAX_AGE가 활성일 때만 채워집니다. 테스트 코드에서는 assertNumQueries가 더 표준.
from django.test import TestCase
class PostListQueryCountTest(TestCase):
def test_no_n_plus_one(self):
for i in range(10):
author = Author.objects.create(name=f"A{i}")
Post.objects.create(title=f"P{i}", author=author)
with self.assertNumQueries(2): # author + posts
list(self.client.get("/posts/").context["posts"])쿼리 수에 대한 회귀 테스트가 N+1의 재발 방지에 가장 효과적.
.explain()
#
DB가 실제로 쓸 실행 계획을 본다.
qs = Post.objects.filter(published=True, author__name__startswith="K")
print(qs.explain())PostgreSQL 출력 일부:
Hash Join (cost=15.50..120.30 rows=200 width=64)
Hash Cond: (post.author_id = author.id)
-> Seq Scan on post (cost=0.00..80.00 rows=2000 width=40)
Filter: published
-> Hash (cost=12.00..12.00 rows=200 width=24)
-> Seq Scan on author (cost=0.00..12.00 rows=200 width=24)
Filter: ((name)::text ~~ 'K%'::text)Seq Scan이 큰 테이블에 보이면 인덱스 누락 신호. Index Scan이 들어오는지 인덱스 추가 후 다시 explain.
PostgreSQL만의 옵션:
qs.explain(analyze=True, verbose=True, buffers=True)실제로 한 번 실행해서 행 수, 시간, IO까지 보여줍니다. 운영 비슷한 데이터에서 돌려야 의미가 있습니다.
인덱스 #
db_index=True — 단일 컬럼
#
class Post(models.Model):
slug = models.SlugField(max_length=200, unique=True) # unique 면 자동 인덱스
status = models.CharField(max_length=20, db_index=True)
created_at = models.DateTimeField(db_index=True)unique=True, primary_key=True, ForeignKey는 자동으로 인덱스가 붙습니다.
Meta.indexes — 멀티컬럼, 함수, 부분 인덱스
#
from django.db.models import Index, F, Q
class Post(models.Model):
status = models.CharField(max_length=20)
created_at = models.DateTimeField()
author = models.ForeignKey("Author", on_delete=models.CASCADE)
title = models.CharField(max_length=200)
class Meta:
indexes = [
# 멀티컬럼 (status, created_at) — 순서 중요
Index(fields=["status", "-created_at"], name="post_status_recent_idx"),
# 함수 인덱스 (PostgreSQL)
Index(F("title").lower(), name="post_title_lower_idx"),
# 부분 인덱스 (조건부)
Index(
fields=["created_at"],
condition=Q(status="published"),
name="post_published_recent_idx",
),
]룰:
- 멀티컬럼 인덱스의 컬럼 순서는 WHERE 절 순서와 맞춰야 효과
- 인덱스 추가는 쓰기 비용이 늘어남 — 모든 컬럼에 붙이지 말 것
- 부분 인덱스는 자주 조회하는 부분만 — 크기 작고 빠름
인덱스가 안 통하는 경우 #
함수가 컬럼을 감싸면 일반 인덱스는 못 씁니다.
WHERE LOWER(title) = 'hello'해결: 함수 인덱스 (Index(F("title").lower(), ...))를 만들거나, 데이터 측에서 미리 정규화 (저장 시 소문자로).
LIKE 'hello%'는 인덱스 OK, LIKE '%hello'는 인덱스 못 씀 (앞이 와일드). 풀텍스트가 필요하면 PostgreSQL의 SearchVector / GIN 인덱스로.
대량 작업 #
bulk_create — 한 번에 INSERT
#
for data in items:
Post.objects.create(**data)posts = [Post(**data) for data in items]
Post.objects.bulk_create(posts, batch_size=500)수백 배 빠릅니다. 다만 한계:
- 자동 PK가 채워지는 것은 PostgreSQL만 보장 (다른 DB는 None 인 경우 있음)
pre_save/post_save시그널이 호출되지 않음 (#5)- M2M은 별도로 처리
bulk_update — 한 번에 UPDATE
#
posts = list(Post.objects.filter(status="draft"))
for p in posts:
p.status = "published"
Post.objects.bulk_update(posts, ["status"], batch_size=500)업데이트할 필드를 명시. save()가 일으키는 시그널은 안 일어납니다.
update_or_create — 멱등 upsert
#
DailyStat.objects.update_or_create(
date=target,
defaults={"order_count": n, "total_amount": total},
)#2의 통계 커맨드에서 본 그것. 재실행해도 안전.
PostgreSQL의 진짜 INSERT … ON CONFLICT는 5.x부터 bulk_create(..., update_conflicts=True)로 지원:
Post.objects.bulk_create(
posts,
update_conflicts=True,
update_fields=["title", "body"],
unique_fields=["slug"],
)iterator — 메모리 절약
#
for post in Post.objects.all():
process(post)for post in Post.objects.iterator(chunk_size=2000):
process(post)iterator는 결과를 캐시하지 않고 DB 커서로 스트리밍합니다. 큰 테이블 순회의 표준.
동시성 — select_for_update
#
같은 행을 여러 워커가 동시에 갱신하는 경우입니다. 잔액 차감, 재고 감소 등.
def withdraw(account_id, amount):
account = Account.objects.get(pk=account_id)
if account.balance < amount:
raise ValueError("부족")
account.balance -= amount
account.save()두 요청이 동시에 들어오면 둘 다 get 시점에 잔액을 보고 통과한 뒤 둘 다 차감 — 잔액이 음수가 됩니다.
from django.db import transaction
def withdraw(account_id, amount):
with transaction.atomic():
account = Account.objects.select_for_update().get(pk=account_id)
if account.balance < amount:
raise ValueError("부족")
account.balance -= amount
account.save()행 단위 락이 트랜잭션이 끝날 때까지 잡힙니다. 두 번째 요청은 첫 번째가 commit/rollback 할 때까지 대기.
nowait, skip_locked
#
Job.objects.select_for_update(skip_locked=True).filter(status="pending").first()Job 큐 패턴에 자주 등장 — 다른 워커가 잡은 행은 건너뛰고 다음 일감을 잡습니다.
account = Account.objects.select_for_update(nowait=True).get(pk=id)데드락 회피 #
데드락은 두 트랜잭션이 서로의 락을 기다리는 상태. 회피 룰:
- 항상 같은 순서로 락 획득 (예: 항상 작은 ID 먼저)
- 트랜잭션을 짧게 — 외부 API 호출을 트랜잭션 안에 넣지 말 것
- 필요한 행만 락 —
select_for_update().filter(...)를 좁게
Raw SQL — 출구 #
ORM으로 표현하기 너무 복잡하거나 DB 고유 기능이 필요할 때.
posts = Post.objects.raw(
"SELECT * FROM post WHERE to_tsvector('simple', title) @@ to_tsquery('simple', %s)",
["python | django"],
)
for p in posts:
print(p.title)raw는 ORM 객체로 결과를 매핑해줘서, 모델의 메소드/프로퍼티가 살아 있습니다.
더 낮은 레이어:
from django.db import connection
with connection.cursor() as cur:
cur.execute(
"UPDATE post SET status = %s WHERE created_at < %s RETURNING id",
["archived", cutoff],
)
archived_ids = [row[0] for row in cur.fetchall()]ORM와 분리된 경로입니다. SQL 인젝션 방지를 위해 항상 파라미터 바인딩 (%s)을 쓸 것 — 절대 Python f-string으로 SQL을 만들지 마세요.
자주 만나는 함정 #
1) len(qs)와 qs.count()
#
n = len(Post.objects.all())n = Post.objects.count()len은 결과를 다 메모리에 올려서 셉니다. 카운트만 필요하면 count().
다만 이미 결과가 캐시된 큐리셋 이라면 len이 추가 쿼리 없이 동작 — 상황에 따라.
2) exists() vs count() > 0
#
if Post.objects.filter(slug=s).count() > 0: ...if Post.objects.filter(slug=s).exists(): ...exists는 LIMIT 1로 끝납니다.
3) .first()로 NULL 체크
#
post = Post.objects.filter(slug=s).first()
if post is None:
return Http404get + try/except DoesNotExist보다 짧고 의도 명확.
4) M2M 쿼리 #
prefetch_related 없이 for tag in post.tags.all()를 N 개의 post에 돌리면 N+1.
5) count()는 캐시되지 않음
#
같은 트랜잭션에서도 count() 호출은 매번 SQL 입니다. 같은 카운트를 여러 번 쓴다면 변수에 담으세요.
정리 #
이번 글에서 잡은 것:
- N+1 — 정방향은
select_related(JOIN), 역방향/M2M은prefetch_related(별도 쿼리) Prefetch(queryset=..., to_attr=...)로 prefetched 결과 커스터마이즈only,defer로 컬럼 선택,values/values_list(flat=True)로 ORM 인스턴스화 회피- 측정: debug-toolbar,
assertNumQueries,connection.queries,.explain() - 인덱스:
db_index,Meta.indexes(멀티컬럼/함수/부분), 멀티컬럼은 순서 중요 - 대량:
bulk_create,bulk_update,update_or_create,iterator(chunk_size=...) - 동시성:
select_for_update(+ atomic),skip_locked(Job 큐), 데드락 회피 - Raw 출구:
Manager.raw,connection.cursor— 파라미터 바인딩 필수 - 함정:
lenvscount,count > 0vsexists, M2M, count 캐시 없음
다음 글(#4 캐싱)에서는 이 위에 캐시를 얹습니다. per-view, template fragment, low-level, Redis 백엔드, 무효화 전략, stampede까지 — 쿼리를 줄이는 또 다른 한 축을 다룹니다.