본문 바로가기

1일1티스토리

260320 - MongoDB Query Explain Plan

쿼리가 뭔지, 인덱스는 뭔지, ... 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으로, 엄청 많이 읽는 비효율적인 플랜이다.

 

끝!