MongoDB: $lookupと複数コレクションの関連付け

最終更新:2026-08-26

$lookupはMongoDBのJOINです—これを使いこなすことで、MongoDBでほとんどのマルチテーブルクエリシナリオを処理できるようになります。

MongoDBエコシステムにおける$lookupの役割: $lookupはMongoDBが提供する結合クエリ機能です—集計パイプライン内でSQL JOINに似た機能を実現します。しかし、MongoDBの設計思想は「埋め込み優先」です—埋め込みドキュメントで解決できるシナリオには$lookupは不要です。$lookupが適しているのは:1. 埋め込みできない大規模データセット(例:注文→商品、1つの商品が数万件の注文から参照される);2. 独立して更新が必要なデータ(例:ユーザー情報の変更、埋め込みの場合は全参照ドキュメントを更新する必要がある);3. 多対多関係(例:タグ→記事)。$lookupを使うべきか埋め込みを使うべきかを理解することは、MongoDBアーキテクチャ設計における核心的な判断です。

JOINと$lookupの根本的な違い: SQL JOINは集合演算です—2つのテーブルの直積を計算し、条件に基づいて結果をフィルタリングします。$lookupはネスト操作です—左集合の各ドキュメントに対して右集合でマッチするドキュメントを検索し、結果を左ドキュメントの配列フィールドとして埋め込みます。この違いにより:1. $lookupの結果は本質的にネスト構造(右テーブルのデータは配列に格納)、JOINの結果はフラットな行;2. $lookupはデフォルトでLEFT JOIN(マッチがない場合asフィールドは空配列)、INNER JOIN効果には$unwindが必要;3. $lookupはRIGHT JOINやFULL JOINをサポートしません。

埋め込みvs $lookupの判断フレームワーク: 埋め込みを使うべきか$lookupを使うべきか?判断基準—1. データ量:子レコード数<100で成長が管理可能→埋め込み、子レコードの成長が予測不能→$lookup;2. 更新頻度:子データがほぼ変わらない(例:住所)→埋め込み、子データが頻繁に独立更新される(例:商品価格)→$lookup;3. アクセスパターン:常に親データと一緒に読み取る→埋め込み、独立したクエリ/ページネーションが必要→$lookup;4. 一貫性要件:強一貫性(埋め込みは原子的更新を保証)→埋め込み、結果整合性が許容される($lookupは古いデータを参照する可能性)→$lookup。ECシステムでの典型的な選択:注文→ユーザー($lookup、ユーザー情報は変更される可能性)、注文→商品($lookup + 冗長化、商品価格は変更されるが注文時の価格を保持)、ユーザー→住所(埋め込み、住所はほぼ変わらず常に一緒に読み取る)。

ハイブリッド戦略:埋め込みと$lookupの組み合わせ: 本番システムでは多くの場合ハイブリッド戦略が必要です—1. 重要パスの埋め込み(読み取り性能優先):注文に商品スナップショット(注文時の価格/名前)を埋め込み、商品変更による履歴注文データへの影響を防止;2. リアルタイムデータは$lookup(一貫性優先):注文はuserIdを参照(ユーザー情報を埋め込まず)、$lookupで最新のユーザーデータをリアルタイム取得(例:最新プロフィール画像/メンバーランク);3. 冗長フィールド + $lookupの二重保護:注文にproductTitleを埋め込み(素早い表示用)、同時にproductsコレクションへの$lookupで完全な商品情報を取得(詳細ページで必要)。ハイブリッド戦略の核心原則は:「表示には埋め込み、詳細には$lookup、頻繁に変わるデータには参照」。

非正規化のコスト評価: 埋め込み(非正規化)は読み取り性能を向上させますが、書き込み増幅を引き起こします—1. 書き込み増幅:ユーザー情報が1,000件の注文に埋め込まれている場合、ユーザー名変更で1,000件のドキュメント更新が必要(参照モデルではユーザードキュメント1件のみ);2. データ不一致ウィンドウ:埋め込み冗長データとソースデータの間に遅延が生じる(ユーザーが名前を変更した後、履歴注文のユーザー名は古いまま);3. ストレージ増大:同じユーザー情報がN件の注文に埋め込まれ、Nコピーの冗長データが保存される。評価式:書き込み頻度 × 冗長コピー数 = 書き込み増幅係数。ユーザー情報の書き込み頻度が低い(月1回更新)× コピー数多い(1,000件の注文)= 月1,000件のドキュメント更新、許容範囲。商品価格が毎日更新 × 1,000件の注文 = 日1,000件のドキュメント更新、許容不可→参照を使用。

冗長フィールドのバージョン管理ソリューション: 冗長フィールドの最大のリスクはデータ不一致です—ソースデータが変更されたのに冗長コピーが同期されない。バージョン管理アプローチでこの問題を解決:1. 冗長フィールドにバージョン番号を含める:注文に{productName: 'iPhone', productVersion: 3}を埋め込み、商品更新時にversionをインクリメント;2. バックグラウンド同期タスク:定期的に冗長フィールドのversionがソースデータのversionと一致しないドキュメントをスキャンし、バッチ更新;3. 取得時のオンデマンドリフレッシュ:APIがデータを返す際、バージョン不一致をチェックし非同期更新をトリガー(今回は古いデータを返し、次回は新しいデータ)。バージョン管理アプローチのトレードオフ:より高い一貫性を提供するが複雑さが増す—冗長フィールドの不一致が深刻なビジネス問題(商品価格の誤りによる金銭的損失など)を引き起こす場合のみ使用。通常のシナリオ(ユーザーのニックネームが古い値を表示など)では、一時的な不一致は許容可能。

1. 学習内容


100%
graph LR
    A[ordersコレクション] -->|$lookup<br/>userId| B[usersコレクション]
    A -->|$lookup<br/>items.productId| C[productsコレクション]
    A -->|$lookup<br/>customer.addressId| D[addressesコレクション]

    B --> E[統合注文ドキュメント<br/>with customer配列]
    C --> E
    D --> E

    style E fill:#d4edda

2. $lookupの基本構文

$lookupによる完全一致の実行: 完全一致形式は最もシンプルな$lookupです—現在のコレクションの各ドキュメントに対して、localFieldの値を取得し、fromコレクションのforeignFieldでマッチする全ドキュメントを検索し、結果をas配列に格納します。このプロセスはSQL LEFT JOINと同等:マッチがない場合、asは空配列になります(nullではない);複数マッチがある場合、asは全マッチドキュメントを含みます。完全一致形式の制限:単純なフィールド等価比較のみで、追加条件(「アクティブユーザーのみ関連付ける」など)を追加できません。

LEFT JOINのセマンティクス理解: $lookupのデフォルト動作はLEFT JOINです—fromコレクションにマッチするドキュメントがなくても、現在のドキュメントは保持され、asフィールドは空配列[]になります。これは正しい設計です—結合クエリは主テーブルのデータを損失すべきではありません。INNER JOINが必要な場合(マッチするドキュメントのみ保持)、$lookupの後に$unwindを追加してグループを分割(空配列のドキュメントは破棄)。マッチなしドキュメントを保持しつつnullとして表示する場合、$unwind: { path: '$field', preserveNullAndEmptyArrays: true }を使用。

$lookup結果構造の分析: $lookupのasフィールドは常に配列です—1ドキュメントのみマッチしても、結果は単一要素配列[{...}]になります。これは$lookupが「一対多」が最も一般的な関連関係であるという前提で設計されているためです。後続ステージで関連データを使用するには、通常$unwindでオブジェクトに展開するか、$arrayElemAt: ['$field', 0]で最初の要素を取得する必要があります。「結果は常に配列」を理解していないことが、$lookup初心者が最もよく遭遇する落とし穴です。

