Published on

MongoDB 느린 쿼리 - explain으로 원인 찾고 인덱스 튜닝

Authors

서버가 느려졌다고 느껴질 때 MongoDB를 바로 의심하기보다는, 어떤 쿼리가 느린지를 먼저 특정하고, 그 다음에 왜 느린지explain으로 확인한 뒤, 마지막으로 인덱스/쿼리/스키마를 조정하는 순서가 가장 안전합니다.

이 글은 explain의 핵심 지표를 읽는 법, 느린 쿼리의 전형적인 패턴, 그리고 인덱스 튜닝을 실제로 적용하는 방법을 단계별로 다룹니다.

1) 느린 쿼리부터 잡기: 로그와 프로파일러

slow query 로그 켜기

운영 환경에서는 모든 쿼리를 로깅하면 비용이 큽니다. 대신 느린 쿼리만 로그로 남기도록 slowms를 조정합니다.

// mongosh
// 100ms 이상 걸리는 쿼리만 slow log로 남기기
// (값은 서비스 특성에 맞게 조정)
db.setProfilingLevel(0, { slowms: 100 })

MongoDB 버전과 배포 형태에 따라 로그 위치/형식이 다르지만, 핵심은 느린 쿼리의 필터 조건, 정렬, limit, 실제 수행 시간을 확보하는 것입니다.

Database Profiler로 샘플링

프로파일러는 system.profile 컬렉션에 실행 정보를 남깁니다. 특정 시간대에만 잠깐 켜서 증거를 모으는 방식이 보통 안전합니다.

// mongosh
// 1: slowms 이상만 기록, 2: 모든 쿼리 기록
// 운영에서는 보통 1을 짧게 사용
db.setProfilingLevel(1, { slowms: 50 })

// 최근 느린 쿼리 확인
use mydb

db.system.profile
  .find({ millis: { $gte: 50 } })
  .sort({ ts: -1 })
  .limit(5)

프로파일러 결과에서 특히 유용한 것은 query 또는 command, planSummary, keysExamined, docsExamined, millis 같은 필드입니다.

2) explain의 3단계: queryPlanner, executionStats, allPlansExecution

MongoDB의 explain은 단순히 “인덱스를 탔는지”만 보는 도구가 아닙니다. 플랜 선택 과정실행 통계를 함께 봐야 튜닝 방향이 명확해집니다.

  • queryPlanner: 어떤 플랜 후보가 있었고, 무엇을 선택했는지
  • executionStats: 실제 실행에서 얼마나 읽었고 얼마나 걸렸는지
  • allPlansExecution: 후보 플랜들을 실제로 조금씩 실행해 비교한 결과
// mongosh
// 실행 통계까지 확인

db.orders.find(
  { userId: "u1", status: "PAID" },
  { _id: 1, createdAt: 1, total: 1 }
)
.sort({ createdAt: -1 })
.limit(20)
.explain("executionStats")

핵심 지표: keysExamined vs docsExamined

  • keysExamined: 인덱스에서 훑은 키 개수
  • docsExamined: 실제 문서를 읽은 개수

이 둘이 쿼리 결과 수에 비해 과도하게 크면 대개 다음 중 하나입니다.

  1. 인덱스가 없음 또는 잘못된 인덱스
  2. 인덱스는 타지만 필터/정렬/프로젝션이 맞지 않아 추가 스캔이 큼
  3. 선택도가 낮은 필드(예: status)만으로 인덱스를 구성해 범위를 너무 넓게 탐색

COLLSCAN, IXSCAN, FETCH, SORT 스테이지

executionStats.executionStages에서 자주 보는 패턴은 다음과 같습니다.

  • COLLSCAN: 컬렉션 풀스캔. 대부분의 경우 개선 여지가 큼
  • IXSCAN: 인덱스 스캔. 좋은 출발점
  • FETCH: 인덱스로 후보를 찾은 뒤 실제 문서를 읽는 단계
  • SORT: 메모리 정렬. 인덱스로 정렬을 해결하지 못했다는 신호

정렬이 느릴 때 SORT가 보이고, limit이 작아도 느리다면 정렬을 커버하는 인덱스가 필요할 가능성이 큽니다.

3) 전형적인 느린 쿼리 패턴과 해결법

3-1) 필터는 있는데 인덱스가 없는 경우: COLLSCAN

// 자주 쓰는 검색
// createdAt 범위 + status

db.orders.find({
  status: "PAID",
  createdAt: { $gte: ISODate("2026-01-01"), $lt: ISODate("2026-02-01") }
})

추천 인덱스는 보통 동등 조건(=) 먼저, 범위 조건 다음입니다.

// status는 equality, createdAt은 range
// 따라서 status, createdAt 순서가 일반적으로 유리

db.orders.createIndex({ status: 1, createdAt: 1 })

다만 status의 값 종류가 매우 적어 선택도가 낮다면, 워크로드에 따라 { createdAt: 1, status: 1 }가 더 나을 수도 있습니다. 이럴 때는 allPlansExecution으로 비교하거나, 동일 쿼리를 두 인덱스에서 테스트해 keysExaminedmillis를 비교합니다.

