광고
~ / posts / developer
💻 개발자

몽고DB 인덱스 잘못 탈 때 대처법, hint·쿼리 설정·플랜 캐시 판단표

몽고DB 쿼리가 평소엔 몇 밀리초인데 특정 고객이나 특정 값으로만 들어오면 수 초씩 걸리고, explain() 을 찍어 보면 멀쩡한 인덱스를 타고 있는 경우가 있어요. 결론부터 말하면 운영 쿼리는 explain 이 보여 준 계획이 아니라 플랜 캐시에 들어 있는 계획으로 돌아요. 그리고 그 캐시 계획은 기대보다 10배 느려지기 전까지는 바뀌지 않아요. 그래서 대처는 "캐시를 비운다"보다 "어느 값이 와도 무난한 인덱스를 만든다"가 먼저고, 당장 급할 때만 캐시 비우기·hint()·쿼리 설정을 골라 써요.

이 글은 플래너가 후보 계획을 경합시켜 고르는 원리는 안다고 보고, 운영에서 고를 때 필요한 것만 다뤄요. 대처 수단을 언제 무엇으로 고를지 판단표, 느려졌을 때 확인하는 순서, 시험 실행 예산과 10배 규칙을 숫자로 계산한 결과, 인덱스 작업 전에 따질 점이에요. 기본값은 mongodb/mongo 저장소의 query_optimization_knobs.idl(2026-10-29 master 기준)에서 직접 확인했어요.

광고

몽고DB가 엉뚱한 인덱스를 탈 때 대처 수단 판단표

먼저 쓸 수 있는 수단을 한 표에 놓고 비교해 볼게요. 핵심 기준은 "얼마나 오래 가나"와 "데이터가 바뀌면 따라가나" 두 가지예요.

수단효과가 가는 범위지속 기간데이터 분포가 바뀌면언제 고르나
ESR 복합 인덱스 새로 만들기그 컬렉션의 해당 쿼리 전부영구플래너가 계속 다시 판단어느 값에도 좋은 후보가 없을 때 (대부분)
planCacheClear (모양 하나만)그 쿼리 모양다음 시험 실행까지다음 첫 상수가 다시 정함장애 중 응급 처치
hint()코드에서 지정한 쿼리 한 곳코드가 바뀔 때까지따라가지 않음앱을 바로 배포할 수 있고 정답 인덱스가 분명할 때
setQuerySettings (8.0+)쿼리 모양 하나, 클러스터 전체재시작 뒤에도 유지따라가지 않음코드 배포 없이 인덱스를 고정해야 할 때
인덱스 필터 (planCacheSetFilter)쿼리 모양 하나, 노드 하나프로세스 종료까지따라가지 않음8.0 부터 사용 중단 예고, 새로 쓰지 않음
내부 파라미터 조정서버 전체설정한 동안모든 쿼리에 영향권하지 않음

setQuerySettings 공식 문서는 8.0 부터 인덱스 필터를 사용 중단(deprecated)으로 두고 쿼리 설정을 쓰라고 안내해요. 인덱스 필터는 재시작하면 사라지고 노드마다 따로 걸어야 하지만, 쿼리 설정은 클러스터에 저장돼 재시작 뒤에도 남아요. 쿼리 설정에는 allowedIndexes 말고도 reject: true 로 특정 모양 쿼리를 아예 거절하는 옵션이 있어서, 폭주하는 쿼리 하나를 급히 막을 때도 쓸 수 있어요.

우선순위도 알아 두면 헷갈리지 않아요. cursor.hint() 공식 문서에 따르면 인덱스 필터가 걸린 모양에서는 hint() 가 무시되고, 일치하는 쿼리 설정이 있으면 명령에 실려 온 힌트도 무시돼요. "hint 를 넣었는데 안 먹는다"면 이 두 가지가 걸려 있는지부터 봐요.

인덱스 후보가 없으면 재설계, 장애 중이면 planCacheClear, 앱 배포가 가능하면 hint, 8.0 이상이면 setQuerySettings 로 이어지는 대처 수단 순서도
좋은 후보 인덱스가 있는지가 첫 갈림길이에요

운영에서 쿼리가 느려졌을 때 확인 순서 6단계

