Published on

MongoDB 느린 쿼리 해결 - explain·인덱스·$lookup 튜닝

Authors

서버가 느려졌다고 느끼는 순간, 대부분의 원인은 “느린 쿼리”입니다. MongoDB는 유연한 스키마와 강력한 Aggregation Pipeline 덕분에 개발 속도가 빠르지만, 인덱스/조인($lookup)을 조금만 방치해도 컬렉션 스캔(COLLSCAN)과 메모리 폭주가 쉽게 발생합니다.

이 글은 다음 순서로 진행합니다.

  • explain()으로 병목 지점을 수치로 확인하는 방법
  • 인덱스가 “왜 안 타는지”를 판별하는 체크리스트
  • Aggregation과 $lookup을 빠르게 만드는 파이프라인 작성 패턴
  • 운영에서 재현/관측/회귀 방지까지 이어지는 실무 팁

비슷한 방식으로 “로그/지표로 원인을 특정하고, 가장 비용이 큰 지점을 먼저 줄이는” 접근은 DB뿐 아니라 다른 장애에서도 유효합니다. 예를 들어 MySQL에서 데드락 로그로 원인 쿼리를 추적하는 흐름도 같은 결입니다: MySQL InnoDB 데드락 로그로 원인 쿼리 추적

1) 느린 쿼리의 1차 분류: CPU, I/O, 메모리

MongoDB 느린 쿼리는 대체로 아래 중 하나(혹은 복합)입니다.

  • I/O 바운드: 인덱스를 못 타서 디스크에서 대량 스캔
  • CPU 바운드: 정렬/그룹/표현식 계산이 과도하거나, $lookup으로 문서 폭증
  • 메모리 바운드: 정렬/그룹이 메모리를 초과해 디스크 스필(spill) 발생

따라서 “쿼리만” 보지 말고, 최소한 아래를 함께 보세요.

  • explain() 결과(실행 계획)
  • 슬로우 쿼리 로그/프로파일러
  • mongostat, mongotop, Atlas Performance Advisor(사용 중이라면)

2) explain() 제대로 읽기: 무엇을 봐야 하나

MongoDB에서 튜닝은 거의 항상 explain()에서 시작합니다.

2.1 find 쿼리에서 핵심 필드

다음은 전형적인 find() + 정렬 쿼리입니다.

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

여기서 확인할 핵심은 다음입니다.

  • winningPlan.stageCOLLSCAN인지, IXSCAN인지
  • executionStats.totalDocsExaminedexecutionStats.nReturned의 비율
  • executionStats.totalKeysExamined가 과도하게 큰지
  • executionTimeMillis(또는 executionTimeMillisEstimate)가 어디서 늘어나는지

실무 기준으로는 대략 다음을 경계선으로 둡니다.

  • totalDocsExamined / nReturned가 수백~수천 이상이면 인덱스/조건 설계 문제일 가능성이 큼
  • sort가 인덱스로 해결되지 않으면(정렬 단계가 따로 있으면) 메모리/스필 위험이 큼

2.2 Aggregation에서 핵심 필드

Aggregation은 explain()이 더 중요합니다.

db.orders.explain("executionStats").aggregate([
  { $match: { status: "PAID", createdAt: { $gte: ISODate("2026-01-01") } } },
  { $group: { _id: "$userId", total: { $sum: "$total" } } },
  { $sort: { total: -1 } },
  { $limit: 20 }
])

체크 포인트:

  • 파이프라인 초반에 $match가 있는지(필터를 최대한 빨리)
  • $sort가 인덱스를 타는지(또는 그룹 이후 정렬이라면 메모리 사용량)
  • executionStats에서 스테이지별 처리량이 어디서 급증하는지

3) “인덱스가 있는데도 느린” 흔한 이유 7가지

인덱스를 추가했는데도 COLLSCAN이 뜨거나 빨라지지 않는 경우가 많습니다. 아래는 빈도가 높은 원인들입니다.

3.1 복합 인덱스의 필드 순서가 틀림

