Published on

MongoDB 느린 $lookup을 인덱스로 10배 튜닝

Authors

서론: 왜 \$lookup은 쉽게 느려질까

MongoDB에서 \$lookup은 사실상 컬렉션 간 조인을 수행하는 단계입니다. 문제는 조인 자체보다도, 조인을 어떤 방식으로 수행하느냐에 따라 비용이 급격히 달라진다는 점입니다. 특히 다음 조건이 겹치면 쿼리가 순식간에 느려집니다.

  • 로컬 컬렉션(왼쪽)의 입력 문서 수가 많다
  • 외부 컬렉션(오른쪽)에 조인 키 인덱스가 없다
  • \$lookup 이후에야 \$match로 필터링한다(즉, 불필요한 조인을 먼저 한다)
  • \$lookup 파이프라인에서 변환이 많거나, 인덱스를 못 타는 조건을 쓴다
  • 데이터 타입이 맞지 않아 조인 키 매칭이 깨지거나 비효율이 발생한다

이 글에서는 \$lookup이 느려지는 이유를 실행계획으로 확인하고, 인덱스 설계와 파이프라인 재구성만으로 10배 수준의 개선을 만드는 실전 접근을 다룹니다.

문제 재현: 전형적인 느린 \$lookup 패턴

예시로 주문(orders)과 사용자(users)를 조인한다고 가정합니다.

  • orders.userIdusers._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.userIdusers._id 조인이라면, users._id는 기본 인덱스가 있으니 괜찮습니다.
  • 하지만 실제로는 users.userId 같은 별도 키로 조인하거나, 서브필드로 조인하는 경우가 많습니다.

예를 들어 조인 키가 users.userId라면 아래 인덱스가 없으면 성능이 급락합니다.

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

조인 키 타입이 다르면 인덱스가 있어도 느리다

가장 흔한 함정은 타입 불일치입니다.

  • orders.userId는 문자열
  • users._idObjectId

이 경우 매칭이 깨질 뿐 아니라, 변환을 끼워 넣으면 인덱스를 못 타는 형태가 되기 쉽습니다. 가능한 한 저장 단계에서 타입을 통일하세요.

부득이하게 변환이 필요하면, \$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이 특히 느려지는 구간은 한 로컬 문서가 외부에서 수십~수백 건과 매칭되는 형태입니다.

예를 들어 poststagIds 배열이 있고 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이 느릴 때 가장 먼저 해야 할 일은 두 가지입니다.

  1. 실행계획으로 외부 컬렉션 스캔과 입력 문서 수를 확인한다
  2. 인덱스와 파이프라인 순서를 바꿔 \$lookup에 들어가는 데이터 양을 줄인다

정리하면, 아래 3가지만 지켜도 대부분의 문제는 해결됩니다.

  • 조인 키 인덱스는 필수
  • \$match/\$limit로 조인 입력을 줄인다
  • 조인 후 필터링을 \$lookup 파이프라인으로 밀어 넣는다

추가로 성능 문제를 체계적으로 추적/진단하는 습관은 다른 영역에서도 그대로 통합니다. 예를 들어 프론트 성능 병목을 Long Task로 좁혀가는 방식은 Chrome INP 급락 원인 찾기 - Long Task 추적 글에서도 같은 결로 설명합니다. 빌드/배포 파이프라인이 느릴 때 캐시로 병목을 줄이는 접근은 Docker 빌드가 느릴 때 BuildKit 캐시 최적화도 함께 참고할 만합니다.