느린 MongoDB 집계 파이프라인 프로파일링 및 최적화 방법

느린 집계 파이프라인을 진단하는 방법을 배워 MongoDB 성능을 마스터하세요. 이 가이드에서는 MongoDB 프로파일러와 `.explain('executionStats')` 메서드를 활성화하고 사용하여 복잡한 단계 내에서 병목 현상을 정확히 찾아내는 방법을 자세히 설명합니다. `$match` 및 `$sort`에 대한 최적의 인덱싱과 `$lookup`의 효율적인 사용에 초점을 맞춰 데이터 변환 속도를 획기적으로 높이는 실행 가능한 튜닝 전략을 알아보세요.

느린 MongoDB 집계 파이프라인 프로파일링 및 최적화 방법

MongoDB 집계 파이프라인은 한 단계씩 쉽게 성장합니다. 보고서는 $match로 시작하고, 누군가 $lookup을 추가하고, $group을 추가하고, 정렬을 추가하고, 6개월 후에는 엔드포인트가 너무 느려져서 모두가 건드리기를 두려워합니다.

수정은 증거로 시작됩니다. 어떤 단계가 너무 많이 읽고, 너무 많이 확장하고, 너무 많이 정렬하거나, 너무 늦게 조인하는지 알아야 합니다. MongoDB는 이 작업을 위해 두 가지 실용적인 도구를 제공합니다. 과거의 느린 작업을 위한 데이터베이스 프로파일러와 하나의 파이프라인을 자세히 살펴보기 위한 .explain("executionStats")입니다.

MongoDB 프로파일러 이해하기

MongoDB 프로파일러는 find, update, delete, 그리고 이 가이드에서 가장 중요한 aggregate 명령을 포함한 데이터베이스 작업의 실행 세부 정보를 기록합니다. 작업이 얼마나 오래 걸렸는지, 어떤 리소스를 소비했는지, 어떤 단계가 지연 시간에 가장 많이 기여했는지 기록합니다.

프로파일링 수준 활성화 및 구성

프로파일링을 수행하기 전에 프로파일러가 활성화되어 있고 필요한 데이터를 캡처할 수 있는 수준으로 설정되어 있는지 확인해야 합니다. 프로파일링 수준은 0(끄기)에서 2(모든 작업 기록)까지입니다.

수준 설명
0 프로파일러가 비활성화됨.
1 slowOpThresholdMs 설정보다 오래 걸리는 작업을 기록합니다.
2 데이터베이스에 대해 실행된 모든 작업을 기록합니다.

프로파일러 수준을 설정하려면 db.setProfilingLevel() 명령을 사용합니다. 일반적으로 과도한 디스크 I/O를 피하기 위해 성능 테스트 중에 임시로 수준 1 또는 2를 사용하는 것이 좋습니다.

예: 프로파일러를 수준 1로 설정(100ms보다 느린 작업 기록)

// 데이터베이스에 연결: use myDatabase
db.setProfilingLevel(1, { slowOpThresholdMs: 100 })

// 설정 확인
db.getProfilingStatus()

모범 사례: 프로덕션 시스템에서 수준 2를 무기한으로 유지하지 마십시오. 모든 작업을 기록하면 쓰기 성능에 심각한 영향을 미칠 수 있습니다.

프로파일링된 집계 데이터 보기

프로파일링된 작업은 프로파일링 중인 데이터베이스 내의 system.profile 컬렉션에 저장됩니다. 이 컬렉션을 쿼리하여 최근의 느린 집계를 찾을 수 있습니다.

느린 집계 쿼리를 찾으려면 op 필드가 'aggregate'이고 실행 시간(millis)이 임계값을 초과하는 결과를 필터링합니다.

// 지난 1시간 동안의 모든 느린 집계 작업 찾기
db.system.profile.find(
  {
    op: 'aggregate',
    millis: { $gt: 100 } // 100ms보다 느린 작업
  }
).sort({ ts: -1 }).limit(5).pretty()

집계 파이프라인 실행 세부 정보 분석

프로파일러의 출력은 매우 중요합니다. 느린 집계 문서를 검토할 때 특히 planSummary와 결과 내의 stages 배열을 찾으십시오.

.explain('executionStats') 상세 출력 활용