概念説明: $lookupはMongoDB集計パイプラインの結合演算子で、機能的にはSQLのLEFT JOINと同等です。別のコレクションでマッチするドキュメントを検索し、結果を現在のドキュメントの配列として埋め込みます。2つの構文形式:(1)等価マッチング(localField/foreignField);(2)pipeline形式(MongoDB 5.0+、複雑条件と変数渡しをサポート)。

動作原理: 値ベースのマッチング形式では、現在のコレクションの各ドキュメントに対して、localFieldの値を使ってfromコレクションのforeignFieldでマッチを検索し、結果をas配列フィールドに格納します。pipelineアプローチではletで変数を定義し、サブパイプラインpipeline内で$$variableを使って参照することで、より柔軟な結合条件(アクティブユーザーのみ結合や特定フィールドのみ返すなど)を実現します。

Pipeline $lookupのパフォーマンスコスト: pipeline形式は等価マッチング形式より遅いです—等価マッチングはforeignFieldのインデックスを活用して効率的に検索できますが、pipeline形式はfromコレクションの各ドキュメントに対してサブパイプラインを実行します。パフォーマンス差:等価マッチング$lookup ≈ O(N)(Nは現在のコレクションのドキュメント数)、pipeline $lookup ≈ O(N*M)(Mはfromコレクションのドキュメント数)。軽減策:1. サブパイプラインの$expr$matchをできるだけ早くフィルタリング;2. fromコレクションに適切なインデックスを作成;3. サブパイプラインの複雑さを制御—$match$projectのみ実行し、$groupなどの重い操作を避ける。

単一レベル結合の設計テンプレート: 単一レベル$lookup + $unwindが最も一般的な結合パターンです—1. $lookupで結合(配列を返す);2. $unwindで配列をオブジェクトに展開;3. $projectで必要フィールドを選択。このテンプレートは結合クエリシナリオの80%をカバーします。高度なバリエーション:$unwind後に$groupを追加して一対多構造を復元し、$pushで関連データを収集。

結合クエリのパフォーマンス最適化チェックリスト: $lookupのパフォーマンス最適化は集計パイプラインのチューニングの鍵です—1. foreignFieldに必ずインデックスを作成(インデックスなしの$lookupはネストループの全件スキャンと同等);2. $lookupの前に$matchを使用して現在のコレクションのドキュメント数を削減(N件少ない結合→N件少ないクエリ);3. pipeline形式では$projectで必要なフィールドのみ返す(メモリとネットワークオーバーヘッドを削減);4. $lookup後の大量データの$sortを避ける(サブパイプライン内でソートし、結合前に$limitを適用);5. マルチレベル$lookupのパフォーマンスはレベルごとに指数関数的に低下—3レベル以上は非正規化を検討。

100%
sequenceDiagram
    participant Order as ordersコレクション
    participant Lookup as $lookup
    participant User as usersコレクション

    Order->>Lookup: doc1: {userId: ObjectId_A}
    Lookup->>User: find({_id: ObjectId_A})
    User-->>Lookup: [{username: 'alice', email: '...'}]
    Lookup-->>Order: doc1 + {userInfo: [{username: 'alice'}]}

    Order->>Lookup: doc2: {userId: ObjectId_B}
    Lookup->>User: find({_id: ObjectId_B})
    User-->>Lookup: [] (マッチなし)
    Lookup-->>Order: doc2 + {userInfo: []} (LEFT JOIN動作)
$lookupパラメータ 完全一致形式 Pipeline形式
from ✅ 関連コレクション名 ✅ 関連コレクション名
localField ✅ 現在のコレクションフィールド ❌ 使用しない
foreignField ✅ 関連コレクションフィールド ❌ 使用しない
let ❌ 使用しない ✅ 変数定義
pipeline ❌ 使用しない ✅ サブパイプライン($match, $project等をサポート)
as ✅ 出力フィールド名 ✅ 出力フィールド名

JOINと$lookupの比較: SQL JOINはネイティブデータベース操作で、オプティマイザはNested Loop、Hash Join、Merge Joinなどの戦略を選択できます。$lookupは本質的に各入力ドキュメントに対してサブクエリを実行—パフォーマンスはSQLのNested Loop Joinと似ていますが、大規模データセットでは非効率。主な違い:1. SQL JOINはフラットな行を返すが、$lookupはネスト配列を返す($unwindで展開が必要);2. SQLにはクエリオプティマイザがあり自動的にJOIN戦略を選択するが、$lookupにはこの最適化がない;3. $lookupのpipeline形式ではフィルタ条件を追加でき、SQLのJOIN + WHEREに類似。

N+1問題と解決策: $lookupのパフォーマンスの落とし穴は「N+1クエリ」です—ordersに1,000件のレコードがある場合、等価結合$lookupは各orderレコードに対してusersクエリを実行し、合計1,001クエリになります。回避策:1. 結合フィールドにインデックスを作成(foreignFieldに必ずインデックス);2. pipelineモードでは$matchで先にフィルタリングしてから結合;3. 大規模データセットでは非正規化を検討(ユーザー名を冗長保存して$lookupクエリを削減);4. mongoose populateもN+1問題があるが、小規模データセットでは許容可能。

$lookup実行モデルの詳細分析: $lookupの内部実行ロジック—1. 等価形式:左コレクションの各ドキュメントに対して、MongoDBはlocalFieldの値を取得し、右コレクションのforeignFieldインデックスでマッチするドキュメントを検索(インデックスがあればIXSCANを使用、なければCOLLSCAN)、結果をas配列に埋め込む。これはSQLのNested Loop Joinと同等—外側ループが左コレクションを走査し、内側ループが右コレクションを検索;2. Pipeline形式:左コレクションの各ドキュメントに対して、letで定義された変数が現在のドキュメントのフィールド値にバインドされ、サブパイプラインが実行される。サブパイプラインは完全な集計パイプライン($match/$project/$group等)、より柔軫だがパフォーマンスは低い(サブパイプラインは$lookup外のインデックスヒントを利用できない);3. パフォーマンス比較:等価結合 > pipeline形式(等価結合はインデックスを利用でき、実行プランがシンプル)。選択原則:単純な結合には等価結合を使用、条件フィルタリングが必要な場合はpipeline形式を使用。

$lookupインデックス最適化の実践チェックリスト: $lookupのパフォーマンス最適化の鍵は関連フィールドのインデックス化です—1. foreignFieldに必ずインデックス:$lookupが右コレクションで検索する際、インデックスがあればIXSCAN(ミリ秒レベルのパフォーマンス)を使用、なければCOLLSCAN(全件スキャン、数万ドキュメントで秒レベルのレイテンシ);2. localFieldにはインデックス不要:$lookupは左から右への一方向検索を実行、左コレクションは順次スキャン;3. pipeline構成では$matchフィールドにインデックスが必要:サブパイプライン内の$matchは標準的なインデックスルールに従う;4. 複合結合シナリオでは:$lookupの後に$matchフィルタがある場合、$matchフィールドにもインデックスを作成。インデックス検証コマンド:db.orders.getIndexes()foreignFieldがインデックス化されているか確認、db.orders.explain('executionStats').aggregate(...)で実行プランの$lookupがIXSCANを使用しているかチェック。

JAVASCRIPT
// === 基本的な$lookup ===
db.orders.aggregate([
  {
    $lookup: {
      from: 'users',              // 関連コレクション
      localField: 'userId',       // 現在のコレクションフィールド
      foreignField: '_id',        // 関連コレクションフィールド
      as: 'userInfo'              // 出力フィールド名
    }
  }
]);
// 結果:各注文ドキュメントにuserInfo配列が追加(マッチしたユーザードキュメントを含む)

// === SQLとの比較 ===
// SELECT orders.*, users.*
// FROM orders
// LEFT JOIN users ON orders.userId = users._id


3. $lookupの実践例

概念概要: このセクションでは、単一レベル結合(注文→ユーザー)、pipeline形式の結合(条件フィルタリング付き)、ネスト結合(注文→ユーザー→住所)という3つの段階的なシナリオを通じて$lookupの実践的な使用法を示します。それぞれのアプローチが異なる複雑さの結合要件に対応します。

