채팅 서비스나 SNS 피드 구성에서 무한 스크롤을 구현할 때, 쿼리의 성능은 사용자 경험에 직결됩니다. (커서 페이지네이션 관련 포스팅)
이번 포스팅은 실제 서비스에서 발생한 쿼리를 "Plan Cache" 기반으로 분석하면서, 후보군 중 어떤 인덱스가 선택되었고 왜 더 효율적인지 정리해보았습니다.
특정 item_id의 채팅 로그를 커서 기반 페이지네이션(cursor pagination) 방식으로 불러오는 쿼리입니다.
db.chat.find({
item_id: '0',
_id: { $lt: ObjectId('687ab67e5d0bb28eef7b1975') }
}).sort({ _id: -1 }).limit(21)
Plan Cache를 확인해보니 예상대로 MongoDB는 item_id + _id 복합 인덱스를 실제로 사용했습니다.
IXSCAN
keyPattern: { item_id: 1, _id: -1 }
indexName: "item_id_1__id_-1"
indexBounds: { item_id: "0", _id: (커서 이후 ~ -∞) }
...
{
nReturned: 21, // 반환된 문서 수
executionTimeMillisEstimate: 0, // 실행 시간(밀리초 단위 추정치)
totalKeysExamined: 21, // 인덱스 키 스캔 개수
totalDocsExamined: 21 // 실제 문서 조회 개수
}
MongoDB는 _id 인덱스만 사용하는 계획도 고려했지만, 해당 경우 item_id는 인덱스가 아니라 FETCH 단계에서 필터링해야 했습니다.
candidatePlanScores: [3.0005, 3.0003]



| 구분 | 최초 로드 시간 | 커서 페이지네이션 로드 시간 |
|---|---|---|
| 개선 전 | 6,824ms (약 6.8초) | 482ms (약 0.4초) |
| 개선 후 | 151ms (약 0.1초) | 28ms (약 0.02초) |
| 개선율 | 97.8% 단축 | 94.2% 단축 |
전반적으로 100ms 수준의 응답성을 확보했고, 체감 성능이 “즉각적”으로 변한 수준의 개선이 이루어졌습니다.
이를 이해하기 위해서는 FETCH 단계를 이해해야 합니다.
FETCH 단계란?
IXSCAN이 가져온 문서의 ObjectId를 통해 실제 Collection에서 문서를 읽어옴. 이때 인덱스에 없는 조건(=인덱스로 커버 안 되는 필드)은 여기서 검사해야 함.
단일 인덱스의 경우는, _id는 뽑아온 데이터(무한스크롤을 위한 x값 이전의 데이터)의 item_id를 하나하나 검사를 하게 됩니다. 이를 FETCH 단계에서의 필터링이라고 합니다.
복합 인덱스의 경우에는, "item_id가 0인 책 중에서 _id < ...인 애들만 주세요"로 데이터를 뽑아오기 떄문에 (item_id + _id) "모두 인덱스에서 처리" 하며 → FETCH는 “결과 읽기”만 담당하게 됩니다.
즉 "단일 인덱스는 하나하나 검열이라 오래걸림, 복합 인덱스는 처음부터 정답만 뽑아준다" 라고 볼 수 있습니다.
이전 사이드 프로젝트에서도 복합 인덱스를 통한 성능 최적화 관련 내용을 포스팅 했었는데, 이번에는 실행 계획을 기반으로 왜 DBMS가 왜? 복합 인덱스를 선택했는지에 대해 상세하게 알아보았습니다.