느려진 쿼리를 보고 바로 hint() 부터 넣으면 원인이 가려져요. 아래 순서로 확인하면 대개 3~4단계 안에 원인이 나와요.

느린 쿼리 로그 확인, planCacheStats 확인, 상수별 explain 비교, 값 분포 확인, 직전 DDL·재시작 확인, ESR 후보 인덱스 확인의 6단계 확인 순서
앞 단계에서 원인이 보이면 거기서 멈춰요

1단계 — 느린 쿼리 로그. 느린 쿼리 로그와 프로파일러에는 planSummary(탄 인덱스)와 planCacheShapeHash, planCacheKey 가 함께 찍혀요. 같은 planCacheShapeHash 인데 어떤 건 빠르고 어떤 건 느리면 캐시 함정일 가능성이 커요. 8.0 이전 버전에서는 같은 값이 queryHash 라는 이름으로 나와요(Query Plans 공식 문서).

2단계 — 캐시에 실제로 든 계획. 해시로 걸러서 그 항목만 봐요.

// mongosh — 1단계에서 얻은 해시로 캐시 항목 하나만 보기
db.orders.aggregate([
  { $planCacheStats: {} },
  { $match: { planCacheShapeHash: "로그에서_본_해시" } },
  { $project: { isActive: 1, works: 1, "cachedPlan.inputStage.indexName": 1, timeOfCreation: 1 } }
])

isActive: false 면 아직 캐시를 안 쓰고 매번 시험 실행 중이라는 뜻이에요. 이때 느리다면 캐시보다 시험 실행 자체가 비싼 쪽을 의심해요. isActive: true 이고 works 가 아주 작다면, 운이 좋은 값으로 이긴 계획이 자리를 잡고 있을 가능성이 커요.

3단계 — 느린 값과 빠른 값으로 각각 explain. explain 은 캐시를 무시하고 매번 새로 고르니, "지금 이 값으로 새로 고르면 무엇을 고르나"를 보여 줘요. 두 값의 explain("executionStats") 결과를 JSON 비교 도구에 나란히 넣으면 winningPlan 의 인덱스 이름과 totalKeysExamined 차이가 바로 보여요. 출력이 한 줄로 길게 나오면 먼저 JSON 정렬 도구로 펼쳐 두면 편해요.

4단계 — 값 분포. $sortByCount 로 조건 필드의 상위 값을 봐요. 상위 한두 값이 전체의 절반을 넘는 필드가 조건에 있으면, 다음 절의 "10배 안쪽 구간"에 걸리는 쿼리가 생기기 쉬워요.

db.orders.aggregate([{ $sortByCount: "$shopId" }, { $limit: 5 }])

5단계 — 직전 DDL·재시작. 인덱스 생성·삭제·숨김, 컬렉션 삭제, mongod 재시작은 그 컬렉션의 캐시를 비워요. 비워진 직후 처음 들어온 값이 다시 계획을 정하니, 느려진 시각과 배포·인덱스 작업 시각을 맞춰 봐요.

6단계 — 후보 인덱스 자체. 3단계 explain 에서 어느 값으로 돌려도 키를 수천 개 넘게 읽는다면 플래너 문제가 아니라 인덱스 문제예요. 이때는 판단표 첫 줄, 인덱스 재설계로 가요.

시험 실행 비용 계산 — works 상한은 컬렉션 크기에 비례

캐시가 없는 모양이 들어오면 후보마다 work() 를 번갈아 호출해 겨루게 해요. 고전 런타임 플래너 README에 적힌 종료 조건은 후보 하나가 101건을 내거나, 끝까지 실행되거나, works 가 max(10000, 0.3 × 컬렉션 문서 수) 에 닿는 것 중 먼저 오는 쪽이에요.

앞의 두 조건이 빨리 오면 비용이 작지만, 조건에 맞는 문서가 드물어서 어느 후보도 101건을 빨리 못 내면 세 번째 상한까지 가요. 그 상한을 컬렉션 크기별로 계산하면 이래요.

컬렉션 크기별 후보 하나당 시험 실행 works 상한
0501001502002503001만 건 · works 상한 1만 works11만 건10만 건 · works 상한 3만 works310만 건100만 건 · works 상한 30만 works30100만 건1000만 건 · works 상한 300만 works3001000만 건