動作原理: 単一レベルのマッピングは最もシンプルです—フィールド値を直接マッチングします。pipeline形式では、letが最初に現在のドキュメントのフィールドを変数として定義し、サブパイプラインが$expr + $$variableを使ってこれらの変数を参照し条件マッチングを実行します。ネスト関連は複数の連続した$lookup + $unwind操作で実現され、各ステップで1レベルを関連付け、徐々に完全なデータを組み立てます。

$lookupの3つの実践モードの比較: 3つの結合モードにはそれぞれ適した用途があります—1. 等価マッチング(localField/foreignField):最もシンプルで高速、一対多結合の90%に適用可能(注文→ユーザー、記事→著者);2. Pipeline形式(let + pipeline + $expr):柔軟だが低速、結合結果のフィルタリングが必要なシナリオに適用(アクティブユーザーのみ結合、最近の注文のみ返すなど);3. ネスト結合(複数の$lookupをチェーン):マルチレベルネストデータを組み立てるが、各レベルでクエリ複雑さが増加、3レベル以上では冗長フィールドの使用を検討(例:usersコレクションへの$lookupではなくordersコレクションにuser.nameを冗長保存)。

$lookupパフォーマンス最適化の黄金ルール: $lookupのパフォーマンスは2つの要因に依存します—1. fromコレクションのforeignFieldがインデックス化されているか(最も重要!インデックスがないと$lookupは各入力ドキュメントに対して全コレクションスキャンを実行、N入力ドキュメント × M fromドキュメント = O(N*M)、パフォーマンス災害);2. 入力ドキュメント数($lookupの前に$matchを使用して入力量を削減)。最適化チェックリスト:① foreignFieldにインデックスを作成;② $matchで前処理して入力量を削減;③ pipelineではサブパイプラインで可能な限り早く$matchと$projectを実行;④ ネスト$lookupを避ける(冗長フィールドで代替);⑤ 「大きい」側から$lookupを実行(例:100件の注文と10人のユーザー—注文側からlookupする方が効率的)。

100%
graph TD
    A[単一レベル関連<br/>localField/foreignField] --> B[Pipeline形式<br/>let + pipeline + $expr]
    B --> C[ネスト関連<br/>複数$lookupを直列]

    A --> D["シンプルな等価マッチング<br/>注文→ユーザー"]
    B --> E["条件付き関連<br/>アクティブユーザーのみ"]
    C --> F["マルチレベルネスト<br/>注文→ユーザー→住所"]

    D --> G["1クエリで完了<br/>populateを代替"]
    E --> H["変数渡し+フィルタ<br/>柔軟性高い"]
    F --> I["段階的組み立て<br/>$unwindに注意"]

    style D fill:#d4edda
    style E fill:#cce5ff
    style F fill:#fff3cd

(1) 単一レベル関連

$lookup後に$unwindが必要な理由: $lookupは常に配列を返します—1ドキュメントのみマッチしても、結果はuserInfo: [{name: 'Alice'}]です。フロントエンドでuser.nameを直接使用できるようにする(user[0].nameではなく)には、$unwindで配列をオブジェクトに展開する必要があります。$unwind: '$userInfo'userInfo: [{name: 'Alice'}]userInfo: {name: 'Alice'}に変換します。注意:$lookupがマッチなし(LEFT JOINでマッチなし)の場合、$unwindはドキュメントを破棄します—preserveNullAndEmptyArrays: trueでLEFT JOINセマンティクスを保持。

$unwindの3つの使用方法: 結合クエリで$unwindは3つの方法で使用できます—1. 配列→複数ドキュメント(標準的使用法):$unwind: '$items'は[{_id:1, items:[{a:1},{a:2}]}]を[{_id:1, items:{a:1}}, {_id:1, items:{a:2}}]に変換、各配列要素が新しいドキュメントを生成し$groupで再集計;2. 配列→オブジェクト($lookup後の値取得):$unwind: '$userInfo'はuserInfo:[{name:'Alice'}]をuserInfo:{name:'Alice'}に変換、preserveNullAndEmptyArraysと併用でLEFT JOINを保持;3. ネスト配列の展開:外側配列に$unwind、次に内側配列に$unwind(例:ordersitemstags)。各レベルの$unwindはデカルト積を生成、データ量の膨張に注意。パターン2が最も一般的($lookupの後にはほぼ必ず$unwindが続く)、パターン1は配列内要素の独立カウントに使用。

単一レベル結合のインデックス要件: $lookupのパフォーマンスは結合フィールドのインデックスに大きく依存します—foreignFieldに必ずインデックスを作成、さもなければマッチごとに全コレクションスキャンが実行されます。等価形式の$lookup (localField/foreignField)ではforeignFieldのみインデックスが必要;pipeline形式の$lookupでは、パフォーマンスはサブパイプライン内の$matchがインデックスを使用できるかどうかに依存します。本番環境では、fromコレクションの外部フィールドにインデックスを作成することが必須です—これが$lookupパフォーマンス最適化の最優先事項です。

JAVASCRIPT
// === 注文 + ユーザー ===
db.orders.aggregate([
  {
    $lookup: {
      from: 'users',
      localField: 'userId',
      foreignField: '_id',
      as: 'customer'
    }
  },
  { $unwind: '$customer' }  // 配列をオブジェクトに変換
]);

(2) Pipeline形式(MongoDB 5.0+)

Pipeline形式の柔軟性: pipeline形式の$lookupは、完全一致では処理できない4種類のシナリオを解決します:1. 条件付き結合(アクティブユーザーのみ結合、最新レコードのみ結合);2. 複数条件マッチング(部門と職位レベルの両方でマッチング);3. 結合時のプロジェクション(結合コレクションから特定フィールドのみ返す);4. 計算結合($expr内の条件が単純なフィールド等価ではない)。pipeline形式はlet + $$variable構文でコレクション間の変数渡しを実現します—letが変数名マッピングを定義し、$$variableでパイプライン内で参照します。注意:$exprが必須—通常の$matchでは$$variableを参照できない、$expr内の集計式でのみ使用可能。これがpipeline形式が等価形式より複雑だがより柔軟である根本的理由です。

let + $$variableの変数渡しメカニズム: pipeline構文の中核は変数渡しです—let: { orderUserId: '$userId' }は現在のドキュメントのuserIdフィールドを変数$$orderUserIdにマッピングし、サブパイプライン内の$match.$exprで参照します。注意:$exprが必須—通常の$matchは$$variableを参照できない、$expr内の集計式でのみ使用可能。これがpipeline形式が等価形式より複雑だがより柔軟である根本的理由です。

$lookup Pipelineのパフォーマンスコスト: pipelineアプローチは等価マッチングアプローチより遅いです—等価マッチングはforeignFieldのインデックスを活用して効率的に検索できますが、pipelineアプローチはfromコレクションでサブパイプラインを実行します。パフォーマンス差:等価マッチング ≈ O(N)(Nは現在のコレクションのドキュメント数)、pipeline ≈ O(N*M)(Mはfromコレクションのドキュメント数)。軽減策:1. サブパイプラインの$expr$matchをできるだけ早くフィルタリング;2. fromコレクションに適切なインデックスを作成;3. サブパイプラインの複雑さを制御—$match$projectのみ実行し、$groupなどの重い操作を避ける。

等価形式とPipeline形式の選択ガイド: 2つの$lookup形式の選択—1. 等価形式が適したシナリオ:localFieldforeignFieldの単純なフィールド等価マッチング(例:orders.userId = users._id)。これは実運用ケースの80%を占め、最適なパフォーマンスを提供;2. pipeline形式が適したシナリオ:追加フィルタ条件が必要(例:status='active'のユーザーのみ結合)、複数フィールドの組み合わせマッチング(例:departmentlevelを同時にマッチング)、結合時のプロジェクション(結合ドキュメントから特定フィールドのみ取得)、または$exprを使った動的計算条件;3. 混合使用:同じ集計パイプライン内で等価形式とpipeline形式を同時に使用可能—単純な結合には等価形式(高速)、複雑な結合にはpipeline形式(柔軟)。原則:まず等価形式を試し、不十分ならpipeline形式にアップグレード。

