Published on

MongoDB $lookup 느림? 인덱스·pipeline 튜닝

Authors

서로 다른 컬렉션을 조인해야 할 때 MongoDB의 대표 도구가 $lookup입니다. 문제는 데이터가 커지고 트래픽이 늘면 $lookup이 갑자기 병목이 되기 쉽다는 점입니다. 특히 “로컬은 빠른데 운영에서만 느리다”, “CPU는 낮은데 쿼리가 오래 걸린다”, “aggregation이 메모리를 많이 먹는다” 같은 증상은 대부분 인덱스 미스, pipeline 순서, **불필요한 fan-out(조인 결과 폭발)**에서 시작합니다.

이 글에서는 $lookup이 느려지는 전형적인 구조를 분해하고, 인덱스 설계pipeline 튜닝을 통해 성능을 개선하는 실전 패턴을 정리합니다. (운영 장애를 추적하는 관점은 EKS에서 CoreDNS 정상인데 DNS가 간헐 실패할 때처럼 “정상 지표 속 실패”를 파고드는 방식과 유사합니다.)

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

1. foreignField에 인덱스가 없다

가장 흔합니다. $lookup은 기본적으로 왼쪽(로컬) 입력 문서마다 오른쪽(외부) 컬렉션을 매칭합니다. foreign 컬렉션에서 매칭 키에 인덱스가 없으면, 사실상 반복 스캔이 발생합니다.

  • 로컬 문서 수가 N
  • foreign 컬렉션 크기가 M

인덱스가 없으면 최악의 경우 N × M에 가까운 비용으로 커집니다.

2. $lookup 전에 $match/$project를 안 해서 입력이 너무 크다

$lookup은 입력 문서 수가 많을수록 선형으로 느려집니다. 즉, 조인 전에 최대한 줄여야 합니다.

3. 조인 후 $unwind로 fan-out이 폭발한다

배열로 붙은 결과를 $unwind하면 문서가 기하급수로 늘 수 있습니다. 이후 스테이지(정렬, 그룹)가 폭탄이 됩니다.

4. $lookup pipeline에서 비SARGable 조건을 쓴다

예: $expr 안에서 $toString, $substr, $regex 등을 매칭 조건에 섞으면 인덱스가 잘 안 타거나(혹은 전혀 못 타고) CPU를 태웁니다.

5. 정렬($sort)이 조인 이후에 발생한다

조인으로 문서가 커진 뒤 정렬하면 메모리 사용량과 spill(디스크 임시 파일)이 늘어납니다.

6. 데이터 모델이 조인 친화적이지 않다

  • 조인 키 타입이 컬렉션마다 다름(ObjectId vs string)
  • 다대다 관계를 그대로 $lookup으로 풀어내려 함
  • “최근 30일”만 필요하지만 전체 이력을 조인

이 경우는 튜닝보다 모델/쿼리 요구사항 재정의가 더 큰 효과를 냅니다.

2) 먼저 확인할 것: explain()으로 병목을 특정하기

Aggregation은 반드시 실행 계획을 봐야 합니다.

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

여기서 눈여겨볼 포인트는 다음입니다.

  • $lookup 단계에서 foreign 컬렉션이 COLLSCAN인지
  • totalDocsExamined, totalKeysExamined 비율
  • executionTimeMillis가 어느 단계에서 튀는지
  • $sort가 있다면 usedDisk(spill) 여부

COLLSCAN이 보이면 인덱스부터입니다. 인덱스가 있는데도 느리다면 pipeline 구조(입력 축소, fan-out 제어)를 봐야 합니다.

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

1. foreignField(또는 pipeline 매칭 필드)에 인덱스 생성

기본 형태의 $lookup이라면 foreignField에 인덱스가 있어야 합니다.

// users._id는 기본적으로 인덱스가 있지만
// 예시로 foreignField가 email 같은 필드라면 반드시 생성
db.users.createIndex({ email: 1 });