3-2) 정렬 때문에 느린 경우: SORT 스테이지 제거

// 최근 주문 20개

db.orders.find({ userId: "u1" })
  .sort({ createdAt: -1 })
  .limit(20)

정렬 키가 인덱스에 없으면 SORT가 발생합니다. 아래처럼 정렬을 포함해 인덱스를 구성하면 대개 개선됩니다.

// userId로 필터하고 createdAt 내림차순 정렬

db.orders.createIndex({ userId: 1, createdAt: -1 })

이 인덱스는 userId로 범위를 좁힌 뒤, 인덱스 자체가 createdAt 순서를 보장하므로 정렬 비용을 줄입니다.

3-3) 커버링 인덱스로 FETCH 줄이기

FETCH는 문서를 읽어오는 단계라 디스크/메모리 비용이 커질 수 있습니다. 조회 필드가 제한적이라면 커버링 인덱스FETCH를 줄일 수 있습니다.

// 필요한 필드가 createdAt, total 뿐이라면

db.orders.find(
  { userId: "u1" },
  { _id: 0, createdAt: 1, total: 1 }
)
.sort({ createdAt: -1 })
.limit(20)
// 커버링을 위해 total까지 포함
// (인덱스 크기 증가와 쓰기 비용 증가를 감안)

db.orders.createIndex({ userId: 1, createdAt: -1, total: 1 })

커버링 여부는 explain에서 FETCH 스테이지가 사라지거나, totalDocsExamined가 크게 줄어드는지로 확인합니다.

3-4) $in이 큰 경우: 인덱스가 있어도 비쌀 수 있음

$in 리스트가 매우 크면 인덱스가 있어도 많은 키 탐색이 발생합니다.

// ids 배열이 수천 개 이상이면 부담

db.users.find({ _id: { $in: ids } })

대안은 상황에 따라 다릅니다.

  • 가능하면 배치로 나눠 호출
  • 서버 측에서 조인 성격이면 $lookup 또는 데이터 모델 재검토
  • 조회 패턴이 고정이면 역색인 형태로 별도 컬렉션 구성

3-5) 정규식/부분일치 검색: 인덱스가 잘 안 먹는 케이스

// 접두사가 아닌 부분일치 정규식은 인덱스 효율이 낮음

db.posts.find({ title: { $regex: "mongo", $options: "i" } })
  • 접두사 검색이라면 ^mongo 형태로 바꿔 인덱스 활용 가능성을 높임
  • 본격 검색은 MongoDB Atlas Search 또는 별도 검색엔진 고려

4) 인덱스 설계 원칙: “쿼리 모양”을 기준으로

인덱스는 테이블의 정답이 아니라 워크로드의 정답입니다. 같은 컬렉션이라도 핵심 쿼리가 3개면, 인덱스도 3개가 될 수 있습니다.

4-1) ESR 규칙을 실무적으로 해석

MongoDB에서 흔히 말하는 ESR은 대략 아래 우선순위를 뜻합니다.

  • Equality: 동등 조건 필드
  • Sort: 정렬 필드
  • Range: 범위 조건 필드

예를 들어 아래 쿼리는

  • userId는 equality
  • createdAt은 sort
  • price는 range
// 예시 쿼리

db.orders.find({
  userId: "u1",
  price: { $gte: 10000, $lte: 50000 }
})
.sort({ createdAt: -1 })
.limit(50)

다음 인덱스를 후보로 볼 수 있습니다.

// equality -> sort -> range 순으로 배치

db.orders.createIndex({ userId: 1, createdAt: -1, price: 1 })

단, 범위 조건이 들어가면 그 뒤 필드의 활용이 제한될 수 있어, 실제 쿼리 조합을 기준으로 explain("executionStats")로 검증해야 합니다.

4-2) 복합 인덱스의 “선두(prefix)”가 중요

복합 인덱스 { a: 1, b: 1, c: 1 }{ a }, { a, b } 형태의 쿼리에는 잘 맞지만, { b } 단독 쿼리에는 도움이 되지 않습니다.

따라서 “자주 쓰는 필터”가 무엇인지가 인덱스 선두를 결정합니다.

4-3) Partial Index로 인덱스 크기와 쓰기 비용 줄이기

상태가 특정 값일 때만 자주 조회한다면 부분 인덱스가 강력합니다.

// PAID만 주로 조회한다면

db.orders.createIndex(
  { createdAt: -1, userId: 1 },
  { partialFilterExpression: { status: "PAID" } }
)

부분 인덱스는 인덱스 크기를 줄여 캐시 적중률을 올리고, 쓰기 시 인덱스 갱신 비용도 줄일 수 있습니다.

4-4) TTL 인덱스로 오래된 데이터 자동 정리

데이터가 계속 쌓이면 인덱스도 비대해지고, 결국 느려집니다. 만료 가능한 로그/세션 데이터는 TTL로 관리하는 편이 좋습니다.

// 7일 후 자동 삭제