JAVASCRIPT
// === Pipeline形式(複雑条件をサポート)===
db.orders.aggregate([
  {
    $lookup: {
      from: 'users',
      let: { order_user_id: '$userId' },
      pipeline: [
        {
          $match: {
            $expr: {
              $and: [
                { $eq: ['$_id', '$$order_user_id'] },
                { $eq: ['$isActive', true] }  // アクティブユーザーのみ
              ]
            }
          }
        },
        {
          $project: {                  // プロジェクション
            username: 1,
            email: 1,
            avatar: 1
          }
        }
      ],
      as: 'customer'
    }
  }
]);

(3) ネスト$lookup

マルチレベル結合のパフォーマンス課題: ネスト$lookup操作はマルチレベル結合(注文→ユーザー→住所)を実装するために使用されますが、各レベルの$lookupがサブクエリを追加し、パフォーマンスはネスト深度に比例して低下します。軽減策:1. pipelineアプローチで各レベルの返却フィールドを削減($projectプロジェクション);2. 非正規化を検討—ユーザー名と住所を注文テーブルに冗長保存し、$lookupを回避;3. 3レベル以上の結合では、パイプライン内で$lookupをネストするよりも、アプリケーション層で複数の単純クエリを実行しメモリ内で結果を組み立てることを推奨。

ネスト結合の代替設計: ネスト$lookupはマルチレベル結合の唯一の解決策ではありません—4つの代替案の比較:1. 冗長フィールド(注文にuser.name + address.cityを保存、書き込み時に冗長フィールドを更新、読み取り時に$lookup不要、読み取り頻度が高く書き込み頻度が低いシナリオに適用);2. 複数独立クエリ(最初に注文をクエリ→userIdを収集→$inでユーザーをクエリ→addressIdを収集→$inで住所をクエリ、コード量は多いがパフォーマンスは制御可能);3. $graphLookup(再帰的関連、ツリー/グラフ構造に適用、単純な3レベル関連には不向き、パフォーマンスが低い);4. アプリケーション層ORM(Mongooseのpopulateはネストpopulateコールをサポート、本質的には複数クエリ)。実プロジェクトでは、オプション1(冗長化)とオプション2(オンデマンドクエリ)の組み合わせが最も一般的で、ネスト$lookupはレポートシナリオでのみ使用。

$lookup結果のデータ量制御: $lookupのasフィールドは配列で、非常に大きくなる可能性があります—例えば、1人のユーザーに1,000件の注文がある場合、$lookupのas配列には1,000件のドキュメントが含まれます。データ量を制御するには:1. pipeline形式で$limitを追加(最新5件の注文のみ返す);2. $projectでフィールドを絞り込む(orderIdtotalのみ返し、完全な注文詳細は返さない);3. $matchでフィルタリング(支払い済み注文のみ返す);4. $sliceで配列をトリミング($project: {recentOrders: {$slice: ['$orders', 5]}})。$lookup結果のデータ量を制御していないことは、メモリオーバーフローの一般的な原因です—$facetと$lookupの組み合わせによる大量結果セットは簡単に100MBを超過します。

データ正規化vs非正規化: MongoDBでのリレーションシップ設計では、正規化と非正規化のバランスが必要です—正規化(参照 + $lookup)は良好なデータ一貫性を保証するがクエリが複雑、非正規化(冗長埋め込み)はクエリを簡素化するが同期更新が必要。判断ルール:1. データがほぼ変わらない(例:ユーザー名、商品タイトル)→冗長保存;2. データが頻繁に変わる(例:ユーザーアバター、在庫)→参照保存;3. 関連データが常に必要→冗長保存;4. 関連データがたまに必要→参照保存。

冗長フィールドの一貫性維持: 冗長保存を選択した場合、データ同期の問題に対処する必要があります—ユーザーがプロフィール画像を変更した場合、注文のプロフィール画像も更新しなければなりません。3つの同期戦略があります:1. イベント駆動(ユーザーが変更するとChange Streamで更新をブロードキャスト、サブスクライバが全冗長コピーを同期)—リアルタイム性は最高だが実装が複雑;2. バッチ同期(定期的タスクが毎時間更新をスキャン)—シンプルだが1時間の遅延が発生;3. クエリ時マージ(基本フィールドを冗長保存し、クエリ時に$lookupで最新フィールドを取得)—妥協案。ほとんどのシナリオでは戦略2で十分、一時的な不一致を許容できる場合。

冗長フィールドのバージョン管理スキーム: より洗練された冗長同期スキーム—冗長フィールドにバージョン番号を追加—1. 注文にuserSnapshot: {name: 'Alice', avatar: 'url1', version: 3}を埋め込み;2. ユーザー更新時にversionをインクリメント;3. クエリ時にバージョン番号を比較、order.userSnapshot.version < user.currentVersionの場合$lookupで最新データを取得;4. バッチ同期時、バージョンが古いドキュメントのみ更新($matchでフィルタして更新量を削減)。バージョン管理アプローチの利点:クエリ時に冗長データが古いかどうかを判断でき、古いデータはオンデマンドで更新、フルスキャンではなく。トレードオフは各クエリにバージョン比較の追加ステップ(しかし比較コストはフルシンクより遥かに低い)。

JAVASCRIPT
// === 注文 → ユーザー → ユーザー住所 ===
db.orders.aggregate([
  {
    $lookup: {
      from: 'users',
      localField: 'userId',
      foreignField: '_id',
      as: 'customer'
    }
  },
  { $unwind: '$customer' },
  {
    $lookup: {
      from: 'addresses',
      localField: 'customer.defaultAddressId',
      foreignField: '_id',
      as: 'customer.defaultAddress'
    }
  },
  { $unwind: '$customer.defaultAddress' }
]);


4. $unwind:配列の展開

$unwindの本質とリスク: $unwindの中核機能は「一対多」関係を配列形式から「複数行」形式に変換することです—これが価値でありリスクでもあります。価値:分割後、$matchで個別要素をフィルタリング、$lookupで関係を再確立、$groupで再集計が可能。リスク:1. 大きな配列の分割はドキュメント膨張を引き起こす(N要素の配列→N倍のドキュメント数);2. 分割後、$groupで_idで再編成が必要;3. preserveNullAndEmptyArraysのデフォルト値はfalseで、空配列を含むドキュメントを破棄。ベストプラクティス:$unwindの直後に$groupまたは$matchを続け、膨張したデータがパイプラインを通過するのを回避。

$unwindの3つの使用パターン: 集計パイプラインでの$unwindの典型的な使用法は3つ—1. $lookup + $unwind(最も一般的):$lookup結合後、asフィールドは配列、$unwindでオブジェクトに分割、「LEFT JOIN」から「INNER JOIN」へのセマンティクス変換を実現;2. $unwind + $group(配列要素のカウント):まずtags配列を複数ドキュメントに分割、次にタグでグループ化してカウントしタグ頻度を決定;3. $unwind + $unwind(ネスト配列の展開):2レベルのネスト配列(例:orderitemsvariants)には、2つの$unwind操作で各レベルを展開。各$unwind操作のデータ膨張係数は平均配列長マイナス3—3要素の配列は3倍に膨張、100要素の配列は100倍に膨張。この膨張を制御するには、$unwindの前に$projectを使用して必要なフィールドのみ保持し、各展開ドキュメントのサイズを削減。

