- Published on
MongoDB $lookup 느림? 인덱스·pipeline 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 컬렉션을 조인해야 할 때 MongoDB의 대표 도구가 $lookup입니다. 문제는 데이터가 커지고 트래픽이 늘면 $lookup이 갑자기 병목이 되기 쉽다는 점입니다. 특히 “로컬은 빠른데 운영에서만 느리다”, “CPU는 낮은데 쿼리가 오래 걸린다”, “aggregation이 메모리를 많이 먹는다” 같은 증상은 대부분 인덱스 미스, pipeline 순서, **불필요한 fan-out(조인 결과 폭발)**에서 시작합니다.
이 글에서는 $lookup이 느려지는 전형적인 구조를 분해하고, 인덱스 설계와 pipeline 튜닝을 통해 성능을 개선하는 실전 패턴을 정리합니다. (운영 장애를 추적하는 관점은 EKS에서 CoreDNS 정상인데 DNS가 간헐 실패할 때처럼 “정상 지표 속 실패”를 파고드는 방식과 유사합니다.)
1) $lookup이 느려지는 대표 원인 6가지
1. foreignField에 인덱스가 없다
가장 흔합니다. $lookup은 기본적으로 왼쪽(로컬) 입력 문서마다 오른쪽(외부) 컬렉션을 매칭합니다. foreign 컬렉션에서 매칭 키에 인덱스가 없으면, 사실상 반복 스캔이 발생합니다.
- 로컬 문서 수가
N - foreign 컬렉션 크기가
M
인덱스가 없으면 최악의 경우 N × M에 가까운 비용으로 커집니다.
2. $lookup 전에 $match/$project를 안 해서 입력이 너무 크다
$lookup은 입력 문서 수가 많을수록 선형으로 느려집니다. 즉, 조인 전에 최대한 줄여야 합니다.
3. 조인 후 $unwind로 fan-out이 폭발한다
배열로 붙은 결과를 $unwind하면 문서가 기하급수로 늘 수 있습니다. 이후 스테이지(정렬, 그룹)가 폭탄이 됩니다.
4. $lookup pipeline에서 비SARGable 조건을 쓴다
예: $expr 안에서 $toString, $substr, $regex 등을 매칭 조건에 섞으면 인덱스가 잘 안 타거나(혹은 전혀 못 타고) CPU를 태웁니다.
5. 정렬($sort)이 조인 이후에 발생한다
조인으로 문서가 커진 뒤 정렬하면 메모리 사용량과 spill(디스크 임시 파일)이 늘어납니다.
6. 데이터 모델이 조인 친화적이지 않다
- 조인 키 타입이 컬렉션마다 다름(ObjectId vs string)
- 다대다 관계를 그대로
$lookup으로 풀어내려 함 - “최근 30일”만 필요하지만 전체 이력을 조인
이 경우는 튜닝보다 모델/쿼리 요구사항 재정의가 더 큰 효과를 냅니다.
2) 먼저 확인할 것: explain()으로 병목을 특정하기
Aggregation은 반드시 실행 계획을 봐야 합니다.
db.orders.explain("executionStats").aggregate([
{ $match: { status: "PAID" } },
{
$lookup: {
from: "users",
localField: "userId",
foreignField: "_id",
as: "user"
}
},
{ $unwind: "$user" },
{ $limit: 50 }
]);
여기서 눈여겨볼 포인트는 다음입니다.
$lookup단계에서 foreign 컬렉션이 COLLSCAN인지totalDocsExamined,totalKeysExamined비율executionTimeMillis가 어느 단계에서 튀는지$sort가 있다면usedDisk(spill) 여부
COLLSCAN이 보이면 인덱스부터입니다. 인덱스가 있는데도 느리다면 pipeline 구조(입력 축소, fan-out 제어)를 봐야 합니다.
3) 인덱스 튜닝: $lookup 성능의 70%는 여기서 결정된다
1. foreignField(또는 pipeline 매칭 필드)에 인덱스 생성
기본 형태의 $lookup이라면 foreignField에 인덱스가 있어야 합니다.
// users._id는 기본적으로 인덱스가 있지만
// 예시로 foreignField가 email 같은 필드라면 반드시 생성
db.users.createIndex({ email: 1 });
2. 복합 조건이면 “복합 인덱스”를 foreign 컬렉션에 맞춘다
$lookup pipeline에서 userId + createdAt 같은 조건을 동시에 걸면 단일 인덱스로는 부족합니다.
// 예: userId로 매칭하고, 최근 30일만 가져오는 패턴
db.userEvents.createIndex({ userId: 1, createdAt: -1 });
이때 인덱스 순서는 일반적으로
- 동등 조건(=) 필드 먼저
- 범위/정렬 필드 다음
이 원칙이 유효합니다.
3. 타입 불일치(ObjectId vs string)를 제거
조인 키 타입이 다르면 $toObjectId 같은 변환이 들어가고 인덱스 활용이 깨집니다.
- 가장 좋은 해결: 저장 타입을 통일
- 차선: 변환된 값을 별도 필드로 저장(denormalized key)
// 예: 문자열 userIdStr을 ObjectId userId로 마이그레이션 후 저장
// (마이그레이션 스크립트는 환경에 맞게 작성)
4. 부분 인덱스/희소 인덱스로 working set을 줄인다
조인 대상이 “활성 사용자만”, “삭제되지 않은 문서만”이라면 partial index가 효과적입니다.
db.users.createIndex(
{ tenantId: 1, _id: 1 },
{ partialFilterExpression: { deletedAt: { $exists: false } } }
);
멀티테넌트에서 특히 강력합니다.
4) Pipeline 튜닝: 스테이지 순서와 fan-out 제어
1. $lookup 전에 $match, $project로 입력을 최소화
조인 전에 줄일수록 이득입니다.
db.orders.aggregate([
{ $match: { status: "PAID", createdAt: { $gte: ISODate("2026-01-01") } } },
{ $project: { userId: 1, total: 1, createdAt: 1 } },
{
$lookup: {
from: "users",
localField: "userId",
foreignField: "_id",
as: "user"
}
},
{ $unwind: "$user" },
{ $limit: 100 }
]);
핵심은 $lookup에 들어가기 전 문서 수/필드 수를 줄여 메모리와 네트워크(샤딩 시) 부담을 낮추는 것입니다.
2. pipeline 기반 $lookup으로 “필요한 것만” 가져오기
단순 $lookup은 매칭된 문서를 통째로 붙입니다. 대신 pipeline $lookup으로 필드 제한 + 추가 필터 + 정렬/limit를 조인 내부에서 끝내면 fan-out을 크게 줄일 수 있습니다.
db.orders.aggregate([
{ $match: { status: "PAID" } },
{
$lookup: {
from: "userEvents",
let: { uid: "$userId" },
pipeline: [
{
$match: {
$expr: { $eq: ["$userId", "$$uid"] },
type: "LOGIN",
createdAt: { $gte: ISODate("2026-01-01") }
}
},
{ $sort: { createdAt: -1 } },
{ $limit: 1 },
{ $project: { _id: 0, createdAt: 1, ip: 1 } }
],
as: "lastLogin"
}
},
{ $set: { lastLogin: { $first: "$lastLogin" } } }
]);
포인트:
- 조인 결과를 배열로 크게 붙이지 않고 최신 1건만 가져옴
$project로 필요한 필드만 유지- foreign 컬렉션에
{ userId: 1, createdAt: -1 }인덱스가 있으면 매우 빠르게 동작
3. $unwind는 정말 필요할 때만, 그리고 가능한 늦게/또는 대체
$unwind는 결과를 늘립니다. “한 건만 필요”하면 $first/$arrayElemAt으로 배열을 스칼라로 바꾸는 편이 낫습니다.
{ $set: { user: { $first: "$user" } } }
4. 조인 이후 $match는 가능하면 조인 내부로 이동
조인 후에 user.country == KR로 필터링하면, 이미 조인을 수행한 뒤 버리는 셈입니다. 가능하면 $lookup.pipeline에서 필터링하거나, 조인 전에 로컬에서 줄일 수 있는 조건을 먼저 적용하세요.
5. $sort/$group은 조인 전후 위치를 재검토
- 정렬 기준이 로컬 필드라면 조인 전에 정렬
- 그룹핑이 로컬 기준으로 가능한 부분이 있다면 미리 집계 후 조인
이 원칙만 지켜도 spill 가능성이 크게 줄어듭니다. (spill은 인프라 레벨에서 보면 파일 디스크립터/IO 압박으로 이어질 수 있는데, 이런 류의 자원 병목은 Linux EMFILE(Too many open files) 원인과 해결처럼 다른 계층의 장애로 번지기도 합니다.)
5) 샤딩(Sharding) 환경에서 $lookup이 더 느린 이유와 대응
샤딩된 클러스터에서 $lookup은 다음 비용이 추가됩니다.
- 라우터(mongos)가 shard 간 결과를 모으는 비용
- foreign 컬렉션이 다른 shard에 퍼져 있으면 네트워크 왕복 증가
- 조인 키가 샤드 키와 맞지 않으면 브로드캐스트가 발생할 수 있음
대응 전략:
- 가능하면 co-locate: 로컬/foreign 컬렉션이 같은 샤드 키(또는 같은 분포)를 갖도록 설계
$lookup전에$match로 샤드 타겟팅이 되게 만들기- 조인 대상이 큰 컬렉션이면, 자주 쓰는 결과를 **사전 집계(materialized view)**로 별도 컬렉션에 유지
6) “조인 자체”를 줄이는 설계 패턴 (현실적인 타협)
1. 읽기 최적화 denormalization
- 주문 문서에 사용자 이름/등급을 스냅샷으로 저장
- 이벤트성 데이터는 최근 N개만 embed
쓰기 시점에 약간의 중복을 허용하면 읽기 경로가 단순해집니다.
2. Outbox/이벤트 기반으로 조인 비용을 다른 작업으로 분산
예를 들어 결제/주문/정산처럼 여러 도메인이 얽혀 조인이 잦다면, 조회용 projection을 별도로 유지하는 방식이 효과적입니다. MSA에서 흔한 접근은 Outbox 패턴이며, 더 큰 설계 관점은 MSA 사가 실패로 중복결제 터질 때 Outbox로 막기 같은 글의 흐름과 맞닿아 있습니다.
7) 실전 체크리스트: $lookup 느릴 때 이렇게 점검한다
explain("executionStats")로$lookup단계가 COLLSCAN인지 확인- foreign 매칭 필드 인덱스 존재 여부 확인
$lookup전에$match/$project로 입력을 줄였는지 확인$unwind로 문서 수가 폭발하지 않는지 확인- pipeline
$lookup으로 필드 제한/limit/정렬을 조인 내부에서 끝낼 수 있는지 검토 - 조인 키 타입 불일치 제거(ObjectId vs string)
- 샤딩이면 샤드 타겟팅/코로케이션 가능성 검토
- 그래도 느리면: denormalization 또는 materialized view로 읽기 경로 재설계
8) 결론: $lookup 최적화는 “인덱스 + 입력 축소 + fan-out 제어”
$lookup이 느린 문제는 대부분 복잡한 트릭이 아니라, (1) foreign 인덱스, (2) 조인 전 입력 최소화, (3) 조인 결과 폭발 방지로 해결됩니다. 특히 pipeline 기반 $lookup으로 limit, project, sort를 조인 내부에서 처리하면 체감 성능이 크게 좋아집니다.
운영에서 중요한 건 “빠르게 만드는 것”뿐 아니라 “느려질 구조를 피하는 것”입니다. $lookup이 필수인 지점과, 데이터 모델/프로젝션으로 우회 가능한 지점을 구분해두면 규모가 커져도 안정적으로 확장할 수 있습니다.