3만 3천 건까지는 1만이 하한으로 걸리고, 그 위로는 문서 수에 정비례해요

단위: 만 works · 자료: max(10000, 0.3 × 문서 수) 공식으로 직접 계산 (기본값: mongodb/mongo master, 2026-10-29)
표로 보기
구분works 상한
1만 건1
10만 건3
100만 건30
1000만 건300

1,000만 건 컬렉션이면 후보 하나가 최대 300만 번 일할 수 있어요. 후보가 셋이면 그만큼 겹쳐서 일해요(소스에는 전체 합계의 상한을 정하는 internalQueryPlanTotalEvaluationCollFraction, 기본값 2.0 도 따로 있어요). 이 값을 줄이는 서버 파라미터는 이름에 internal 이 붙은 내부용이라 바꾸지 않는 편이 맞고, 실무에서 줄일 수 있는 건 후보 수예요.

플랜 캐시 10배 규칙 계산 — 버려지지 않고 남는 구간

캐시된 계획은 첫 배치를 얻는 데 든 works 가 기록값의 10배(internalQueryCacheEvictionRatio, 기본 10.0)를 넘을 때만 비활성화되고 다시 계획돼요. 반대로 말하면 2~9배 느린 계획은 아무 경고 없이 계속 쓰여요. 이 구간이 얼마나 넓은지 예시로 계산해 볼게요.

예시 조건은 이래요. 주문 100만 건이 여러 점포에 섞여 시간순으로 쌓여 있고, 인덱스는 {shopId: 1} 과 {createdAt: -1} 두 개예요. 쿼리는 "한 점포의 최근 주문 20건"이에요.

db.orders.find({ shopId: s, createdAt: { $gte: since } })
  .sort({ createdAt: -1 }).limit(20)

최근 주문의 절반을 차지하는 큰 점포(비중 50%)로 처음 계획되면 {createdAt: -1} 계획이 이겨요. 시간 역순으로 읽다가 두 건에 한 건꼴로 맞으니 20건을 얻는 데 키를 약 40개 읽어요. 그래서 기록 works 는 약 40, 재계획 문턱은 그 10배인 400 이에요. 여기서는 work 한 번에 인덱스 키 하나를 읽는다고 보고 어림했어요.

같은 계획을 비중이 p 인 점포에 쓰면 20건을 얻는 데 약 20 ÷ p 번 일해요. 비중별로 계산하면 이래요.

점포 비중캐시 계획 works (20 ÷ p)기록 대비결과
50%401배그대로 사용
20%1002.5배그대로 사용
10%2005배그대로 사용
5%40010배문턱 (넘어야 재계획)
2%1,00025배재계획
1%2,00050배재계획
점포 비중별 캐시 계획과 ESR 복합 인덱스의 works 비교
캐시된 createdAt 계획shopId+createdAt 복합 인덱스
05001,0001,5002,00050% · 캐시된 createdAt 계획 40works50% · shopId+createdAt 복합 인덱스 21works50%20% · 캐시된 createdAt 계획 100works20% · shopId+createdAt 복합 인덱스 21works20%10% · 캐시된 createdAt 계획 200works10% · shopId+createdAt 복합 인덱스 21works10%5% · 캐시된 createdAt 계획 400works5% · shopId+createdAt 복합 인덱스 21works5%2% · 캐시된 createdAt 계획 1,000works2% · shopId+createdAt 복합 인덱스 21works2%1% · 캐시된 createdAt 계획 2,000works1% · shopId+createdAt 복합 인덱스 21works1%

비중 5~50% 점포는 최대 10배 느린 계획을 계속 쓰고, 복합 인덱스는 비중과 무관하게 약 21번이에요

단위: works · 자료: 20건을 얻는 데 필요한 인덱스 키 수를 20 ÷ 비중 으로 어림한 예시 계산 (재계획 문턱 = 기록 40 × 10)
표로 보기
구분캐시된 createdAt 계획shopId+createdAt 복합 인덱스
50%4021
20%10021
10%20021
5%40021
2%1,00021
1%2,00021