$unwindの代替手段: $unwindがすべてのシナリオで必要なわけではありません—1. 配列長のみが必要な場合:$sizeを使用($project: {tagCount: {$size: '$tags'}}、展開不要);2. 配列内の特定の要素のみが必要な場合:$arrayElemAtを使用($project: {firstTag: {$arrayElemAt: ['$tags', 0]}}、分割不要);3. 配列内の要素をフィルタリングが必要な場合:$filterを使用($project: {highPrice: {$filter: {input: '$items', cond: {$gte: ['$$this.price', 1000]}}}}、展開不要);4. 配列を変換のみが必要な場合:$mapを使用($project: {upperTags: {$map: {input: '$tags', in: {$toUpper: '$$this'}}}}、分割不要)。「配列要素を個別のドキュメントとして扱い、後続の集計を行う」場合のみ$unwindを使用—例:$groupで配列要素ごとにグループ化、または$lookupで配列要素に基づいて検索。

$unwindパフォーマンス最適化の実践経験: $unwindのパフォーマンスボトルネックはデータ膨張です—最適化原則は「データ量をできるだけ早く削減」—1. $unwind前に$project:_idと展開するフィールドのみ保持し、各展開ドキュメントのサイズを削減(例:元ドキュメントに50フィールドある場合、$unwind後は5フィールドのみ必要、$unwind前に$projectでメモリ使用量を90%削減);2. $unwind後に$match:不要な配列要素を即座フィルタリング(例:$unwind後にstatus: 'active'の要素のみ保持)し、後続ステージの処理負荷を軽減;3. $unwind + $sortを避ける:まず$match/$groupでドキュメント数を削減し、その後$sort、$unwindで先にデータを膨張させてからソートするのではなく(N倍に膨張したデータをメモリ内でソート);4. ネスト$unwindの最適化:外側配列が短い場合(3–5要素)、ネスト$unwindは許容可能、外側配列が長い場合(100+要素)、まず$reduceで内側配列をマージすることを検討。

100%
graph LR
    A["{item: [A, B, C]}"] --> B["$unwind: '$item'"]
    B --> C["{item: A}"]
    B --> D["{item: B}"]
    B --> E["{item: C}"]

    F["{item: []}"] --> G["$unwind<br/>preserveNull: false"]
    G --> H[❌ ドキュメント破棄]

    F --> I["$unwind<br/>preserveNull: true"]
    I --> J["{item: null} ✅"]

    style C fill:#d4edda
    style D fill:#d4edda
    style E fill:#d4edda
    style H fill:#f8d7da
    style J fill:#d4edda
$unwindオプション 効果 SQL相当
{ path: '$items' } 分割、空配列を破棄 INNER JOIN
{ path: '$items', preserveNullAndEmptyArrays: true } 分割、空配列を保持 LEFT JOIN

「$unwind + $group」再編成パターン: $unwindでグループを分割した後、通常は$groupで_idごとに再編成します—これは集計パイプラインで最も一般的な「分割→処理→再編成」パターンです。典型的なワークフロー:$unwind '$items' → $groupでorderIdで再グループ化、$pushで処理済みitemsを収集。重要な違い:$pushは$unwind後の個別要素(処理済み)を収集し、元の配列要素ではありません。例えば:$unwind後、$addFieldsで各itemに割引価格を追加、$group使用時に$pushは割引価格を含む新しいオブジェクトを収集。

$unwindの代替手段: すべての配列操作に$unwindが必要なわけではありません—$mapや$filterで処理できる場合は$unwindを避けるべきです。$mapはドキュメント数を変更せずに配列要素を変換し、$filterはドキュメント数を変更せずに配列要素をフィルタリング。「配列要素を個別のドキュメントとして扱う」必要がある場合のみ$unwindを使用。判断基準:1. 配列要素のフィルタリングや変換のみ必要→$filter/$mapを使用;2. 各要素に対して$lookupが必要→$unwind + $lookupを使用;3. 配列要素で$groupが必要→$unwind + $groupを使用;4. 配列要素でソートが必要→$unwind + $sortを使用。

JAVASCRIPT
// === $unwindでサブグループに分割 ===
db.orders.aggregate([
  {
    $lookup: {
      from: 'order_items',
      localField: '_id',
      foreignField: 'orderId',
      as: 'items'
    }
  },
  { $unwind: '$items' }
]);
// 各items配列要素が個別ドキュメントになる

// === preserveNullAndEmptyArraysで空配列を保持 ===
db.orders.aggregate([
  { $lookup: { from: 'order_items', localField: '_id', foreignField: 'orderId', as: 'items' } },
  { $unwind: { path: '$items', preserveNullAndEmptyArrays: true } }
]);


5. $lookup vs mongoose populate

次元 $lookup mongoose populate
実装場所 データベース層 アプリケーション層(複数クエリ)
パフォーマンス 単一集計クエリ 複数ラウンドトリップ(populateが多いほど遅くなる)
柔軟性 複雑なパイプラインをサポート ref関連のみサポート
ネスト マルチレベルをサポート マルチレベルをサポート(ネストpopulate)
大規模結果セット ⚠️ メモリ圧迫 ⚠️ N+1クエリ問題

$lookup vs populate:2つの結合パラダイムの選択: $lookupとpopulateはどちらも結合クエリを実行できますが、異なるシナリオに適しています。$lookupが適しているのは:1. 複雑な結合条件(アクティブユーザーのみ結合、特定フィールドのみ返すなど);2. 結合後の集計が必要な状況(結合後に統計を計算);3. 大規模データセット(N+1クエリより効率的)。populateが適しているのは:1. シンプルなref結合(参照フィールドをpopulateするのみ);2. チェーンコール(.populate().populate());3. Mongooseミドルウェアや仮想フィールドが必要な場合。実プロジェクトでは、シンプルな結合にはpopulateを使用(開発効率が高い)、複雑な結合と集計には$lookupを使用(実行時効率が高い)。

$lookupとpopulateの組み合わせ戦略: 本番プロジェクトでは多くの場合、これら2つの関連方法を組み合わせる必要があります—1. APIリストページには$lookupを使用:単一リクエストで関連データを返し、ネットワークラウンドトリップを削減(例:商品リスト + カテゴリ名 + ブランド名);2. API詳細ページにはpopulateを使用:チェーンpopulateクエリでマルチレベル関係をより直感的に(例:注文詳細 → ユーザー + 住所 + 商品 + レビュー)、単一レコードクエリではN+1問題は大きな懸念ではない;3. 管理ダッシュボードにはpopulateを使用:開発効率を優先(迅速な実装)、小規模データ(管理者ユーザーは少ない)でパフォーマンスは問題にならない;4. 統計レポートには$lookupを使用:集計パイプラインが必要で、populateは統計計算を処理できない。このハイブリッド戦略の核心原則:「ユーザー向けリストと統計には$lookup、開発者向け詳細と管理にはpopulate」。

populateのN+1問題の詳細解説: Mongooseのpopulateのパフォーマンスの落とし穴はN+1クエリです—リストクエリがN件の注文を返した後、populate('userId')は各userIdに対してfindOneを実行し、合計N+1クエリになります。回避策:1. lean() + 手動$lookup(N+1を単一集計クエリに削減);2. バッチpopulate(Mongoose 5.0+は自動的に最適化、複数のpopulateコールを単一の$inクエリにマージ);3. 必要なフィールドのみpopulate(.populate('userId', 'username')でI/Oを削減)。実践的影響:populateは100件まで許容可能(<50 ms)、100件超の場合は$lookupに切り替え。

N+1問題の自動検出: N+1問題は開発フェーズ(テストデータが限定的)では明らかでなく、デプロイ後にデータ量が増えてから顕著になります—1. スロークエリログ:MongoDBのslowms設定は実行時間100ms超のクエリをログに記録、短時間に同じコレクションに対する複数クエリはN+1問題の特徴;2. APMツール:New Relic/DataDogは「単一リクエスト内で同じコレクションが複数回クエリされる」パターンを自動検出;3. コードレビュー:コントローラ内のforループ内にawait Model.findOne()があるのは古典的なN+1パターン;4. ユニットテスト:Model.findのコール回数をモック、期待を超える場合はN+1問題が存在。N+1問題検出後の修正優先度:リストページ > 詳細ページ > 管理ダッシュボード(ユーザー影響の範囲で順序付け)。

