Published on

MongoDB $lookup 느림 해결 - 인덱스·pipeline 최적화

Authors

서버에서 MongoDB Aggregation의 $lookup이 느려지기 시작하면 체감상 “조인 때문에 어쩔 수 없다”로 결론 내리기 쉽습니다. 하지만 대부분의 병목은 조인 자체가 아니라 인덱스 부재, 필터/프로젝션 순서, 불필요한 카디널리티(조인 결과 폭증), 메모리/디스크 스필 같은 “피할 수 있는 비용”에서 발생합니다. 이 글에서는 $lookup이 느려지는 전형적인 패턴을 짚고, 인덱스와 pipeline을 어떻게 재구성해야 하는지 실전 기준으로 정리합니다.

> 참고: 성능 이슈는 애플리케이션 레벨의 Long Task로 이어지기도 합니다. 프론트에서 INP가 떨어진다면 서버의 응답 지연이 원인일 수 있으니 Chrome INP 급락 - Long Task 찾고 쪼개기도 함께 보시면 전체 병목을 연결해 진단하는 데 도움이 됩니다.

1) $lookup이 느려지는 대표 원인 6가지

1. foreign 컬렉션 조인 키에 인덱스가 없다

가장 흔합니다. localField -> foreignField 매칭이 COLLSCAN으로 돌면, 입력 문서 수(N)만큼 foreign 컬렉션을 반복 스캔하는 형태가 되어 급격히 느려집니다.

2. $match$lookup 뒤에 있다

가능한 한 입력(N)을 먼저 줄이고 조인을 해야 합니다. $lookup 이후에 $match로 거르는 구조는 “조인 결과를 만든 뒤 버리는” 비용을 지불합니다.

3. $lookup 결과 배열이 너무 커진다(카디널리티 폭증)

1:N, N:M 관계에서 매칭이 과도하면 결과 배열이 커지고, 이어지는 $unwind, $group에서 CPU/메모리 사용량이 폭증합니다.

4. $lookup pipeline에서 필터/정렬이 인덱스를 못 탄다

$lookuppipeline 내부에서 $expr로 복잡한 조건을 만들거나, 정렬이 인덱스 순서와 맞지 않으면 foreign 쪽에서 스캔/정렬 비용이 커집니다.

5. 불필요한 필드까지 가져온다

조인 결과에서 실제로 필요한 필드가 몇 개뿐인데 전체 문서를 끌고 오면 네트워크/메모리/디스크 스필 가능성이 커집니다.

6. 메모리 제한으로 디스크 스필이 발생한다

Aggregation은 단계에 따라 메모리를 많이 씁니다. 특히 $sort, $group, 큰 $lookup 결과는 스필을 유발합니다(로그/프로파일러에서 확인 가능).

2) 진단: explain()과 프로파일러로 “어디서” 느린지 먼저 본다

explain("executionStats")로 플랜 확인

아래처럼 $lookup이 들어간 aggregation에 explain을 붙여 확인합니다.

// Mongo 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);

확인 포인트:

  • winningPlan에서 foreign 쪽이 IXSCAN인지, COLLSCAN인지
  • executionStatstotalDocsExamined, totalKeysExamined가 과도한지
  • $lookup 단계에서 시간이 몰리는지

프로파일러로 “실제 느린 쿼리” 수집

운영에서는 explain만으로는 부족할 때가 많습니다. 프로파일러를 짧게 켜서 느린 aggregation을 수집합니다.

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

// 프로파일링 로그 확인
db.system.profile.find({
  ns: "mydb.orders",
  "command.aggregate": "orders"
}).sort({ ts: -1 }).limit(5).pretty();

3) 인덱스 최적화: $lookup은 결국 “foreign 인덱스” 게임이다

(1) 기본형 $lookup: foreignField 인덱스는 거의 필수

// orders.userId -> users._id 조인
// users._id는 기본 인덱스가 있으므로 OK

// 반대로 orders.userEmail -> users.email 이라면
// users.email 인덱스가 필요

db.users.createIndex({ email: 1 });
  • 조인 키가 _id가 아닌 경우 특히 놓치기 쉽습니다.
  • 타입이 다르면 인덱스가 있어도 매칭이 안 됩니다(예: ObjectId vs 문자열). 조인 키 타입을 통일하세요.

(2) $lookup pipeline에서 추가 필터가 있다면 “복합 인덱스”로