MongoDB 복합 인덱스는 “왼쪽(prefix)”부터 의미가 있습니다. 예를 들어 아래 쿼리를 자주 한다면:

  • 조건: userId, status
  • 정렬: createdAt 내림차순

권장 인덱스는 보통 다음입니다.

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

반대로 { createdAt: -1, userId: 1, status: 1 } 같은 인덱스는 특정 쿼리에서 비효율적일 수 있습니다(정렬은 되지만 필터가 약해져 스캔이 커짐).

3.2 범위 조건($gte 등)이 앞쪽에 오면 뒤 필드 효율 급감

복합 인덱스에서 범위 조건이 등장하면 그 뒤 필드는 “정렬/필터”에서 활용도가 떨어질 수 있습니다.

예: { createdAt: { $gte: ... } }가 인덱스 앞에 있으면, 뒤의 status로 강하게 좁히기 어려워집니다.

3.3 $in이 너무 큼

$in 배열이 수백~수천이면 인덱스를 타더라도 키 탐색이 폭증합니다. 가능하면:

  • 후보 집합을 먼저 줄이거나
  • 별도 컬렉션/캐시로 해결하거나
  • 쿼리를 여러 번으로 분할(배치)

을 고려합니다.

3.4 정렬이 인덱스로 해결되지 않음

다음은 “필터는 인덱스, 정렬은 메모리”가 되는 전형적인 형태입니다.

db.orders.find({ status: "PAID" }).sort({ total: -1 }).limit(50)

이 경우 { status: 1, total: -1 } 같은 인덱스가 필요합니다.

3.5 프로젝션 때문에 커버링 인덱스를 못 씀

find()에서 필요한 필드가 인덱스에 모두 포함되면(커버링) 디스크 접근이 줄어듭니다.

// 커버링을 노리는 인덱스(조회 필드까지 포함)
db.orders.createIndex({ userId: 1, status: 1, createdAt: -1, total: 1 })

// 조회는 _id, total, createdAt만

단, 인덱스가 커질수록 쓰기 비용/메모리 비용이 증가하므로 “핫 쿼리”에만 적용하세요.

3.6 타입 불일치(문자열 vs ObjectId vs Date)

userId가 어떤 문서는 문자열, 어떤 문서는 ObjectId면 인덱스 효율이 깨집니다. 특히 $lookup에서 조인 키 타입이 다르면 매칭이 실패하거나 비정상적으로 느려질 수 있습니다.

3.7 부정 조건($ne, $not) 남발

부정 조건은 인덱스로 효율적으로 거르기 어려운 경우가 많습니다. 가능한 긍정 조건으로 재구성하거나, 별도 상태 필드를 두는 편이 낫습니다.

4) $lookup이 느려지는 구조와 해결책

$lookup은 MongoDB에서 사실상 “조인”이지만, RDB의 조인과 같은 최적화가 항상 자동으로 되진 않습니다. 특히 다음 상황에서 급격히 느려집니다.

  • 로컬 컬렉션에서 $match 없이 먼저 대량 문서를 만든 뒤 $lookup
  • foreign 컬렉션의 조인 키에 인덱스가 없음
  • $lookup 결과 배열이 커져 문서가 비대해짐
  • 조인 후 $unwind로 문서 수가 폭증

4.1 가장 중요한 원칙: $match를 최대한 앞에

나쁜 예(먼저 조인부터 하고 나중에 필터):

db.orders.aggregate([
  { $lookup: {
      from: "users",
      localField: "userId",
      foreignField: "_id",
      as: "user"
  }},
  { $match: { status: "PAID" } },
  { $sort: { createdAt: -1 } },
  { $limit: 20 }
])

좋은 예(필터로 후보를 줄인 후 조인):

db.orders.aggregate([
  { $match: { status: "PAID" } },
  { $sort: { createdAt: -1 } },
  { $limit: 20 },
  { $lookup: {
      from: "users",
      localField: "userId",
      foreignField: "_id",
      as: "user"
  }},
  { $project: { _id: 1, total: 1, createdAt: 1, user: { $slice: ["$user", 1] } } }
])

핵심은 $lookup 이전에 문서 수를 줄이는 것입니다. 조인은 “마지막에 가까울수록” 안전합니다.

