Published on

MongoDB 느린 $lookup 5배 빠르게 - 인덱스·튜닝

Authors

서로 다른 컬렉션을 조인해야 할 때 MongoDB의 $lookup은 강력합니다. 하지만 운영 환경에서 “특정 API만 유독 느리다”를 파고 들어가면, 범인으로 $lookup이 자주 등장합니다. 특히 조인 대상이 커지거나(수백만 문서), 조인 결과가 배열로 폭발하거나, 조인 전에 필터링이 덜 된 상태라면 $lookup은 CPU/메모리/디스크를 동시에 압박하며 레이턴시를 급격히 끌어올립니다.

이 글에서는 느린 $lookup을 5배 이상 빠르게 만드는 대표적인 접근을 다룹니다.

  • 실행 계획으로 병목을 먼저 “증명”하기
  • 조인 키 인덱스와 복합 인덱스 설계
  • $lookup 파이프라인 최적화(필터 선적용, 프로젝션 최소화, 조인 결과 제한)
  • 조인 폭발 방지(카디널리티 관리)
  • 필요 시 스키마/쿼리 패턴 변경(denormalization, pre-aggregation)

운영 장애를 다루는 관점에서, 리소스 압박이 동반되는 경우도 많습니다. 조인 쿼리로 메모리가 치솟아 프로세스가 죽는다면 함께 참고할 만합니다: 리눅스 OOM Killer로 프로세스 죽음 진단·방지

1) 왜 $lookup이 느려지는가: 3가지 전형적인 패턴

1-1. 조인 키에 인덱스가 없다

$lookup은 내부적으로 foreign 컬렉션에서 매칭을 찾는 작업이 들어갑니다. foreignField(또는 파이프라인 $match에서 쓰는 필드)에 인덱스가 없으면, 매번 광범위한 스캔이 발생할 수 있습니다.

1-2. 필터링보다 조인을 먼저 한다

파이프라인에서 $match가 뒤에 있으면, 불필요하게 많은 “왼쪽 문서”에 대해 조인을 수행합니다. 조인 비용은 대개 왼쪽 입력 문서 수 × 매칭 탐색 비용이므로, 입력을 먼저 줄이는 것이 가장 큰 레버리지입니다.

1-3. 조인 결과 배열이 폭발한다(카디널리티 문제)

user -> orders처럼 1:N 관계에서, 어떤 사용자는 주문이 수천 건일 수 있습니다. 이때 $lookup 결과 배열이 커지면 메모리 사용량이 급증하고, 이후 단계($unwind, $group, $sort)가 연쇄적으로 느려집니다.

2) 먼저 측정: explain()으로 병목을 고정하라

튜닝의 시작은 “감”이 아니라 실행 계획입니다. MongoDB에서는 aggregation에 대해 explain을 걸어 다음을 확인합니다.

  • foreign 컬렉션에서 IXSCAN(인덱스 스캔)을 타는가, COLLSCAN(컬렉션 스캔)인가
  • $lookup 단계가 전체 시간의 대부분을 차지하는가
  • 반환 문서 수 대비 처리 문서 수가 과도하게 큰가
// mongosh
const pipeline = [
  { $match: { status: "ACTIVE" } },
  {
    $lookup: {
      from: "orders",
      localField: "_id",
      foreignField: "userId",
      as: "orders"
    }
  },
  { $project: { email: 1, orders: { $slice: ["$orders", 5] } } }
];

db.users.explain("executionStats").aggregate(pipeline);

executionStats에서 특히 아래를 봅니다.

  • totalDocsExamined, totalKeysExamined
  • stage별 time/works(버전에 따라 표현 상이)
  • foreign 쪽이 인덱스를 타는지

목표: $lookup이 foreign 컬렉션에서 COLLSCAN을 유발하고 있다면, 인덱스가 1순위입니다.

3) 인덱스 튜닝: $lookup 성능의 70%는 여기서 결정된다

3-1. 기본: foreignField에 단일 인덱스

가장 흔한 형태:

{
  $lookup: {
    from: "orders",
    localField: "_id",
    foreignField: "userId",
    as: "orders"
  }
}

이 경우 orders.userId 인덱스가 사실상 필수입니다.

db.orders.createIndex({ userId: 1 });

