Published on

MongoDB 느린 집계 파이프라인 $lookup 최적화 8단계

Authors

서로 다른 컬렉션을 조인해야 하는 순간, MongoDB 집계 파이프라인의 $lookup은 강력하지만 가장 흔한 병목이 되기도 합니다. 특히 트래픽이 늘거나 데이터가 커지면, “개발 환경에서는 괜찮았는데 운영에서 갑자기 느려졌다”는 상황이 자주 발생합니다. 이 글은 느린 $lookup측정 → 원인 분해 → 구조 개선 → 인덱스 설계 → 결과 검증 순서로 접근하는 8단계 최적화 절차를 제공합니다.

> 목표: $lookup이 포함된 집계가 느릴 때, 감으로 튜닝하지 않고 재현 가능한 절차로 처리하기


0) 전제: 느린 $lookup의 대표 원인

$lookup이 느려지는 패턴은 대체로 아래 중 하나(또는 복합)입니다.

  • 조인 키 인덱스 부재: foreign collection에서 매번 풀스캔
  • 조인 전에 데이터가 너무 커짐: $match가 늦게 등장, $lookup 대상이 과도
  • fan-out 폭발: 한 문서가 수천 개의 foreign 문서와 매칭되어 배열이 비대해짐
  • 불필요한 필드 전개: $project/$set이 늦어 메모리/네트워크 낭비
  • 정렬/그룹이 조인 이후에 발생: 정렬이 인덱스를 못 타고 메모리 정렬로 전환
  • 파이프라인 $lookup에서 인덱스가 안 타는 조건: $expr 조합이 비효율

이제부터는 “원인 추정”이 아니라 “단계별로 확인하고 줄이는” 방식으로 진행합니다.


1단계: explain()으로 병목을 눈으로 확인하기

최적화의 시작은 실행계획입니다. 집계는 aggregate(...).explain() 또는 Compass의 Explain으로 확인합니다.

db.orders.explain("executionStats").aggregate([
  { $match: { status: "PAID" } },
  { $lookup: {
      from: "users",
      localField: "userId",
      foreignField: "_id",
      as: "user"
  }},
  { $unwind: "$user" },
  { $sort: { createdAt: -1 } },
  { $limit: 50 }
])

여기서 체크할 포인트:

  • 전체 executionTimeMillis와 각 stage의 nReturned
  • $lookup 관련 stage에서 foreign collection이 COLLSCAN인지 IXSCAN인지
  • totalDocsExamined가 비정상적으로 큰지
  • usedDisk(디스크 스필) 발생 여부

> 팁: 실제 운영 병목은 DB만이 아니라 인프라 자원에도 영향을 줍니다. 트래픽 급증 시 오토스케일이 기대대로 동작하지 않으면 병목이 더 악화될 수 있으니, Kubernetes 환경이라면 EKS에서 HPA가 안 늘어날 때 Metrics 오류 해결 같은 체크리스트도 함께 점검하는 것이 좋습니다.


2단계: $match를 최대한 앞당겨 입력 집합 줄이기

가장 효과가 큰 최적화는 조인 전에 줄이는 것입니다. $lookup 이전에 가능한 조건을 모두 $match로 당깁니다.

나쁜 예: 조인 후 필터

[
  { $lookup: { from: "users", localField: "userId", foreignField: "_id", as: "user" } },
  { $unwind: "$user" },
  { $match: { "user.country": "KR" } }
]

좋은 예: 조인 전 필터 + 필요한 경우 파이프라인 lookup

조인 키가 orders에 있고, users의 조건이 필요하다면 $lookup 파이프라인을 사용하되, orders 자체의 필터는 먼저 수행합니다.

[
  { $match: { status: "PAID", createdAt: { $gte: ISODate("2026-01-01") } } },
  { $lookup: {
      from: "users",
      let: { uid: "$userId" },
      pipeline: [
        { $match: { $expr: { $eq: ["$_id", "$$uid"] } } },
        { $match: { country: "KR" } },
        { $project: { _id: 1, name: 1, country: 1 } }
      ],
      as: "user"
  }},
  { $unwind: "$user" }
]

핵심은 $lookup에 들어가는 orders 문서 수를 줄이고, users에서 필요한 필드만 가져오는 것입니다.