2. 복합 조건이면 “복합 인덱스”를 foreign 컬렉션에 맞춘다

$lookup pipeline에서 userId + createdAt 같은 조건을 동시에 걸면 단일 인덱스로는 부족합니다.

// 예: userId로 매칭하고, 최근 30일만 가져오는 패턴
db.userEvents.createIndex({ userId: 1, createdAt: -1 });

이때 인덱스 순서는 일반적으로

  • 동등 조건(=) 필드 먼저
  • 범위/정렬 필드 다음

이 원칙이 유효합니다.

3. 타입 불일치(ObjectId vs string)를 제거

조인 키 타입이 다르면 $toObjectId 같은 변환이 들어가고 인덱스 활용이 깨집니다.

  • 가장 좋은 해결: 저장 타입을 통일
  • 차선: 변환된 값을 별도 필드로 저장(denormalized key)
// 예: 문자열 userIdStr을 ObjectId userId로 마이그레이션 후 저장
// (마이그레이션 스크립트는 환경에 맞게 작성)

4. 부분 인덱스/희소 인덱스로 working set을 줄인다

조인 대상이 “활성 사용자만”, “삭제되지 않은 문서만”이라면 partial index가 효과적입니다.

db.users.createIndex(
  { tenantId: 1, _id: 1 },
  { partialFilterExpression: { deletedAt: { $exists: false } } }
);

멀티테넌트에서 특히 강력합니다.

4) Pipeline 튜닝: 스테이지 순서와 fan-out 제어

1. $lookup 전에 $match, $project로 입력을 최소화

조인 전에 줄일수록 이득입니다.

db.orders.aggregate([
  { $match: { status: "PAID", createdAt: { $gte: ISODate("2026-01-01") } } },
  { $project: { userId: 1, total: 1, createdAt: 1 } },
  {
    $lookup: {
      from: "users",
      localField: "userId",
      foreignField: "_id",
      as: "user"
    }
  },
  { $unwind: "$user" },
  { $limit: 100 }
]);

핵심은 $lookup에 들어가기 전 문서 수/필드 수를 줄여 메모리와 네트워크(샤딩 시) 부담을 낮추는 것입니다.

2. pipeline 기반 $lookup으로 “필요한 것만” 가져오기

단순 $lookup은 매칭된 문서를 통째로 붙입니다. 대신 pipeline $lookup으로 필드 제한 + 추가 필터 + 정렬/limit를 조인 내부에서 끝내면 fan-out을 크게 줄일 수 있습니다.

db.orders.aggregate([
  { $match: { status: "PAID" } },
  {
    $lookup: {
      from: "userEvents",
      let: { uid: "$userId" },
      pipeline: [
        {
          $match: {
            $expr: { $eq: ["$userId", "$$uid"] },
            type: "LOGIN",
            createdAt: { $gte: ISODate("2026-01-01") }
          }
        },
        { $sort: { createdAt: -1 } },
        { $limit: 1 },
        { $project: { _id: 0, createdAt: 1, ip: 1 } }
      ],
      as: "lastLogin"
    }
  },
  { $set: { lastLogin: { $first: "$lastLogin" } } }
]);

포인트:

  • 조인 결과를 배열로 크게 붙이지 않고 최신 1건만 가져옴
  • $project로 필요한 필드만 유지
  • foreign 컬렉션에 { userId: 1, createdAt: -1 } 인덱스가 있으면 매우 빠르게 동작

3. $unwind는 정말 필요할 때만, 그리고 가능한 늦게/또는 대체

$unwind는 결과를 늘립니다. “한 건만 필요”하면 $first/$arrayElemAt으로 배열을 스칼라로 바꾸는 편이 낫습니다.

{ $set: { user: { $first: "$user" } } }

4. 조인 이후 $match는 가능하면 조인 내부로 이동

조인 후에 user.country == KR로 필터링하면, 이미 조인을 수행한 뒤 버리는 셈입니다. 가능하면 $lookup.pipeline에서 필터링하거나, 조인 전에 로컬에서 줄일 수 있는 조건을 먼저 적용하세요.

