
네, 대망의 완결편입니다. 백엔드 성능 최적화의 '끝판왕'이자, 아무리 코드를 잘 짜도 이걸 모르면 말짱 도루묵이 되는 데이터베이스 인덱스(Index)에 대한 이야기입니다. (사실 제 기준 끝판왕이지,, 새로 추가될게 언젠가는 있을 거 같습니다.. )
Django ORM은 db_index=True 옵션 하나로 인덱스를 너무 쉽게 만들 수 있게 해주지만, 그 이면에 숨겨진 작동 원리와 함정까지 알려주지는 않습니다. 쿼리 속도를 0.01초로 만드는 마지막 열쇠를 쥐어봅시다.
인덱스(Index) 설계: "쿼리가 느리다면 답은 정해져 있다"
1. 100만 건 중 1개를 찾는 방법
지난 시리즈들을 통해 우리는 N+1 문제를 해결하고, Bulk 연산으로 대량 처리를 최적화했습니다. 그런데 데이터가 100만 건, 1,000만 건으로 늘어나자 다시 API가 느려지기 시작합니다.
쿼리 로그를 보니 복잡한 JOIN도 없고, 단순한 filter 조회입니다.SELECT * FROM users WHERE email = 'test@example.com'
이 단순한 쿼리가 왜 1초나 걸릴까요? 인덱스가 없다면 데이터베이스는 100만 개의 행을 처음부터 끝까지 하나씩 다 뒤져야(Full Table Scan) 하기 때문입니다. 마치 두꺼운 전공 책에서 '색인(Index)' 없이 특정 단어를 찾으려고 첫 페이지부터 정독하는 것과 같습니다.
오늘은 Django에서 인덱스를 제대로 거는 법, 특히 개발자들이 자주 실수하는 복합 인덱스(Composite Index)의 순서 비밀에 대해 파헤쳐 보겠습니다.
2. 기본 인덱스: db_index=True
가장 기본적인 방법입니다. 특정 컬럼으로 조회를 자주 한다면 인덱스를 걸어줍니다.
class User(models.Model):
# 이메일로 검색할 일이 많다면?
email = models.CharField(max_length=100, db_index=True)
db_index=True를 설정하고 마이그레이션을 하면, DB는 이메일 컬럼을 기준으로 정렬된 별도의 목차(B-Tree)를 만듭니다. 이제 DB는 이진 탐색(Binary Search)을 통해 순식간에 데이터를 찾아낼 수 있습니다.
여기까진 누구나 압니다. 진짜 문제는 조건이 2개 이상일 때 발생합니다.
3. 복합 인덱스(Composite Index)와 함정
쇼핑몰 주문 내역을 조회한다고 가정해 봅시다. 보통 "특정 고객(user)"의 주문 중 최신순(created_at) 으로 조회하는 경우가 많습니다.
# 쿼리 예시
Order.objects.filter(user=my_user).order_by('-created_at')
이 쿼리를 빠르게 하려면 인덱스를 어떻게 걸어야 할까요?
user에만 인덱스를 건다?created_at에만 인덱스를 건다?- 둘 다 각각 건다?
정답은 "둘을 묶어서 하나의 복합 인덱스로 건다"입니다. Django에서는 Meta 클래스에 정의합니다.
class Order(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
status = models.CharField(max_length=10)
class Meta:
indexes = [
# user와 created_at을 묶어서 인덱스 생성
models.Index(fields=['user', 'created_at']),
]
하지만 여기서 실수가 나옵니다. 바로 컬럼의 순서(Order)입니다.
4. Leftmost Prefix Rule (좌측 접두사 규칙)
복합 인덱스 (A, B)를 만들었다고 해서, A로 검색하든 B로 검색하든 다 빨라지는 것이 아닙니다.
데이터베이스는 인덱스를 만들 때 첫 번째 컬럼(A)을 기준으로 먼저 정렬하고, A가 같으면 두 번째 컬럼(B)으로 정렬합니다. 마치 전화번호부와 같습니다.
전화번호부 예시: (성, 이름) 순으로 정렬
('김', '철수')('김', '태희')('이', '민호')('박', '지성')
상황 1: 성이 '김'씨인 사람 찾기 (filter(user=...))
-> 목차 앞부분만 보면 되므로 빠릅니다. (인덱스 탐!)
상황 2: 성이 '김'씨이고 이름이 '태희'인 사람 찾기 (filter(user=..., created_at=...))
-> '김'씨 섹션으로 가서 '태희'를 찾으면 되므로 빠릅니다. (인덱스 탐!)
상황 3: 이름이 '태희'인 사람 찾기 (filter(created_at=...))
-> 성(Last Name)을 모르고 이름(First Name)만 가지고 찾으려면? 전화번호부 전체를 다 뒤져야 합니다. '김'씨 중에도 태희가 있고,
'이'씨 중에도 태희가 있을 수 있으니까요. 느립니다. (인덱스 안 탐! )
결론: 순서가 생명이다
fields=['user', 'created_at']으로 인덱스를 만들었다면
filter(user=A): 빠름filter(user=A, created_at=B): 빠름filter(created_at=B): 느림 (인덱스 무용지물)
따라서 카디널리티(Cardinality, 중복도가 낮은 정도)가 높거나, = (Equal) 조건으로 자주 조회하는 컬럼을 인덱스의 앞쪽에 배치해야 합니다.
5. explain()
"내 쿼리가 진짜 인덱스를 타고 있는지 어떻게 알죠?"
Django QuerySet의 explain() 메서드를 사용하면 DB가 어떤 전략을 짰는지(실행 계획) 훔쳐볼 수 있습니다.
# 인덱스를 잘 타는 경우
print(Order.objects.filter(user_id=1).explain())
# 결과 예시 (PostgreSQL):
# "Index Scan using myapp_order_user_created_at_idx on myapp_order ..."
# -> Index Scan! 성공!
# 인덱스를 못 타는 경우
print(Order.objects.filter(created_at__gte='2024-01-01').explain())
# 결과 예시:
# "Seq Scan on myapp_order ..."
# -> Seq Scan (Sequential Scan) = Full Table Scan = 망함
개발 단계에서 쿼리가 조금이라도 복잡하다면 반드시 explain()을 찍어보는 습관을 들여야 합니다.
6. 인덱스도 공짜는 아니다 (Trade-off)
"그럼 모든 컬럼에 인덱스를 걸면 되지 않나요?"
절대 안 됩니다. 인덱스는 책의 '맨 뒤 색인 페이지'와 같습니다.
- 저장 공간 차지: 인덱스도 디스크 용량을 꽤 많이 차지합니다.
- 쓰기 성능 저하: 데이터를
INSERT,UPDATE,DELETE할 때마다 목차(인덱스)도 같이 수정하고 다시 정렬해야 합니다.
읽기(Read) 속도를 얻는 대신 쓰기(Write) 속도와 저장 공간을 지불하는 것입니다. 따라서 조회는 자주 일어나지만 수정은 빈번하지 않은 컬럼, 그리고 전체 데이터의 5~10% 미만으로 필터링되는 컬럼에 거는 것이 효율적입니다.
시리즈를 마치며: 백엔드 개발자의 성장
지금까지 총 5편에 걸쳐 [실전 Django] 시리즈를 진행했습니다.
- 동시성:
select_for_update로 데이터 덮어쓰기 방지 - 조회 성능:
select_related/prefetch_related로 N+1 문제 해결 - 쓰기 성능:
bulk_create로 대용량 데이터 처리 - 안정성:
transaction.on_commit으로 비동기 작업 타이밍 이슈 해결 - 검색 성능: 복합 인덱스 설계와 실행 계획 분석
이 5가지 기술은 단순히 Django의 기능이 아니라, 어떤 백엔드 프레임워크나 데이터베이스를 사용하더라도 통용되는 서버 개발의 핵심 원리들입니다.
로컬에서 혼자 개발할 때는 절대 알 수 없지만, 사용자가 늘어나고 데이터가 쌓이는 순간 반드시 마주치게 될 문제들이죠. 사실 해도해도 이게 맞나? 싶긴 합니다.. ㅜㅜ
추후 추가될것이 있다면,, 그때 다시 데이터베이스 정복기로 써보겠습니다