몽고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 를 넣었는데 안 먹는다"면 이 두 가지가 걸려 있는지부터 봐요.
운영에서 쿼리가 느려졌을 때 확인 순서 6단계
느려진 쿼리를 보고 바로 hint() 부터 넣으면 원인이 가려져요. 아래 순서로 확인하면 대개 3~4단계 안에 원인이 나와요.
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건을 빨리 못 내면 세 번째 상한까지 가요. 그 상한을 컬렉션 크기별로 계산하면 이래요.
1,000만 건 컬렉션이면 후보 하나가 최대 300만 번 일할 수 있어요. 후보가 셋이면 그만큼 겹쳐서 일해요(소스에는 전체 합계의 상한을 정하는 internalQueryPlanTotalEvaluationCollFraction, 기본값 2.0 도 따로 있어요). 이 값을 줄이는 서버 파라미터는 이름에 internal 이 붙은 내부용이라 바꾸지 않는 편이 맞고, 실무에서 줄일 수 있는 건 후보 수예요.
- 쓰이지 않는 인덱스, 다른 인덱스의 앞부분과 겹치는 인덱스(예:
{a:1}과{a:1, b:1})를 정리하면 후보가 줄어요. - 고전 엔진에서 후보가 하나뿐인 쿼리는 경합 없이 바로 실행되고 캐시 항목도 만들지 않아요(플랜 캐시 README). 핵심 쿼리에 딱 맞는 인덱스가 하나뿐이면 캐시 함정 자체가 없어요.
- 맨 위에
$or가 있는 쿼리는 갈래마다 따로 계획하고 캐시해요. 갈래가 많을수록 시험 실행도 갈래 수만큼 늘어요.
플랜 캐시 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% | 40 | 1배 | 그대로 사용 |
| 20% | 100 | 2.5배 | 그대로 사용 |
| 10% | 200 | 5배 | 그대로 사용 |
| 5% | 400 | 10배 | 문턱 (넘어야 재계획) |
| 2% | 1,000 | 25배 | 재계획 |
| 1% | 2,000 | 50배 | 재계획 |
비중 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 쓸 때 주의할 점
인덱스를 고정하는 수단은 데이터가 바뀌어도 따라가지 않아요. 고정하기 전에 아래를 확인해요.
- 인덱스 이름이 바뀌면 깨져요.
hint("shopId_1_createdAt_-1")처럼 이름으로 지정했는데 인덱스를 다시 만들며 이름이 바뀌면, 그 인덱스가 없다는 오류로 쿼리가 실패해요. 키 패턴({shopId: 1, createdAt: -1})으로 지정하는 쪽이 인덱스 재생성에 덜 민감해요. 단, 시계열 컬렉션은 이름으로만 지정할 수 있어요. $text쿼리에는 hint 를 못 써요. 공식 문서의 제한이에요.- 쿼리 설정은 해시 종류가 달라요.
setQuerySettings는queryShapeHash로 모양을 지정하고, 플랜 캐시는planCacheShapeHash를 써요. 이름이 비슷해서 로그에서 잘못 복사하기 쉬워요. 해시 대신 대표 쿼리 문서를 직접 넘기는 방식도 있어요. - 설정은 반드시 기록해 둬요. 쿼리 설정에는
comment를 달 수 있어요. "왜, 언제, 어떤 장애로 걸었는지"를 남기지 않으면 몇 달 뒤 인덱스를 바꿔도 쿼리가 옛 인덱스에 묶여 있는 이유를 아무도 몰라요. 현재 설정은$querySettings집계 단계로 정기적으로 확인해요. - 응급 처치는 그 모양만.
db.orders.getPlanCache().clear()는 컬렉션의 모든 모양을 비워서 다른 쿼리까지 한꺼번에 시험 실행에 들어가요. 문제 모양 하나만 비우려면planCacheClear명령에query·sort·projection을 함께 넘겨요.
// 문제 모양 하나만 비우기 — 상수 값은 아무거나, 모양만 맞으면 돼요
db.runCommand({
planCacheClear: "orders",
query: { shopId: 1, createdAt: { $gte: new Date(0) } },
sort: { createdAt: -1 }
})
ORM 이나 드라이버가 만든 쿼리가 의도와 다른 모양으로 나가는 경우도 많아요. 쿼리 수 자체가 많아서 느린 문제라면 JPA N+1 해결 방법 고르기 글의 쿼리 수 계산 방식도 함께 참고할 만해요.
인덱스 작업·배포 전 체크리스트
플랜 캐시는 메모리에만 있고, 컬렉션 DDL 이 있으면 통째로 비워져요. 플랜 캐시 README는 캐시가 빈 상태에서 같은 모양 쿼리가 한꺼번에 몰리는 상황을 multiplanning storm 이라고 부르고, 멀티키 인덱스 스캔이나 블로킹 정렬이 섞이면 메모리 부족 위험이 크다고 적어요. 인덱스 작업과 배포 전에 아래를 확인해요.
- [ ] 대상 컬렉션의 초당 쿼리 수가 가장 낮은 시간대에 인덱스를 만들고 지우나요?
- [ ] 레플리카셋 재시작은 한 노드씩 하고, 재시작한 노드가 캐시를 다시 채울 시간을 두나요?
- [ ] 새 인덱스를 만든 뒤 핵심 쿼리 모양마다
$planCacheStats로 의도한 인덱스가 활성 항목에 올라왔는지 보나요? - [ ] 같은 앞부분을 가진 중복 인덱스를 정리해 후보 수를 줄였나요?
- [ ] 값 분포가 한쪽으로 크게 치우친 필드(상태값, 대형 고객 ID 등)가 조건에 있는 쿼리에 복합 인덱스가 있나요?
- [ ] 걸어 둔
hint()·쿼리 설정·인덱스 필터 목록과 이유가 문서로 남아 있나요? - [ ] 느린 쿼리 로그 기준(
slowms)과 로그 수집이 켜져 있어planCacheShapeHash를 찾을 수 있나요?
캐시 항목 수와 크기에도 한도가 있어요. 소스 기본값으로 컬렉션당 항목은 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 결과가 아니라 플랜 캐시의 계획으로 돌아요. 느린 쿼리 로그의 해시 →
$planCacheStats→ 값별 explain 비교 → 값 분포 → 직전 DDL → 후보 인덱스 순으로 확인해요. - 캐시 계획은 기록 works 의 10배를 넘어야 버려져요. 예시 계산에서는 비중 5~50% 점포가 최대 10배 느린 계획을 계속 썼고, ESR 복합 인덱스는 비중과 무관하게 약 21 works 였어요.
- 시험 실행 상한은
max(10000, 0.3 × 문서 수)라 큰 컬렉션일수록 비싸요. 실무에서 줄일 수 있는 건 중복 인덱스 정리로 줄이는 후보 수예요. - 대처는 인덱스 재설계가 먼저이고, 장애 중엔 그 모양만 캐시 비우기, 고정이 필요하면
hint()나 8.0 이상의setQuerySettings를 쓰되 이유를 기록해요.
explain 결과 두 개를 비교할 때는 JSON 비교 도구에 붙여 넣으면 인덱스 이름과 읽은 키 수 차이를 경로별로 볼 수 있어요.
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 몽고DB 쿼리 플래너·플랜 캐시 원리 — 후보 경합과 재계획에서 도식과 코드로 단계별로 풀었어요.