인덱스 설계 — 순서·커버·부분·TTL
목표
주문 60,000건 위에서 같은 목록 쿼리를 인덱스 없이·필드 순서대로(ERS)·같음→정렬→범위(ESR)로 재어 숫자로 비교하고, 커버드 쿼리·부분 인덱스·TTL·정규식 접두어를 실행 계획의 숫자로 확인합니다. 마지막에는 화면 셋이 느려진 사건을 가릅니다.
왜 중요한가
인덱스 문제는 대개 「없어서」 가 아니라 「맞지 않아서」 입니다. 순서 하나로 훑는 키가 수천 배 달라지고, 정규식 하나로
IXSCAN 이라 적힌 계획이 키를 전부 훑습니다. 단계 이름만 보면 놓치고, totalKeysExamined·totalDocsExamined·nReturned
세 숫자와 SORT 단계의 유무를 보면 보입니다. 이 실습은 그 숫자를 직접 재게 하고, 채점기도 같은 숫자를 다시 재서 맞춥니다.
단계
- 재료 적재와 기준선(인덱스 없음)
- 필드 순서대로 만든 인덱스(ERS)
- 같음→정렬→범위 인덱스(ESR)와 플래너의 선택
- 문서를 열지 않는 쿼리(커버드)
- 부분 인덱스
- TTL 과 문자열 시각
- 정규식 접두어
- 사건 — 화면 셋의 쿼리가 느려졌다
접속과 재료
python3 /opt/lab/fixtures/mg/mgkit.py load-index # idx.orders 60,000건(다시 돌리면 인덱스까지 처음 상태로)
mongosh idx
목록 화면의 쿼리(1–3단계): db.orders.find({status: "shipped", amount: {$gte: 150000}}).sort({createdAt: -1}).limit(20)
참고
- 결과 파일은
/root/work/mgidx/아래에 둡니다(mkdir -p /root/work/mgidx). - 특정 인덱스로 재려면
.hint({<keys>}), 인덱스 없이 재려면.hint({$natural: 1})를 붙입니다. - 흔한 실수: 단계 이름(IXSCAN)만 보고 안심하기, 투영에서
_id를 빼지 않아 커버드가 깨지기. - 사건 도구:
incident list로 낼 수 있는 사건과 한 줄 증상을,incident show <name>으로 티켓을 다시 봅니다. 같은 사건을 다시 내면 새 무작위 재료로 처음 고장 상태가 됩니다(앞서 적은 답은 맞지 않게 됩니다). - 보고서는
/root/incident/<name>/report.txt입니다. 대상마다<target>: <cause-code>한 줄씩, 마지막에evidence: <line>를 붙입니다. 증거는 고치기 전에 본 출력의 한 줄을 그대로(20자 이상) — 채점기는 사건 때 떠 둔 관찰 기록과 통째로 대조합니다. 채점기는 고친 뒤의 실제 상태를 먼저 보고, 보고서는 판단을 확인하는 데 씁니다. - 실습 파드에는 볼륨이 없습니다. 세션이 끝나면
/root아래 작업물이 사라집니다.
재료를 적재하고 기준선을 잰다
python3 /opt/lab/fixtures/mg/mgkit.py load-index 로 idx.orders 에 주문 60,000건을 적재합니다. 목록 화면의 쿼리 db.orders.find({status: "shipped", amount: {$gte: 150000}}).sort({createdAt: -1}).limit(20) 를 idx DB 에서 인덱스 없이 explain('executionStats') 로 읽어 /root/work/mgidx/01-baseline.txt 에 plan:·keysExamined:·docsExamined:·nReturned: 네 줄을 적습니다. plan 은 바깥 단계(winningPlan)부터 inputStage 를 따라 단계 이름을 > 로 이은 것입니다.
방향: 고치기 전에 재야 고친 뒤와 견줄 수 있습니다. 이 쿼리는 status 가 같고 amount 가 150000 이상인 것을 거른 뒤 createdAt 으로 정렬해 20건만 씁니다 — 인덱스가 없으면 무엇을 얼마나 훑는지 봅니다. 도구: explain('executionStats'), queryPlanner.winningPlan 과 그 안의 inputStage, executionStats 의 세 숫자. 구체: 계획 트리는 바깥이 마지막에 하는 일입니다. 정렬 단계가 있다면 그 안쪽에 무엇이 있는지 내려가 보세요.
idx.orders 60,000건 그대로 · 01-baseline.txt 의 네 값이 인덱스 없는 계획과 일치
쿼리에 나온 순서대로 인덱스를 만든다
{status: 1, amount: 1, createdAt: -1} 인덱스를 만들고, 같은 목록 쿼리를 그 인덱스로(hint) 재어 /root/work/mgidx/02-ers.txt 에 같은 네 줄을 적습니다. 기준선과 무엇이 달라졌고 무엇이 그대로인지 보세요.
방향: 인덱스를 탔다고 끝이 아닙니다 — 정렬 단계가 남았는지, 키를 몇 개 훑었는지를 봅니다. 도구: createIndex, cursor.hint({<keys>}), explain('executionStats'). 구체: 범위(amount)로 훑은 키는 createdAt 순서가 아닙니다. 그래서 계획 어딘가에 정렬이 따로 붙습니다 — 그 단계의 위치를 적으세요.
{status: 1, amount: 1, createdAt: -1} 인덱스가 있고 그 인덱스로 잰 02-ers.txt 의 네 값이 일치
같음 → 정렬 → 범위 순서로 만든다
{status: 1, createdAt: -1, amount: 1} 인덱스를 만들고, 같은 목록 쿼리를 그 인덱스로 재어 /root/work/mgidx/03-esr.txt 에 같은 네 줄을 적습니다. 그리고 힌트 없이 물었을 때 플래너가 고른 인덱스 이름을 winner: <indexName> 한 줄로 더합니다.
방향: 같음 구간 안이 이미 createdAt 순서라면 그 순서대로 걸으며 amount 를 거르다 20건에서 멈출 수 있습니다. 도구: createIndex, hint, 힌트 없는 explain() 의 winningPlan 속 indexName, 진 계획은 rejectedPlans. 구체: 2단계와 keysExamined·plan 을 나란히 놓고 보세요. 플래너는 후보 계획을 잠깐씩 돌려 보고 먼저 결과를 낸 쪽을 고릅니다.
{status: 1, createdAt: -1, amount: 1} 인덱스로 잰 03-esr.txt 의 네 값과 winner 가 지금 계획과 일치
문서를 열지 않고 답한다
목록 화면 하나가 status 가 paid 이고 createdAt 이 2026-10-01T00:00:00Z 이후(포함)인 주문의 createdAt·amount 만 보여 줍니다. 이 화면의 쿼리를 /root/work/mgidx/04-covered.json 에 {"filter": …, "projection": …} 로 적되(날짜는 Extended JSON {"$date": "…"}), 문서를 하나도 열지 않고(totalDocsExamined: 0) 답하게 만듭니다. 필요하면 인덱스를 더해도 됩니다.
방향: 돌려줄 필드가 전부 인덱스 키 안에 있으면 문서를 열 이유가 없습니다. 도구: 투영, 3단계의 ESR 인덱스, explain('executionStats') 의 totalDocsExamined 와 PROJECTION_COVERED, 날짜 확인은 EJSON.stringify. 구체: 투영에 적지 않아도 기본으로 따라오는 필드가 하나 있습니다 — 그 필드가 인덱스에 없으면 그것 때문에 문서를 엽니다.
04-covered.json 의 쿼리가 그 화면의 결과와 같고 totalDocsExamined 가 0
필요한 문서만 담는 인덱스
미처리 주문(status: "pending")을 고객별로 찾는 화면을 위해, customerId 에 status 가 pending 인 문서만 담는 부분 인덱스를 만듭니다. /root/work/mgidx/05-partial.txt 에 그 인덱스에 실제로 들어간 키 수 partialKeys: <n>, 고객 3728 의 {customerId: 3728, status: "pending"} 조회 계획 withStatus: <plan>, {customerId: 3728} 조회 계획 withoutStatus: <plan> 세 줄을 적습니다(plan 은 1단계처럼 단계 이름을 > 로 이은 것).
방향: 부분 인덱스는 작고 싸지만, 쿼리 조건이 그 인덱스의 조건을 포함할 때만 쓸 수 있습니다. 도구: createIndex 의 partialFilterExpression, 키 수는 db.orders.validate().keysPerIndex, 계획은 explain(). 구체: status 를 빼고 물으면 플래너는 그 인덱스가 모든 문서를 담지 않았으니 쓸 수 없다고 봅니다 — 두 계획이 어떻게 다른지 적으세요.
customerId 부분 인덱스가 있고 05-partial.txt 의 키 수·두 조회의 계획이 지금과 일치
TTL 로 오래된 세션을 지운다
python3 /opt/lab/fixtures/mg/mgkit.py load-sessions 로 idx.sessions 300건을 적재합니다(다시 돌리면 처음 상태로). 마지막 접속(lastSeen) 뒤 1시간(3600초)이 지난 세션을 데이터베이스가 스스로 지우게 하세요. 옛 앱이 적은 세션 일부는 lastSeen 이 문자열입니다 — 그것도 지워지게 해야 합니다. 살아 있어야 할 세션은 지우지 않고, 시각은 그대로 옮깁니다.
방향: TTL 인덱스는 백그라운드 감시자가 60초마다 날짜 필드를 보고 지웁니다. 날짜가 아닌 값은 그냥 지나칩니다. 도구: createIndex(…, {expireAfterSeconds: …}), $type 으로 문자열 세기, 갱신 파이프라인의 $toDate. 구체: 문자열을 날짜로 바꾼 뒤 1분쯤 기다려 남은 건수를 다시 세 보세요. 손으로 지우면 다음에 만료될 세션은 또 남습니다.
lastSeen TTL 3600초 · 문자열 시각 0건 · 만료 세션은 TTL 이 지우고 살아 있는 세션은 남음
정규식은 접두어일 때만 좁힌다
orders 의 sku 에 인덱스를 만들고, 세 조회 {sku: /^TN-123/}·{sku: /TN-123/}·{sku: /^tn-123/i} 를 재어 /root/work/mgidx/07-prefix.txt 에 anchored:·unanchored:·ignoreCase: 세 줄로 keysExamined=<n> nReturned=<n> 을 적습니다.
방향: 세 조회 모두 계획에는 IXSCAN 이라고 나옵니다. 차이는 단계 이름이 아니라 몇 개의 키를 훑었는가에 있습니다. 도구: createIndex, explain('executionStats') 의 totalKeysExamined·nReturned, indexBounds. 구체: indexBounds 를 함께 보세요 — 앞이 고정된 정규식만 구간이 좁고, 나머지는 처음부터 끝까지입니다.
sku 인덱스를 타고 07-prefix.txt 의 세 조회의 훑은 키·돌려준 수가 지금과 일치
사건 — 화면 셋의 쿼리가 느려졌다
incident mg-slow-endpoints 를 실행하면 사건이 납니다(앞 단계와 무관한 inc DB 를 새로 만듭니다). 화면 ep1·ep2·ep3 의 쿼리(/root/slowapp/queries/<ep>.json)가 셋 다 느린 쿼리 경보를 울립니다. /root/slowapp/slowapp run|explain|indexes <ep> 로 돌려 보며 화면마다 원인을 찾아, 돌려주는 결과는 그대로 두고 통째로 훑기(COLLSCAN)와 메모리 정렬(SORT) 없이 필요한 만큼만 훑게 고치세요. 이메일 화면은 대소문자를 가리지 않고 찾는 화면입니다. 데이터는 지우거나 바꾸지 않습니다. /root/incident/mg-slow-endpoints/report.txt 에 ep1: <code>·ep2: <code>·ep3: <code> 세 줄과 evidence: <line>(고치기 전에 본 slowapp explain·slowapp indexes 출력 한 줄 그대로)을 적습니다. 코드는 화면마다 다음 중 하나입니다: collation-mismatch(쿼리와 인덱스의 collation 이 달라 인덱스를 못 탐), hidden-index(맞는 인덱스가 숨겨져 있음), index-missing(맞는 인덱스가 아예 없음), sort-after-range(인덱스가 범위 뒤에 정렬 필드를 둬 메모리 정렬), unanchored-regex(정규식의 앞이 열려 키를 전부 훑음).
방향: 화면마다 「계획(explain)」 과 「그 컬렉션의 인덱스 목록」 을 나란히 봅니다. 통째로 훑는데 맞는 인덱스가 목록에 있다면, 왜 플래너가 그것을 못 쓰는지가 원인입니다. 인덱스를 탔는데도 키를 많이 훑거나 SORT 가 있다면 인덱스의 모양이나 쿼리의 모양이 원인입니다. 도구: slowapp explain·slowapp indexes, mongosh 의 unhideIndex·createIndex·dropIndex, 쿼리 파일의 collation·$regex. 구체: 고치는 자리가 화면마다 다릅니다 — 인덱스 쪽(되살리기·순서 바꾼 새 인덱스)인지 쿼리 쪽(collation 넣기·정규식 앞 고정)인지 가르세요. 고친 뒤 slowapp explain 으로 keys·docs 가 returned 에 가까워졌는지, slowapp run 의 건수가 그대로인지 봅니다.
세 화면 모두 결과가 같고 COLLSCAN·메모리 SORT 없이 필요한 만큼만 훑음 · 원인 코드와 증거 줄