3단계: foreignField(또는 pipeline 조건)에 맞는 인덱스 설계

localField/foreignField 형태의 $lookup은 foreign collection의 foreignField 인덱스가 사실상 필수입니다.

// users._id는 기본 인덱스가 있지만
// 다른 키로 조인한다면 반드시 인덱스를 만들어야 합니다.

db.users.createIndex({ email: 1 })

파이프라인 $lookup에서는 인덱스가 더 중요합니다. 예를 들어 아래 조건이 있다면:

  • _id로 매칭 + country로 추가 필터

복합 인덱스가 도움이 될 수 있습니다.

db.users.createIndex({ _id: 1, country: 1 })

주의점:

  • $expr는 형태에 따라 인덱스 사용이 제한될 수 있습니다.
  • 가능한 한 동등 조건(equality) 중심으로 인덱스를 설계하세요.

4단계: $lookup 결과 크기 제한(Projection + Limit)으로 fan-out 제어

$lookup이 느린 경우, 인덱스가 있어도 결과 배열이 너무 커서 느린 경우가 많습니다. 특히 1:N 관계에서 fan-out이 폭발하면 메모리와 네트워크 비용이 급증합니다.

필요한 필드만 가져오기

{
  $lookup: {
    from: "orderItems",
    let: { oid: "$_id" },
    pipeline: [
      { $match: { $expr: { $eq: ["$orderId", "$$oid"] } } },
      { $project: { _id: 0, sku: 1, qty: 1, price: 1 } }
    ],
    as: "items"
  }
}

상위 N개만 필요하면 $limit

예: 최신 댓글 3개만 조인

{
  $lookup: {
    from: "comments",
    let: { pid: "$_id" },
    pipeline: [
      { $match: { $expr: { $eq: ["$postId", "$$pid"] } } },
      { $sort: { createdAt: -1 } },
      { $limit: 3 },
      { $project: { _id: 1, body: 1, createdAt: 1 } }
    ],
    as: "topComments"
  }
}

이때 comments에는 아래 인덱스가 강력합니다.

db.comments.createIndex({ postId: 1, createdAt: -1 })

5단계: $unwind는 조심해서 쓰고, 가능하면 늦추거나 대체하기

$unwind는 결과 문서 수를 폭발시킬 수 있습니다. $lookup으로 배열을 만든 뒤 곧바로 $unwind하면, 후속 $sort/$group 비용이 크게 증가합니다.

대체 전략 A: 배열 상태로 필요한 계산만 수행

예: 첫 번째 유저만 필요하다면 $arrayElemAt 사용

[
  { $lookup: { from: "users", localField: "userId", foreignField: "_id", as: "user" } },
  { $set: { user: { $arrayElemAt: ["$user", 0] } } }
]

대체 전략 B: $unwind가 필요하다면 가능한 한 뒤로

정렬/필터를 먼저 해서 입력을 줄인 뒤 unwind하는 식으로 재배치합니다.


6단계: 조인 이후 정렬/그룹을 피하고, 인덱스 기반 정렬을 먼저 확보

많은 파이프라인이 아래처럼 구성됩니다.

  • $lookup$unwind$sort

이 경우 $sort가 인덱스를 타기 어렵고 메모리 정렬로 가기 쉽습니다. 가능하면 정렬은 조인 이전에 수행해 상위 N개만 조인하도록 바꿉니다.

[
  { $match: { status: "PAID" } },
  { $sort: { createdAt: -1 } },
  { $limit: 50 },
  { $lookup: { from: "users", localField: "userId", foreignField: "_id", as: "user" } },
  { $set: { user: { $arrayElemAt: ["$user", 0] } } }
]

그리고 orders에는 아래 인덱스가 필요합니다.

db.orders.createIndex({ status: 1, createdAt: -1 })

이 패턴은 “최근 50건 주문 + 유저 정보” 같은 API에서 체감 성능 개선이 큽니다.


7단계: $lookup 대신 사전 집계/비정규화/머티리얼라이즈드 뷰 고려

모든 조인을 런타임에 해결하려고 하면, 데이터가 커질수록 비용이 선형이 아니라 폭발적으로 증가할 수 있습니다.