結合クエリのパフォーマンス最適化チェックリスト: 結合クエリ($lookupまたはpopulate)のパフォーマンス最適化には5つの重要ポイントがあります—1. foreignFieldに必ずインデックスを作成($lookupクエリのfromコレクションの結合フィールドのインデックス化が最も重要なパフォーマンス要因);2. $lookupの前に$matchで入力データ量を前処理して削減(まず$matchでフィルタリング、次に少数のドキュメントを$lookupで結合);3. $projectでフィールドを絞り込む($lookupパイプラインでできるだけ早く$projectを使用し、必要なフィールドのみ取得、メモリと送信オーバーヘッドを削減);4. ネスト深度を制御($lookupネストは2レベル以下に抑える、3レベル以上は冗長フィールドまたはアプリケーション層でデータを組み立て);5. データ冗長化を検討(ordersコレクションにuser.nameを冗長保存し、usersコレクションへの$lookupを回避、書き込み一貫性を犠牲にして読み取り性能を向上)。

▶ サンプル 1:複数コレクション$lookupの実践的応用 - 注文詳細レポート

マルチテーブル結合レポートのアーキテクチャ選択: 注文詳細レポートには4テーブル結合(注文 + ユーザー + 商品 + 住所)が必要です。2つの実装アプローチがあります:1. 単一パスの集計パイプライン:複数レイヤーの$lookup + $unwind + $group。単一クエリで完了するがパイプラインが複雑で保守困難;2. アプリケーション層での組み立て:複数の単純クエリをNode.jsでメモリ内連結、コードは明確だがN+1問題が発生。選択基準:データ量 < 1,000件→アプリケーション層での組み立て(シンプルで信頼性高い)、データ量 > 1,000件→集計パイプライン(より良いパフォーマンス)。

$lookup + $group再編成パターン: マルチテーブル結合レポートの典型的なプロセスは「展開→結合→再編成」の3ステップ—$unwindでitem配列を単一ドキュメントに展開→$lookupで各itemに商品情報を結合→$groupでorderIdで再集計($pushでitem配列を収集)。このパターンの課題は$groupステップの正確性確保:_id: '$_id'で注文IDでグループ化、$firstで非配列フィールドを保持、$pushで配列フィールドを収集。

JAVASCRIPT
// データ準備:注文 + ユーザー + 商品 + 住所の4テーブル
db.users.insertMany([
  { _id: ObjectId('507f1f77bcf86cd799439011'), username: 'alice', email: 'alice@example.com', isActive: true },
  { _id: ObjectId('507f1f77bcf86cd799439012'), username: 'bob',   email: 'bob@example.com',   isActive: true }
]);

db.products.insertMany([
  { _id: ObjectId('507f1f77bcf86cd799439021'), sku: 'PHONE-001', title: 'Smartphone X', price: 599.99 },
  { _id: ObjectId('507f1f77bcf86cd799439022'), sku: 'LAPTOP-001', title: 'Laptop Pro',  price: 1299.99 }
]);

db.orders.insertOne({
  _id: ObjectId('507f1f77bcf86cd799439031'),
  orderNumber: 'ORD-2026-001',
  userId: ObjectId('507f1f77bcf86cd799439011'),
  status: 'paid',
  total: 1899.98,
  items: [
    { productId: ObjectId('507f1f77bcf86cd799439021'), qty: 1, price: 599.99 },
    { productId: ObjectId('507f1f77bcf86cd799439022'), qty: 1, price: 1299.99 }
  ],
  createdAt: new Date('2026-07-01')
});

// マルチレイヤー$lookup:注文 → ユーザー → 商品詳細
db.orders.aggregate([
  { $match: { status: 'paid' } },

  // ユーザーを結合
  {
    $lookup: {
      from: 'users',
      localField: 'userId',
      foreignField: '_id',
      as: 'customer'
    }
  },
  { $unwind: '$customer' },

  // 注文の商品を関連付け(pipeline形式 + 変数)
  {
    $lookup: {
      from: 'products',
      let: { items: '$items' },
      pipeline: [
        { $match: { $expr: { $in: ['$_id', '$$items.productId'] } } },
        { $project: { sku: 1, title: 1, price: 1 } }
      ],
      as: 'productDetails'
    }
  },

  // 最終プロジェクションレポート
  {
    $project: {
      orderNumber: 1,
      total: 1,
      createdAt: 1,
      customer: { username: '$customer.username', email: '$customer.email' },
      itemCount: { $size: '$items' },
      products: '$productDetails'
    }
  }
]);

// 出力結果:
// {
//   orderNumber: 'ORD-2026-001',
//   total: 1899.98,
//   createdAt: 2026-07-01T00:00:00.000Z,
//   customer: { username: 'alice', email: 'alice@example.com' },
//   itemCount: 2,
//   products: [
//     { sku: 'PHONE-001',  title: 'Smartphone X', price: 599.99 },
//     { sku: 'LAPTOP-001', title: 'Laptop Pro',   price: 1299.99 }
//   ]
// }

// 同時にmongoose populateのデモンストレーション(アプリケーション層ソリューション):
const order = await Order.findById(orderId)
  .populate('userId', 'username email')
  .populate({
    path: 'items.productId',
    select: 'sku title price'
  })
  .lean();
// populateはN+1クエリが必要(パフォーマンスは劣るが柔軟)、$lookupは一括(パフォーマンス良好)

出力: ユーザーと商品情報を自動的に関連付ける包括的な注文レポート、複数クエリの必要性を排除。



6. 総合実践トレーニング

概念説明: この総合演習は$lookup、$unwind、$groupを組み合わせて、ECシナリオで最も複雑なマルチテーブル結合レポートを実装します:注文、ユーザー、商品、住所の4テーブル結合。中核課題は$unwind使用後に$groupで再集計し、一対多関係を復元することです。

マルチテーブル結合レポートのパイプ設計パターン: 4テーブルを含むレポートのパイプ設計は固定パターンに従います—1. $match:メインテーブルからデータをフィルタリング(例:支払い済み注文のみ取得);2. $lookup + $unwind:従属テーブルを順次結合(まずユーザーテーブル、次に住所テーブル、最後に商品テーブル)、各$lookup後に$unwindで即座に配列をオブジェクトに変換;3. $group:メインテーブルの_idで再集計し、$pushで一対多関係を収集(例:単一注文の複数商品item);4. $project:出力フィールドを絞り込み、フロントエンドで必要なもののみ返す。重要ポイント:$groupの_idはメインテーブルの全必要フィールドを含める必要がある($groupは_idとアグリゲータの結果のみ出力)、さもなければ非_idフィールドが失われる。

$lookup使用のアンチパターン: 1. 過剰結合—不要なフィールドを$lookupで取得、I/Oを浪費(pipelineで$projectを使用し必要なフィールドのみ取得);2. preserveNullAndEmptyArraysなしで$unwindを使用—LEFT JOINセマンティクスが失われる(マッチなし注文が破棄される);3. 3レベルを超えるネスト$lookup—パフォーマンスが急激に低下、非正規化を検討;4. $lookup後に$unwindなしで関連フィールドを使用—結果がオブジェクトではなく配列のまま(例:customer: [{name: 'alice'}]ではなくcustomer: {name: 'alice'})。