3-2. 파이프라인 $lookup에서는 “실제로 매치하는 조건”에 맞춘 복합 인덱스

운영에서는 조인하면서 특정 기간/상태만 가져오는 경우가 많습니다.

{
  $lookup: {
    from: "orders",
    let: { uid: "$_id" },
    pipeline: [
      {
        $match: {
          $expr: {
            $and: [
              { $eq: ["$userId", "$$uid"] },
              { $eq: ["$status", "PAID"] },
              { $gte: ["$createdAt", ISODate("2025-01-01") ] }
            ]
          }
        }
      },
      { $sort: { createdAt: -1 } },
      { $limit: 10 },
      { $project: { _id: 1, total: 1, createdAt: 1 } }
    ],
    as: "recentPaidOrders"
  }
}

이때는 orders에 다음처럼 복합 인덱스를 고려합니다.

// 매치 + 정렬을 함께 만족시키는 인덱스
db.orders.createIndex({ userId: 1, status: 1, createdAt: -1 });

핵심은:

  • $match에서 고정 조건(카디널리티 높은 것)을 앞쪽에
  • $sort까지 커버하려면 정렬 필드를 뒤에 같은 방향으로
  • $limit과 결합되면 인덱스 상단에서 필요한 만큼만 읽고 끝낼 수 있음

3-3. 부분 인덱스(Partial Index)로 인덱스 크기와 스캔량을 줄이기

status: "PAID"만 자주 조인한다면 부분 인덱스가 효과적입니다.

db.orders.createIndex(
  { userId: 1, createdAt: -1 },
  { partialFilterExpression: { status: "PAID" } }
);

부분 인덱스는 디스크/메모리(인덱스 캐시) 압박도 줄여줘서, 조인뿐 아니라 전체 시스템 안정성에도 기여합니다.

4) 파이프라인 튜닝: “조인 전 줄이고, 조인 후 더 줄이기”

4-1. $match는 최대한 앞에 두기

가장 흔한 실수는 이런 형태입니다.

// 나쁜 예: 조인을 먼저 하고 필터링
[
  { $lookup: { from: "orders", localField: "_id", foreignField: "userId", as: "orders" } },
  { $match: { status: "ACTIVE" } }
]

반대로:

// 좋은 예: 왼쪽 입력을 먼저 줄임
[
  { $match: { status: "ACTIVE" } },
  { $lookup: { from: "orders", localField: "_id", foreignField: "userId", as: "orders" } }
]

단순하지만, 입력 문서가 10배 줄면 $lookup 호출도 10배 줄어 체감 성능이 크게 좋아집니다.

4-2. $lookup 파이프라인에서 $project로 필드 최소화

조인 결과에 큰 문서(예: items, shippingAddress, metadata)를 통째로 붙이면 네트워크/메모리/후속 stage 비용이 커집니다.

{
  $lookup: {
    from: "orders",
    let: { uid: "$_id" },
    pipeline: [
      { $match: { $expr: { $eq: ["$userId", "$$uid"] } } },
      { $project: { _id: 1, total: 1, createdAt: 1 } }
    ],
    as: "orders"
  }
}

원칙: 조인 결과는 “정말 필요한 필드만” 남기고, 큰 배열/서브도큐먼트는 가능한 한 제거합니다.

4-3. 조인 결과가 많을 때는 $sort + $limit로 폭발을 제어

최근 10개 주문만 필요하다면, 전체를 붙여놓고 나중에 자르지 말고 foreign에서부터 제한해야 합니다.

{
  $lookup: {
    from: "orders",
    let: { uid: "$_id" },
    pipeline: [
      { $match: { $expr: { $eq: ["$userId", "$$uid"] } } },
      { $sort: { createdAt: -1 } },
      { $limit: 10 },
      { $project: { total: 1, createdAt: 1 } }
    ],
    as: "recentOrders"
  }
}

이 패턴은 위에서 만든 { userId: 1, createdAt: -1 } 인덱스와 결합될 때 특히 강력합니다.

4-4. $unwind는 신중하게: 필요하면 “조인 전/후” 위치를 바꿔라

$unwind는 문서를 늘립니다. 조인 결과 배열을 unwind하면 문서 수가 폭발하고, 이후 $group/$sort가 급격히 무거워질 수 있습니다.