프로파일러가 과거 데이터를 캡처하는 반면, .explain('executionStats')로 집계를 실행하면 MongoDB가 현재 데이터 세트에서 파이프라인을 어떻게 실행했는지에 대한 실시간 세분화된 세부 정보(단계별 타이밍 포함)를 제공합니다.

Explain 사용 예:

db.collection('sales').aggregate([
  { $match: { status: 'A' } },
  { $group: { _id: '$customerId', total: { $sum: '$amount' } } }
]).explain('executionStats');

출력에서 stages 배열은 파이프라인의 각 연산자를 자세히 설명합니다. 각 단계에 대해 다음을 찾으십시오:

  • executionTimeMillis: 해당 특정 단계를 실행하는 데 소요된 시간.
  • nReturned: 다음 단계로 전달된 문서 수.
  • totalKeysExamined / totalDocsExamined: I/O 비용을 나타내는 메트릭.

executionTimeMillis가 매우 높거나 반환하는 문서(nReturned)보다 훨씬 더 많은 문서(totalDocsExamined)를 검사하는 단계가 주요 최적화 대상입니다.

느린 집계 단계 최적화 전략

프로파일링이 병목 단계(예: $match, $lookup 또는 정렬 단계)를 식별하면 대상 최적화 기술을 적용할 수 있습니다.

1. 초기 필터링 최적화 ($match)

가능하면 $match 단계는 항상 파이프라인의 첫 번째 단계여야 합니다. 초기에 필터링하면 이후의 리소스 집약적인 단계(예: $group 또는 $lookup)가 처리해야 하는 문서 수가 줄어듭니다.

인덱싱의 역할: 초기 $match 단계가 느리다면 필터에 사용된 필드에 대한 인덱스가 거의 확실히 누락된 것입니다. $match에 사용된 필드를 포함하는 인덱스가 있는지 확인하십시오.

$match 단계에 인덱스되지 않은 필드가 포함된 경우 단계가 전체 컬렉션 스캔을 수행할 수 있으며, 이는 explain 출력에서 높은 totalDocsExamined로 명확하게 표시됩니다.

2. $lookup 효율적으로 활용하기 (조인)

$lookup 단계는 종종 가장 느린 구성 요소입니다. 이는 효과적으로 다른 컬렉션에 대한 안티 조인을 수행합니다.

  • 외래 키 인덱싱: 조인하는 외부(lookup된) 컬렉션의 필드가 인덱싱되었는지 확인하십시오. 이렇게 하면 내부 조회 프로세스 속도가 크게 빨라집니다.
  • 조회 전 필터링: 가능하면 $lookup 전에 $match 단계를 적용하여 필요한 문서에 대해서만 조인을 수행하십시오.

3. 비용이 많이 드는 정렬 처리 ($sort)

문서 정렬은 특히 큰 결과 집합의 경우 계산 비용이 많이 듭니다. MongoDB는 인덱스 접두사가 쿼리 필터와 일치하고 정렬 순서가 인덱스 정의와 일치하는 경우에만 인덱스를 사용하여 정렬할 수 있습니다.

$sort를 위한 주요 최적화: $sort 단계가 비용이 많이 드는 것으로 보이면 필터 및 필요한 정렬 순서와 일치하는 커버드 인덱스를 생성해 보십시오. 예를 들어 { status: 1 }로 필터링한 다음 { date: -1 }로 정렬하는 경우 { status: 1, date: -1 } 인덱스를 사용하면 MongoDB가 비용이 많이 드는 인메모리 정렬 없이 필요한 순서로 문서를 검색할 수 있습니다.

4. $project로 데이터 이동 최소화

$project 단계를 전략적으로 사용하여 파이프라인을 통해 전달되는 데이터 양을 줄이십시오. 이후 단계에서 몇 개의 필드만 필요한 경우 파이프라인 초기에 $project를 사용하여 불필요한 필드와 포함된 문서를 제거하십시오. 문서가 작을수록 파이프라인 단계 간에 이동되는 데이터가 줄어들고 잠재적으로 메모리 활용도가 향상됩니다.

5. 인덱스를 사용할 수 없는 비용이 많이 드는 단계 피하기

$unwind와 같은 단계는 많은 새 문서를 생성하여 처리 오버헤드를 빠르게 증가시킬 수 있습니다. 때로는 필요하지만 $unwind에 대한 입력이 가능한 한 작은지 확인하십시오. 마찬가지로 인덱스 지원 없이 계산이나 복잡한 표현식에 의존하는 단계와 같이 데이터 세트의 완전한 재평가를 강제하는 단계는 최소화해야 합니다.

