TT Lab
블로그 시작하기
배우기 러닝패스 코스

MongoDB — 문서 데이터베이스의 판단

인덱스 설계 — 순서·커버·부분·TTL

TT Lab 에서 이어서 보기

목표

주문 60,000건 위에서 같은 목록 쿼리를 인덱스 없이·필드 순서대로(ERS)·같음→정렬→범위(ESR)로 재어 숫자로 비교하고, 커버드 쿼리·부분 인덱스·TTL·정규식 접두어를 실행 계획의 숫자로 확인합니다. 마지막에는 화면 셋이 느려진 사건을 가릅니다.

왜 중요한가

인덱스 문제는 대개 「없어서」 가 아니라 「맞지 않아서」 입니다. 순서 하나로 훑는 키가 수천 배 달라지고, 정규식 하나로 IXSCAN 이라 적힌 계획이 키를 전부 훑습니다. 단계 이름만 보면 놓치고, totalKeysExamined·totalDocsExamined·nReturned 세 숫자와 SORT 단계의 유무를 보면 보입니다. 이 실습은 그 숫자를 직접 재게 하고, 채점기도 같은 숫자를 다시 재서 맞춥니다.

단계

  1. 재료 적재와 기준선(인덱스 없음)
  2. 필드 순서대로 만든 인덱스(ERS)
  3. 같음→정렬→범위 인덱스(ESR)와 플래너의 선택
  4. 문서를 열지 않는 쿼리(커버드)
  5. 부분 인덱스
  6. TTL 과 문자열 시각
  7. 정규식 접두어
  8. 사건 — 화면 셋의 쿼리가 느려졌다

접속과 재료

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)

참고

재료를 적재하고 기준선을 잰다

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 없이 필요한 만큼만 훑음 · 원인 코드와 증거 줄