結合クエリのパフォーマンスチューニング手法: $lookupのパフォーマンス最適化には体系的なアプローチがあります—1. インデックスチェック:fromコレクションのforeignFieldがインデックス化されていることを確認(これが重要!インデックスなしの$lookupは全コレクションスキャンになり、N×Mのパフォーマンス災害);2. データ量制御:$lookup前に$matchで入力ドキュメント数を削減、$lookupパイプラインに$projectを追加して返却フィールド数を削減;3. 代替案の評価:シンプルな結合にはpopulateを使用(開発効率が高い)、複雑な結合には$lookupを使用(実行時効率が高い)、非常に大規模なデータセットには冗長フィールドを使用(結合を回避);4. explain()分析:db.orders.aggregate([...]).explain()で実行プランを確認し、$lookupステージがインデックスを使用しているかチェック(IXSCAN vs COLLSCAN);5. 段階的デバッグ:まず$lookupを削除してパイプラインの他の部分の正確性をテスト、次に$lookupを追加して個別にデバッグ。

アンチパターン 結果 ベストプラクティス
$projectなし 返却フィールド過多 pipelineに$projectを追加
preserveNullなし LEFT JOINがINNER JOINに preserveNullAndEmptyArrays
3+レベルのネスト パフォーマンス低下 非正規化による冗長化
$unwindなし 関連フィールドが配列 $unwindまたは$arrayElemAt
100%
graph LR
    A[orders] -->|"$lookup<br/>users"| B[orders + customer配列]
    B -->|"$unwind"| C[orders + customerオブジェクト]
    C -->|"$lookup<br/>order_items"| D[orders + items配列]
    D -->|"$unwind"| E[各行1item]
    E -->|"$lookup<br/>products"| F[各行1item+product]
    F -->|"$group<br/>$_id"| G[注文レベル集計<br/>items: $push]
    G -->|"$sort/$limit"| H[最終レポート]

    style H fill:#d4edda

(1) EC注文レポート

JAVASCRIPT
// === 注文 + ユーザー + 商品 + 住所 完全レポート ===
db.orders.aggregate([
  { $match: { status: 'paid' } },
  {
    $lookup: {
      from: 'users',
      localField: 'userId',
      foreignField: '_id',
      as: 'customer'
    }
  },
  { $unwind: '$customer' },
  {
    $lookup: {
      from: 'order_items',
      localField: '_id',
      foreignField: 'orderId',
      as: 'item'
    }
  },
  { $unwind: '$item' },
  {
    $lookup: {
      from: 'products',
      localField: 'item.productId',
      foreignField: '_id',
      as: 'item.product'
    }
  },
  { $unwind: '$item.product' },
  {
    $group: {
      _id: '$_id',
      orderNumber: { $first: '$orderNumber' },
      customer: { $first: '$customer' },
      total: { $first: '$total' },
      item: { $push: '$item' },
      createdAt: { $first: '$createdAt' }
    }
  },
  { $sort: { createdAt: -1 } },
  { $limit: 50 }
]);

ドキュメント構造再構築のパターン: マルチレベル$lookup + $unwindプロセスの最終ステップは、$groupで_idごとに再集計し、「一対多」ネスト構造を復元することです。$groupの$firstは最初の要素の値を取得(例:注文番号、ユーザー情報)、$pushは配列を収集(例:商品リスト)。この「展開→処理→再構成」パターンは、MongoDBで複雑な関係を処理する標準パラダイムです—SQLのGROUP BY + 集計関数操作に対応。

$lookupの位置がパフォーマンスに与える影響: パイプラインでは、$lookupが後で登場するほどパフォーマンスが向上します—先行する$match/$project操作が入力ドキュメント数を削減済みだからです。逆例:まず$lookupで全データを結合、次に$matchでフィルタリング—これは不要なデータを結合し、計算とメモリを浪費。ベストプラクティス:まず$matchでフィルタリング(例:支払い済み注文のみ取得)、次に$lookupで結合—必要なデータのみ結合。この原則は「$matchはできるだけ早く」に合致。


▶ サンプル 2:条件付き結合のpipeline形式$lookup

条件付き結合の典型的なビジネスシナリオ: pipeline形式の$lookupは、完全一致では処理できないシナリオを解決します—(1)アクティブユーザーのみ結合(このサンプルのように):退職または無効化されたユーザーをフィルタリングし無効情報の表示を回避;(2)最新レコードのみ結合:サブパイプライン内で$sort + $limit(1)を使用、各ユーザーの最新ログインを検索;(3)複数条件結合:部門と職位レベルを同時にマッチング、同じ部門で同じ職位レベルの同僚を検索;(4)計算結合:$expr内の結合条件が単純なフィールド等価ではなく、計算結果の等価に基づく。pipeline形式はパフォーマンスが比較的低いですが、これらのビジネスシナリオでは代替不可能です。

$expr内の変数参照ルール: pipeline $lookupはletで変数を定義し、サブパイプライン内で$$variableで参照します。重要なルール:1. let内の変数名はカスタム可能(例:order_user_id)、$$プレフィックスが必須;2. $$variable$expr内でのみ使用可能—$match: {field: '$$var'}は無効、$match: {$expr: {$eq: ['$field', '$$var']}}と書く必要がある;3. サブパイプ内で現在のコレクションフィールドを参照するには$fieldlet変数を参照するには$$var—これら2つのプレフィックスは異なり、混同がよくあるミス。

JAVASCRIPT
// シナリオ:TechCorpの注文システムで注文を検索、アクティブユーザーのみを含む、ユーザー基本情報のみ返す
db.users.insertMany([
  { _id: ObjectId('507f1f77bcf86cd799439011'), username: 'alice', email: 'alice@techcorp.com', isActive: true, role: 'admin' },
  { _id: ObjectId('507f1f77bcf86cd799439012'), username: 'bob', email: 'bob@techcorp.com', isActive: false, role: 'customer' },
  { _id: ObjectId('507f1f77bcf86cd799439013'), username: 'charlie', email: 'charlie@techcorp.com', isActive: true, role: 'customer' }
]);

db.orders.insertMany([
  { orderNumber: 'ORD-001', userId: ObjectId('507f1f77bcf86cd799439011'), total: 599, status: 'paid', createdAt: new Date('2026-06-01') },
  { orderNumber: 'ORD-002', userId: ObjectId('507f1f77bcf86cd799439012'), total: 299, status: 'paid', createdAt: new Date('2026-06-15') },
  { orderNumber: 'ORD-003', userId: ObjectId('507f1f77bcf86cd799439013'), total: 899, status: 'paid', createdAt: new Date('2026-07-01') }
]);

// pipeline $lookup:アクティブユーザーのみ、フィールドをフィルタリング
db.orders.aggregate([
  { $match: { status: 'paid' } },
  {
    $lookup: {
      from: 'users',
      let: { orderUserId: '$userId' },
      pipeline: [
        {
          $match: {
            $expr: {
              $and: [
                { $eq: ['$_id', '$$orderUserId'] },
                { $eq: ['$isActive', true] }
              ]
            }
          }
        },
        { $project: { username: 1, email: 1, role: 1, _id: 0 } }
      ],
      as: 'customerInfo'
    }
  },
  {
    $addFields: {
      // 空配列をnullに変換(bobはアクティブユーザーではないため、マッチしない)
      customerInfo: { $arrayElemAt: ['$customerInfo', 0] }
    }
  },
  { $sort: { createdAt: -1 } }
]);

// 出力:
// ORD-003: customerInfo: {username: 'charlie', email: 'charlie@techcorp.com', role: 'customer'}
// ORD-001: customerInfo: {username: 'alice', email: 'alice@techcorp.com', role: 'admin'}
// ORD-002: customerInfo: null(bobはアクティブユーザーではないためフィルタリング済み)

出力: pipeline形式の$lookup操作はアクティブユーザーのみ返す。ORD-002のbobはisActiveがfalseのためフィルタリングされ、customerInfoはnull。

▶ サンプル 3:商品とユーザー結合の注文詳細レポート(難易度 ⭐⭐)

JAVASCRIPT
// シナリオ:ShopHub注文詳細ページで注文+顧客+商品情報を1クエリで表示
db.users.insertMany([
  { _id: ObjectId('507f1f77bcf86cd799439011'), username: 'alice', email: 'alice@shop.com', city: 'San Francisco' },
  { _id: ObjectId('507f1f77bcf86cd799439012'), username: 'bob', email: 'bob@shop.com', city: 'New York' }
]);