가능한 대안:

  • foreign에서 먼저 집계해서 작은 결과로 만들고 붙이기
  • $unwind 대신 $arrayElemAt, $first 같은 연산으로 필요한 값만 추출

예: 주문 총액 합만 필요하다면 주문을 전부 붙이지 말고 foreign에서 그룹핑:

[
  { $match: { status: "ACTIVE" } },
  {
    $lookup: {
      from: "orders",
      let: { uid: "$_id" },
      pipeline: [
        { $match: { $expr: { $eq: ["$userId", "$$uid"] } } },
        { $group: { _id: null, sumTotal: { $sum: "$total" }, cnt: { $sum: 1 } } },
        { $project: { _id: 0, sumTotal: 1, cnt: 1 } }
      ],
      as: "orderAgg"
    }
  },
  {
    $addFields: {
      orderCount: { $ifNull: [{ $first: "$orderAgg.cnt" }, 0] },
      orderSum: { $ifNull: [{ $first: "$orderAgg.sumTotal" }, 0] }
    }
  },
  { $project: { orderAgg: 0 } }
]

조인 결과를 “데이터”가 아니라 “요약”으로 바꾸면, 처리량이 급격히 좋아집니다.

5) $lookup 자체를 줄이는 전략: 데이터 모델/쿼리 패턴 변경

튜닝을 해도 물리적으로 조인량이 너무 크면 한계가 있습니다. 다음을 검토합니다.

5-1. Denormalization(부분 중복 저장)

예: 주문 목록 화면에서 사용자 이름/등급이 필요할 때 매번 users를 조인하지 말고, 주문 생성 시점에 userName, userTierorders에 복사해 둡니다.

  • 장점: 읽기 성능 극대화
  • 단점: 업데이트 동기화 필요(이름 변경 등)

5-2. Pre-aggregation(사전 집계 컬렉션)

대시보드/리포트처럼 동일한 집계를 반복한다면, 배치/스트림으로 user_order_stats 같은 컬렉션을 만들어 $lookup을 “작은 테이블 조인”으로 바꿉니다.

5-3. 조회 API에서 “필요한 화면 단위”로 쿼리를 쪼개기

한 번의 aggregation에 모든 걸 우겨 넣는 대신:

    1. users 기본 정보
    1. 최근 주문 10개

처럼 2번 쿼리로 나누고 애플리케이션에서 합치는 방식이 더 빠를 때가 있습니다(특히 캐시 전략과 결합 시).

6) 실전 체크리스트: 5배 개선을 만드는 우선순위

  1. explain("executionStats")로 foreign가 COLLSCAN인지 확인
  2. foreign 매치 필드에 인덱스(필요 시 복합/부분 인덱스)
  3. $match를 파이프라인 최전방으로 이동(왼쪽 입력 줄이기)
  4. $lookup.pipeline에서 $project로 필드 최소화
  5. 결과 폭발 시 $sort + $limit를 foreign에서 적용
  6. $unwind를 줄이거나, foreign에서 먼저 $group으로 요약해서 붙이기
  7. 그래도 느리면 denormalization / pre-aggregation 검토

리소스가 빡빡한 환경(컨테이너/노드 메모리 제한)에서는 $lookup 튜닝이 곧 안정성 튜닝입니다. 조인 쿼리로 메모리 스파이크가 나고 프로세스가 종료된다면 위의 “결과 폭발 제어”와 함께, 시스템 관점의 진단도 병행하세요: 리눅스 OOM Killer로 프로세스 죽음 진단·방지

7) 마무리: “빠른 $lookup”의 본질은 조인량과 스캔량을 줄이는 것

$lookup을 5배 빠르게 만드는 방법은 특별한 트릭이라기보다, 데이터베이스 튜닝의 정석에 가깝습니다.

  • 스캔량을 줄이는 인덱스
  • 불필요한 입력을 줄이는 파이프라인 순서
  • 조인 결과를 작게 유지하는 제한/요약

이 세 가지를 실행 계획으로 검증하면서 반복하면, 대부분의 느린 $lookup은 눈에 띄게 개선됩니다. 운영에서 중요한 건 “한 번의 최적화”가 아니라, 데이터 증가와 사용 패턴 변화에 맞춰 인덱스·파이프라인을 지속적으로 재점검하는 습관입니다.