- Published on
MongoDB 느린 $lookup을 인덱스로 10배 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론: 왜 \$lookup은 쉽게 느려질까
MongoDB에서 \$lookup은 사실상 컬렉션 간 조인을 수행하는 단계입니다. 문제는 조인 자체보다도, 조인을 어떤 방식으로 수행하느냐에 따라 비용이 급격히 달라진다는 점입니다. 특히 다음 조건이 겹치면 쿼리가 순식간에 느려집니다.
- 로컬 컬렉션(왼쪽)의 입력 문서 수가 많다
- 외부 컬렉션(오른쪽)에 조인 키 인덱스가 없다
\$lookup이후에야\$match로 필터링한다(즉, 불필요한 조인을 먼저 한다)\$lookup파이프라인에서 변환이 많거나, 인덱스를 못 타는 조건을 쓴다- 데이터 타입이 맞지 않아 조인 키 매칭이 깨지거나 비효율이 발생한다
이 글에서는 \$lookup이 느려지는 이유를 실행계획으로 확인하고, 인덱스 설계와 파이프라인 재구성만으로 10배 수준의 개선을 만드는 실전 접근을 다룹니다.
문제 재현: 전형적인 느린 \$lookup 패턴
예시로 주문(orders)과 사용자(users)를 조인한다고 가정합니다.
orders.userId로users._id를 조인- 특정 기간 주문만 조회
- 사용자 상태가
active인 경우만 조인
많이들 아래처럼 작성합니다.
db.orders.aggregate([
{
$lookup: {
from: "users",
localField: "userId",
foreignField: "_id",
as: "user"
}
},
{ $unwind: "$user" },
{ $match: { createdAt: { $gte: ISODate("2026-01-01"), $lt: ISODate("2026-02-01") } } },
{ $match: { "user.status": "active" } },
{ $sort: { createdAt: -1 } },
{ $limit: 50 }
])
겉보기엔 맞는 쿼리지만, 성능 관점에서는 치명적인 문제가 있습니다.
- 기간 필터(
createdAt)가\$lookup뒤에 있어 조인 대상 주문이 과도하게 커짐 users에서status조건을 조인 전에 걸지 못해 조인 후 필터링이 됨users에 인덱스가 없거나, 조인 키 외 조건을 못 타서 반복 스캔이 발생
1단계: 실행계획으로 병목 확인하기
튜닝은 감이 아니라 증거로 해야 합니다. Aggregation은 explain으로 확인합니다.
db.orders.explain("executionStats").aggregate([
{
$lookup: {
from: "users",
localField: "userId",
foreignField: "_id",
as: "user"
}
},
{ $unwind: "$user" },
{ $match: { createdAt: { $gte: ISODate("2026-01-01"), $lt: ISODate("2026-02-01") } } },
{ $match: { "user.status": "active" } },
{ $sort: { createdAt: -1 } },
{ $limit: 50 }
])
여기서 체크할 포인트는 다음입니다.
\$lookup단계에서totalDocsExamined가 과도하게 큰지- 외부 컬렉션(
users)에서COLLSCAN이 발생하는지 \$match가 뒤에 있어nReturned대비 처리량이 과도한지
실무에서 자주 보는 패턴은 \$lookup 내부가 사실상 N번 반복 실행되며, 외부 컬렉션이 인덱스를 못 타서 COLLSCAN이 누적되는 케이스입니다.
2단계: 가장 먼저 할 일은 “조인 키 인덱스”
\$lookup의 기본 형태(localField/foreignField)는 외부 컬렉션의 foreignField 인덱스가 사실상 필수입니다.
orders.userId로users._id조인이라면,users._id는 기본 인덱스가 있으니 괜찮습니다.- 하지만 실제로는
users.userId같은 별도 키로 조인하거나, 서브필드로 조인하는 경우가 많습니다.
예를 들어 조인 키가 users.userId라면 아래 인덱스가 없으면 성능이 급락합니다.
db.users.createIndex({ userId: 1 })
조인 키 타입이 다르면 인덱스가 있어도 느리다
가장 흔한 함정은 타입 불일치입니다.
orders.userId는 문자열users._id는ObjectId
이 경우 매칭이 깨질 뿐 아니라, 변환을 끼워 넣으면 인덱스를 못 타는 형태가 되기 쉽습니다. 가능한 한 저장 단계에서 타입을 통일하세요.
부득이하게 변환이 필요하면, \$lookup 파이프라인에서 \$expr을 쓰게 되는데 이때도 인덱스 활용이 제한될 수 있습니다. 그래서 “타입 통일”은 성능 최적화의 0순위입니다.
3단계: \$match를 앞으로 당겨 입력을 줄인다
\$lookup이 느릴 때 가장 큰 개선은 보통 조인 전에 로컬 입력을 줄이는 것에서 나옵니다.
기존 쿼리를 아래처럼 바꿉니다.
db.orders.aggregate([
{ $match: { createdAt: { $gte: ISODate("2026-01-01"), $lt: ISODate("2026-02-01") } } },
{ $sort: { createdAt: -1 } },
{ $limit: 50 },
{
$lookup: {
from: "users",
localField: "userId",
foreignField: "_id",
as: "user"
}
},
{ $unwind: "$user" },
{ $match: { "user.status": "active" } }
])
핵심은 \$lookup 이전에 \$match와 \$limit까지 최대한 적용해 조인 횟수 자체를 줄이는 것입니다.
이때 필요한 인덱스
위 패턴이 제대로 빨라지려면 orders에 다음 인덱스가 필요합니다.
db.orders.createIndex({ createdAt: -1 })
만약 기간 필터와 함께 userId도 자주 필터링한다면 복합 인덱스도 고려합니다.
db.orders.createIndex({ createdAt: -1, userId: 1 })
복합 인덱스는 “자주 쓰는 \$match 조건”과 “정렬”을 함께 커버할 때 효과가 큽니다.
4단계: 파이프라인 \$lookup으로 조건을 조인 내부로 밀어 넣기
users.status = active를 조인 이후에 거르는 대신, \$lookup 파이프라인으로 조인 단계에서 걸러야 합니다.
db.orders.aggregate([
{ $match: { createdAt: { $gte: ISODate("2026-01-01"), $lt: ISODate("2026-02-01") } } },
{ $sort: { createdAt: -1 } },
{ $limit: 200 },
{
$lookup: {
from: "users",
let: { uid: "$userId" },
pipeline: [
{
$match: {
$expr: { $eq: ["$_id", "$$uid"] },
status: "active"
}
},
{ $project: { _id: 1, name: 1, status: 1 } }
],
as: "user"
}
},
{ $unwind: "$user" },
{ $limit: 50 }
])
이 방식의 장점은 다음과 같습니다.
- 조인 결과 배열 크기를 줄인다
- 불필요한 사용자 문서를 가져오지 않는다(
\$project) - 조인 이후
\$match비용을 줄인다
외부 컬렉션 인덱스는 이렇게 잡는다
파이프라인 \$lookup에서 status까지 함께 걸면, 외부 컬렉션에 다음 인덱스가 도움이 됩니다.
db.users.createIndex({ _id: 1, status: 1 })
다만 _id는 기본 인덱스가 있으므로, 실제로는 조인 키가 _id가 아닌 경우에 더 효과가 큽니다. 예를 들어 users.userId로 조인한다면 아래가 실전적으로 강력합니다.
db.users.createIndex({ userId: 1, status: 1 })
인덱스 설계 원칙은 간단합니다.
\$expr로 equality 매칭하는 키를 선두에 둔다- 그 다음으로 자주 함께 필터링하는 필드를 붙인다
5단계: \$lookup 결과 폭 줄이기(\$project)
조인이 느린데 네트워크와 메모리도 같이 터지는 케이스는, 조인 결과로 사용자 문서 전체를 들고 오는 경우가 많습니다(프로필, 설정, 권한 등).
\$lookup 파이프라인에서 필요한 필드만 남기면, CPU보다 I/O가 병목인 상황에서 특히 효과가 큽니다.
{
$lookup: {
from: "users",
let: { uid: "$userId" },
pipeline: [
{ $match: { $expr: { $eq: ["$_id", "$$uid"] } } },
{ $project: { _id: 1, name: 1 } }
],
as: "user"
}
}
6단계: 다대다 조인에서 폭발을 막는 패턴
\$lookup이 특히 느려지는 구간은 한 로컬 문서가 외부에서 수십~수백 건과 매칭되는 형태입니다.
예를 들어 posts에 tagIds 배열이 있고 tags를 조인하는 경우:
db.posts.aggregate([
{ $match: { isPublic: true } },
{
$lookup: {
from: "tags",
localField: "tagIds",
foreignField: "_id",
as: "tags"
}
}
])
이때는 다음을 점검하세요.
tags._id는 기본 인덱스라 괜찮지만,posts쪽에서 입력이 너무 많으면 결국 느립니다posts.isPublic같은 필터를 앞에 두고, 페이징이면\$limit를 적극 사용tags에서 필요한 필드만 남겨 네트워크/메모리 사용량 최소화
추가로 태그가 너무 많아 조인 결과가 크면, 화면 요구사항에 맞춰 태그를 일부만 보여주거나(예: 상위 5개) 별도 캐시/역정규화를 고려해야 합니다.
7단계: 튜닝 체크리스트(실무용)
아래 체크리스트대로만 점검해도 \$lookup 성능 문제의 대부분은 해결됩니다.
인덱스
- 외부 컬렉션의 조인 키(
foreignField또는pipeline매칭 키)에 인덱스가 있는가 - 로컬 컬렉션에서
\$lookup이전\$match/\$sort를 커버하는 인덱스가 있는가 - 조인과 함께 자주 쓰는 추가 필터가 있다면 복합 인덱스로 묶었는가
파이프라인 순서
\$match는 최대한 앞에 있는가- 페이징/상위 N 조회라면
\$sort후\$limit를\$lookup이전에 둘 수 있는가 - 조인 후 필터링을 조인 내부로 옮길 수 있는가(
pipeline \$lookup)
데이터 모델
- 조인 키 타입이 완전히 일치하는가(문자열 vs
ObjectId) - 자주 조인하는 데이터는 역정규화가 더 낫지 않은가
8단계: 실제로 10배가 나오는 전형적 시나리오
현장에서 “10배 개선”이 자주 나오는 조합은 보통 이렇습니다.
- 기존:
\$lookup먼저 수행 후\$match로 기간 필터 - 개선: 기간
\$match+\$sort+\$limit를 먼저 적용 - 추가: 외부 컬렉션 조인 키 인덱스가 없던 것을 추가
- 추가:
\$lookup파이프라인으로 불필요한 문서를 조인 단계에서 제거
이 경우 \$lookup 호출 횟수 자체가 줄고, 외부 컬렉션 접근이 COLLSCAN에서 IXSCAN으로 바뀌면서 체감 성능이 크게 좋아집니다.
결론: \$lookup 튜닝은 “인덱스 + 입력 축소 + 조인 내부 필터”
\$lookup이 느릴 때 가장 먼저 해야 할 일은 두 가지입니다.
- 실행계획으로 외부 컬렉션 스캔과 입력 문서 수를 확인한다
- 인덱스와 파이프라인 순서를 바꿔
\$lookup에 들어가는 데이터 양을 줄인다
정리하면, 아래 3가지만 지켜도 대부분의 문제는 해결됩니다.
- 조인 키 인덱스는 필수
\$match/\$limit로 조인 입력을 줄인다- 조인 후 필터링을
\$lookup파이프라인으로 밀어 넣는다
추가로 성능 문제를 체계적으로 추적/진단하는 습관은 다른 영역에서도 그대로 통합니다. 예를 들어 프론트 성능 병목을 Long Task로 좁혀가는 방식은 Chrome INP 급락 원인 찾기 - Long Task 추적 글에서도 같은 결로 설명합니다. 빌드/배포 파이프라인이 느릴 때 캐시로 병목을 줄이는 접근은 Docker 빌드가 느릴 때 BuildKit 캐시 최적화도 함께 참고할 만합니다.