2025년 11월 25일 작성
MongoDB - Aggregation Pipeline에서 Index 활용하기
aggregation pipeline에서는 초기 stage만 index를 활용하므로, index를 사용하는 stage를 앞쪽에 배치하여 성능을 최적화합니다.
Aggregation Pipeline과 Index
- aggregation pipeline에서 index는 특정 stage에서만 활용되며, pipeline 구성 방식에 따라 성능이 크게 달라집니다.
$match,$sort같은 초기 stage에서는 index를 활용할 수 있지만,$group,$unwind이후에는 index를 사용할 수 없습니다.
- pipeline 최적화의 핵심은 index를 활용하는 stage를 가능한 한 앞쪽에 배치하는 것입니다.
- 초기 단계에서 document 수를 줄이고 index를 활용하면 전체 pipeline 성능이 향상됩니다.
Stage별 Index 활용 여부
- aggregation pipeline의 각 stage는 index 활용 가능 여부가 다르며,
$match와$sort는 pipeline 초기에 있을 때만 index를 사용합니다.
Index를 활용하는 Stage
- pipeline 초기 단계에서
$match,$sort,$geoNearstage들은 index를 활용할 수 있습니다.
$match
- pipeline의 첫 번째 stage이거나 index를 사용하지 않는 stage 바로 다음에 오면 index를 활용합니다.
- 일반적인
find()query와 동일하게 index를 사용합니다.
- 일반적인
// index 활용 (pipeline 첫 단계)
db.orders.aggregate([
{ $match: { status: "pending" } }, // index 사용
{ $group: { _id: "$customerId", total: { $sum: "$amount" } } }
]);
// index 활용 안 됨 ($group 이후)
db.orders.aggregate([
{ $group: { _id: "$customerId", total: { $sum: "$amount" } } },
{ $match: { total: { $gte: 1000 } } } // index 사용 불가
]);
$match를 가능한 한 pipeline 앞쪽에 배치해야 index를 활용할 수 있습니다.
$sort
- pipeline 초기에 위치하고 적절한 index가 있으면 index를 활용하여 정렬합니다.
- memory 정렬 없이 index의 순서대로 document를 읽습니다.
// index 활용
db.orders.aggregate([
{ $match: { status: "pending" } },
{ $sort: { createdAt: -1 } }, // index 사용 (status_1_createdAt_-1 index 있는 경우)
{ $limit: 10 }
]);
// index 활용 안 됨 ($group 이후)
db.orders.aggregate([
{ $group: { _id: "$customerId", total: { $sum: "$amount" } } },
{ $sort: { total: -1 } } // memory 정렬
]);
$sort가$group,$project등의 변환 stage 이후에 오면 index를 사용할 수 없습니다.
$geoNear
- geospatial index를 필수로 사용하는 stage입니다.
- pipeline의 첫 번째 stage여야 합니다.
db.places.aggregate([
{
$geoNear: {
near: { type: "Point", coordinates: [127.0, 37.5] },
distanceField: "distance",
spherical: true
}
},
{ $limit: 10 }
]);
$geoNear는 hint와 함께 사용할 수 없으며, 자동으로 적절한 geospatial index를 선택합니다.
Index를 활용하지 않는 Stage
$group,$unwind,$project같은 변환 stage들은 index를 사용하지 않으며, 이들 이후의 stage도 index를 활용할 수 없습니다.
$group
- document를 grouping하고 집계하는 stage로, index를 사용하지 않습니다.
- memory나 disk에서 grouping 작업을 수행합니다.
db.orders.aggregate([
{ $match: { status: "pending" } }, // index 사용
{ $group: { // index 사용 안 함
_id: "$customerId",
total: { $sum: "$amount" }
}}
]);
$group이후의 stage는 grouping 결과에 대해 작동하므로 원본 collection의 index를 사용할 수 없습니다.
$unwind
- array field를 펼쳐서 개별 document로 만드는 stage로, index를 사용하지 않습니다.
db.orders.aggregate([
{ $match: { status: "pending" } }, // index 사용
{ $unwind: "$items" }, // index 사용 안 함
{ $group: { _id: "$items.product", count: { $sum: 1 } } }
]);
$project
- field를 선택하거나 새로운 field를 생성하는 stage로, index를 사용하지 않습니다.
db.orders.aggregate([
{ $match: { status: "pending" } }, // index 사용
{ $project: { // index 사용 안 함
customerId: 1,
totalAmount: { $multiply: ["$quantity", "$price"] }
}}
]);
$lookup
- 다른 collection과 join하는 stage로, 각 collection은 독립적으로 index를 사용합니다.
- lookup 대상 collection의 조건에 맞는 index가 있으면 활용됩니다.
db.orders.aggregate([
{ $match: { status: "pending" } }, // orders collection의 index 사용
{
$lookup: {
from: "customers",
localField: "customerId",
foreignField: "_id", // customers collection의 _id index 사용
as: "customer"
}
}
]);
$lookup의 성능은 대상 collection의 join field에 index가 있는지에 크게 영향받습니다.
Pipeline 최적화 전략
- aggregation pipeline의 성능을 최적화하려면 index 활용, document 수 조기 감소, memory 사용 최소화를 고려하여 stage 순서를 조정해야 합니다.
Index 활용 Stage를 앞쪽에 배치하기
$match와$sort를 pipeline 초기에 배치하여 index를 활용합니다.- 가능한 한 첫 번째 또는 두 번째 stage로 배치합니다.
// 비효율적
db.orders.aggregate([
{ $project: { customerId: 1, amount: 1, status: 1 } },
{ $match: { status: "pending" } }, // index 사용 불가
{ $sort: { amount: -1 } } // memory 정렬
]);
// 효율적
db.orders.aggregate([
{ $match: { status: "pending" } }, // index 사용
{ $sort: { amount: -1 } }, // index 사용 가능
{ $project: { customerId: 1, amount: 1 } }
]);
Document 수를 조기에 감소시키기
$match와$limit를 사용하여 초기 단계에서 처리할 document 수를 줄입니다.- 이후 stage의 처리 부하가 감소합니다.
db.orders.aggregate([
{ $match: { status: "pending", amount: { $gte: 1000 } } }, // 대량 감소
{ $sort: { createdAt: -1 } },
{ $limit: 100 }, // 추가 감소
{ $lookup: { from: "customers", ... } }, // 100개만 join
{ $group: { _id: "$customer.region", total: { $sum: "$amount" } } }
]);
$match를 여러 단계에 배치하기
$match는 여러 번 사용할 수 있으며, 각 단계마다 불필요한 document를 제거합니다.
db.orders.aggregate([
{ $match: { status: "pending" } }, // 초기 filtering (index 사용)
{ $lookup: { from: "customers", ... } },
{ $match: { "customer.tier": "vip" } }, // join 후 추가 filtering
{ $group: { _id: "$product", total: { $sum: "$amount" } } },
{ $match: { total: { $gte: 10000 } } } // 집계 후 filtering
]);
Covered Query 활용하기
$project를 사용하여 index만으로 필요한 field를 제공할 수 있으면 성능이 향상됩니다.
// index : { status: 1, customerId: 1, amount: 1 }
db.orders.aggregate([
{ $match: { status: "pending" } },
{ $project: { customerId: 1, amount: 1, _id: 0 } }, // covered query
{ $group: { _id: "$customerId", total: { $sum: "$amount" } } }
]);
- index에 포함된 field만 projection하면 실제 document를 읽지 않아도 됩니다.
$sort + $limit 조합 최적화
$sort바로 다음에$limit가 오면 MongoDB가 자동으로 최적화합니다.- 모든 document를 정렬하지 않고 top N만 유지합니다.
db.orders.aggregate([
{ $match: { status: "pending" } },
{ $sort: { amount: -1 } },
{ $limit: 10 } // top 10만 정렬
]);
$lookup과 Index
$lookup은 join 대상 collection에서 독립적으로 index를 사용하며, join field에 index가 있으면 성능이 크게 향상됩니다.
$lookup의 기본 동작
$lookup은 각 입력 document마다 대상 collection을 조회합니다.foreignField에 index가 있으면 효율적으로 조회합니다.
db.orders.aggregate([
{ $match: { status: "pending" } },
{
$lookup: {
from: "customers",
localField: "customerId",
foreignField: "_id", // _id는 자동으로 indexing
as: "customer"
}
}
]);
foreignField에 index가 없으면 매번 collection scan이 발생하여 매우 느립니다.
$lookup 성능 최적화
- join 대상 field에 반드시 index를 생성합니다.
// customers collection에 index 생성
db.customers.createIndex({ _id: 1 }); // 기본으로 존재
db.customers.createIndex({ email: 1 });
// $lookup에서 활용
db.orders.aggregate([
{
$lookup: {
from: "customers",
localField: "customerEmail",
foreignField: "email", // index 있음
as: "customer"
}
}
]);
Pipeline 형태의 $lookup
- pipeline 형태의
$lookup은 더 복잡한 조건을 지원하며, pipeline 내부에서도 index를 활용할 수 있습니다.
db.orders.aggregate([
{
$lookup: {
from: "customers",
let: { customerId: "$customerId" },
pipeline: [
{ $match: { // customers collection의 index 사용 가능
$expr: { $eq: ["$_id", "$$customerId"] },
tier: "vip"
}},
{ $project: { name: 1, email: 1 } }
],
as: "customer"
}
}
]);
$matchstage가 pipeline 내부에서도 index를 활용합니다.
$unionWith와 Index
$unionWith는 여러 collection의 결과를 합치며, 각 collection은 독립적으로 자신의 index를 사용하지만, union 이후의 작업은 전체 결과를 대상으로 수행됩니다.
$unionWith의 Index 동작
- main collection과
$unionWith로 합쳐지는 collection은 각각 독립적으로 index를 활용합니다.- 합쳐진 결과는 memory상의 document 집합이므로 index가 없습니다.
// orders collection의 index 사용
db.orders.aggregate([
{ $match: { status: "pending" } }, // orders의 status index 사용
{
$unionWith: {
coll: "archived_orders",
pipeline: [
{ $match: { status: "pending" } } // archived_orders의 status index 사용
]
}
},
{ $sort: { createdAt: -1 } } // union 결과 정렬 (index 없음, memory 정렬)
]);
- union 이후의
$sort,$match등은 결과 집합에 대해 작동하므로 index를 사용할 수 없습니다.
Union 후 정렬의 실제 동작
$unionWith이후$sort는 합쳐진 전체 결과를 대상으로 수행됩니다.
// 실행 단계별 분석
db.payments.aggregate([
{ $match: { type: "card" } }, // 1. index 사용, 10,000건 추출
{
$unionWith: {
coll: "payments",
pipeline: [
{ $match: { type: "bank_transfer" } } // 2. index 사용, 15,000건 추출
]
}
},
// 3. 10,000 + 15,000 = 25,000건 병합
{ $sort: { createdAt: -1 } }, // 4. 25,000건 전체를 memory 정렬
{ $limit: 100 } // 5. 상위 100개만 반환
]);
- 각
$match는 index를 활용하여 빠르게 document를 추출합니다. - union은 단순히 두 결과를 붙이므로 빠릅니다.
$sort는 병합된 25,000건 전체를 대상으로 정렬합니다.- index가 없으므로 memory 정렬이 발생합니다.
$limit와 함께 사용되므로 top-k algorithm으로 100개만 memory에 유지합니다.- 하지만 25,000건을 모두 비교하는 연산은 필요합니다.
$sort + $skip + $limit 조합의 영향
$skip값이 클수록 처리해야 할 document 수가 증가합니다.
db.payments.aggregate([
{ $match: { type: "card" } }, // 10,000건
{
$unionWith: {
coll: "payments",
pipeline: [
{ $match: { type: "bank_transfer" } } // 15,000건
]
}
},
// 25,000건 병합
{ $sort: { createdAt: -1 } }, // 25,000건 전체 정렬
{ $skip: 1000 }, // 1,000개 건너뛰기
{ $limit: 100 } // 100개 반환
]);
$skip: 1000,$limit: 100이면 상위 1,100개를 유지하면서 정렬합니다.- 25,000건을 모두 비교하지만, memory에는 1,100개만 유지합니다.
$skip값이 클수록 memory 사용량이 증가합니다.
$unionWith 성능 최적화 전략
$match에서 최대한 document 수를 줄이는 것이 핵심입니다.
// 비효율적 (전체 조회 후 filtering)
db.orders.aggregate([
{ $match: { status: "pending" } }, // 50,000건
{
$unionWith: {
coll: "archived_orders",
pipeline: [
{ $match: { status: "pending" } } // 100,000건
]
}
},
// 150,000건 정렬
{ $sort: { createdAt: -1 } },
{ $limit: 100 }
]);
// 효율적 (조기에 filtering)
db.orders.aggregate([
{ $match: {
status: "pending",
amount: { $gte: 1000 }, // 추가 조건
createdAt: { $gte: new Date("2025-01-01") } // 날짜 범위 제한
}}, // 5,000건으로 감소
{
$unionWith: {
coll: "archived_orders",
pipeline: [
{ $match: {
status: "pending",
amount: { $gte: 1000 },
createdAt: { $gte: new Date("2025-01-01") }
}} // 8,000건으로 감소
]
}
},
// 13,000건 정렬 (150,000건 대비 크게 감소)
{ $sort: { createdAt: -1 } },
{ $limit: 100 }
]);
각 Pipeline에서 미리 제한하는 방법
- 각 pipeline에서 미리 정렬하고 제한할 수 있지만, 결과가 부정확할 수 있습니다.
db.orders.aggregate([
{ $match: { status: "pending" } },
{ $sort: { createdAt: -1 } },
{ $limit: 100 }, // 메인에서 100개만
{
$unionWith: {
coll: "archived_orders",
pipeline: [
{ $match: { status: "pending" } },
{ $sort: { createdAt: -1 } },
{ $limit: 100 } // union 대상도 100개만
]
}
},
// 200건만 정렬 (전체 대비 크게 감소)
{ $sort: { createdAt: -1 } },
{ $limit: 100 }
]);
- union 후 정렬 대상이 200건으로 줄어듭니다.
- 하지만 각 collection에서 100개씩 자른 후 합치므로, 실제 최신 100개가 아닐 수 있습니다.
- 예를 들어, 실제 최신 100개가 모두
orders에 있다면archived_orders의 100개는 불필요합니다.
- 예를 들어, 실제 최신 100개가 모두
Application Level에서 병합
- 가장 정확하고 효율적인 방법은 application에서 병합하는 것입니다.
// 각각 독립적으로 조회 (각각 index 활용)
const cardPayments = await db.payments.find({ type: "card", ... })
.sort({ createdAt: -1 })
.limit(100)
.toArray();
const bankTransfers = await db.payments.find({ type: "bank_transfer", ... })
.sort({ createdAt: -1 })
.limit(100)
.toArray();
// Application에서 merge sort (O(n) 시간)
const merged = mergeSortedArrays(cardPayments, bankTransfers, 100);
- 각 query는 index를 활용하여 최적화됩니다.
- 이미 정렬된 두 배열을 병합하는 것은 매우 빠릅니다.
- 정확한 상위 100개를 보장하려면 각각 충분히 많이 가져와야 합니다.
Union 후에는 Index 최적화 불가
$unionWith이후에는 index를 활용한 모든 최적화 기법이 적용되지 않습니다.
db.orders.aggregate([
{ $match: { status: "pending" } }, // index 사용
{ $project: { status: 1, amount: 1, _id: 0 } }, // covered query 가능
{
$unionWith: {
coll: "archived_orders",
pipeline: [
{ $match: { status: "pending" } }, // index 사용
{ $project: { status: 1, amount: 1, _id: 0 } } // covered query 가능
]
}
},
// union 이후
{ $match: { amount: { $gte: 1000 } } }, // index 없음, memory filtering
{ $sort: { amount: -1 } }, // index 없음, memory 정렬
{ $project: { status: 1 } } // covered query 불가, memory에서 직접 접근
]);
- union 이전 각 pipeline에서는 index 최적화가 가능합니다.
$match는 index 사용.$project는 covered query 가능.$sort는 index 활용 가능.
- union 이후에는 모든 작업이 memory상에서 수행됩니다.
$match: memory에서 filtering.$sort: memory에서 정렬.$project: covered query 불가, memory에서 field 접근.$limit,$skip: memory에서 처리.
Aggregation에서 Explain 사용
explain()method를 사용하여 aggregation pipeline이 index를 제대로 활용하는지 확인합니다.
Aggregation Explain 사용법
db.orders.explain("executionStats").aggregate([
{ $match: { status: "pending" } },
{ $sort: { createdAt: -1 } },
{ $limit: 10 }
]);
Explain 결과 확인 사항
stagesfield에서 각 stage의 실행 계획을 확인합니다.
{
"stages": [
{
"$cursor": {
"queryPlanner": {
"winningPlan": {
"stage": "LIMIT",
"inputStage": {
"stage": "FETCH",
"inputStage": {
"stage": "IXSCAN", // index 사용
"indexName": "status_1_createdAt_-1"
}
}
}
}
}
}
]
}
IXSCAN이 나타나면 index를 사용하고,COLLSCAN이 나타나면 collection scan입니다.
성능 지표 확인
{
"executionStats": {
"nReturned": 10,
"executionTimeMillis": 5,
"totalKeysExamined": 10,
"totalDocsExamined": 10
}
}
totalDocsExamined/nReturned비율이 낮을수록 효율적입니다.executionTimeMillis가 짧을수록 좋습니다.
실전 활용 사례
- 실무에서 자주 사용하는 aggregation pattern에서 index를 효과적으로 활용할 수 있는 여러 방법들이 있습니다.
시계열 Data 집계
// 비효율적
db.logs.aggregate([
{ $group: {
_id: { $dateToString: { format: "%Y-%m-%d", date: "$timestamp" } },
count: { $sum: 1 }
}},
{ $match: { _id: { $gte: "2025-01-01" } } } // group 후 filtering
]);
// 효율적
db.logs.aggregate([
{ $match: { // index 사용
timestamp: {
$gte: ISODate("2025-01-01"),
$lt: ISODate("2026-01-01")
}
}},
{ $group: {
_id: { $dateToString: { format: "%Y-%m-%d", date: "$timestamp" } },
count: { $sum: 1 }
}}
]);
timestampfield에 index가 있으면 초기 filtering에서 활용됩니다.
Top N Query
db.products.aggregate([
{ $match: { category: "electronics", inStock: true } }, // index 사용
{ $sort: { salesCount: -1 } }, // index 사용 (category_1_salesCount_-1)
{ $limit: 10 },
{ $lookup: {
from: "reviews",
localField: "_id",
foreignField: "productId", // reviews의 productId index 사용
as: "reviews"
}},
{ $project: {
name: 1,
price: 1,
avgRating: { $avg: "$reviews.rating" }
}}
]);
- compound index
{ category: 1, salesCount: -1 }가 있으면 최적입니다.
Hierarchical Data 조회
db.comments.aggregate([
{ $match: { postId: ObjectId("...") } }, // postId index 사용
{ $sort: { createdAt: -1 } }, // postId_1_createdAt_-1 index 사용
{
$lookup: {
from: "users",
localField: "userId",
foreignField: "_id", // users의 _id index 사용
as: "author"
}
},
{ $unwind: "$author" },
{ $project: {
content: 1,
createdAt: 1,
"author.name": 1,
"author.avatar": 1
}}
]);
복잡한 Filtering 후 집계
db.sales.aggregate([
{ $match: { // index 사용
saleDate: { $gte: ISODate("2025-01-01") },
status: "completed"
}},
{ $lookup: {
from: "products",
localField: "productId",
foreignField: "_id",
as: "product"
}},
{ $unwind: "$product" },
{ $match: { "product.category": "electronics" } }, // join 후 filtering
{ $group: {
_id: "$product.brand",
totalRevenue: { $sum: { $multiply: ["$quantity", "$price"] } },
totalSold: { $sum: "$quantity" }
}},
{ $match: { totalRevenue: { $gte: 100000 } } }, // 집계 후 filtering
{ $sort: { totalRevenue: -1 } }
]);
- 각 단계에서 점진적으로 filtering하여 처리량을 줄입니다.
주의 사항
- aggregation pipeline에서 index를 활용할 때 주의해야 할 사항들입니다.
Memory 제한
- 각 stage는 기본적으로 100MB memory 제한이 있습니다.
$group,$sort등에서 대량 data를 처리하면 error가 발생할 수 있습니다.
// error 발생 가능
db.orders.aggregate([
{ $group: { _id: "$customerId", orders: { $push: "$$ROOT" } } }
]);
// allowDiskUse 옵션 사용
db.orders.aggregate(
[
{ $group: { _id: "$customerId", orders: { $push: "$$ROOT" } } }
],
{ allowDiskUse: true }
);
allowDiskUse: trueoption을 사용하면 disk를 임시 저장소로 사용합니다.
Index 선택 강제
- aggregation에서도
hint를 사용하여 특정 index를 강제할 수 있습니다.
db.orders.aggregate(
[
{ $match: { status: "pending", customerId: 12345 } },
{ $sort: { createdAt: -1 } }
],
{ hint: "status_1_customerId_1_createdAt_-1" }
);
Pipeline 순서 자동 최적화
- MongoDB는 일부 stage의 순서를 자동으로 최적화합니다.
$project+$match를$match+$project로 재배치하는 등의 최적화가 자동으로 이루어집니다.
- 그러나 명시적으로 최적의 순서로 작성하는 것이 더 명확합니다.
Shard 환경에서의 고려 사항
- sharded collection에서는 각 shard가 독립적으로 aggregation을 수행합니다.
$group,$sort등은 각 shard에서 실행된 후 결과를 병합합니다.- shard key에 맞는 index가 중요합니다.
Reference
- https://www.mongodb.com/docs/manual/core/aggregation-pipeline/
- https://www.mongodb.com/docs/manual/core/aggregation-pipeline-optimization/
- https://www.mongodb.com/docs/manual/reference/operator/aggregation/lookup/
- https://www.mongodb.com/docs/manual/reference/operator/aggregation/unionWith/