- Published on
MongoDB $lookup 느림? 인덱스·파이프라인 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 갑자기 느려져서 프로파일링을 해보면, 의외로 많은 경우 범인은 aggregate() 안의 $lookup입니다. 특히 트래픽이 늘거나 데이터가 커지는 시점에 조인이 폭발하면서 CPU와 I/O가 급증하고, P95/P99 지연이 튀며, 애플리케이션 타임아웃까지 연쇄적으로 이어집니다.
$lookup 자체가 나쁜 기능은 아닙니다. 문제는 조인 대상 컬렉션을 어떻게 탐색하는지(인덱스), 조인하기 전에 얼마나 줄였는지(파이프라인 순서), 조인 결과를 얼마나 가져오는지(프로젝션/카디널리티), 그리고 **조인을 해야만 하는 모델인지(스키마)**에 달려 있습니다.
이 글에서는 $lookup이 느려지는 대표 원인을 explain()으로 확인하고, 인덱스/파이프라인/스키마 측면에서 실전 튜닝 체크리스트를 제공합니다.
$lookup이 느려지는 전형적인 패턴
1) foreignField에 인덱스가 없다
가장 흔합니다. $lookup은 기본적으로 localField 값을 기준으로 from 컬렉션의 foreignField를 찾습니다. 이때 from.foreignField에 인덱스가 없으면, 조인 대상 컬렉션을 반복 스캔하거나(사실상 N번 탐색) 매우 비싼 플랜이 나올 수 있습니다.
2) 조인 전에 데이터를 줄이지 않는다
$match/$project/$limit 같은 축소 단계가 뒤에 오면, 조인해야 하는 문서 수가 불필요하게 커집니다. $lookup은 파이프라인 중간에서 “데이터 증폭”을 일으키기 쉬운 스테이지라, 가능한 한 앞단에서 모수를 줄이는 것이 핵심입니다.
3) 조인 결과를 과도하게 가져온다
조인된 배열에 큰 문서가 통째로 들어오거나, 필요 없는 필드까지 전부 가져오면 메모리/네트워크 비용이 커집니다. 특히 $unwind까지 붙으면 문서 수가 폭발할 수 있습니다.
4) 파이프라인 expr를 남발한다
파이프라인 형태의 $lookup은 강력하지만, $expr로 복잡한 조건을 걸면 인덱스 사용이 제한되거나 플랜이 비싸질 수 있습니다(버전/쿼리 형태에 따라 다름). 인덱스를 타는 형태로 조건을 재구성해야 합니다.
5) 카디널리티(매칭 개수)가 크다
1:N이 아니라 사실상 1:수천이 되는 구조라면, $lookup은 조인 결과만으로도 병목이 됩니다. 이 경우는 튜닝만으로 한계가 있고, **데이터 모델 변경(denormalize, pre-aggregate)**까지 고려해야 합니다.
먼저 원인부터: explain으로 플랜 확인하기
MongoDB 튜닝의 시작은 늘 explain()입니다. executionStats로 실제 실행 통계를 확인하세요.
// MongoDB Shell / mongosh
const pipeline = [
{ $match: { status: "paid" } },
{
$lookup: {
from: "users",
localField: "userId",
foreignField: "_id",
as: "user"
}
},
{ $unwind: "$user" },
{ $project: { _id: 1, total: 1, "user.email": 1 } }
];
db.orders.explain("executionStats").aggregate(pipeline);
여기서 집중해서 볼 포인트는 다음입니다.
$lookup스테이지가 시간의 대부분을 차지하는가from컬렉션 탐색이IXSCAN인지COLLSCAN인지totalDocsExamined,totalKeysExamined가 비정상적으로 큰지nReturned대비docsExamined비율(선택도)이 나쁜지
운영 환경에서는 느린 쿼리 로그/프로파일러도 같이 보세요.
// 느린 쿼리를 100ms 이상으로 로깅
// (운영 적용 전 영향 검토 필요)
db.setProfilingLevel(1, { slowms: 100 });
db.system.profile.find({ millis: { $gte: 100 } })
.sort({ ts: -1 })
.limit(5);
인덱스 튜닝: $lookup은 결국 from 컬렉션 탐색 비용
1) foreignField에 인덱스 생성(가장 먼저)
localField -> foreignField 조인에서 foreignField는 거의 항상 인덱스가 필요합니다.
// users._id는 기본 인덱스가 있지만, 예시로 userId 같은 필드라면 생성 필요
db.users.createIndex({ externalUserId: 1 });
만약 조인 조건이 단일 필드가 아니라 복합 조건(예: tenantId + userId)이라면 복합 인덱스가 효과적입니다.
// 멀티테넌트 예시
db.users.createIndex({ tenantId: 1, _id: 1 });
2) 조인 후 바로 필터링한다면: 인덱스 방향을 바꿔라
예를 들어 orders -> users 조인 후 users.status = "active"만 필요하다면, $lookup 파이프라인에서 status 조건을 넣고, 그 조건을 타는 인덱스를 준비해야 합니다.
// users에서 tenantId + _id 매칭 후 status 필터까지 자주 한다면
db.users.createIndex({ tenantId: 1, _id: 1, status: 1 });
다만 인덱스는 쓰기 비용/메모리 비용이 있습니다. 조인 패턴이 1~2개로 고정돼 있다면 과감히 최적화하고, 패턴이 다양하다면 최소 인덱스 + 파이프라인 최적화로 균형을 잡는 편이 낫습니다.
3) 타입 불일치(ObjectId vs string)는 인덱스를 죽인다
orders.userId가 문자열이고 users._id가 ObjectId면, $lookup이 매칭을 못 하거나 $toObjectId 같은 변환을 넣게 되는데, 이때 인덱스 활용이 꼬일 수 있습니다.
가능하면 스키마 레벨에서 타입을 통일하세요. 불가피하면 변환은 조인 전에(그리고 가능한 한 제한된 범위에서) 처리합니다.
파이프라인 튜닝: lookup 안에서 더 줄여라
1) $match는 가능한 한 앞에
가장 기본이지만 효과가 큽니다.
나쁜 예:
[
{ $lookup: { from: "users", localField: "userId", foreignField: "_id", as: "user" } },
{ $match: { status: "paid" } }
]
좋은 예:
[
{ $match: { status: "paid" } },
{ $lookup: { from: "users", localField: "userId", foreignField: "_id", as: "user" } }
]
2) $project로 로컬 문서 크기부터 줄이기
$lookup은 조인 결과를 붙이기 때문에, 그 전에 로컬 문서가 불필요하게 크면 메모리 압박이 커집니다.
[
{ $match: { status: "paid" } },
{ $project: { userId: 1, total: 1, createdAt: 1 } },
{ $lookup: { from: "users", localField: "userId", foreignField: "_id", as: "user" } }
]
3) 파이프라인 $lookup으로 조인 결과를 최소화
단순 $lookup은 조인된 문서 전체를 배열로 가져옵니다. 필요한 필드만 뽑고, 조건을 조인 내부로 밀어 넣으세요.
[
{ $match: { status: "paid" } },
{
$lookup: {
from: "users",
let: { uid: "$userId" },
pipeline: [
{ $match: { $expr: { $eq: ["$_id", "$$uid"] } } },
{ $project: { _id: 1, email: 1, name: 1 } }
],
as: "user"
}
},
{ $unwind: "$user" }
]
여기서 핵심은 $project로 조인 결과 문서 크기를 줄이는 것입니다. 조인 결과가 큰 컬렉션(프로필, 설정, 권한 등)을 통째로 붙이는 순간 비용이 급증합니다.
4) $unwind는 정말 필요할 때만
$unwind는 문서를 늘립니다. 1:1 조인이라면 괜찮지만, 1:N에서 N이 커지면 폭발합니다. 1:1이라면 $lookup 후에 $first로 단일화하는 방식도 고려할 수 있습니다.
[
{ $match: { status: "paid" } },
{
$lookup: {
from: "users",
localField: "userId",
foreignField: "_id",
as: "user"
}
},
{ $set: { user: { $first: "$user" } } },
{ $project: { total: 1, "user.email": 1 } }
]
5) limit/$sort를 당길 수 있나 점검
예: “최근 주문 20개만”이면, 조인 전에 정렬/리밋을 적용할 수 있습니다.
[
{ $match: { status: "paid" } },
{ $sort: { createdAt: -1 } },
{ $limit: 20 },
{ $lookup: { from: "users", localField: "userId", foreignField: "_id", as: "user" } }
]
이때 orders 쪽에 { status: 1, createdAt: -1 } 같은 인덱스가 있으면 더 좋습니다.
흔한 실수: $lookup에서 인덱스를 못 타는 조건
1) $expr + 함수/변환 남발
아래처럼 변환을 foreignField 쪽에 걸면 인덱스가 무력화될 가능성이 큽니다.
// 위험: foreignField에 변환 적용
{
$match: {
$expr: { $eq: [ { $toString: "$_id" }, "$$uidStr" ] }
}
}
가능하면 변환은 로컬 값에 맞추거나(혹은 애초에 타입 통일), 인덱스를 타는 형태로 재구성하세요.
2) 정규식/부분일치로 조인하려는 시도
$lookup은 관계 키 조인에 적합합니다. 이메일 prefix 같은 것으로 조인하면 비용이 급증합니다. 이런 요구는 보통 검색 문제이므로, 별도의 검색 인덱스/전처리/역인덱스 전략이 맞습니다.
메모리/디스크 스필 관점: allowDiskUse는 만능이 아니다
allowDiskUse: true는 메모리 제한을 넘는 집계를 디스크로 스필해 실패를 막을 수 있지만, 느린 쿼리를 빠르게 만들지는 못합니다. 오히려 “버티게” 만들 뿐입니다.
db.orders.aggregate(pipeline, { allowDiskUse: true });
만약 $lookup 결과가 커서 스필이 발생한다면, 근본 처방은 다음 중 하나입니다.
- 조인 대상/결과를 더 줄이기(앞단
$match,$project,$limit) - 카디널리티를 줄이기(조인 키 재설계)
- 비정규화/사전 집계로 런타임 조인 제거
스키마/아키텍처 튜닝: $lookup을 “안 하게” 만드는 방법
1) 자주 쓰는 필드는 비정규화(denormalize)
예: 주문 목록에서 사용자 email, name만 항상 필요하다면, 주문 문서에 스냅샷으로 박아두는 게 더 싸게 먹힙니다.
- 장점: 조회가 빠르고 단순해짐
- 단점: 사용자 정보 변경 시 동기화 필요(이벤트/배치)
2) 사전 집계 컬렉션(materialized view) 운영
대시보드/리포팅처럼 동일한 조인을 반복한다면, 주기적으로 집계해 둔 컬렉션을 조회하는 구조가 안정적입니다.
3) 조인 방향을 바꾸기
어떤 경우엔 A -> B로 조인하는 것보다, 애초에 B에서 시작해 조건을 좁힌 뒤 A를 붙이는 게 훨씬 싸기도 합니다(선택도가 높은 쪽부터 시작).
실전 체크리스트: $lookup 느릴 때 10분 점검
explain("executionStats")로$lookup이 병목인지 확인from.foreignField에 인덱스 존재 여부 확인- 조인 전에
$match/$project/$limit를 최대한 당겼는지 확인 $lookup결과에서 필요한 필드만$project로 제한- 1:N 조인에서
$unwind로 폭발하고 있지 않은지 확인 - 타입 불일치(ObjectId/string)로 변환이 끼어 있지 않은지 확인
$expr조건이 인덱스를 타는 형태인지 점검- 멀티테넌트면
tenantId를 조인 키/인덱스에 포함 - 캐시/사전 집계/비정규화가 더 합리적인지 판단
- 운영에서 타임아웃/재시도 정책도 함께 점검(느린 조인은 연쇄 장애를 만든다)
타임아웃/재시도 설계는 DB 튜닝과 별개가 아니라, 느린 쿼리가 발생했을 때 장애 전파를 막는 마지막 안전장치입니다. 분산 환경에서의 타임아웃 설계 관점은 Go gRPC DEADLINE_EXCEEDED 원인과 재시도·타임아웃 설계도 함께 참고하면 좋습니다.
예제: 느린 $lookup을 단계적으로 개선하기
상황:
orders에서 결제 완료 주문을 조회- 사용자 정보에서
email만 필요 - 기존 쿼리가 느리고, users 컬렉션이 큼
Step 1) 기본 파이프라인
const pipeline = [
{
$lookup: {
from: "users",
localField: "userId",
foreignField: "_id",
as: "user"
}
},
{ $match: { status: "paid" } },
{ $unwind: "$user" }
];
문제:
$match가 뒤에 있어 조인 대상이 과도user전체 문서를 붙임
Step 2) project를 앞으로
const pipeline = [
{ $match: { status: "paid" } },
{ $project: { userId: 1, total: 1, createdAt: 1 } },
{
$lookup: {
from: "users",
localField: "userId",
foreignField: "_id",
as: "user"
}
},
{ $set: { user: { $first: "$user" } } },
{ $project: { total: 1, createdAt: 1, "user.email": 1 } }
];
Step 3) lookup 파이프라인으로 조인 결과 최소화
const pipeline = [
{ $match: { status: "paid" } },
{ $project: { userId: 1, total: 1, createdAt: 1 } },
{
$lookup: {
from: "users",
let: { uid: "$userId" },
pipeline: [
{ $match: { $expr: { $eq: ["$_id", "$$uid"] } } },
{ $project: { _id: 1, email: 1 } }
],
as: "user"
}
},
{ $set: { user: { $first: "$user" } } }
];
Step 4) 인덱스 확정
users._id는 기본 인덱스 OKorders에서status로 필터하고createdAt으로 정렬한다면:
db.orders.createIndex({ status: 1, createdAt: -1 });
이 조합이면 “앞단에서 모수 축소 → 조인 대상 최소화 → 조인 결과 최소화”가 맞물리면서 $lookup 비용이 크게 내려갑니다.
마무리
$lookup 성능은 결국 **조인 대상 탐색(인덱스)**과 **조인 전/후 데이터 크기(파이프라인 설계)**의 곱으로 결정됩니다. 가장 먼저 explain()으로 병목을 확인하고, foreignField 인덱스 → $match/$project/$limit 전진 배치 → $lookup 파이프라인으로 결과 최소화 순서로 접근하면, 대부분의 “$lookup 느림” 문제는 눈에 띄게 개선됩니다.
그래도 P99이 안 내려간다면, 그때는 튜닝의 영역을 넘어 비정규화/사전 집계/조인 제거 같은 아키텍처 선택을 검토할 타이밍입니다.