- Published on
MongoDB $lookup 느림 해결 - 인덱스·pipeline 최적화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 MongoDB Aggregation의 $lookup이 느려지기 시작하면 체감상 “조인 때문에 어쩔 수 없다”로 결론 내리기 쉽습니다. 하지만 대부분의 병목은 조인 자체가 아니라 인덱스 부재, 필터/프로젝션 순서, 불필요한 카디널리티(조인 결과 폭증), 메모리/디스크 스필 같은 “피할 수 있는 비용”에서 발생합니다. 이 글에서는 $lookup이 느려지는 전형적인 패턴을 짚고, 인덱스와 pipeline을 어떻게 재구성해야 하는지 실전 기준으로 정리합니다.
> 참고: 성능 이슈는 애플리케이션 레벨의 Long Task로 이어지기도 합니다. 프론트에서 INP가 떨어진다면 서버의 응답 지연이 원인일 수 있으니 Chrome INP 급락 - Long Task 찾고 쪼개기도 함께 보시면 전체 병목을 연결해 진단하는 데 도움이 됩니다.
1) $lookup이 느려지는 대표 원인 6가지
1. foreign 컬렉션 조인 키에 인덱스가 없다
가장 흔합니다. localField -> foreignField 매칭이 COLLSCAN으로 돌면, 입력 문서 수(N)만큼 foreign 컬렉션을 반복 스캔하는 형태가 되어 급격히 느려집니다.
2. $match가 $lookup 뒤에 있다
가능한 한 입력(N)을 먼저 줄이고 조인을 해야 합니다. $lookup 이후에 $match로 거르는 구조는 “조인 결과를 만든 뒤 버리는” 비용을 지불합니다.
3. $lookup 결과 배열이 너무 커진다(카디널리티 폭증)
1:N, N:M 관계에서 매칭이 과도하면 결과 배열이 커지고, 이어지는 $unwind, $group에서 CPU/메모리 사용량이 폭증합니다.
4. $lookup pipeline에서 필터/정렬이 인덱스를 못 탄다
$lookup의 pipeline 내부에서 $expr로 복잡한 조건을 만들거나, 정렬이 인덱스 순서와 맞지 않으면 foreign 쪽에서 스캔/정렬 비용이 커집니다.
5. 불필요한 필드까지 가져온다
조인 결과에서 실제로 필요한 필드가 몇 개뿐인데 전체 문서를 끌고 오면 네트워크/메모리/디스크 스필 가능성이 커집니다.
6. 메모리 제한으로 디스크 스필이 발생한다
Aggregation은 단계에 따라 메모리를 많이 씁니다. 특히 $sort, $group, 큰 $lookup 결과는 스필을 유발합니다(로그/프로파일러에서 확인 가능).
2) 진단: explain()과 프로파일러로 “어디서” 느린지 먼저 본다
explain("executionStats")로 플랜 확인
아래처럼 $lookup이 들어간 aggregation에 explain을 붙여 확인합니다.
// Mongo 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);
확인 포인트:
winningPlan에서 foreign 쪽이 IXSCAN인지, COLLSCAN인지executionStats의totalDocsExamined,totalKeysExamined가 과도한지$lookup단계에서 시간이 몰리는지
프로파일러로 “실제 느린 쿼리” 수집
운영에서는 explain만으로는 부족할 때가 많습니다. 프로파일러를 짧게 켜서 느린 aggregation을 수집합니다.
// 100ms 이상 쿼리만 기록
db.setProfilingLevel(1, { slowms: 100 });
// 프로파일링 로그 확인
db.system.profile.find({
ns: "mydb.orders",
"command.aggregate": "orders"
}).sort({ ts: -1 }).limit(5).pretty();
3) 인덱스 최적화: $lookup은 결국 “foreign 인덱스” 게임이다
(1) 기본형 $lookup: foreignField 인덱스는 거의 필수
// orders.userId -> users._id 조인
// users._id는 기본 인덱스가 있으므로 OK
// 반대로 orders.userEmail -> users.email 이라면
// users.email 인덱스가 필요
db.users.createIndex({ email: 1 });
- 조인 키가
_id가 아닌 경우 특히 놓치기 쉽습니다. - 타입이 다르면 인덱스가 있어도 매칭이 안 됩니다(예:
ObjectIdvs 문자열). 조인 키 타입을 통일하세요.
(2) $lookup pipeline에서 추가 필터가 있다면 “복합 인덱스”로
예: 특정 기간 주문만 조인하거나, 사용자 상태가 ACTIVE인 것만 조인하는 경우.
// users 컬렉션에서 _id 매칭 + status 필터를 함께 탄다고 가정
// pipeline에서 _id와 status를 같이 사용한다면 복합 인덱스가 유리
db.users.createIndex({ _id: 1, status: 1 });
주의: _id는 이미 인덱스지만, $lookup pipeline에서 _id + 다른 조건을 동시에 강하게 걸면 복합 인덱스가 더 유리할 수 있습니다.
(3) 조인 후 정렬/페이징이 있다면 “정렬 인덱스”를 먼저 설계
$lookup 결과를 $unwind 후 정렬하는 구조는 매우 비쌉니다. 가능하면 조인 이전에 정렬/페이징을 끝내거나, 정렬 키를 기준으로 먼저 대상 집합을 좁히세요.
4) Pipeline 최적화 핵심: “줄이고, 필요한 것만 조인하고, 작게 가져오기”
(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" } }
]
입력 문서 수가 100만 → 1만으로 줄어드는 순간, $lookup 비용도 거의 선형으로 줄어듭니다.
(2) $lookup는 pipeline 형태로 바꾸고 $project로 슬림하게
기본형 $lookup은 foreign 문서 전체를 가져오기 쉽습니다. pipeline을 사용하면 필요한 필드만 남길 수 있습니다.
[
{ $match: { status: "PAID" } },
{
$lookup: {
from: "users",
let: { uid: "$userId" },
pipeline: [
{ $match: { $expr: { $eq: ["$_id", "$$uid"] } } },
{ $project: { _id: 1, email: 1, plan: 1 } }
],
as: "user"
}
},
{ $unwind: "$user" },
{ $project: { _id: 1, total: 1, "user.email": 1, "user.plan": 1 } }
]
포인트:
- foreign 쪽에서
$project로 문서 크기를 줄이면 메모리/네트워크 비용이 크게 감소합니다. $unwind가 필요 없다면 배열로 유지하는 것도 비용을 줄일 수 있습니다(단, 후속 단계 요구사항에 따라).
(3) $lookup 결과가 1건이면 “1건만 가져오기”
논리적으로 1:1인데 데이터 품질 문제로 여러 건이 매칭될 여지가 있다면, 방어적으로 제한을 걸어 결과 폭증을 막습니다.
{
$lookup: {
from: "users",
let: { uid: "$userId" },
pipeline: [
{ $match: { $expr: { $eq: ["$_id", "$$uid"] } } },
{ $project: { email: 1 } },
{ $limit: 1 }
],
as: "user"
}
}
(4) $unwind는 정말 필요할 때만, 필요하면 옵션을 명확히
{ $unwind: { path: "$user", preserveNullAndEmptyArrays: false } }
preserveNullAndEmptyArrays: true는 결과를 늘릴 수 있습니다(의도적으로 필요할 때만).
5) 카디널리티 폭증 패턴과 해결: “조인 전에 집계/축약”
예를 들어 orders -> orderItems 조인 후 아이템을 전부 펼쳐서 계산하면 매우 비쌉니다. 가능하면 orderItems에서 먼저 필요한 형태로 축약한 뒤 붙입니다.
// orders에 orderItems를 붙이되, 아이템 전체 대신 합계만 붙이기
[
{ $match: { createdAt: { $gte: ISODate("2026-01-01") } } },
{
$lookup: {
from: "orderItems",
let: { oid: "$_id" },
pipeline: [
{ $match: { $expr: { $eq: ["$orderId", "$$oid"] } } },
{ $group: { _id: "$orderId", itemCount: { $sum: 1 }, amount: { $sum: "$price" } } }
],
as: "itemsAgg"
}
},
{ $unwind: { path: "$itemsAgg", preserveNullAndEmptyArrays: true } },
{ $addFields: { itemCount: "$itemsAgg.itemCount", amount: "$itemsAgg.amount" } },
{ $project: { itemsAgg: 0 } }
]
이 방식은 “조인 결과 배열”을 만들지 않고 foreign 쪽에서 먼저 줄여서 붙이므로, $unwind 폭발을 피하는 데 효과적입니다.
6) $lookup에서 인덱스를 깨는 조건을 피하는 법
(1) 조인 키에 변환/연산을 걸지 않는다
예: toString, substr, dateToString 같은 변환을 조인 키에 걸면 인덱스 활용이 어려워집니다. 조인 키는 저장 시점에 정규화하는 편이 낫습니다.
(2) $expr를 쓰더라도 “단순 동등 비교”를 우선
$expr: { $eq: [...] } 형태는 비교적 최적화가 잘 되는 편이지만, 복잡한 OR/함수 조합은 성능이 급격히 나빠질 수 있습니다. 가능한 한 foreign 컬렉션에 “검색 가능한 필드”를 만들고 인덱스를 설계하세요.
(3) 정렬 + 제한은 인덱스로
foreign 쪽에서 최신 1건만 필요하다면 다음 패턴이 강력합니다.
// 예: userId별 최신 로그인 1건
// logins: { userId: 1, createdAt: -1 } 인덱스 권장
db.logins.createIndex({ userId: 1, createdAt: -1 });
[
{
$lookup: {
from: "logins",
let: { uid: "$userId" },
pipeline: [
{ $match: { $expr: { $eq: ["$userId", "$$uid"] } } },
{ $sort: { createdAt: -1 } },
{ $limit: 1 },
{ $project: { _id: 0, createdAt: 1, ip: 1 } }
],
as: "lastLogin"
}
}
]
인덱스가 맞으면 $sort 비용이 사실상 사라지고, $limit: 1로 문서 접근도 최소화됩니다.
7) 운영 팁: allowDiskUse, 배치 전략, 사전 계산(denormalization)
(1) allowDiskUse는 “해결”이 아니라 “응급처치”
메모리 스필로 실패하거나 매우 느릴 때 임시로 켤 수는 있지만, 디스크 스필은 지연을 크게 늘립니다.
db.orders.aggregate(pipeline, { allowDiskUse: true });
근본적으로는 위에서 다룬 입력 감소, 결과 슬림화, 인덱스 설계가 먼저입니다.
(2) 자주 쓰는 조인은 사전 계산 컬렉션/필드로
- “주문 + 사용자 요약”처럼 거의 항상 같이 쓰는 조합은 쓰기 시점에 일부를 중복 저장하는 것이 전체 비용을 줄일 수 있습니다.
- 단, 정합성(업데이트 전파) 비용이 생기므로 변경 빈도/읽기 빈도에 따라 결정합니다.
(3) 쿼리 병목은 인프라 병목으로도 번진다
$lookup이 길어지면 워커 스레드가 막혀 API 타임아웃, 커넥션 고갈, NAT 포트 사용 증가 같은 2차 장애로 이어질 수 있습니다. egress가 불안정해지는 케이스까지 확장되면 GCP Cloud NAT 포트 고갈로 egress 실패 진단법처럼 네트워크 레벨 진단도 필요해질 수 있습니다.
8) 체크리스트: $lookup 느림을 15분 안에 줄이는 순서
- foreignField 인덱스 존재 여부 확인(COLLSCAN이면 1순위)
$match를$lookup앞으로 이동해 입력을 최소화$lookup를 pipeline으로 바꾸고 foreign에서$project로 필요한 필드만- 1:1이면 **
$limit: 1**로 폭증 방지 $unwind/$group가 있다면 카디널리티 폭증 여부 점검(조인 전 축약 고려)- 정렬/페이징은 가능하면 조인 전에, 또는 인덱스 설계로 정렬 비용 제거
explain/프로파일러로 개선 전후docsExamined, 실행 시간 비교
9) 마무리
$lookup은 “MongoDB에서 조인을 하면 느리다”가 아니라, 조인에 필요한 조건을 인덱스로 받쳐주고, pipeline을 입력 최소화 중심으로 재배치하면 충분히 빠르게 만들 수 있는 연산입니다. 특히 실무에서는 $match의 위치와 foreign 쪽 $project, 그리고 카디널리티 폭증을 막는 설계가 체감 성능을 좌우합니다.
원하시면 실제 사용 중인 pipeline(민감정보 제거)과 컬렉션 인덱스 목록을 기준으로, 어떤 인덱스를 추가/변경해야 하는지와 $lookup pipeline을 어떻게 재작성하면 좋은지까지 구체적으로 리라이트해 드릴게요.