쿼리가 뭔지, 인덱스는 뭔지, ... Query Explain Plan 에 대해 내 코드로 실험하면서 공부해봤다.
현재 뜯어보는 코드는 실시간 채팅 서비스이고, DB에는 주로 사용자들이 나눈 메시지를 저장한다.
메시지의 종류는 총 3가지로
{
$or: [
{ targetUsername: null }, // 공개 메시지
{ targetUsername: requestUser }, // 나에게 온 귓속말
{ username: requestUser } // 내가 보낸 귓속말
]
}
이렇게 존재한다. 그리고 인덱스는
chatSchema.index({ username: 1, createdAt: 1 });
chatSchema.index({ targetUsername: 1, createdAt: 1 });
이렇게 두 개를 보유하고 있다.
메시지 탐색을 할 때 살펴볼 필드는 targetUsername, username 2개이니까 그냥 이렇게 하면 되겠다고 아주 간단히 생각했다.

힝
이 글을 쓰게 된 것은 그 생각이 1000% 틀렸기 때문이다.
Query
: DB에게 원하는 데이터를 요청하는 표현
const chats = await Chat.find(query).sort({ createdAt: 1 }).lean();
현재 내 코드에서 쿼리를 사용하는 부분이다.
find() 로 문서를 찾고, 그 뒤에 .explan("executionStats") 를 붙이면 쿼리를 MongoDB가 어떤 방식으로 실행했는지 확인 가능하다.
- 데이터 : 저장된 문서들
- 쿼리 : 어떤 문서를 원하는지 적는 조건
- 결과 : 조건에 맞아서 반환된 문서
- 쿼리 플랜 : MongoDB가 그 결과를 찾기 위해 택한 실행 방법
Analyze Query Plan
: 그 쿼리를 DB가 효율적으로 처리했는지를 보자
인덱스가 없다면?
데이터는 저장되어 있으나, 특정 필드 값을 빠르게 찾기 위해 별도로 정리된 구조가 없다.
따라서 DB는 하나하나 직접 확인해야 한다.
COLLSCAN(Collection Scan) : 컬렉션 전체를 훑는 방식, 인덱스가 없으니까 처음부터 끝까지 다 본다.
인덱스가 있으면?
책 전체를 다 읽지 않고도 필요한 페이지로 바로 갈 수 있다.
IXSCAN(Index Scan) : 문서 전체를 보는게 아니라, 인덱스부터 보고 필요한 것을 본다.
*인덱스가 있다고 모두 좋은 것은 아니다.
인덱스도 별도 자료구조 이기 때문에 저장 공간도 필요하고, 쓰기 성능에는 부담이 될 수도 있다.
단계
쿼리 작성 > explain 실행 방식 확인 > 필요한 인덱스 설계
1. 쿼리 작성
const appDb = db.getSiblingDB("db");
appDb.chats.getIndexes()
appDb.chats.find({
$or: [
{ targetUsername: null },
{ targetUsername: "sim" },
{ username: "sim" },
],
}).explain("executionStats")
"sim" 이라는 유저의 메시지를 불러오기 위해 전체 메시지와, 보내고 받은 귓속말만 불러온다.
2. explan 실행 방식 확인
{
explainVersion: '1',
queryPlanner: {
namespace: 'db.chats',
indexFilterSet: false,
parsedQuery: {
'$or': [
{
username: {
'$eq': 'sim'
}
},
{
targetUsername: {
'$in': [
null,
'sim'
]
}
}
]
},
queryHash: '2A675C92',
planCacheKey: 'B4561185',
optimizationTimeMillis: 0,
maxIndexedOrSolutionsReached: false,
maxIndexedAndSolutionsReached: false,
maxScansToExplodeReached: false,
보면, username = 'sim' 인 결과와 targetUsername = ( null or 'sim' ) 인 결과를 OR(합쳐) 반환하는 쿼리라는걸 알 수 있다.
winningPlan: {
stage: 'SUBPLAN',
inputStage: {
stage: 'FETCH',
inputStage: {
stage: 'OR',
inputStages: [
{
stage: 'IXSCAN',
keyPattern: {
username: 1,
createdAt: 1
},
indexName: 'username_1_createdAt_1',
isMultiKey: false,
multiKeyPaths: {
username: [],
createdAt: []
},
isUnique: false,
isSparse: false,
isPartial: false,
indexVersion: 2,
direction: 'forward',
indexBounds: {
username: [
'["sim", "sim"]'
],
createdAt: [
'[MinKey, MaxKey]'
]
}
},
{
stage: 'FETCH',
filter: {
'$or': [
{
targetUsername: {
'$in': [
null,
'sim'
]
}
}
]
},
inputStage: {
stage: 'IXSCAN',
keyPattern: {
targetUsername: 1,
createdAt: 1
},
indexName: 'targetUsername_1_createdAt_1',
isMultiKey: false,
multiKeyPaths: {
targetUsername: [],
createdAt: []
},
isUnique: false,
isSparse: false,
isPartial: false,
indexVersion: 2,
direction: 'forward',
indexBounds: {
targetUsername: [
'[undefined, undefined]',
'[null, null]',
'["sim", "sim"]'
],
createdAt: [
'[MinKey, MaxKey]'
]
}
}
}
]
}
}
},
rejectedPlans: []
},
또한, 이 쿼리는 COLLSCAN(전체 탐색) 이 아니라, $or 각 절을 인덱스로 처리 중이다.
{ username: "sim" } 은 username_1_createdAt_1 인덱스를 사용하고,
{ targetUsername: null , targetusername : "sim" } 은 targetUsername_1_createdAt_1 인덱스를 사용한다.
+ 만약 A만 인덱스로 찾을 수 있고, B는 인덱스가 없으면 문서 전체를 훑는 COLLSCAN 쪽으로 간다.
여기서 문제가 하나 발견되는데, 바로 이부분이다.

{ targetUsername: null } 은, null 값뿐만 아니라 missing field도 같이 매칭하기 때문에 undefined와 null이 함께 잡힌 것이다.
1. targetUsername: null 인 코드와 targetUsername 필드를 뺀 경우가 같은 쿼리에 걸리게 되면 공개 메시지와 필드 누락/비정상 데이터를 구분할 수 없게 된다는 문제점이 있다.
2. 매칭 범위가 넓어져서 인덱스를 쓰더라도 실제는 더 많은 후보를 스캔하게 만들 수 있다. 이는 성능 문제로 이어질 수도 있다.
3. Covered Query는 인덱스만 보고 끝내서 문서를 안 읽는 가장 이상적인 형태인데, null 비교가 들어가면 그 최적화를 못 받는다.
>> 따라서 명시적 필드 (ex. scope: "public" | "whisper") 를 두는 방향으로 변경이 필요했다. [변경 포인트 1]
executionStats: {
executionSuccess: true,
nReturned: 118,
executionTimeMillis: 0,
totalKeysExamined: 128,
totalDocsExamined: 228,
최종적인 결과는 다음과 같다.
- nReturned: 쿼리 조건과 일치하여 실제로 반환된 문서의 총 개수
- totalKeysExamined: 인덱스에서 확인한 키 개수 (COLLSCAN 진행 시 이 값은 0)
- totalDocsExamined: 실제로 확인한 문서 개수
executionStages: {
stage: 'SUBPLAN',
nReturned: 118,
executionTimeMillisEstimate: 1,
works: 130,
advanced: 118,
needTime: 11,
needYield: 0,
saveState: 0,
restoreState: 0,
isEOF: 1,
inputStage: {
stage: 'FETCH',
nReturned: 118,
executionTimeMillisEstimate: 1,
works: 130,
advanced: 118,
needTime: 11,
needYield: 0,
saveState: 0,
restoreState: 0,
isEOF: 1,
docsExamined: 118,
alreadyHasObj: 101,
inputStage: {
stage: 'OR',
nReturned: 118,
executionTimeMillisEstimate: 1,
works: 130,
advanced: 118,
needTime: 11,
needYield: 0,
saveState: 0,
restoreState: 0,
isEOF: 1,
dupsTested: 127,
dupsDropped: 9, //중복 9개
inputStages: [
{
stage: 'IXSCAN',
nReturned: 17, //username - 17개
executionTimeMillisEstimate: 1,
works: 18,
advanced: 17,
needTime: 0,
needYield: 0,
saveState: 0,
restoreState: 0,
isEOF: 1,
keyPattern: {
username: 1,
createdAt: 1
},
indexName: 'username_1_createdAt_1',
isMultiKey: false,
multiKeyPaths: {
username: [],
createdAt: []
},
isUnique: false,
isSparse: false,
isPartial: false,
indexVersion: 2,
direction: 'forward',
indexBounds: {
username: [
'["sim", "sim"]'
],
createdAt: [
'[MinKey, MaxKey]'
]
},
keysExamined: 17,
seeks: 1,
dupsTested: 0,
dupsDropped: 0
},
{
stage: 'FETCH',
filter: {
'$or': [
{
targetUsername: {
'$in': [
null,
'sim'
]
}
}
]
},
nReturned: 110, //targetUsername - 110개
executionTimeMillisEstimate: 0,
works: 112,
advanced: 110,
needTime: 1,
needYield: 0,
saveState: 0,
restoreState: 0,
isEOF: 1,
docsExamined: 110,
alreadyHasObj: 0,
inputStage: {
stage: 'IXSCAN',
nReturned: 110,
executionTimeMillisEstimate: 0,
works: 112,
advanced: 110,
needTime: 1,
needYield: 0,
saveState: 0,
restoreState: 0,
isEOF: 1,
keyPattern: {
targetUsername: 1,
createdAt: 1
},
indexName: 'targetUsername_1_createdAt_1',
isMultiKey: false,
multiKeyPaths: {
targetUsername: [],
createdAt: []
},
isUnique: false,
isSparse: false,
isPartial: false,
indexVersion: 2,
direction: 'forward',
indexBounds: {
targetUsername: [
'[undefined, undefined]',
'[null, null]',
'["sim", "sim"]'
],
createdAt: [
'[MinKey, MaxKey]'
]
},
keysExamined: 111,
seeks: 2,
dupsTested: 0,
dupsDropped: 0
}
}
]
}
}
}
},
...
}
username 에서는 17개 반환, targetUsername 에서는 110개가 반환된다.
그리고 중복이 9개 라서 110 + 17 - 9 = 127 - 9 = 118 의 최종 nReturned 값이 나오게 된 것이다.
여기서 또 문제가 발견되는데, 거의 모든 결과는 targetUsername 브랜치 하나에서 발생한다.
이 조건으로는 대부분의 문서가 걸리는 것이다.
>> 그래서 인덱스가 부족해서라기보다는, 전체 메시지가 너무 큰 비중이라 쿼리 선택도가 낮다는 문제점이 존재했다. [변경 포인트 2]
3. 필요한 인덱스 설계
일단 메시지에 대한 스키마 구조 변경을 먼저 진행한 후, 인덱스 설계를 했다. [변경 포인트 3]
개선 방안
[변경 포인트 1] 메시지 스키마 구조 변경
직접 변경하기 전에, 5만건의 메시지를 담은 컬렉션을 만들어서 결과를 확인해봤다.
구현한 서비스의 채팅방은 1개 뿐이다. 그 방에 모든 유저들이 참여 가능하고 따로 이야기를 나누고 싶다면 귓속말 기능을 사용해야 한다.
단순한 나의 생각으로, 프라이빗한 대화를 나누고자 하는 유저라면 굳이 이 채팅을 사용하지 않을 것 같다.
따라서 대부분의 메시지는 public 한 공개 메시지이고, 소수만 귓속말이지 않을까 했다.
79%만 공개 텍스트/파일, 20%는 귓속말, 1%는 시스템 메시지로 DB에 데이터를 넣었다.
구조 변경 전에는
coll.find({
$or: [
{ targetUsername: null },
{ targetUsername: "sim" },
{ username: "sim" }
]
})
.sort({ createdAt: 1 })
.explain("executionStats");
{
explainVersion: '1',
queryPlanner: {
...
winningPlan: {
stage: 'SUBPLAN',
inputStage: {
stage: 'SORT',
sortPattern: {
createdAt: 1
},
memLimit: 104857600,
type: 'simple',
inputStage: {
stage: 'COLLSCAN',
filter: {
'$or': [
{
targetUsername: {
'$eq': null
}
},
{
targetusername: {
'$eq': 'sim'
}
},
{
username: {
'$eq': 'sim'
}
}
]
},
direction: 'forward'
}
}
},
rejectedPlans: []
},
executionStats: {
executionSuccess: true,
nReturned: 40886,
executionTimeMillis: 51,
totalKeysExamined: 0,
totalDocsExamined: 50000,
executionStages: {
stage: 'SUBPLAN',
nReturned: 40886,
executionTimeMillisEstimate: 0,
works: 90888,
advanced: 40886,
needTime: 50001,
needYield: 0,
saveState: 90,
restoreState: 90,
isEOF: 1,
inputStage: {
stage: 'SORT',
nReturned: 40886,
executionTimeMillisEstimate: 0,
works: 90888,
advanced: 40886,
needTime: 50001,
needYield: 0,
saveState: 90,
restoreState: 90,
isEOF: 1,
sortPattern: {
createdAt: 1
},
memLimit: 104857600,
type: 'simple',
totalDataSizeSorted: 7783079,
usedDisk: false,
spills: 0,
spilledDataStorageSize: 0,
inputStage: {
stage: 'COLLSCAN',
filter: {
'$or': [
{
targetUsername: {
'$eq': null
}
},
{
targetusername: {
'$eq': 'sim'
}
},
{
username: {
'$eq': 'sim'
}
}
]
},
nReturned: 40886,
executionTimeMillisEstimate: 0,
works: 50001,
advanced: 40886,
needTime: 9114,
needYield: 0,
saveState: 90,
restoreState: 90,
isEOF: 1,
direction: 'forward',
docsExamined: 50000
}
}
}
},
...
}
COLLSCAN이 나와버렸다. 5만개 중에 40,886개의 문서가 반환된다.
내가 만든 인덱스 (username ~ , targetUsername~) 이 있지만, 공개 메시지가 너무 많아서 선택도가 낮다.
구조 변경을 해서 scope를 추가했다.
{
explainVersion: '1',
queryPlanner: {
...
winningPlan: {
stage: 'SUBPLAN',
inputStage: {
stage: 'SORT',
sortPattern: {
createdAt: 1
},
memLimit: 104857600,
type: 'simple',
inputStage: {
stage: 'COLLSCAN',
filter: {
'$or': [
{
'$and': [
{
scope: {
'$eq': 'whisper'
}
},
{
targetUsername: {
'$eq': 'sim'
}
}
]
},
{
'$and': [
{
scope: {
'$eq': 'whisper'
}
},
{
username: {
'$eq': 'sim'
}
}
]
},
{
scope: {
'$eq': 'public'
}
}
]
},
direction: 'forward'
}
}
},
rejectedPlans: []
},
username, targetUsername 의 인덱스가 있음에도 불구하고 COLLSCAN 을 했다.
executionStats: {
executionSuccess: true,
nReturned: 41954,
executionTimeMillis: 70,
totalKeysExamined: 0,
totalDocsExamined: 50000,
executionStages: {
stage: 'SUBPLAN',
nReturned: 41954,
executionTimeMillisEstimate: 0,
works: 91956,
advanced: 41954,
needTime: 50001,
needYield: 0,
saveState: 91,
restoreState: 91,
isEOF: 1,
inputStage: {
stage: 'SORT',
nReturned: 41954,
executionTimeMillisEstimate: 0,
works: 91956,
advanced: 41954,
needTime: 50001,
needYield: 0,
saveState: 91,
restoreState: 91,
isEOF: 1,
sortPattern: {
createdAt: 1
},
memLimit: 104857600,
type: 'simple',
totalDataSizeSorted: 8748579,
usedDisk: false,
spills: 0,
spilledDataStorageSize: 0,
inputStage: {
stage: 'COLLSCAN',
filter: {
'$or': [
{
'$and': [
{
scope: {
'$eq': 'whisper'
}
},
{
targetUsername: {
'$eq': 'sim'
}
}
]
},
{
'$and': [
{
scope: {
'$eq': 'whisper'
}
},
{
username: {
'$eq': 'sim'
}
}
]
},
{
scope: {
'$eq': 'public'
}
}
]
},
nReturned: 41954,
executionTimeMillisEstimate: 0,
works: 50001,
advanced: 41954,
needTime: 8046,
needYield: 0,
saveState: 91,
restoreState: 91,
isEOF: 1,
direction: 'forward',
docsExamined: 50000
}
}
}
},
...
}
5만개의 문서를 모두 읽은 뒤, 조건에 맞는 것만 골랐다.
현재 존재하는 targetUsername, username 인덱스로는 의미가 없다는 뜻이다.
그래도 이젠 공개 메시지와 필드 누락/비정상 데이터를 구분할 수는 있게 되었다.
[변경 포인트 2] 전체 메시지가 너무 큰 비중이라 쿼리 선택도가 낮은 현상은 해결하기 어렵다고 판단했다.
[변경 포인트 3] 인덱스 재설계
이젠 새로운 인덱스가 필요한 시점이다..
인덱스 순서는 Equality > Sort > Range 순서로 하는걸 추천한다고 한다.
즉, 같은 값으로 거를 수 있는 필드 먼저 하는게 좋다.
{ username: 1, createdAt: 1 } 인덱스는 find({ username: "sim" }).sort({ createdAt: 1 })를 잘 지원한다.
제일 이해가 안 갔던 부분은 createdAt: 1을 쓰고, 왜 또 .sort() 를 하는가? 였는데
인덱스는 정렬할 수 있는 길을 만드는 것이고, .sort는 그 길을 실제로 써서 그대로 달라고 요청하는 것이라고 생각하니 이해가 됐다.
예를 들어 인덱스가 아래와 같다면,
{ username: 1, createdAt: 1 }
이 인덱스는 alice 묶음 안에서 createdAt 순서, sim 묶음 안에서 createdAt 순서로 잘 묶여있다.
인덱스에 createdAt 이 들어 있다는 것만으로 결과가 자동으로 그 순서로 반환된다고 약속되지는 않는다.
.sort({ createdAt: 1 }) 을 써야 MongoDB가 그 결과를 createdAt 순서로 내놔야 한다는 요구사항을 명확히 알기 때문이다.
내 코드에서는 3가지 유형의 메시지를 모두 가져와서, 최종적으로 createdAt 순서로 메시지 정렬이 필요하다.
따라서 저 세 가지는 이미 createdAt 순서로 읽히고 있고 MongoDB는 그 정렬된 결과를 병합merge 해서 최종적으로 createdAt 순서를 만든다.
이를 고려해서 인덱스 설계를 다시 해봤다.
1 공개 메시지
{ scope: 1, createdAt: 1 }
2 귓속말 메시지
{ scope: 1, username: 1, createdAt: 1 }
{ scope: 1, targetUsername: 1, createdAt: 1 }
이렇게 인덱스를 추가하고 .explain 도 돌려봤다.
{
explainVersion: '1',
queryPlanner: {
...
winningPlan: {
stage: 'SUBPLAN',
inputStage: {
stage: 'FETCH',
inputStage: {
stage: 'SORT_MERGE',
sortPattern: {
createdAt: 1
},
inputStages: [
{
stage: 'IXSCAN',
keyPattern: {
scope: 1,
username: 1,
createdAt: 1
},
indexName: 'scope_1_username_1_createdAt_1',
isMultiKey: false,
multiKeyPaths: {
scope: [],
username: [],
createdAt: []
},
isUnique: false,
isSparse: false,
isPartial: false,
indexVersion: 2,
direction: 'forward',
indexBounds: {
scope: [
'["whisper", "whisper"]'
],
username: [
'["sim", "sim"]'
],
createdAt: [
'[MinKey, MaxKey]'
]
}
},
{
stage: 'IXSCAN',
keyPattern: {
scope: 1,
targetUsername: 1,
createdAt: 1
},
indexName: 'scope_1_targetUsername_1_createdAt_1',
isMultiKey: false,
multiKeyPaths: {
scope: [],
targetUsername: [],
createdAt: []
},
isUnique: false,
isSparse: false,
isPartial: false,
indexVersion: 2,
direction: 'forward',
indexBounds: {
scope: [
'["whisper", "whisper"]'
],
targetUsername: [
'["sim", "sim"]'
],
createdAt: [
'[MinKey, MaxKey]'
]
}
},
{
stage: 'IXSCAN',
keyPattern: {
scope: 1,
createdAt: 1
},
indexName: 'scope_1_createdAt_1',
isMultiKey: false,
multiKeyPaths: {
scope: [],
createdAt: []
},
isUnique: false,
isSparse: false,
isPartial: false,
indexVersion: 2,
direction: 'forward',
indexBounds: {
scope: [
'["public", "public"]'
],
createdAt: [
'[MinKey, MaxKey]'
]
}
}
]
}
}
},
rejectedPlans: []
},
scope 인덱스를 사용하기 시작했다.

executionStats: {
executionSuccess: true,
nReturned: 41954,
executionTimeMillis: 69,
totalKeysExamined: 41954,
totalDocsExamined: 41954,
executionStages: {
stage: 'SUBPLAN',
nReturned: 41954,
executionTimeMillisEstimate: 8,
works: 83912,
advanced: 41954,
needTime: 41957,
needYield: 0,
saveState: 85,
restoreState: 85,
isEOF: 1,
inputStage: {
stage: 'FETCH',
nReturned: 41954,
executionTimeMillisEstimate: 2,
works: 83911,
advanced: 41954,
needTime: 41957,
needYield: 0,
saveState: 84,
restoreState: 84,
isEOF: 1,
docsExamined: 41954,
alreadyHasObj: 0,
inputStage: {
stage: 'SORT_MERGE',
nReturned: 41954,
executionTimeMillisEstimate: 0,
works: 83911,
advanced: 41954,
needTime: 41957,
needYield: 0,
saveState: 84,
restoreState: 84,
isEOF: 1,
sortPattern: {
createdAt: 1
},
dupsTested: 41954,
dupsDropped: 0,
inputStages: [
{
stage: 'IXSCAN',
nReturned: 917,
executionTimeMillisEstimate: 0,
works: 918,
advanced: 917,
needTime: 0,
needYield: 0,
saveState: 84,
restoreState: 84,
isEOF: 1,
keyPattern: {
scope: 1,
username: 1,
createdAt: 1
},
indexName: 'scope_1_username_1_createdAt_1',
isMultiKey: false,
multiKeyPaths: {
scope: [],
username: [],
createdAt: []
},
isUnique: false,
isSparse: false,
isPartial: false,
indexVersion: 2,
direction: 'forward',
indexBounds: {
scope: [
'["whisper", "whisper"]'
],
username: [
'["sim", "sim"]'
],
createdAt: [
'[MinKey, MaxKey]'
]
},
keysExamined: 917,
seeks: 1,
dupsTested: 0,
dupsDropped: 0
},
{
stage: 'IXSCAN',
nReturned: 959,
executionTimeMillisEstimate: 0,
works: 960,
advanced: 959,
needTime: 0,
needYield: 0,
saveState: 84,
restoreState: 84,
isEOF: 1,
keyPattern: {
scope: 1,
targetUsername: 1,
createdAt: 1
},
indexName: 'scope_1_targetUsername_1_createdAt_1',
isMultiKey: false,
multiKeyPaths: {
scope: [],
targetUsername: [],
createdAt: []
},
isUnique: false,
isSparse: false,
isPartial: false,
indexVersion: 2,
direction: 'forward',
indexBounds: {
scope: [
'["whisper", "whisper"]'
],
targetUsername: [
'["sim", "sim"]'
],
createdAt: [
'[MinKey, MaxKey]'
]
},
keysExamined: 959,
seeks: 1,
dupsTested: 0,
dupsDropped: 0
},
{
stage: 'IXSCAN',
nReturned: 40078,
executionTimeMillisEstimate: 0,
works: 40079,
advanced: 40078,
needTime: 0,
needYield: 0,
saveState: 84,
restoreState: 84,
isEOF: 1,
keyPattern: {
scope: 1,
createdAt: 1
},
indexName: 'scope_1_createdAt_1',
isMultiKey: false,
multiKeyPaths: {
scope: [],
createdAt: []
},
isUnique: false,
isSparse: false,
isPartial: false,
indexVersion: 2,
direction: 'forward',
indexBounds: {
scope: [
'["public", "public"]'
],
createdAt: [
'[MinKey, MaxKey]'
]
},
keysExamined: 40078,
seeks: 1,
dupsTested: 0,
dupsDropped: 0
}
]
}
}
}
},
...
}
1. 공개 / 보낸 귓속말 / 받은 귓속말을 각각 따로 인덱스로 읽고 있다.
COLLSCAN 이 아니라 IXSCAN 3개를 탄 결과를 통해 확인할 수 있다.
2. SORT_MERGE를 통해 세 인덱스가 이미 각각 CreatedAt: 1 순서로 결과를 내고 있어서, MongoDB 는 그 정렬된 결과들을 병합하고 있다는 것을 확인할 수 있었다.
3. 마지막으로 nReturnd = totalKeysExamined = totalDocsExamined
불필요하게 더 많은 키나 문서를 훑지 않았다는 뜻이다.
최종
export interface MessageInterface {
username: string;
scope: "public" | "whisper";
type: "text" | "file" | "system";
content?: string;
fileUrl?: string;
fileName?: string;
targetUsername?: string;
createdAt?: string;
}
type PublicMessageFilter = {
scope: "public";
};
type WhisperSentFilter = {
scope: "whisper";
username: string;
};
type WhisperReceivedFilter = {
scope: "whisper";
targetUsername: string;
};
type MessageOrFilter =
| PublicMessageFilter
| WhisperSentFilter
| WhisperReceivedFilter;
export interface MessageQueryFilter {
$or?: MessageOrFilter[];
}
chatSchema.index({ scope: 1, createdAt: 1 });
chatSchema.index({ scope: 1, username: 1, createdAt: 1 });
chatSchema.index({ scope: 1, targetUsername: 1, createdAt: 1 });
난 내 로직에서 뭐가 잘못된건 당연히 있었겠지만, 인덱스 설계 부분이 문제라는건 생각도 못했다.
그냥 단순히 인덱싱을 추가하는 건줄 알았는데 이렇게 설명 해주는 것도 있었다니.
또 공부할 거 뭐 있나 찾아보고 MongoDB 도 좀 더 공부해보고 싶다.
역시 백엔드는 아직 어렵고 공부할게 참 많다.