예: 특정 기간 주문만 조인하거나, 사용자 상태가 ACTIVE인 것만 조인하는 경우.

// users 컬렉션에서 _id 매칭 + status 필터를 함께 탄다고 가정
// pipeline에서 _id와 status를 같이 사용한다면 복합 인덱스가 유리

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

주의: _id는 이미 인덱스지만, $lookup pipeline에서 _id + 다른 조건을 동시에 강하게 걸면 복합 인덱스가 더 유리할 수 있습니다.

(3) 조인 후 정렬/페이징이 있다면 “정렬 인덱스”를 먼저 설계

$lookup 결과를 $unwind 후 정렬하는 구조는 매우 비쌉니다. 가능하면 조인 이전에 정렬/페이징을 끝내거나, 정렬 키를 기준으로 먼저 대상 집합을 좁히세요.

4) Pipeline 최적화 핵심: “줄이고, 필요한 것만 조인하고, 작게 가져오기”

(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" } }
]

입력 문서 수가 100만 → 1만으로 줄어드는 순간, $lookup 비용도 거의 선형으로 줄어듭니다.

(2) $lookup는 pipeline 형태로 바꾸고 $project로 슬림하게

기본형 $lookup은 foreign 문서 전체를 가져오기 쉽습니다. pipeline을 사용하면 필요한 필드만 남길 수 있습니다.

[
  { $match: { status: "PAID" } },
  {
    $lookup: {
      from: "users",
      let: { uid: "$userId" },
      pipeline: [
        { $match: { $expr: { $eq: ["$_id", "$$uid"] } } },
        { $project: { _id: 1, email: 1, plan: 1 } }
      ],
      as: "user"
    }
  },
  { $unwind: "$user" },
  { $project: { _id: 1, total: 1, "user.email": 1, "user.plan": 1 } }
]

포인트:

  • foreign 쪽에서 $project로 문서 크기를 줄이면 메모리/네트워크 비용이 크게 감소합니다.
  • $unwind가 필요 없다면 배열로 유지하는 것도 비용을 줄일 수 있습니다(단, 후속 단계 요구사항에 따라).

(3) $lookup 결과가 1건이면 “1건만 가져오기”

논리적으로 1:1인데 데이터 품질 문제로 여러 건이 매칭될 여지가 있다면, 방어적으로 제한을 걸어 결과 폭증을 막습니다.

{
  $lookup: {
    from: "users",
    let: { uid: "$userId" },
    pipeline: [
      { $match: { $expr: { $eq: ["$_id", "$$uid"] } } },
      { $project: { email: 1 } },
      { $limit: 1 }
    ],
    as: "user"
  }
}

(4) $unwind는 정말 필요할 때만, 필요하면 옵션을 명확히

{ $unwind: { path: "$user", preserveNullAndEmptyArrays: false } }
  • preserveNullAndEmptyArrays: true는 결과를 늘릴 수 있습니다(의도적으로 필요할 때만).

5) 카디널리티 폭증 패턴과 해결: “조인 전에 집계/축약”

예를 들어 orders -> orderItems 조인 후 아이템을 전부 펼쳐서 계산하면 매우 비쌉니다. 가능하면 orderItems에서 먼저 필요한 형태로 축약한 뒤 붙입니다.

// orders에 orderItems를 붙이되, 아이템 전체 대신 합계만 붙이기
[
  { $match: { createdAt: { $gte: ISODate("2026-01-01") } } },
  {
    $lookup: {
      from: "orderItems",
      let: { oid: "$_id" },
      pipeline: [
        { $match: { $expr: { $eq: ["$orderId", "$$oid"] } } },
        { $group: { _id: "$orderId", itemCount: { $sum: 1 }, amount: { $sum: "$price" } } }
      ],
      as: "itemsAgg"
    }
  },
  { $unwind: { path: "$itemsAgg", preserveNullAndEmptyArrays: true } },
  { $addFields: { itemCount: "$itemsAgg.itemCount", amount: "$itemsAgg.amount" } },
  { $project: { itemsAgg: 0 } }
]

이 방식은 “조인 결과 배열”을 만들지 않고 foreign 쪽에서 먼저 줄여서 붙이므로, $unwind 폭발을 피하는 데 효과적입니다.

6) $lookup에서 인덱스를 깨는 조건을 피하는 법