db.products.insertMany([
  { _id: ObjectId('507f1f77bcf86cd799439021'), sku: 'PHONE-001', title: 'Smartphone X', price: 599 },
  { _id: ObjectId('507f1f77bcf86cd799439022'), sku: 'LAPTOP-001', title: 'Laptop Pro', price: 1299 }
]);

db.orders.insertOne({
  _id: ObjectId('507f1f77bcf86cd799439031'),
  orderNumber: 'ORD-2026-001',
  userId: ObjectId('507f1f77bcf86cd799439011'),
  items: [
    { productId: ObjectId('507f1f77bcf86cd799439021'), qty: 2, price: 599 },
    { productId: ObjectId('507f1f77bcf86cd799439022'), qty: 1, price: 1299 }
  ],
  status: 'paid',
  total: 2497,
  createdAt: new Date('2026-07-15')
});

// マルチコレクション結合:注文 → ユーザー + 商品
const orderDetail = db.orders.aggregate([
  { $match: { orderNumber: 'ORD-2026-001' } },
  // ユーザーコレクションを結合
  {
    $lookup: {
      from: 'users',
      localField: 'userId',
      foreignField: '_id',
      as: 'customer'
    }
  },
  { $unwind: '$customer' },
  // 商品コレクションを結合
  {
    $lookup: {
      from: 'products',
      let: { itemIds: '$items.productId' },
      pipeline: [
        { $match: { $expr: { $in: ['$_id', '$$itemIds'] } } },
        { $project: { sku: 1, title: 1, price: 1 } }
      ],
      as: 'productDetails'
    }
  },
  // 最終プロジェクション
  {
    $project: {
      orderNumber: 1,
      status: 1,
      total: 1,
      createdAt: 1,
      customer: { username: '$customer.username', email: '$customer.email', city: '$customer.city' },
      items: {
        $map: {
          input: '$items',
          as: 'item',
          in: {
            qty: '$$item.qty',
            price: '$$item.price',
            product: { $arrayElemAt: [
              { $filter: {
                input: '$productDetails',
                cond: { $eq: ['$$this._id', '$$item.productId'] }
              }}, 0
            ]}
          }
        }
      }
    }
  }
]);

console.log(JSON.stringify(orderDetail.toArray()[0], null, 2));

出力:

TEXT 📖 参照専用
{
  "orderNumber": "ORD-2026-001",
  "status": "paid",
  "total": 2497,
  "customer": { "username": "alice", "email": "alice@shop.com", "city": "San Francisco" },
  "items": [
    { "qty": 2, "price": 599, "product": { "sku": "PHONE-001", "title": "Smartphone X", "price": 599 } },
    { "qty": 1, "price": 1299, "product": { "sku": "LAPTOP-001", "title": "Laptop Pro", "price": 1299 } }
  ]
}

❓ よくある質問

$lookup使用時によくある落とし穴: 1. $unwind忘れでasフィールドが常に配列—後続コードでobj.fieldではなくobj.field[0]としてアクセスするとエラー;2. $lookupのパフォーマンス落とし穴—fromコレクションにforeignFieldインデックスがない大規模コレクションへの$lookupは全コレクションスキャンになる;3. $unwind膨張—一対多関係で$unwind後、ドキュメント数が倍増、複数$unwindを重ねるとデカルト積爆発;4. pipeline構文の変数名スペルミス—$$variableは大文字小文字を区別、スペルミスはエラーをトリガーせずnullを返す。

Q $lookupとpopulateのパフォーマンス差はどの程度?
A $lookupは単一クエリで完了、populateはN+1クエリが必要。複雑な結合には$lookupを推奨。
Q $lookupはクロスデータベース操作をサポート?
A MongoDB 4.0+は$unionWithでクロスデータベース操作をサポートするが、$lookupは同じデータベース内に制限される。
Q $lookupのメモリ圧迫問題をどう解決?
A allowDiskUse: trueを設定してディスク書き込みを許可、バッチ処理(1,000件ごと)。

よくある質問の詳細解説: これら3つの質問は$lookupの3つの制限を浮き彫りにします—パフォーマンス制限(N+1 vs 単一クエリ)、スコープ制限(同じデータベース内の制限)、メモリ制限(100MB制限)。これらの制限を理解することは答えを暗記するより重要です—$lookupの限界を知ってこそ、スキーマ設計時に正しい選択ができます:クロスデータベースクエリには$unionWith、非常に大規模なデータセットにはセグメント処理、シンプルな結合にはpopulate


📖 まとめ

知識ネットワーク: $lookupは「単一コレクション操作」と「マルチコレクション結合」の架け橋です—レッスン14–15は単一コレクション内の集計に焦点、このレッスンはクロスコレクション結合の能力を紹介。$lookup、$unwind、$groupの組み合わせはMongoDBでのマルチコレクションクエリの標準パターンで、SQLのJOIN + GROUP BYに対応。このパターンを理解すれば、MongoDBでほとんどのSQLマルチテーブルクエリシナリオを実装できます。

$lookup学習パス: $lookup習得のための推奨学習パス—1. まず等価マッチング形式(localField/foreignField)を学び、LEFT JOINセマンティクスとasが返す配列結果を理解;2. $unwindを学び、配列からオブジェクトへの変換とpreserveNullAndEmptyArraysオプションを習得;3. $lookupのpipeline形式を学び、let$$variableによる変数渡し、サブパイプライン内の条件フィルタリングを理解;4. ネスト$lookupを学び、マルチレベル結合からのデータ組み立てを習得;5. $lookup + $groupを学び、結合後の再集計パターンを習得;6. populateと比較し、アプリケーション層とデータベース層の結合の違いを理解。各ステップで、Compass Aggregation Pipeline Builderを使用した視覚的デバッグを推奨—ドラッグ&ドロップ、中間結果の表示、リアルタイム検証。

結合クエリのアーキテクチャ決定まとめ: 結合クエリは技術問題ではなくアーキテクチャ問題です—1. 埋め込み優先:データ量が管理可能、更新頻度が低く、常に一緒に読み取られる場合、埋め込みが最もシンプルなソリューション($lookup不要);2. 参照 + $lookup:データ量が大きく、独立更新が必要、独立クエリが必要な場合、参照 + $lookupが標準ソリューション;3. 参照 + populate:Mongooseアプリケーション層の関連付け方法、実装はシンプルだがパフォーマンスは低い(N+1クエリ)、管理バックエンドなど低同時実行シナリオに適用;4. 冗長 + 参照:重要データを埋め込み(スナップショット)、リアルタイムデータを参照($lookup)、パフォーマンスと一貫性をバランス。ソリューション選択の鍵はビジネスシナリオの読み取り/書き込みパターンを理解すること—汎用的なソリューションはなく、最適なもののみ。


📝 練習問題

課題設計アプローチ: 4つの課題は簡単から複雑へ—基本問題は$lookupの基本構文をテスト、応用問題はパイプライン構造と条件フィルタリングをテスト、総合問題は$lookupと$groupを使ったマルチステップパイプライン構成をテスト。まずCompassで段階的にパイプラインをデバッグし、その後コードを書くことを推奨。

  1. 基本問題(⭐):$lookupを使用して注文とユーザーテーブルを結合し、ユーザー情報をクエリ。
  2. 基本問題(⭐):$unwindを使用して注文のitem配列を分割。
  3. 応用問題(⭐⭐):$lookup + 条件フィルタリング(アクティブユーザーのみ)で構成されるパイプラインを使用。
  4. 応用問題(⭐⭐):Mongooseのpopulateメソッドを使用してマルチレベル関係(注文 → ユーザー → 住所)を実装。
  5. チャレンジ問題(⭐⭐⭐):完全な注文レポート(注文、ユーザー、商品、住所の4テーブル結合)。
Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%