+ .sort() 에서 동일한 createdAt 값이 있을 때 순서를 안정적으로 보장하기 위해서 createdAt에 추가로 _id 도 하는 것이 좋다고 한다.
+ 그냥 궁금해서 { scope: 1, createdAt: 1 } 인덱스만 남기고 다시 돌려봤다.
나름 scope로 나뉘어져 있는 메시지니까, 잘 나눌 수도 있겠다고 생각했다.
이 결과는 scope_username_ / scope_targetUsername_ 을 따로 두는 것이 효율적임을 설명한다.
{
explainVersion: '1',
queryPlanner: {
...
winningPlan: {
stage: 'SUBPLAN',
inputStage: {
stage: 'FETCH',
inputStage: {
stage: 'SORT_MERGE',
sortPattern: {
createdAt: 1
},
inputStages: [
{
stage: 'FETCH',
filter: {
'$or': [
{
username: {
'$eq': 'sim'
}
},
{
targetUsername: {
'$eq': 'sim'
}
}
]
},
inputStage: {
stage: 'IXSCAN',
keyPattern: {
scope: 1,
createdAt: 1
},
indexName: 'scope_1_createdAt_1',
isMultiKey: false,
multiKeyPaths: {
scope: [],
createdAt: []
},
isUnique: false,
isSparse: false,
isPartial: false,
indexVersion: 2,
direction: 'forward',
indexBounds: {
scope: [
'["whisper", "whisper"]'
],
createdAt: [
'[MinKey, MaxKey]'
]
}
}
},
{
stage: 'IXSCAN',
keyPattern: {
scope: 1,
createdAt: 1
},
indexName: 'scope_1_createdAt_1',
isMultiKey: false,
multiKeyPaths: {
scope: [],
createdAt: []
},
isUnique: false,
isSparse: false,
isPartial: false,
indexVersion: 2,
direction: 'forward',
indexBounds: {
scope: [
'["public", "public"]'
],
createdAt: [
'[MinKey, MaxKey]'
]
}
}
]
}
}
},
rejectedPlans: []
},
인덱스는 2개 뿐인데, 둘 다 scope_1_createdAt_1을 사용한다.
하나는 scope="public" 용이고
하나는 scope="whisper" 전체를 IXSCAN 하고, username="sim" OR targetUsername="sim" 을 다시 거르는 구조이다.
executionStats: {
executionSuccess: true,
nReturned: 41954,
executionTimeMillis: 70,
totalKeysExamined: 50000,
totalDocsExamined: 51876,
executionStages: {
stage: 'SUBPLAN',
nReturned: 41954,
executionTimeMillisEstimate: 2,
works: 91957,
advanced: 41954,
needTime: 50002,
needYield: 0,
saveState: 91,
restoreState: 91,
isEOF: 1,
inputStage: {
stage: 'FETCH',
nReturned: 41954,
executionTimeMillisEstimate: 2,
works: 91956,
advanced: 41954,
needTime: 50002,
needYield: 0,
saveState: 91,
restoreState: 91,
isEOF: 1,
docsExamined: 41954,
alreadyHasObj: 1876,
inputStage: {
stage: 'SORT_MERGE',
nReturned: 41954,
executionTimeMillisEstimate: 0,
works: 91956,
advanced: 41954,
needTime: 50002,
needYield: 0,
saveState: 91,
restoreState: 91,
isEOF: 1,
sortPattern: {
createdAt: 1
},
dupsTested: 41954,
dupsDropped: 0,
inputStages: [
{
stage: 'FETCH',
filter: {
'$or': [
{
username: {
'$eq': 'sim'
}
},
{
targetUsername: {
'$eq': 'sim'
}
}
]
},
nReturned: 1876,
executionTimeMillisEstimate: 0,
works: 9923,
advanced: 1876,
needTime: 8046,
needYield: 0,
saveState: 91,
restoreState: 91,
isEOF: 1,
docsExamined: 9922,
alreadyHasObj: 0,
inputStage: {
stage: 'IXSCAN',
nReturned: 9922,
executionTimeMillisEstimate: 0,
works: 9923,
advanced: 9922,
needTime: 0,
needYield: 0,
saveState: 91,
restoreState: 91,
isEOF: 1,
keyPattern: {
scope: 1,
createdAt: 1
},
indexName: 'scope_1_createdAt_1',
isMultiKey: false,
multiKeyPaths: {
scope: [],
createdAt: []
},
isUnique: false,
isSparse: false,
isPartial: false,
indexVersion: 2,
direction: 'forward',
indexBounds: {
scope: [
'["whisper", "whisper"]'
],
createdAt: [
'[MinKey, MaxKey]'
]
},
keysExamined: 9922,
seeks: 1,
dupsTested: 0,
dupsDropped: 0
}
},
{
stage: 'IXSCAN',
nReturned: 40078,
executionTimeMillisEstimate: 0,
works: 40079,
advanced: 40078,
needTime: 0,
needYield: 0,
saveState: 91,
restoreState: 91,
isEOF: 1,
keyPattern: {
scope: 1,
createdAt: 1
},
indexName: 'scope_1_createdAt_1',
isMultiKey: false,
multiKeyPaths: {
scope: [],
createdAt: []
},
isUnique: false,
isSparse: false,
isPartial: false,
indexVersion: 2,
direction: 'forward',
indexBounds: {
scope: [
'["public", "public"]'
],
createdAt: [
'[MinKey, MaxKey]'
]
},
keysExamined: 40078,
seeks: 1,
dupsTested: 0,
dupsDropped: 0
}
]
}
}
}
},
...
}
whisper 브랜치
- IXSCAN.nReturned: 9922
- FETCH.nReturned: 1876
- docsExamined: 9922
즉 whisper 문서 9,922개를 읽어서 1,876개만 남겼다는 뜻이다.
또 totalDocsExamined: 51876으로, 엄청 많이 읽는 비효율적인 플랜이다.

끝!
'1일1티스토리' 카테고리의 다른 글
| 251004 - LSTM (0) | 2025.10.04 |
|---|---|
| 251003 - Overlap / Covering constraints (0) | 2025.10.03 |
| 251001 - 30개의 1일1티스토리를 쓰며 (0) | 2025.10.01 |
| 250929 - 기능 요구사항과 비기능 요구사항 (0) | 2025.09.29 |
| 250928 - Weak Entities (0) | 2025.09.28 |