현실적인 최적화 워크스루

지난 30일 동안 고객별 총 환불 금액을 보여주는 지원 대시보드를 상상해 보십시오. 처음에는 빨랐지만 1년 동안 주문이 누적된 후 느려졌습니다. 파이프라인은 무해해 보입니다:

db.orders.aggregate([
  { $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customer"
  }},
  { $unwind: "$customer" },
  { $match: { status: "refunded", createdAt: { $gte: startDate } } },
  { $group: { _id: "$customerId", totalRefunded: { $sum: "$amount" } } },
  { $sort: { totalRefunded: -1 } },
  { $limit: 50 }
])

비용이 많이 드는 실수는 작업 순서를 살펴볼 때까지 명확하지 않습니다. 이 파이프라인은 지난 30일 동안의 환불된 주문으로 필터링하기 전에 모든 주문을 고객과 조인합니다. 대규모 컬렉션의 경우 MongoDB는 나중에 버려질 문서에 대해 많은 조인을 수행합니다.

더 나은 첫 번째 버전은 초기에 필터링합니다:

db.orders.aggregate([
  { $match: { status: "refunded", createdAt: { $gte: startDate } } },
  { $group: { _id: "$customerId", totalRefunded: { $sum: "$amount" } } },
  { $sort: { totalRefunded: -1 } },
  { $limit: 50 },
  { $lookup: {
      from: "customers",
      localField: "_id",
      foreignField: "_id",
      as: "customer"
  }},
  { $unwind: "$customer" }
])

이제 조인은 컬렉션의 모든 주문이 아닌 상위 50개의 그룹화된 고객에 대해서만 발생합니다. 이것이 프로파일링이 당신을 이끌어야 하는 종류의 변화입니다: 더 적은 데이터가 비용이 많이 드는 단계로 들어갑니다.

이 버전의 경우 orders에 대한 유용한 인덱스는 다음과 같을 수 있습니다:

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

정확한 인덱스는 실제 필터 및 정렬 요구 사항에 따라 다르지만 아이디어는 안정적입니다: 초기 $match를 지원하고 가능한 경우 파이프라인이 추가 문서 읽기를 피하는 데 도움이 되는 필드를 포함합니다. customers 컬렉션에서 _id는 이미 인덱싱되어 있으므로 $lookup은 일반적으로 괜찮습니다. 다른 필드에서 조인하는 경우 해당 외래 필드를 인덱싱하십시오.

.explain("executionStats")를 검토할 때 총 실행 시간만 보지 마십시오. 팬아웃을 찾으십시오. 한 단계가 500개의 문서를 반환하고 다음 단계가 $unwind로 인해 200만 개를 반환한다면 문제의 형태를 바꾼 단계를 찾은 것입니다. totalDocsExaminednReturned보다 훨씬 크면 인덱스가 충분히 선택적이지 않거나 예상한 방식으로 사용되지 않는 것입니다. 큰 그룹 후에 파이프라인 후반에 정렬이 나타나면 더 일찍 제한하거나 초 단위의 최신성이 필요하지 않은 대시보드를 위해 별도의 컬렉션으로 사전 집계할 수 있는지 고려하십시오.

또한 메모리 동작을 주시하십시오. $group, $sort, $setWindowFields 및 일부 $lookup 패턴은 많은 메모리를 필요로 할 수 있습니다. allowDiskUse: true는 인메모리 제한을 초과할 때 파이프라인이 실패하는 것을 방지할 수 있지만 그 자체로 성능 수정은 아닙니다. 디스크로 넘어가는 것은 일반적으로 파이프라인이 한 번에 너무 많은 작업을 수행하고 있음을 의미합니다. 야간 보고서에는 허용될 수 있습니다. 모든 페이지 로드 시 실행되는 사용자 대면 API 엔드포인트에는 거의 허용되지 않습니다.

한 가지 실용적인 습관은 느린 파이프라인, explain 출력 및 인덱스를 인시던트 노트에 함께 저장하는 것입니다. 다음 사람은 인덱스가 존재하는 이유나 $lookup$limit 이후로 이동된 이유를 다시 발견할 필요가 없어야 합니다. 집계 튜닝은 추론이 디버깅 세션보다 오래 지속될 때 훨씬 쉽습니다.