비중 2% 이하 점포는 재계획이 걸려 새 계획이 만들어지는데, 그 뒤 다시 큰 점포가 들어오면 또 다른 계획이 이길 수 있어요. 큰 점포와 작은 점포 쿼리가 번갈아 들어오는 서비스에서는 이 때문에 계획이 뒤집히고 시험 실행이 반복될 수 있어요. 반면 {shopId: 1, createdAt: -1} 복합 인덱스가 있으면 어느 점포든 키를 약 20개만 읽고 끝나서, 어떤 값으로 캐시가 만들어져도 손해가 없어요. 판단표에서 인덱스 재설계를 맨 위에 둔 이유가 이 계산이에요.

새 캐시 항목이 처음부터 쓰이지 않는 점도 알아 두세요. Query Plans 공식 문서에 따르면 첫 승자는 비활성 상태로 들어가고, 같은 모양이 다시 와서 works 가 기록 이하일 때 활성이 돼요. 그래서 캐시를 비운 직후 몇 번은 매번 시험 실행 비용을 내요.

ESR 복합 인덱스로 근본 해결하기

어느 값에도 좋은 후보를 만드는 기준은 ESR 가이드라인 공식 문서의 키 순서예요. 일치(Equality) 필드 → 정렬(Sort) 필드 → 범위(Range) 필드 순으로 놓아요. 위 예시에 대입하면 shopId(일치) 다음 createdAt(정렬 겸 범위)이라 {shopId: 1, createdAt: -1} 이 돼요.

실무에서 판단이 갈리는 조건만 모으면 이래요.

쿼리 조건인덱스에서 둘 자리주의할 점
field: 값, $eq맨 앞 (일치)일치 필드끼리 순서는 자유
$ne, $nin, $regex뒤쪽 (범위)일치처럼 보여도 범위로 취급
$in 원소가 적고 정렬도 있음앞쪽 (일치처럼)원소 201개 미만이면 SORT_MERGE 로 병합
$in 원소가 많음 (201개 이상)범위 자리뒤 필드로 정렬 못 해요, 경계값은 버전마다 바뀔 수 있음
범위 조건이 아주 좁음정렬 앞 (ERS)정렬할 문서가 적으면 메모리 정렬이 싸요

$in 의 201개 기준은 공식 문서가 "버전마다 같다고 보장하지 않는다"고 적어 둔 값이에요. 화면에서 고르는 필터 개수가 대개 몇 개 수준이라면 앞쪽에, 사용자 목록 수천 개를 넣는 식이면 범위 자리에 두는 쪽으로 판단해요.

인덱스를 새로 만들 때는 쓸모가 없어진 옛 인덱스를 함께 정리해요. 옛 인덱스가 남아 있으면 후보 수가 늘어 시험 실행이 길어지고, 엉뚱한 승자가 나올 여지도 남아요. 지우기 전에 인덱스를 숨겨(hideIndex) 두고 느린 쿼리가 늘지 않는지 며칠 지켜본 뒤 지우는 방법이 안전해요. 기본키·인덱스 설계에서 값의 순서가 쓰기 성능까지 바꾸는 예는 UUID와 자동증가 기본키 비교 글에서 다뤘어요.

광고
광고

hint와 setQuerySettings 쓸 때 주의할 점

인덱스를 고정하는 수단은 데이터가 바뀌어도 따라가지 않아요. 고정하기 전에 아래를 확인해요.

// 문제 모양 하나만 비우기 — 상수 값은 아무거나, 모양만 맞으면 돼요
db.runCommand({
  planCacheClear: "orders",
  query: { shopId: 1, createdAt: { $gte: new Date(0) } },
  sort: { createdAt: -1 }
})

ORM 이나 드라이버가 만든 쿼리가 의도와 다른 모양으로 나가는 경우도 많아요. 쿼리 수 자체가 많아서 느린 문제라면 JPA N+1 해결 방법 고르기 글의 쿼리 수 계산 방식도 함께 참고할 만해요.

인덱스 작업·배포 전 체크리스트