db.sessions.createIndex(
  { expiresAt: 1 },
  { expireAfterSeconds: 0 }
)

5) 튜닝 절차: 재현, 측정, 변경, 검증

인덱스 튜닝은 “만들고 끝”이 아니라, 변경이 성능과 비용에 미치는 영향을 함께 봐야 합니다.

5-1) 변경 전후 explain 비교 템플릿

// 변경 전
const before = db.orders.find({ userId: "u1" })
  .sort({ createdAt: -1 })
  .limit(20)
  .explain("executionStats")

// 인덱스 생성
// db.orders.createIndex({ userId: 1, createdAt: -1 })

// 변경 후
const after = db.orders.find({ userId: "u1" })
  .sort({ createdAt: -1 })
  .limit(20)
  .explain("executionStats")

print({
  beforeMillis: before.executionStats.executionTimeMillis,
  afterMillis: after.executionStats.executionTimeMillis,
  beforeKeys: before.executionStats.totalKeysExamined,
  afterKeys: after.executionStats.totalKeysExamined,
  beforeDocs: before.executionStats.totalDocsExamined,
  afterDocs: after.executionStats.totalDocsExamined
})

보통은 다음이 동시에 개선되는 쪽이 정답에 가깝습니다.

  • executionTimeMillis 감소
  • totalKeysExamined 감소
  • totalDocsExamined 감소
  • SORT 스테이지 제거 또는 축소

5-2) 인덱스는 공짜가 아니다

인덱스가 늘면 다음 비용이 증가합니다.

  • 쓰기 성능 저하: insert/update 시 인덱스도 갱신
  • 스토리지 증가: 인덱스 공간
  • 메모리 압박: 워킹셋이 RAM을 초과하면 성능 급락

따라서 “느린 쿼리 1개를 위해 인덱스 5개를 추가”하는 방식은 장기적으로 장애를 부릅니다. 꼭 필요한 쿼리부터, 최소 인덱스로 해결하는 방향이 좋습니다.

6) Node.js에서 느린 쿼리 관측 포인트

애플리케이션에서도 최소한의 관측을 넣어두면, DB만 탓하는 상황을 줄일 수 있습니다.

// Node.js (MongoDB Driver)
import { MongoClient } from "mongodb";

const client = new MongoClient(process.env.MONGODB_URI);
await client.connect();

const db = client.db("mydb");

const t0 = Date.now();
const docs = await db.collection("orders")
  .find({ userId: "u1" })
  .sort({ createdAt: -1 })
  .limit(20)
  .toArray();
const t1 = Date.now();

console.log("orders query ms:", t1 - t0, "count:", docs.length);

DB 튜닝 글에서 애플리케이션 링크가 뜬금없어 보일 수 있지만, 실무에서는 프레임워크 레벨 문제와 DB 문제가 함께 나타나는 경우가 많습니다. 예를 들어 Next.js에서 데이터 요청이 직렬로 쌓이면 DB 쿼리가 느린 것처럼 보일 수 있습니다. 관련해서는 Next.js 14 RSC fetch waterfall 끊는 캐시·prefetch 최적화도 같이 점검해보면 원인 분리가 빨라집니다.

또한 DB가 느려서 커넥션이 오래 붙잡히면 커넥션 풀 고갈이나 누수 경고처럼 관측될 수 있는데, 이런 케이스는 Spring Boot 3.2 HikariCP 커넥션 누수 경고 추적법처럼 “애플리케이션에서 보이는 증상”을 함께 해석하는 습관이 도움이 됩니다.

7) 실전 체크리스트

  • 느린 쿼리를 먼저 특정했는가: slow log 또는 profiler
  • explain("executionStats")keysExamined, docsExamined, SORT, COLLSCAN을 확인했는가
  • 필터와 정렬을 동시에 만족하는 복합 인덱스를 고려했는가
  • 결과 필드가 작으면 커버링 인덱스로 FETCH를 줄일 수 있는가
  • 선택도가 낮은 필드만 선두에 두지 않았는가
  • 부분 인덱스, TTL 등으로 인덱스 크기와 데이터량을 관리하고 있는가
  • 인덱스 추가로 쓰기 성능과 스토리지 비용이 증가하는 점을 평가했는가

8) 마무리: explain은 “정답지”가 아니라 “계기판”

MongoDB 느린 쿼리는 대개 COLLSCAN, 과도한 SORT, 낮은 선택도 인덱스, 불필요한 FETCH에서 시작합니다. explain을 계기판처럼 보고, 읽는 양을 줄이는 방향으로 인덱스와 쿼리를 맞추면 대부분의 latency 문제는 눈에 띄게 개선됩니다.

마지막으로, 튜닝은 한 번에 끝내기보다 “측정 가능한 작은 변경”을 반복하는 게 안전합니다. 인덱스 하나 추가할 때마다 executionTimeMillis, keysExamined, docsExamined가 어떻게 변했는지 기록해두면, 다음 성능 이슈가 왔을 때도 훨씬 빠르게 대응할 수 있습니다.