집계에 도움이 되는 인덱스와 도움이 되는 것처럼 보이는 인덱스

집계 파이프라인은 종종 약한 복합 인덱스를 드러냅니다. API가 테넌트와 날짜별로 필터링한 다음 상태별로 그룹화한다고 가정해 보십시오:

db.orders.aggregate([
  { $match: { tenantId, createdAt: { $gte: start, $lt: end } } },
  { $group: { _id: "$status", count: { $sum: 1 } } }
])

{ createdAt: -1 } 인덱스는 약간 도움이 될 수 있지만 다중 테넌트 시스템에서는 모든 테넌트에 대해 큰 날짜 범위를 스캔할 수 있습니다. { tenantId: 1, createdAt: -1 } 인덱스는 일반적으로 먼저 테넌트로 좁힌 다음 날짜 범위를 탐색하므로 액세스 패턴과 더 잘 일치합니다. 대부분의 쿼리에 상태도 포함되는 경우 { tenantId: 1, status: 1, createdAt: -1 }이 해당 워크로드에 더 나은지 테스트하십시오. 추측하지 마십시오. 프로덕션과 유사한 데이터에서 explain을 실행하고 keysExamined, docsExamined 및 경과 시간을 비교하십시오.

인덱스 앞부분의 낮은 카디널리티 필드를 조심하십시오. { status: 1 }로 시작하는 인덱스는 거의 모든 주문이 complete인 경우 선택적이지 않을 수 있습니다. 다른 필드와 결합될 때 여전히 유용할 수 있지만 쿼리 형태를 반영해야 합니다. 가장 좋은 인덱스는 필드가 가장 많은 인덱스가 아니라 불필요한 쓰기 오버헤드를 생성하지 않으면서 검색 공간을 조기에 줄이는 인덱스입니다.

파이프라인 최적화를 중단해야 하는 경우

때로는 올바른 수정이 또 다른 파이프라인 재작성이 아닙니다. 관리자가 페이지를 열 때마다 동일한 비용이 많이 드는 집계를 실행하는 대시보드의 경우 사전 집계가 더 깔끔할 수 있습니다. 예약된 작업은 시간별 합계를 order_stats_hourly 컬렉션에 쓰고 대시보드는 몇 개의 작은 문서를 읽을 수 있습니다. 최신성과 예측 가능한 지연 시간을 맞바꾸는 것입니다.

이러한 트레이드오프는 사람들이 추세를 읽을 때 종종 허용됩니다. 체크아웃 결정이나 사기 규칙을 구동하는 파이프라인에서는 덜 허용됩니다. 최신성 요구 사항을 명시적으로 만드십시오. "5분 이내"는 사전 집계 및 캐싱을 가능하게 합니다. "마지막으로 확인된 주문을 포함해야 함"은 아마도 더 강력한 쓰기 및 읽기 동작으로 라이브 읽기에 더 가깝게 유지할 것입니다.

집계 최적화는 모든 파이프라인을 영리하게 만드는 것이 아닙니다. 요청 경로에서 데이터베이스가 수행하지 않아도 되는 작업을 제거하는 것입니다.

요약 및 다음 단계

MongoDB 집계 파이프라인을 프로파일링하고 최적화하려면 체계적이고 증거 기반 접근 방식이 필요합니다. 내장 프로파일러(db.setProfilingLevel)를 활용하고 상세한 실행 통계(.explain('executionStats'))를 실행하면 복잡한 성능 문제를 해결 가능한 단계로 변환할 수 있습니다.

최적화 워크플로는 다음과 같습니다:

  1. 프로파일링 활성화: 수준 1을 설정하고 slowOpThresholdMs를 정의합니다.
  2. 쿼리 실행: 느린 집계 파이프라인을 실행합니다.
  3. 프로파일링된 데이터 분석: 가장 많은 시간을 소비하는 특정 단계를 식별합니다.
  4. 상세 설명: 문제가 있는 파이프라인에 .explain('executionStats')를 사용합니다.
  5. 튜닝: 필요한 인덱스를 생성하고, 단계를 재정렬하고(먼저 필터링), 비용이 많이 드는 연산자에 전달되는 데이터를 단순화합니다.

지속적인 모니터링은 새로 추가된 기능이나 증가된 데이터 볼륨이 해결한 성능 문제를 다시 도입하지 않도록 보장합니다.