플랜 캐시는 메모리에만 있고, 컬렉션 DDL 이 있으면 통째로 비워져요. 플랜 캐시 README는 캐시가 빈 상태에서 같은 모양 쿼리가 한꺼번에 몰리는 상황을 multiplanning storm 이라고 부르고, 멀티키 인덱스 스캔이나 블로킹 정렬이 섞이면 메모리 부족 위험이 크다고 적어요. 인덱스 작업과 배포 전에 아래를 확인해요.

캐시 항목 수와 크기에도 한도가 있어요. 소스 기본값으로 컬렉션당 항목은 5,000개(internalQueryCacheMaxEntriesPerCollection)이고, 넘으면 가장 오래 안 쓴 항목부터 밀려나요. 공식 문서에 따르면 5.0 부터 모든 컬렉션의 캐시 합계가 0.5GB 를 넘으면 새 항목은 cachedPlan·createdFromQuery 같은 진단 정보 없이 저장돼요. 쿼리 모양이 수천 가지로 흩어지는 서비스라면 2단계에서 cachedPlan 이 비어 보일 수 있다는 뜻이에요. 쿼리를 문자열로 조립해 필드 조합이 매번 달라지는 코드가 있다면 모양 수를 줄이는 것도 점검 항목이에요.

광고
광고

자주 묻는 질문

explain 에서는 좋은 인덱스를 타는데 운영에서만 느려요. 왜 그런가요?

explain 은 플랜 캐시를 무시하고 매번 새로 계획하고, 결과를 캐시에 남기지도 않아요. 운영 쿼리는 예전에 다른 상수로 이긴 캐시 계획을 쓰니 둘이 다를 수 있어요. $planCacheStats 의 cachedPlan 이나 느린 쿼리 로그의 planSummary 로 실제 계획을 확인해요.

플랜 캐시를 비우면 해결되나요?

당장은 빨라질 수 있지만 다음에 처음 들어오는 값이 다시 계획을 정해요. 그 값이 또 치우친 값이면 같은 문제가 돌아와요. 비우기는 응급 처치로만 쓰고, 재발하면 복합 인덱스나 쿼리 설정으로 넘어가요.

hint 와 setQuerySettings 중 무엇을 쓰나요?

앱 코드를 바로 배포할 수 있고 정답 인덱스가 하나로 분명하면 hint() 가 단순해요. 배포 없이 운영에서 바로 고정해야 하거나 여러 서비스가 같은 쿼리를 보낸다면 8.0 이상에서 setQuerySettings 가 맞아요. 둘 다 데이터 분포가 바뀌어도 따라가지 않으니 이유를 기록해 두고 정기적으로 다시 봐요.

인덱스를 하나 만들었더니 다른 쿼리까지 잠깐 느려졌어요.

인덱스 생성은 그 컬렉션의 플랜 캐시를 전부 비워요. 직후에 들어온 모든 모양이 시험 실행을 다시 하고, 새 항목은 비활성으로 시작해 한 번 더 확인돼야 쓰여요. 트래픽이 적은 시간에 작업하면 이 구간의 영향이 작아요.

내부 파라미터(internalQuery…)를 바꿔서 10배 문턱을 낮추면 안 되나요?

이 파라미터들은 서버 전체 쿼리에 영향을 주고 이름부터 내부용이라, 문턱을 낮추면 재계획이 잦아져 시험 실행 비용이 늘어요. 특정 쿼리 하나의 문제라면 그 모양에만 걸리는 인덱스 재설계나 쿼리 설정이 범위가 좁고 되돌리기 쉬워요.

정리

explain 결과 두 개를 비교할 때는 JSON 비교 도구에 붙여 넣으면 인덱스 이름과 읽은 키 수 차이를 경로별로 볼 수 있어요.

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 몽고DB 쿼리 플래너·플랜 캐시 원리 — 후보 경합과 재계획에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#몽고DB 인덱스 안 탐#MongoDB 플랜 캐시 초기화#MongoDB hint 사용법#setQuerySettings#planCacheStats#몽고DB 느린 쿼리 확인#ESR 복합 인덱스#MongoDB explain 차이
← 이전 글FlashAttention 버전 고르기, GPU별 FA2·FA3·FA4와 학습 비용다음 글 →타입스크립트 타입 좁히기 고르는 법, 타입 가드 판단표와 안 될 때 점검
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →