Published on

MongoDB $lookup 느림? 인덱스·파이프라인 튜닝

Authors

서버가 갑자기 느려져서 프로파일링을 해보면, 의외로 많은 경우 범인은 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) 파이프라인 lookup에서lookup에서 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._idObjectId면, $lookup이 매칭을 못 하거나 $toObjectId 같은 변환을 넣게 되는데, 이때 인덱스 활용이 꼬일 수 있습니다.

가능하면 스키마 레벨에서 타입을 통일하세요. 불가피하면 변환은 조인 전에(그리고 가능한 한 제한된 범위에서) 처리합니다.

파이프라인 튜닝: lookup전에줄이고,lookup 전에 줄이고, 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) lookup전에lookup 전에 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분 점검

  1. explain("executionStats")$lookup이 병목인지 확인
  2. from.foreignField에 인덱스 존재 여부 확인
  3. 조인 전에 $match/$project/$limit를 최대한 당겼는지 확인
  4. $lookup 결과에서 필요한 필드만 $project로 제한
  5. 1:N 조인에서 $unwind로 폭발하고 있지 않은지 확인
  6. 타입 불일치(ObjectId/string)로 변환이 끼어 있지 않은지 확인
  7. $expr 조건이 인덱스를 타는 형태인지 점검
  8. 멀티테넌트면 tenantId를 조인 키/인덱스에 포함
  9. 캐시/사전 집계/비정규화가 더 합리적인지 판단
  10. 운영에서 타임아웃/재시도 정책도 함께 점검(느린 조인은 연쇄 장애를 만든다)

타임아웃/재시도 설계는 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) match/match/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는 기본 인덱스 OK
  • orders에서 status로 필터하고 createdAt으로 정렬한다면:
db.orders.createIndex({ status: 1, createdAt: -1 });

이 조합이면 “앞단에서 모수 축소 → 조인 대상 최소화 → 조인 결과 최소화”가 맞물리면서 $lookup 비용이 크게 내려갑니다.

마무리

$lookup 성능은 결국 **조인 대상 탐색(인덱스)**과 **조인 전/후 데이터 크기(파이프라인 설계)**의 곱으로 결정됩니다. 가장 먼저 explain()으로 병목을 확인하고, foreignField 인덱스 → $match/$project/$limit 전진 배치 → $lookup 파이프라인으로 결과 최소화 순서로 접근하면, 대부분의 “$lookup 느림” 문제는 눈에 띄게 개선됩니다.

그래도 P99이 안 내려간다면, 그때는 튜닝의 영역을 넘어 비정규화/사전 집계/조인 제거 같은 아키텍처 선택을 검토할 타이밍입니다.