선택지:

  • 비정규화: 자주 쓰는 유저 이름/등급 등을 orders에 복제(변경 이벤트로 동기화)
  • 사전 집계 컬렉션: 매일/매시간 통계를 별도 컬렉션에 저장
  • $merge로 머티리얼라이즈: 집계 결과를 컬렉션으로 저장해 조회는 단순 find로 처리

예: 주문+유저 조인 결과를 order_enriched로 머지

db.orders.aggregate([
  { $match: { createdAt: { $gte: ISODate("2026-01-01") } } },
  { $lookup: { from: "users", localField: "userId", foreignField: "_id", as: "user" } },
  { $set: { user: { $arrayElemAt: ["$user", 0] } } },
  { $project: { userId: 1, status: 1, createdAt: 1, "user.name": 1, "user.tier": 1 } },
  { $merge: { into: "order_enriched", on: "_id", whenMatched: "replace", whenNotMatched: "insert" } }
])

이 방식은 읽기 성능을 극적으로 올리지만, **갱신 전략(동기화/지연 허용)**을 설계해야 합니다. 이벤트 기반 보상/재처리 이슈가 있다면 분산 트랜잭션 관점에서 Saga 패턴 보상 트랜잭션 중복 실행 방지법 같은 접근이 도움이 됩니다.


8단계: 운영 검증(프로파일링/슬로우쿼리/리소스)으로 재발 방지

최적화는 “한 번 빨라짐”으로 끝나지 않고, 재발 방지 장치까지 포함해야 합니다.

(1) Database Profiler로 느린 집계 수집

환경에 맞게 임계값을 설정합니다.

// 200ms 이상 걸리는 쿼리만 기록
db.setProfilingLevel(1, { slowms: 200 })

// 프로파일 로그 확인
db.system.profile.find({ ns: /orders/ }).sort({ ts: -1 }).limit(5)

(2) allowDiskUse는 “해결”이 아니라 “증상 완화”일 수 있음

정렬/그룹이 메모리를 넘으면 디스크 스필이 발생합니다. allowDiskUse: true로 장애를 피할 수는 있지만, 근본적으로는 입력 축소/인덱스/파이프라인 재배치가 우선입니다.

(3) 애플리케이션/인프라 레벨 병목도 함께 점검

DB가 느려지면 타임아웃이 늘고, 재시도/큐 적체로 더 악화됩니다. 클러스터 환경에서 5xx가 증가한다면 로드밸런서/인그레스 설정도 함께 확인하세요. 예를 들어 EKS에서 502가 동반된다면 EKS에서 ALB Ingress 502 Bad Gateway 원인 9가지 같은 체크리스트로 상위 계층 병목을 제거해야 전체 지연이 줄어듭니다.


실전 체크리스트: 8단계 요약

  1. explain("executionStats")$lookup에서 COLLSCAN/DocsExamined 확인
  2. $match$lookup 이전으로 최대한 당겨 입력 집합 축소
  3. foreign collection의 조인 키/필터 키 인덱스 설계(필요 시 복합 인덱스)
  4. $lookup 파이프라인에서 $project로 필드 최소화, 필요 시 $limit
  5. $unwind는 폭발 요인이므로 대체($arrayElemAt) 또는 뒤로 미루기
  6. $sort/$limit를 조인 전에 수행해 인덱스 기반으로 상위 N개만 조인
  7. 반복 조회라면 비정규화/사전 집계/$merge로 머티리얼라이즈 고려
  8. Profiler/슬로우쿼리/리소스 모니터링으로 재발 방지

마무리

$lookup 최적화의 핵심은 “조인을 빨리”가 아니라 조인할 필요가 있는 데이터만 조인하도록 파이프라인을 재구성하는 것입니다. 인덱스는 필수지만, 인덱스만으로 해결되지 않는 케이스(팬아웃, 조인 후 정렬, 과도한 필드/배열)는 구조적인 접근이 필요합니다.

원하시면 실제 파이프라인(explain 결과 포함)을 기준으로, 어떤 단계가 가장 큰 병목인지와 인덱스 후보/파이프라인 재배치안을 함께 리뷰해 드릴게요.