인덱스는 순서와 범위로 설계한다
한 줄 요약
인덱스는 「어느 필드에 걸까」 가 아니라 「어떤 순서로, 무엇을 담을까」 의 문제다. 같은 세 필드라도 순서에 따라 훑는 키가 수천 배 달라지고, 담는 범위(부분·TTL)에 따라 크기와 수명이 달라진다. 판단은 실행 계획의 숫자로 한다.
왜 필요했나 — 인덱스가 있는데도 느리다
「인덱스를 걸었는데 왜 느리냐」 는 신고의 대부분은 인덱스가 없는 경우가 아니라 맞지 않는 경우다. 목록 화면의 흔한 쿼리를 보자.
db.orders.find({status: "shipped", amount: {$gte: 150000}}).sort({createdAt: -1}).limit(20)
쿼리에 나온 순서대로 {status, amount, createdAt} 인덱스를 만들면 status 로 좁힌 뒤 amount 범위를 훑는다. 그런데 범위를
훑은 결과는 createdAt 순서가 아니다. 그래서 계획에 SORT 단계가 생기고, 조건에 맞는 수천 건을 전부 메모리에 올려
정렬한 뒤 20건을 자른다. limit 20 은 아무 도움이 안 된다.
어떻게 동작하나 — ESR
복합 인덱스는 앞 필드부터 값으로 정렬된 하나의 목록이다. 그래서 순서 규칙이 나온다.
| 순서 | 필드 | 이유 |
|---|---|---|
| E | 같음(status: "shipped") |
한 구간으로 좁힌다. 그 구간 안은 다음 필드 순서로 정렬돼 있다 |
| S | 정렬(createdAt: -1) |
같음 구간 안에서 이미 정렬된 순서로 걸으면 정렬이 공짜다 |
| R | 범위(amount >= …) |
걸으면서 키에서 바로 거른다. 20건이 차면 멈춘다 |
{status, createdAt, amount} 로 만들면 계획은 LIMIT>FETCH>IXSCAN 이 되고, 훑은 키가 20건을 채울 만큼으로 줄어든다.
범위 필드 뒤에 둔 필드는 탐색이 아니라 거르기에만 쓰인다 — 이것이 ERS 와 ESR 의 차이 전부다.
세 가지가 더 있다.
- 커버드 쿼리: 돌려줄 필드가 전부 인덱스 안에 있으면 문서를 아예 열지 않는다(
totalDocsExamined: 0,PROJECTION_COVERED). 투영에서_id를 빼는 것을 잊으면_id를 가지러 문서를 연다. - 부분 인덱스:
partialFilterExpression에 맞는 문서만 담는다. 미처리 주문 3%만 찾는 화면이면 인덱스가 30분의 1이 된다. 대신 쿼리 조건이 그 식을 포함해야 플래너가 쓸 수 있다. 조건에 status 를 빼면 그 인덱스는 없는 것과 같다. - TTL 인덱스: 날짜 필드에
expireAfterSeconds를 주면 백그라운드 감시자가 60초마다 만료된 문서를 지운다. 날짜 타입만 본다 — 문자열로 적힌 시각은 영영 지워지지 않는다.
계획을 읽는 순서
실행 계획은 나무다. 바깥(winningPlan)이 마지막에 하는 일이고, inputStage 를 따라 내려가면 처음 하는 일이 나온다.
LIMIT>FETCH>IXSCAN 은 「인덱스를 훑어 → 문서를 가져와 → 20건에서 자른다」 이고, SORT>COLLSCAN 은 「전부 훑어 → 메모리에서
정렬한다」 이다. 읽을 때는 세 숫자를 함께 본다.
| 비교 | 뜻 |
|---|---|
| totalKeysExamined ≫ nReturned | 인덱스를 탔지만 구간이 넓다 — 순서나 정규식을 의심한다 |
| totalDocsExamined ≫ nReturned | 문서를 가져와서 거른다 — 거르는 필드가 인덱스에 없다 |
| totalDocsExamined = 0 | 인덱스만으로 답했다(커버드) |
플래너는 후보 계획을 잠깐씩 같이 돌려 보고 먼저 결과를 많이 낸 쪽을 고른 뒤, 그 선택을 쿼리 모양별로 캐시한다. 진 계획은
rejectedPlans 에 남는다. 그래서 인덱스를 새로 만들면 캐시가 비워지고, 같은 쿼리의 계획이 바뀔 수 있다 — 고친 뒤에 다시
재는 이유다. 특정 인덱스의 성적을 따로 재고 싶으면 hint 로 그 인덱스를 강제하고, 인덱스 없이 재려면 {$natural: 1} 을 준다.
현장에서 만나는 모습
- 정규식 검색은
^로 시작하는 접두어일 때만 인덱스 구간을 좁힌다./TN-123/이나 대소문자 무시/^tn/i는 IXSCAN 이라고 적혀도 키를 전부 훑는다 — 단계 이름이 아니라totalKeysExamined를 봐야 하는 이유다. 대소문자 없는 검색은collation(strength 2) 인덱스로 하고, 그 인덱스는 같은 collation 을 준 쿼리만 탈 수 있다. - 인덱스를 지우기 전에는
hideIndex로 숨겨 본다. 숨긴 인덱스는 갱신은 계속되지만 플래너가 보지 않아, 지웠을 때의 영향을 되돌릴 수 있는 채로 잴 수 있다. 반대로 숨긴 것을 잊으면 「인덱스는 목록에 있는데 COLLSCAN」 이라는 사고가 난다. - 인덱스를 고칠 때는 「느려졌다」 가 아니라 숫자로 말한다. 훑은 키·연 문서·돌려준 수, 그리고 SORT 단계의 유무.
다음 실습에서 할 것
주문 60,000건 위에서 같은 목록 쿼리를 인덱스 없이·ERS·ESR 로 재어 숫자를 비교하고, 커버드 쿼리·부분 인덱스·TTL·정규식 접두어를 차례로 잰다. 마지막에는 화면 셋이 느려진 사건을 받아, 같은 증상 뒤의 서로 다른 원인을 가른다.