MongoDB 인덱스 설계 실전: explain() 분석, 복합 인덱스 ESR 규칙, Partial·Sparse 인덱스 선택 기준

인덱스가 없으면 느리고, 잘못 만들면 더 느리다
MongoDB를 프로덕션에서 처음 운영해보는 팀은 대개 비슷한 경험을 합니다. 로컬에서는 순식간에 응답하던 쿼리가 실 데이터가 수백만 건을 넘어서면서 갑자기 수 초씩 걸리기 시작하고, "인덱스를 추가하면 되겠지"라는 생각에 db.collection.createIndex()를 몇 개 추가하지만 기대한 만큼 빨라지지 않습니다.
우리 팀이 e커머스 주문 플랫폼에서 일 평균 600만 건의 주문 도큐먼트를 처리하던 시절, 정산 배치가 매일 밤 orders 컬렉션을 15분 이상 점유하는 문제를 마주했습니다. explain() 출력을 처음 제대로 읽었을 때 원인이 단번에 드러났습니다. 배치 쿼리가 복합 인덱스를 타고 있었지만 ESR 규칙을 무시한 필드 순서 때문에 인덱스의 Range 단계에서 전체 인덱스를 스캔하고 있었습니다.
이 글은 MongoDB 인덱스를 설계하고 진단하고 운영하는 과정을 실무 관점에서 순서대로 다룹니다. PostgreSQL의 EXPLAIN ANALYZE와 비교하며 읽고 싶다면 PostgreSQL explain analyze 실전 튜닝 가이드를 함께 보면 두 엔진의 접근 방식 차이가 선명해집니다.
1. MongoDB 인덱스 내부 구조: B-tree와 doc storage
MongoDB의 모든 인덱스 구조는 B-tree(정확히는 B+-tree)를 기반으로 합니다. MongoDB 공식 인덱스 문서에서는 기본 인덱스 타입을 "B-tree data structure"로 명시하고 있습니다.
B+-tree에서 리프 노드는 실제 인덱스 키와 함께 도큐먼트의 위치 정보인 RecordId를 보관합니다. 쿼리가 인덱스를 통해 도큐먼트를 찾으면, 먼저 B+-tree를 탐색해 키에 맞는 RecordId를 수집하고, 그 다음 실제 도큐먼트 저장소(WiredTiger 데이터 파일)에서 해당 RecordId를 따라 도큐먼트를 읽어옵니다. 이 두 번의 조회 단계를 IXSCAN(인덱스 탐색) + FETCH(도큐먼트 패치)라고 부릅니다.
B+-tree의 탐색 시간 복잡도는 O(log n)입니다. 컬렉션에 도큐먼트가 100만 건이라면 최대 약 20번의 노드 이동으로 키를 찾을 수 있습니다. 반면 인덱스가 없으면 O(n)의 컬렉션 전체 스캔(COLLSCAN)이 발생합니다.
WiredTiger 스토리지 엔진에서 인덱스 파일과 도큐먼트 파일은 물리적으로 분리된 파일에 저장됩니다. 쓰기 작업이 발생할 때마다 변경된 도큐먼트와 관련된 모든 인덱스 파일이 동시에 업데이트됩니다. 이것이 인덱스가 많을수록 쓰기 성능이 저하되는 이유입니다.
2. explain() 출력 읽는 법: IXSCAN, COLLSCAN, FETCH
MongoDB의 explain() 메서드는 쿼리 플래너가 어떤 실행 계획을 선택했는지를 보여줍니다. explain() 공식 출력 형식 문서에 따르면 가장 중요한 세 개의 stage는 COLLSCAN, IXSCAN, FETCH입니다.
db.orders.find({ status: "pending", createdAt: { $gte: ISODate("2026-01-01") } })
.explain("executionStats")
executionStats 모드를 쓰면 실제 실행 결과까지 포함됩니다.
{
"executionStats": {
"nReturned": 1243,
"totalKeysExamined": 98765,
"totalDocsExamined": 98765,
"executionTimeMillis": 312,
"executionStages": {
"stage": "FETCH",
"nReturned": 1243,
"docsExamined": 98765,
"inputStage": {
"stage": "IXSCAN",
"nReturned": 98765,
"keyPattern": { "status": 1 },
"indexName": "status_1",
"keysExamined": 98765
}
}
}
}
위 출력은 전형적인 "인덱스는 타고 있지만 선택도(selectivity)가 나쁜" 케이스입니다. nReturned가 1,243임에도 totalKeysExamined가 98,765로 약 80배나 많은 키를 검사했습니다.
진단 시 체크해야 할 지표는 세 가지입니다. 첫째, nReturned 대비 totalKeysExamined 비율이 10배 이상이면 인덱스 설계를 재검토해야 합니다. 둘째, stage가 COLLSCAN이면 해당 쿼리에 적합한 인덱스가 전혀 없는 상태입니다. 셋째, IXSCAN 아래에 FETCH stage가 존재하는 것 자체는 정상이지만 docsExamined가 nReturned를 크게 초과한다면 Covered Query 적용을 고려할 시점입니다.
3. 단일 인덱스 vs 복합 인덱스의 trade-off
| 구분 | 단일 인덱스 | 복합 인덱스 |
|---|---|---|
| 쿼리 적용 범위 | 해당 필드 하나에만 최적 | 필드 조합 전체 커버 가능 |
| 인덱스 크기 | 작음 | 포함 필드 수만큼 커짐 |
| 선택도 개선 | 필드 단위로 한계 | 필드 조합으로 선택도 높일 수 있음 |
| 쓰기 비용 | 개별 인덱스 수만큼 분산 | 하나의 트리로 집중 |
| 커버링 쿼리 가능성 | 낮음 | 프로젝션 필드 포함 시 가능 |
MongoDB 쿼리 플래너는 조건이 두 단일 인덱스에 나뉘어 있을 때 "인덱스 교차(index intersection)"를 시도할 수 있습니다. 두 인덱스의 결과 집합을 메모리에서 교차 계산하는 방식인데, 이 과정의 정렬과 비교 비용이 적절한 복합 인덱스보다 거의 항상 느립니다.
4. ESR 규칙: Equality·Sort·Range 순서로 정렬
MongoDB 공식 문서는 ESR(Equality, Sort, Range) 규칙을 통해 복합 인덱스 필드 순서의 황금 원칙을 제시합니다.
- E(Equality): 동등 조건 필드를 가장 앞에
- S(Sort): 정렬 필드를 그 다음에
- R(Range): 범위 조건 필드를 마지막에
// 잘못된 순서: Range를 Sort 앞에 둔 경우
db.orders.createIndex({ status: 1, amount: 1, completedAt: -1 })
// ESR 규칙 적용: Equality → Sort → Range
db.orders.createIndex({ status: 1, completedAt: -1, amount: 1 })
db.orders
.find({ status: "completed", amount: { $gte: 500000 } })
.sort({ completedAt: -1 })
.explain("executionStats")
ESR 순서로 만든 인덱스에서는 explain() 출력의 executionStages에 별도의 SORT stage가 나타나지 않습니다. MongoDB에서 in-memory sort는 기본적으로 100MB 제한이 있어, 대용량 결과셋에서는 에러를 일으킬 수 있습니다.
5. Partial 인덱스로 인덱스 크기 줄이기
Partial 인덱스는 컬렉션의 도큐먼트 중 특정 필터 조건을 만족하는 도큐먼트만 인덱싱합니다.
db.orders.createIndex(
{ createdAt: -1, userId: 1 },
{
partialFilterExpression: { status: { $eq: "pending" } },
name: "idx_pending_orders_createdAt_userId"
}
)
이 인덱스는 status: "pending" 조건이 쿼리에 포함될 때만 사용됩니다. 쿼리 플래너가 Partial 인덱스를 활용하려면 쿼리의 필터 조건이 partialFilterExpression을 포함(superset)해야 합니다.
Partial 인덱스의 주요 이점은 크기입니다. 예를 들어 e커머스 서비스에서 주문 컬렉션의 도큐먼트 중 status: "pending" 상태는 전체의 2~5%에 불과합니다. Partial 인덱스를 쓰면 인덱스 크기를 실질적으로 95% 가까이 줄일 수 있어 메모리 효율이 크게 좋아집니다.
6. Sparse 인덱스와 null 처리
Sparse 인덱스는 해당 필드가 존재하지 않거나 null인 도큐먼트를 인덱스에서 제외합니다.
db.users.createIndex(
{ referralCode: 1 },
{ sparse: true, name: "idx_users_referralCode_sparse" }
)
Sparse 인덱스에서 주의해야 할 함정이 있습니다. 쿼리에서 { referralCode: { $exists: false } } 처럼 필드 부재를 조건으로 걸면, 인덱스에 해당 도큐먼트가 없으므로 Sparse 인덱스를 사용할 수 없어 COLLSCAN이 발생합니다.
2026년 기준 MongoDB 5.0 이후부터는 Sparse 인덱스 대신 Partial 인덱스를 사용하는 것이 공식적으로 권장됩니다.
7. 텍스트·지리공간 인덱스의 사용 시점
db.products.createIndex(
{ name: "text", description: "text", tags: "text" },
{
weights: { name: 10, tags: 5, description: 1 },
default_language: "none",
name: "idx_products_text_search"
}
)
db.products.find(
{ $text: { $search: "무선 이어폰 노이즈 캔슬링" } },
{ score: { $meta: "textScore" } }
).sort({ score: { $meta: "textScore" } })
Text 인덱스는 MongoDB Atlas Search(Lucene 기반)나 Elasticsearch가 지원하는 퍼지 검색, 동의어, 복잡한 관련성 스코어링에는 미치지 못합니다. 검색 기능이 서비스의 핵심이라면 Atlas Search를, 간단한 키워드 필터 수준이라면 Text 인덱스를 선택하는 것이 현실적인 기준입니다.
8. Covered Query 설계로 FETCH 단계 제거
Covered Query(커버링 쿼리)는 쿼리 실행에 필요한 모든 데이터가 인덱스 자체에 포함되어 있어, 실제 도큐먼트를 읽지 않아도 되는 최적화 상태입니다.
db.orders.createIndex(
{ userId: 1, createdAt: -1, status: 1, amount: 1 },
{ name: "idx_orders_covered_user_history" }
)
// 이 쿼리는 FETCH 없이 IXSCAN만으로 완결
db.orders.find(
{ userId: "usr_98765" },
{ _id: 0, status: 1, amount: 1, createdAt: 1 }
).sort({ createdAt: -1 })
explain() 출력에서 Covered Query가 성립하면 최상위 stage가 IXSCAN이고, totalDocsExamined가 0으로 나타납니다. 이것이 Covered Query 성립의 확인 신호입니다.
내부 인덱스 설계 분류 가이드에서 쿼리 패턴별 인덱스 전략을 비교해볼 수 있습니다.
9. 불필요한 인덱스 탐지·제거 도구
$indexStats 집계 파이프라인은 각 인덱스가 마지막으로 사용된 시점과 누적 사용 횟수를 보여줍니다.
db.orders.aggregate([
{ $indexStats: {} },
{
$project: {
name: 1,
key: 1,
"accesses.ops": 1,
"accesses.since": 1
}
},
{ $sort: { "accesses.ops": 1 } }
])
인덱스를 삭제하기 전에는 hideIndex() 메서드를 먼저 활용합니다.
db.orders.hideIndex("idx_orders_status_single")
// 며칠 뒤 영향 없음 확인 후 최종 삭제
db.orders.dropIndex("idx_orders_status_single")
10. 운영 중 인덱스 추가: rollingBuild와 ESR 변경
프로덕션 컬렉션에서 인덱스를 추가하는 작업은 신중해야 합니다. MongoDB 4.2부터는 기본적으로 인덱스 빌드 중에도 읽기·쓰기가 허용됩니다. 그러나 빌드 완료 시점에 잠금(lock)이 발생하며, 대용량 컬렉션에서는 이 완료 잠금이 수백 밀리초에서 수 초간 지속될 수 있습니다.
레플리카셋 환경에서 더 안전한 방법은 롤링 인덱스 빌드(Rolling Index Build)입니다. Primary에서 즉시 빌드하는 대신, 각 Secondary를 레플리카셋에서 제거 → 인덱스 빌드 → 다시 합류하는 순서로 돌아가며 인덱스를 추가합니다.
ESR 규칙 위반 인덱스를 올바른 순서로 교체할 때도 같은 접근이 필요합니다. 새 인덱스를 먼저 빌드하고, explain() 으로 쿼리 플래너가 새 인덱스를 선택하는지 확인한 뒤, 이전 인덱스를 제거하는 순서로 진행합니다.
결론
MongoDB 인덱스 설계는 한 번 잘 만들어두고 잊는 작업이 아닙니다.
- 신규 쿼리 배포 전 반드시
explain("executionStats")를 실행하고nReturned대비totalKeysExamined비율을 확인한다. - 복합 인덱스 필드 순서는 항상 ESR 원칙을 적용한다.
- 상태 필드처럼 카디널리티가 낮고 특정 상태만 실시간 조회 대상이 되는 경우 Partial 인덱스를 우선 고려한다.
- 읽기가 집중되는 hot path API에 대해 Covered Query 가능 여부를 검토한다.
$indexStats집계를 2주 이상 주기로 확인해accesses.ops가 극히 낮거나 0인 인덱스를 찾는다.