(1) 조인 키에 변환/연산을 걸지 않는다

예: toString, substr, dateToString 같은 변환을 조인 키에 걸면 인덱스 활용이 어려워집니다. 조인 키는 저장 시점에 정규화하는 편이 낫습니다.

(2) $expr를 쓰더라도 “단순 동등 비교”를 우선

$expr: { $eq: [...] } 형태는 비교적 최적화가 잘 되는 편이지만, 복잡한 OR/함수 조합은 성능이 급격히 나빠질 수 있습니다. 가능한 한 foreign 컬렉션에 “검색 가능한 필드”를 만들고 인덱스를 설계하세요.

(3) 정렬 + 제한은 인덱스로

foreign 쪽에서 최신 1건만 필요하다면 다음 패턴이 강력합니다.

// 예: userId별 최신 로그인 1건
// logins: { userId: 1, createdAt: -1 } 인덱스 권장

db.logins.createIndex({ userId: 1, createdAt: -1 });

[
  {
    $lookup: {
      from: "logins",
      let: { uid: "$userId" },
      pipeline: [
        { $match: { $expr: { $eq: ["$userId", "$$uid"] } } },
        { $sort: { createdAt: -1 } },
        { $limit: 1 },
        { $project: { _id: 0, createdAt: 1, ip: 1 } }
      ],
      as: "lastLogin"
    }
  }
]

인덱스가 맞으면 $sort 비용이 사실상 사라지고, $limit: 1로 문서 접근도 최소화됩니다.

7) 운영 팁: allowDiskUse, 배치 전략, 사전 계산(denormalization)

(1) allowDiskUse는 “해결”이 아니라 “응급처치”

메모리 스필로 실패하거나 매우 느릴 때 임시로 켤 수는 있지만, 디스크 스필은 지연을 크게 늘립니다.

db.orders.aggregate(pipeline, { allowDiskUse: true });

근본적으로는 위에서 다룬 입력 감소, 결과 슬림화, 인덱스 설계가 먼저입니다.

(2) 자주 쓰는 조인은 사전 계산 컬렉션/필드로

  • “주문 + 사용자 요약”처럼 거의 항상 같이 쓰는 조합은 쓰기 시점에 일부를 중복 저장하는 것이 전체 비용을 줄일 수 있습니다.
  • 단, 정합성(업데이트 전파) 비용이 생기므로 변경 빈도/읽기 빈도에 따라 결정합니다.

(3) 쿼리 병목은 인프라 병목으로도 번진다

$lookup이 길어지면 워커 스레드가 막혀 API 타임아웃, 커넥션 고갈, NAT 포트 사용 증가 같은 2차 장애로 이어질 수 있습니다. egress가 불안정해지는 케이스까지 확장되면 GCP Cloud NAT 포트 고갈로 egress 실패 진단법처럼 네트워크 레벨 진단도 필요해질 수 있습니다.

8) 체크리스트: $lookup 느림을 15분 안에 줄이는 순서

  1. foreignField 인덱스 존재 여부 확인(COLLSCAN이면 1순위)
  2. $match$lookup 앞으로 이동해 입력을 최소화
  3. $lookup를 pipeline으로 바꾸고 foreign에서 $project로 필요한 필드만
  4. 1:1이면 **$limit: 1**로 폭증 방지
  5. $unwind/$group가 있다면 카디널리티 폭증 여부 점검(조인 전 축약 고려)
  6. 정렬/페이징은 가능하면 조인 전에, 또는 인덱스 설계로 정렬 비용 제거
  7. explain/프로파일러로 개선 전후 docsExamined, 실행 시간 비교

9) 마무리

$lookup은 “MongoDB에서 조인을 하면 느리다”가 아니라, 조인에 필요한 조건을 인덱스로 받쳐주고, pipeline을 입력 최소화 중심으로 재배치하면 충분히 빠르게 만들 수 있는 연산입니다. 특히 실무에서는 $match의 위치와 foreign 쪽 $project, 그리고 카디널리티 폭증을 막는 설계가 체감 성능을 좌우합니다.

원하시면 실제 사용 중인 pipeline(민감정보 제거)과 컬렉션 인덱스 목록을 기준으로, 어떤 인덱스를 추가/변경해야 하는지와 $lookup pipeline을 어떻게 재작성하면 좋은지까지 구체적으로 리라이트해 드릴게요.