- Published on
MongoDB 느린 $lookup 5배 빠르게 - 인덱스·튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 컬렉션을 조인해야 할 때 MongoDB의 $lookup은 강력합니다. 하지만 운영 환경에서 “특정 API만 유독 느리다”를 파고 들어가면, 범인으로 $lookup이 자주 등장합니다. 특히 조인 대상이 커지거나(수백만 문서), 조인 결과가 배열로 폭발하거나, 조인 전에 필터링이 덜 된 상태라면 $lookup은 CPU/메모리/디스크를 동시에 압박하며 레이턴시를 급격히 끌어올립니다.
이 글에서는 느린 $lookup을 5배 이상 빠르게 만드는 대표적인 접근을 다룹니다.
- 실행 계획으로 병목을 먼저 “증명”하기
- 조인 키 인덱스와 복합 인덱스 설계
$lookup파이프라인 최적화(필터 선적용, 프로젝션 최소화, 조인 결과 제한)- 조인 폭발 방지(카디널리티 관리)
- 필요 시 스키마/쿼리 패턴 변경(denormalization, pre-aggregation)
운영 장애를 다루는 관점에서, 리소스 압박이 동반되는 경우도 많습니다. 조인 쿼리로 메모리가 치솟아 프로세스가 죽는다면 함께 참고할 만합니다: 리눅스 OOM Killer로 프로세스 죽음 진단·방지
1) 왜 $lookup이 느려지는가: 3가지 전형적인 패턴
1-1. 조인 키에 인덱스가 없다
$lookup은 내부적으로 foreign 컬렉션에서 매칭을 찾는 작업이 들어갑니다. foreignField(또는 파이프라인 $match에서 쓰는 필드)에 인덱스가 없으면, 매번 광범위한 스캔이 발생할 수 있습니다.
1-2. 필터링보다 조인을 먼저 한다
파이프라인에서 $match가 뒤에 있으면, 불필요하게 많은 “왼쪽 문서”에 대해 조인을 수행합니다. 조인 비용은 대개 왼쪽 입력 문서 수 × 매칭 탐색 비용이므로, 입력을 먼저 줄이는 것이 가장 큰 레버리지입니다.
1-3. 조인 결과 배열이 폭발한다(카디널리티 문제)
user -> orders처럼 1:N 관계에서, 어떤 사용자는 주문이 수천 건일 수 있습니다. 이때 $lookup 결과 배열이 커지면 메모리 사용량이 급증하고, 이후 단계($unwind, $group, $sort)가 연쇄적으로 느려집니다.
2) 먼저 측정: explain()으로 병목을 고정하라
튜닝의 시작은 “감”이 아니라 실행 계획입니다. MongoDB에서는 aggregation에 대해 explain을 걸어 다음을 확인합니다.
- foreign 컬렉션에서
IXSCAN(인덱스 스캔)을 타는가,COLLSCAN(컬렉션 스캔)인가 $lookup단계가 전체 시간의 대부분을 차지하는가- 반환 문서 수 대비 처리 문서 수가 과도하게 큰가
// mongosh
const pipeline = [
{ $match: { status: "ACTIVE" } },
{
$lookup: {
from: "orders",
localField: "_id",
foreignField: "userId",
as: "orders"
}
},
{ $project: { email: 1, orders: { $slice: ["$orders", 5] } } }
];
db.users.explain("executionStats").aggregate(pipeline);
executionStats에서 특히 아래를 봅니다.
totalDocsExamined,totalKeysExamined- stage별 time/works(버전에 따라 표현 상이)
- foreign 쪽이 인덱스를 타는지
목표: $lookup이 foreign 컬렉션에서 COLLSCAN을 유발하고 있다면, 인덱스가 1순위입니다.
3) 인덱스 튜닝: $lookup 성능의 70%는 여기서 결정된다
3-1. 기본: foreignField에 단일 인덱스
가장 흔한 형태:
{
$lookup: {
from: "orders",
localField: "_id",
foreignField: "userId",
as: "orders"
}
}
이 경우 orders.userId 인덱스가 사실상 필수입니다.
db.orders.createIndex({ userId: 1 });
3-2. 파이프라인 $lookup에서는 “실제로 매치하는 조건”에 맞춘 복합 인덱스
운영에서는 조인하면서 특정 기간/상태만 가져오는 경우가 많습니다.
{
$lookup: {
from: "orders",
let: { uid: "$_id" },
pipeline: [
{
$match: {
$expr: {
$and: [
{ $eq: ["$userId", "$$uid"] },
{ $eq: ["$status", "PAID"] },
{ $gte: ["$createdAt", ISODate("2025-01-01") ] }
]
}
}
},
{ $sort: { createdAt: -1 } },
{ $limit: 10 },
{ $project: { _id: 1, total: 1, createdAt: 1 } }
],
as: "recentPaidOrders"
}
}
이때는 orders에 다음처럼 복합 인덱스를 고려합니다.
// 매치 + 정렬을 함께 만족시키는 인덱스
db.orders.createIndex({ userId: 1, status: 1, createdAt: -1 });
핵심은:
$match에서 고정 조건(카디널리티 높은 것)을 앞쪽에$sort까지 커버하려면 정렬 필드를 뒤에 같은 방향으로$limit과 결합되면 인덱스 상단에서 필요한 만큼만 읽고 끝낼 수 있음
3-3. 부분 인덱스(Partial Index)로 인덱스 크기와 스캔량을 줄이기
status: "PAID"만 자주 조인한다면 부분 인덱스가 효과적입니다.
db.orders.createIndex(
{ userId: 1, createdAt: -1 },
{ partialFilterExpression: { status: "PAID" } }
);
부분 인덱스는 디스크/메모리(인덱스 캐시) 압박도 줄여줘서, 조인뿐 아니라 전체 시스템 안정성에도 기여합니다.
4) 파이프라인 튜닝: “조인 전 줄이고, 조인 후 더 줄이기”
4-1. $match는 최대한 앞에 두기
가장 흔한 실수는 이런 형태입니다.
// 나쁜 예: 조인을 먼저 하고 필터링
[
{ $lookup: { from: "orders", localField: "_id", foreignField: "userId", as: "orders" } },
{ $match: { status: "ACTIVE" } }
]
반대로:
// 좋은 예: 왼쪽 입력을 먼저 줄임
[
{ $match: { status: "ACTIVE" } },
{ $lookup: { from: "orders", localField: "_id", foreignField: "userId", as: "orders" } }
]
단순하지만, 입력 문서가 10배 줄면 $lookup 호출도 10배 줄어 체감 성능이 크게 좋아집니다.
4-2. $lookup 파이프라인에서 $project로 필드 최소화
조인 결과에 큰 문서(예: items, shippingAddress, metadata)를 통째로 붙이면 네트워크/메모리/후속 stage 비용이 커집니다.
{
$lookup: {
from: "orders",
let: { uid: "$_id" },
pipeline: [
{ $match: { $expr: { $eq: ["$userId", "$$uid"] } } },
{ $project: { _id: 1, total: 1, createdAt: 1 } }
],
as: "orders"
}
}
원칙: 조인 결과는 “정말 필요한 필드만” 남기고, 큰 배열/서브도큐먼트는 가능한 한 제거합니다.
4-3. 조인 결과가 많을 때는 $sort + $limit로 폭발을 제어
최근 10개 주문만 필요하다면, 전체를 붙여놓고 나중에 자르지 말고 foreign에서부터 제한해야 합니다.
{
$lookup: {
from: "orders",
let: { uid: "$_id" },
pipeline: [
{ $match: { $expr: { $eq: ["$userId", "$$uid"] } } },
{ $sort: { createdAt: -1 } },
{ $limit: 10 },
{ $project: { total: 1, createdAt: 1 } }
],
as: "recentOrders"
}
}
이 패턴은 위에서 만든 { userId: 1, createdAt: -1 } 인덱스와 결합될 때 특히 강력합니다.
4-4. $unwind는 신중하게: 필요하면 “조인 전/후” 위치를 바꿔라
$unwind는 문서를 늘립니다. 조인 결과 배열을 unwind하면 문서 수가 폭발하고, 이후 $group/$sort가 급격히 무거워질 수 있습니다.
가능한 대안:
- foreign에서 먼저 집계해서 작은 결과로 만들고 붙이기
$unwind대신$arrayElemAt,$first같은 연산으로 필요한 값만 추출
예: 주문 총액 합만 필요하다면 주문을 전부 붙이지 말고 foreign에서 그룹핑:
[
{ $match: { status: "ACTIVE" } },
{
$lookup: {
from: "orders",
let: { uid: "$_id" },
pipeline: [
{ $match: { $expr: { $eq: ["$userId", "$$uid"] } } },
{ $group: { _id: null, sumTotal: { $sum: "$total" }, cnt: { $sum: 1 } } },
{ $project: { _id: 0, sumTotal: 1, cnt: 1 } }
],
as: "orderAgg"
}
},
{
$addFields: {
orderCount: { $ifNull: [{ $first: "$orderAgg.cnt" }, 0] },
orderSum: { $ifNull: [{ $first: "$orderAgg.sumTotal" }, 0] }
}
},
{ $project: { orderAgg: 0 } }
]
조인 결과를 “데이터”가 아니라 “요약”으로 바꾸면, 처리량이 급격히 좋아집니다.
5) $lookup 자체를 줄이는 전략: 데이터 모델/쿼리 패턴 변경
튜닝을 해도 물리적으로 조인량이 너무 크면 한계가 있습니다. 다음을 검토합니다.
5-1. Denormalization(부분 중복 저장)
예: 주문 목록 화면에서 사용자 이름/등급이 필요할 때 매번 users를 조인하지 말고, 주문 생성 시점에 userName, userTier를 orders에 복사해 둡니다.
- 장점: 읽기 성능 극대화
- 단점: 업데이트 동기화 필요(이름 변경 등)
5-2. Pre-aggregation(사전 집계 컬렉션)
대시보드/리포트처럼 동일한 집계를 반복한다면, 배치/스트림으로 user_order_stats 같은 컬렉션을 만들어 $lookup을 “작은 테이블 조인”으로 바꿉니다.
5-3. 조회 API에서 “필요한 화면 단위”로 쿼리를 쪼개기
한 번의 aggregation에 모든 걸 우겨 넣는 대신:
- users 기본 정보
- 최근 주문 10개
처럼 2번 쿼리로 나누고 애플리케이션에서 합치는 방식이 더 빠를 때가 있습니다(특히 캐시 전략과 결합 시).
6) 실전 체크리스트: 5배 개선을 만드는 우선순위
explain("executionStats")로 foreign가 COLLSCAN인지 확인- foreign 매치 필드에 인덱스(필요 시 복합/부분 인덱스)
$match를 파이프라인 최전방으로 이동(왼쪽 입력 줄이기)$lookup.pipeline에서$project로 필드 최소화- 결과 폭발 시
$sort + $limit를 foreign에서 적용 $unwind를 줄이거나, foreign에서 먼저$group으로 요약해서 붙이기- 그래도 느리면 denormalization / pre-aggregation 검토
리소스가 빡빡한 환경(컨테이너/노드 메모리 제한)에서는 $lookup 튜닝이 곧 안정성 튜닝입니다. 조인 쿼리로 메모리 스파이크가 나고 프로세스가 종료된다면 위의 “결과 폭발 제어”와 함께, 시스템 관점의 진단도 병행하세요: 리눅스 OOM Killer로 프로세스 죽음 진단·방지
7) 마무리: “빠른 $lookup”의 본질은 조인량과 스캔량을 줄이는 것
$lookup을 5배 빠르게 만드는 방법은 특별한 트릭이라기보다, 데이터베이스 튜닝의 정석에 가깝습니다.
- 스캔량을 줄이는 인덱스
- 불필요한 입력을 줄이는 파이프라인 순서
- 조인 결과를 작게 유지하는 제한/요약
이 세 가지를 실행 계획으로 검증하면서 반복하면, 대부분의 느린 $lookup은 눈에 띄게 개선됩니다. 운영에서 중요한 건 “한 번의 최적화”가 아니라, 데이터 증가와 사용 패턴 변화에 맞춰 인덱스·파이프라인을 지속적으로 재점검하는 습관입니다.