5. $sort/$group은 조인 전후 위치를 재검토

  • 정렬 기준이 로컬 필드라면 조인 전에 정렬
  • 그룹핑이 로컬 기준으로 가능한 부분이 있다면 미리 집계 후 조인

이 원칙만 지켜도 spill 가능성이 크게 줄어듭니다. (spill은 인프라 레벨에서 보면 파일 디스크립터/IO 압박으로 이어질 수 있는데, 이런 류의 자원 병목은 Linux EMFILE(Too many open files) 원인과 해결처럼 다른 계층의 장애로 번지기도 합니다.)

5) 샤딩(Sharding) 환경에서 $lookup이 더 느린 이유와 대응

샤딩된 클러스터에서 $lookup은 다음 비용이 추가됩니다.

  • 라우터(mongos)가 shard 간 결과를 모으는 비용
  • foreign 컬렉션이 다른 shard에 퍼져 있으면 네트워크 왕복 증가
  • 조인 키가 샤드 키와 맞지 않으면 브로드캐스트가 발생할 수 있음

대응 전략:

  1. 가능하면 co-locate: 로컬/foreign 컬렉션이 같은 샤드 키(또는 같은 분포)를 갖도록 설계
  2. $lookup 전에 $match로 샤드 타겟팅이 되게 만들기
  3. 조인 대상이 큰 컬렉션이면, 자주 쓰는 결과를 **사전 집계(materialized view)**로 별도 컬렉션에 유지

6) “조인 자체”를 줄이는 설계 패턴 (현실적인 타협)

1. 읽기 최적화 denormalization

  • 주문 문서에 사용자 이름/등급을 스냅샷으로 저장
  • 이벤트성 데이터는 최근 N개만 embed

쓰기 시점에 약간의 중복을 허용하면 읽기 경로가 단순해집니다.

2. Outbox/이벤트 기반으로 조인 비용을 다른 작업으로 분산

예를 들어 결제/주문/정산처럼 여러 도메인이 얽혀 조인이 잦다면, 조회용 projection을 별도로 유지하는 방식이 효과적입니다. MSA에서 흔한 접근은 Outbox 패턴이며, 더 큰 설계 관점은 MSA 사가 실패로 중복결제 터질 때 Outbox로 막기 같은 글의 흐름과 맞닿아 있습니다.

7) 실전 체크리스트: $lookup 느릴 때 이렇게 점검한다

  1. explain("executionStats")$lookup 단계가 COLLSCAN인지 확인
  2. foreign 매칭 필드 인덱스 존재 여부 확인
  3. $lookup 전에 $match/$project로 입력을 줄였는지 확인
  4. $unwind로 문서 수가 폭발하지 않는지 확인
  5. pipeline $lookup으로 필드 제한/limit/정렬을 조인 내부에서 끝낼 수 있는지 검토
  6. 조인 키 타입 불일치 제거(ObjectId vs string)
  7. 샤딩이면 샤드 타겟팅/코로케이션 가능성 검토
  8. 그래도 느리면: denormalization 또는 materialized view로 읽기 경로 재설계

8) 결론: $lookup 최적화는 “인덱스 + 입력 축소 + fan-out 제어”

$lookup이 느린 문제는 대부분 복잡한 트릭이 아니라, (1) foreign 인덱스, (2) 조인 전 입력 최소화, (3) 조인 결과 폭발 방지로 해결됩니다. 특히 pipeline 기반 $lookup으로 limit, project, sort를 조인 내부에서 처리하면 체감 성능이 크게 좋아집니다.

운영에서 중요한 건 “빠르게 만드는 것”뿐 아니라 “느려질 구조를 피하는 것”입니다. $lookup이 필수인 지점과, 데이터 모델/프로젝션으로 우회 가능한 지점을 구분해두면 규모가 커져도 안정적으로 확장할 수 있습니다.