4.2 foreignField에 인덱스는 필수

$lookup이 다음 형태라면:

  • localField: "userId"
  • foreignField: "_id"

users._id는 기본 인덱스가 있으니 괜찮습니다. 하지만 foreignField_id가 아니라면 반드시 인덱스를 확인하세요.

// 예: users.email로 조인한다면
// users 컬렉션에 인덱스가 없으면 조인 때마다 스캔 가능

db.users.createIndex({ email: 1 })

4.3 $lookup with pipeline로 “조인 결과를 줄이기”

조인 결과에서 필요한 필드만 가져오고, 추가 필터를 foreign 쪽에서 수행하면 배열 비대화를 줄일 수 있습니다.

db.orders.aggregate([
  { $match: { status: "PAID" } },
  { $limit: 50 },
  { $lookup: {
      from: "users",
      let: { uid: "$userId" },
      pipeline: [
        { $match: { $expr: { $eq: ["$_id", "$$uid"] } } },
        { $project: { _id: 1, name: 1, tier: 1 } }
      ],
      as: "user"
  }},
  { $addFields: { user: { $first: "$user" } } },
  { $project: { userId: 1, total: 1, user: 1 } }
])

포인트:

  • pipeline 내부에서 $project로 payload를 줄임
  • 결과가 1건이라면 $first로 배열을 평탄화

4.4 $unwind는 폭발 장치다

$lookup 결과를 $unwind하면 문서 수가 곱해집니다. 정말 필요할 때만 사용하고, 가능하면:

  • $lookup 단계에서 필요한 것만 가져오기
  • $unwind 전에 $match/$limit
  • 또는 $unwind 대신 $first, $arrayElemAt로 1건만 쓰기

을 우선 검토하세요.

4.5 조인이 잦다면 “비정규화”가 정답일 때도

MongoDB는 문서 DB입니다. 조회 패턴이 고정되어 있고 $lookup이 병목이라면, 다음을 고려할 수 있습니다.

  • 주문 문서에 사용자 name, tier 같은 자주 쓰는 필드를 스냅샷으로 저장
  • 변경 가능성이 큰 값만 별도 컬렉션에서 조회

이는 쓰기 시점에 약간의 비용을 지불하고 읽기 성능을 얻는 전형적인 트레이드오프입니다.

5) Aggregation 튜닝 체크리스트

5.1 파이프라인 초반에 $project로 문서 다이어트

대형 문서를 그대로 다음 스테이지로 넘기면 CPU/메모리 사용량이 커집니다.

db.events.aggregate([
  { $match: { type: "click", ts: { $gte: ISODate("2026-01-01") } } },
  { $project: { userId: 1, ts: 1, pageId: 1 } },
  { $group: { _id: "$pageId", uv: { $addToSet: "$userId" } } },
  { $project: { uv: { $size: "$uv" } } }
])

5.2 정렬/그룹에서 메모리 스필 확인

정렬/그룹이 큰 데이터를 다루면 메모리 제한을 넘기고 디스크를 사용합니다. 필요 시 allowDiskUse를 켤 수 있지만, 이는 “느려도 죽지 않게” 하는 옵션에 가깝습니다.

db.orders.aggregate(
  [
    { $match: { status: "PAID" } },
    { $group: { _id: "$userId", sum: { $sum: "$total" } } },
    { $sort: { sum: -1 } }
  ],
  { allowDiskUse: true }
)

근본 해결은 보통:

  • 그룹 전에 후보를 더 줄이기
  • 필요한 기간만 집계하기
  • 사전 집계 컬렉션(rollup) 도입

입니다.

5.3 $facet는 편하지만 비싸다

$facet는 한 번의 파이프라인에서 여러 결과를 만들 수 있지만, 내부적으로는 비용이 큽니다. 특히 같은 입력을 여러 갈래로 처리하므로, 입력을 최대한 줄인 뒤 사용하세요.

6) 운영에서 “재발 방지”까지: 프로파일링과 회귀 테스트

6.1 슬로우 쿼리 프로파일러

