MongoDB: Mongooseスキーマ設計と埋め込み vs 参照
最終更新:2026-08-26
スキーマ設計はMongoDB開発の基礎であり、パフォーマンスと保守性を決定づける重要な要素です。
1. このレッスンで学ぶこと
- 埋め込みドキュメント vs 参照(正規化)
- 1対1・1対多・多対多の関係モデリング
- mongoose Schema定義の基本
- スキーマ設計のベストプラクティス
graph TB
A[スキーマ設計] --> B[埋め込み<br/>埋め込み]
A --> C[参照<br/>参照]
B --> B1[1対1 関係]
B --> B2[1対少関係]
B --> B3[頻繁に一緒に読み取る]
C --> C1[1対多関係]
C --> C2[多対多関係]
C --> C3[頻繁に更新する]
style B fill:#d4edda
style C fill:#cce5ff
2. 埋め込みドキュメント vs 参照
概念説明: MongoDBはスキーマレスですが、実務では明確なスキーマ設計が不可欠です。埋め込みドキュメントは親ドキュメント内に子データを格納し、参照は他のコレクションへのポインタ(通常はObjectId)を格納します。
動作原理: 埋め込みドキュメントは単一のドキュメントとして保存され、1回の読み取りですべての関連データを取得できます。参照は2つ以上のコレクションにデータを分散させ、$lookupやMongooseのpopulate()で結合します。
比較分析:
| 次元 | 埋め込み | 参照 |
|---|---|---|
| 読み取りパフォーマンス | ⭐⭐⭐ 高速(1回のクエリ) | ⭐⭐ 追加クエリ必要 |
| 書き込みパフォーマンス | ⭐⭐ 親更新が必要 | ⭐⭐⭐ 独立更新可能 |
| データ整合性 | ⭐⭐⭐ 原子性保証 | ⭐⭐ 分散データ |
| ストレージ | 重複可能性 | 正規化済み |
| 柔軟性 | ⭐⭐ 構造固定 | ⭐⭐⭐ 独立クエリ可能 |
選択基準:
| シナリオ | 推奨案 | 理由 |
|---|---|---|
| 1対1関係 | 埋め込み | シンプル、高速 |
| 1対少数(<100) | 埋め込み | 1回読み取りで完結 |
| 1対多数(不定) | 参照 | ドキュメントサイズ制限回避 |
| 多対多 | 参照 | 双方向の柔軟なクエリ |
| 頻繁に更新 | 参照 | 独立更新で競合回避 |
| 頻繁に一緒に読み取る | 埋め込み | パフォーマンス最適 |
JAVASCRIPT
// === 埋め込みパターン:ユーザー内に住所を埋め込み ===
const userSchema = new mongoose.Schema({
username: String,
email: String,
addresses: [{ // 埋め込みドキュメント配列
street: String,
city: String,
zipCode: String,
isDefault: Boolean
}]
});
// === 参照パターン:注文がユーザーを参照 ===
const orderSchema = new mongoose.Schema({
orderNumber: String,
userId: { // 参照フィールド
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
},
total: Number,
status: String
});
3. 関係モデリング
(1) 1対1関係
概念説明: 1つのエンティティが別の1つのエンティティと関連付く関係。例:ユーザーとプロフィール、製品と詳細仕様。
埋め込み案(推奨): 1回の読み取りで全データ取得、パフォーマンス最適。
JAVASCRIPT
// === 1対1 埋め込みパターン ===
const userSchema = new mongoose.Schema({
username: String,
email: String,
profile: { // 埋め込みドキュメント
avatar: String,
bio: String,
website: String
}
});
// 使用例
const user = await User.findById(userId);
console.log(user.profile.bio); // 追加クエリ不要
(2) 1対多関係
概念説明: 1つの親が複数の子を持つ関係。例:ユーザーと注文、ブログとコメント。
少件数(<100)→埋め込み: 配列で格納、1回読み取りで完結。
多件数(不定)→参照: 子コレクションが親を参照。
JAVASCRIPT
// === 1対少:カテゴリに製品を埋め込み ===
const categorySchema = new mongoose.Schema({
name: String,
slug: String,
products: [{
sku: String,
title: String,
price: Number
}]
});
// === 1対多:注文がユーザーを参照 ===
const orderSchema = new mongoose.Schema({
orderNumber: String,
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
},
items: [orderItemSchema],
total: Number
});
// populateで結合
const orders = await Order.find()
.populate('userId', 'username email');
(3) 多対多関係
概念説明: 複数のエンティティが相互に関連付く関係。例:学生とコース、製品とタグ。
参照配列パターン: 双方に参照配列を持つ。
JAVASCRIPT
// === 多対多:製品とタグ ===
const productSchema = new mongoose.Schema({
sku: String,
title: String,
tagIds: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Tag'
}]
});
const tagSchema = new mongoose.Schema({
name: String,
slug: String,
productIds: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Product'
}]
});
// 双方向populate
const product = await Product.findById(productId)
.populate('tagIds');
4. mongoose Schema基本
概念説明: mongoose Schemaはドキュメント構造を定義し、型検証、デフォルト値、インデックス、ミドルウェアなどの機能を提供します。
Schema定義の基本構文:
JAVASCRIPT
const productSchema = new mongoose.Schema({
// 基本型
sku: {
type: String,
required: [true, 'SKU is required'],
unique: true,
trim: true,
uppercase: true
},
title: {
type: String,
required: true,
maxlength: [200, 'Title cannot exceed 200 characters']
},
price: {
type: Number,
required: true,
min: [0, 'Price cannot be negative']
},
category: {
type: String,
enum: ['Electronics', 'Books', 'Clothing', 'Home']
},
isActive: {
type: Boolean,
default: true
},
tags: [String], // 文字列配列
metadata: { // 埋め込みドキュメント
views: { type: Number, default: 0 },
likes: { type: Number, default: 0 }
},
createdAt: {
type: Date,
default: Date.now
}
});
// インデックス作成
productSchema.index({ title: 'text', description: 'text' });
// モデル作成
const Product = mongoose.model('Product', productSchema);
5. スキーマ設計のベストプラクティス
▶ サンプル 1:eコマース注文スキーマ(難易度 ⭐)
JAVASCRIPT
// ShopHub Eコマース:注文とアイテムのスキーマ設計
const orderItemSchema = new mongoose.Schema({
productId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Product',
required: true
},
sku: String, // 冗長フィールド(高速表示用)
title: String, // 注文時点の商品名
price: Number, // 注文時点の価格
qty: { type: Number, min: 1 }
});
const orderSchema = new mongoose.Schema({
orderNumber: { type: String, unique: true },
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
items: [orderItemSchema], // 埋め込み配列
total: { type: Number, min: 0 },
status: {
type: String,
enum: ['pending', 'paid', 'shipped', 'delivered', 'cancelled'],
default: 'pending'
},
shippingAddress: { // 埋め込み(注文時に固定)
street: String,
city: String,
zipCode: String,
country: String
},
createdAt: { type: Date, default: Date.now }
});
// 複合インデックス
orderSchema.index({ userId: 1, createdAt: -1 });
orderSchema.index({ status: 1 });
const Order = mongoose.model('Order', orderSchema);
出力:
TEXT 📖 参照専用注文スキーマは、アイテムを埋め込み(注文時点のデータ固定)、配送先住所を埋め込み(変更不可)、ユーザーを参照(最新情報取得)の設計。
▶ サンプル 2:ブログ記事とコメント(難易度 ⭐⭐)
JAVASCRIPT
// TechBlog:記事とコメントの1対多関係
const commentSchema = new mongoose.Schema({
author: { type: String, required: true },
content: { type: String, required: true, maxlength: 1000 },
createdAt: { type: Date, default: Date.now }
});
const articleSchema = new mongoose.Schema({
title: { type: String, required: true },
slug: { type: String, unique: true },
content: String,
author: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
},
comments: [commentSchema], // 埋め込み:コメント数が有限
tags: [{ type: String }], // 多対多:タグは参照でも可
viewCount: { type: Number, default: 0 },
isPublished: { type: Boolean, default: false },
publishedAt: Date,
createdAt: { type: Date, default: Date.now }
});
// テキストインデックス
articleSchema.index({ title: 'text', content: 'text' });
const Article = mongoose.model('Article', articleSchema);
// コメント追加
const article = await Article.findByIdAndUpdate(
articleId,
{
$push: {
comments: {
author: 'Alice',
content: 'Great article!'
}
}
},
{ new: true }
);
出力:
TEXT 📖 参照専用記事スキーマは、コメントを埋め込み配列として格納。$pushでコメント追加、1回の読み取りで記事とコメントを同時に取得可能。
▶ サンプル 3:多対多関係のソーシャルアプリ(難易度 ⭐⭐⭐)
JAVASCRIPT
// SocialApp:ユーザーとフォロー関係(多対多)
const userSchema = new mongoose.Schema({
username: { type: String, required: true, unique: true },
email: { type: String, required: true, unique: true },
avatar: String,
bio: { type: String, maxlength: 200 },
// フォローしているユーザー(参照配列)
following: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}],
// フォロワー(参照配列)
followers: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}],
createdAt: { type: Date, default: Date.now }
});
// 複合インデックス
userSchema.index({ username: 1 });
userSchema.index({ email: 1 });
// フォロー追加メソッド
userSchema.methods.follow = async function(targetUserId) {
if (this._id.equals(targetUserId)) {
throw new Error('自分自身をフォローできません');
}
const targetUser = await this.constructor.findById(targetUserId);
if (!targetUser) {
throw new Error('ユーザーが見つかりません');
}
// 既にフォロー済みかチェック
const alreadyFollowing = this.following.some(id => id.equals(targetUserId));
if (alreadyFollowing) {
throw new Error('既にフォローしています');
}
// 双方向に追加
this.following.push(targetUserId);
targetUser.followers.push(this._id);
await Promise.all([this.save(), targetUser.save()]);
};
const User = mongoose.model('User', userSchema);
// 使用例:ユーザー情報とフォロワーを取得
const user = await User.findById(userId)
.populate('following', 'username avatar')
.populate('followers', 'username avatar');
出力:
TEXT 📖 参照専用多対多関係では双方に参照配列を持つパターン。follow()メソッドで一貫性を維持し、populate()でフォロワー/フォロー中ユーザー情報を取得。
❓ よくある質問
Q 埋め込みと参照どちらを選ぶべき?
A 「一緒に読み取るなら埋め込み、独立して更新するなら参照」が基本原則。
Q 埋め込みドキュメントの最大サイズは?
A MongoDBのドキュメントサイズ制限は16MB。埋め込み配列が大きくなる場合は参照を検討。
Q 多対多関係で双方向参照は必須?
A いいえ。クエリパターンに応じて片方向だけで十分な場合が多い。
Q 冗長フィールドを保存すべき?
A 高速表示のために、注文時点の商品名・価格などは冗長保存が一般的。
📖 まとめ
- 埋め込み:1回読み取りで完結、パフォーマンス最適
- 参照:独立更新、データ整合性維持
- 1対1:埋め込み推奨
- 1対少:埋め込み可能
- 1対多・多対多:参照推奨
- スキーマ設計は「読み取りパターン」と「更新パターン」に基づく
📝 練習問題
- 基本問題(⭐):ユーザースキーマにプロフィール情報(アバター、自己紹介)を埋め込み定義せよ。
- 基本問題(⭐):製品スキーマにカテゴリへの参照フィールドを追加せよ。
- 応用問題(⭐⭐):ブログシステムの記事とコメントのスキーマを設計せよ。
- 応用問題(⭐⭐):多対多関係(学生とコース)のスキーマを設計せよ。
- チャレンジ問題(⭐⭐⭐):eコマースの注文システム(注文、アイテム、配送先、ユーザー)の完全なスキーマを設計せよ。