개발/스테이징에서 재현이 어렵다면 프로파일러를 짧게 켜서 실제 느린 쿼리를 수집합니다.

// 100ms 넘는 쿼리 기록
// 운영에서는 짧게, 신중하게 사용
db.setProfilingLevel(1, { slowms: 100 })

// 프로파일 로그 조회
// system.profile 컬렉션은 환경에 따라 접근 방식이 다를 수 있음
db.system.profile.find().sort({ ts: -1 }).limit(20)

6.2 쿼리 플랜 캐시/플랜 변동 주의

데이터 분포가 바뀌면 같은 쿼리도 플랜이 달라질 수 있습니다. 배포 후 특정 시간대에만 느려진다면:

  • 파라미터 값에 따라 선택도가 달라지는지
  • 인덱스가 너무 많아 플래너가 흔들리는지
  • 통계/캐시 영향이 있는지

를 점검하세요.

6.3 애플리케이션 관점: 타임아웃과 재시도

느린 쿼리로 인해 상위 계층에서 502/504가 늘어날 수 있습니다. DB 튜닝과 함께 API 타임아웃/재시도 정책도 같이 보세요. 인프라에서 타임아웃 체크 포인트를 정리한 글도 참고가 됩니다: AWS ALB 502/504 급증? 타임아웃 7곳 점검

7) 실전 예시: 느린 검색 + $lookup을 단계적으로 개선

7.1 문제 쿼리

  • 최근 30일 결제 주문
  • 사용자 정보 조인
  • 최신순 20개
db.orders.aggregate([
  { $match: { status: "PAID", createdAt: { $gte: ISODate("2026-01-26") } } },
  { $lookup: {
      from: "users",
      localField: "userId",
      foreignField: "_id",
      as: "user"
  }},
  { $sort: { createdAt: -1 } },
  { $limit: 20 }
])

병목 가능성:

  • orders에서 status + createdAt 필터 후 정렬이 인덱스로 해결되지 않음
  • $lookup이 불필요하게 많은 문서에 적용될 수 있음

7.2 인덱스 추가

db.orders.createIndex({ status: 1, createdAt: -1, userId: 1 })
  • status로 먼저 좁히고
  • createdAt 정렬을 인덱스로 처리하고
  • 조인 키 userId까지 포함해 후속 단계 비용을 낮춤

7.3 파이프라인 순서 조정

db.orders.aggregate([
  { $match: { status: "PAID", createdAt: { $gte: ISODate("2026-01-26") } } },
  { $sort: { createdAt: -1 } },
  { $limit: 20 },
  { $lookup: {
      from: "users",
      let: { uid: "$userId" },
      pipeline: [
        { $match: { $expr: { $eq: ["$_id", "$$uid"] } } },
        { $project: { _id: 1, name: 1 } }
      ],
      as: "user"
  }},
  { $addFields: { user: { $first: "$user" } } },
  { $project: { _id: 1, total: 1, createdAt: 1, user: 1 } }
])

이렇게 하면 $lookup이 20건에만 수행되어 비용이 급감합니다.

8) 요약: 가장 효과 큰 순서대로

  1. explain("executionStats")COLLSCAN 여부, docsExamined / nReturned, 정렬 단계 유무를 확인
  2. “필터 + 정렬”을 만족하는 복합 인덱스를 먼저 설계(필드 순서 중요)
  3. Aggregation은 $match/$project로 입력을 줄이고, 비싼 스테이지($group, $sort, $lookup)를 뒤로 미룸
  4. $lookup은 foreign 키 인덱스 확인, pipeline + $project로 결과 슬림화, $unwind 폭증 주의
  5. 운영에서는 프로파일러/슬로우 로그로 실쿼리 기반 튜닝, 배포 후 플랜 변동과 타임아웃 정책까지 함께 점검

느린 쿼리는 “인덱스 하나 추가”로 끝나는 경우도 있지만, $lookup과 Aggregation이 섞이면 파이프라인 설계 자체가 성능을 결정합니다. explain()으로 숫자를 보고, 문서 수를 줄이는 방향으로 단계별로 손보면 대부분의 케이스에서 체감 성능을 크게 개선할 수 있습니다.