Notionパフォーマンス・ハック:多階層リレーションとロールアップの地獄から脱却するアーキテクチャ設計
プロダクト開発の規模が拡大するにつれ、Notionを単なるドキュメントツールから、プロジェクト管理や要件定義、QAトラッキングを網羅するハブへと昇華させるチームは多い。しかし、ここで多くの組織が「Notionの壁」に激突する。
「リレーションを幾重にも張り巡らせた結果、データベースを開くだけで数秒のフリーズが発生する」
「ロールアップの多階層集計がループし、クエリ制限に抵触してデータが同期されない」
美しい構造化を求めてリレーションを多用した結果、Notionのバックエンド(内部的にはSQLiteや分散ドキュメントストアが複雑に絡み合うアーキテクチャ)に過剰なクエリ負荷を与え、ワークスペース全体のベロシティを著しく低下させているのだ。
本稿では、数万件規模のレコードを抱える巨大データベース群において、ロールアップとリレーションの呪縛を断ち切り、ミリ秒単位の応答速度を取り戻すための極限の最適化手法を解説する。生粋のエンジニアリング視点から、データモデリングの再定義、数式プロパティによるクエリ削減、そしてAPIを駆使した非同期同期の極意を授けよう。
—
1. なぜ多階層ロールアップは「パフォーマンスの毒」なのか?
まずは敵を知ることから始める。Notionのロールアップ(Rollup)およびリレーション(Relation双方向)は、フロントエンドからはシンプルに見えるが、内部的には「オンデマンド・グラフ走査」を実行している。
内部アーキテクチャの脆弱性
1. N+1クエリ問題の蔓延: データベースAがデータベースBを参照し、BがCを参照している状態で、A側でCのロールアップを表示しようとすると、Notionクライアント/サーバーは動的に結合クエリ(Joinに類似した処理)をツリー状に展開する。
2. メモリ消費の爆発: 各プロパティが変更されるたびに、依存関係グラフ(DAG: Directed Acyclic Graph)全体が再計算される。階層が3つ(A -> B -> C -> D)を超えると、計算コストは幾何級数的に跳ね上がる。
3. UIレンダリングのブロッキング: メインスレッド上でプロパティの再計算とDOMの差分更新が同期的に行われるため、ページスクロールすらカクつく原因となる。
この構造的欠陥を回避するには、「リレーションの階層を浅く保つ」か、「計算を非正規化(Denormalization)して持たせる」かの二択しかない。
—
2. 最適化設計:中間データベース(Materialized View パターン)の導入
多階層リレーションの直接的な参照を避けるための王道が、「中間データベース」の設置である。これはデータベース工学における「マテリアライズド・ビュー(実体化されたビュー)」の概念をNotionに応用したものだ。
アーキテクチャの比較
- アンチパターン(多階層直結)
`[タスク] <---> [スプリント] <---> [エピック] <---> [プロジェクト]`
- 問題: タスク画面でプロジェクトの進捗率をロールアップしようとすると、4階層分のグラフ走査が走る。
- 推奨パターン(中間データベースによるフラット化)
`[タスク] <---> [プロジェクト集約ビュー (中間DB)] <---> [プロジェクト]`
- 効果: タスクとプロジェクトの間に「集約レイヤー」を挟み、プロジェクト側の数値を中間DBへ一度キャッシュ(転記)する。これにより、リレーションの深さを常に「最大2階層」に制限できる。
—
3. 数式プロパティ(Formula 2.0)によるクエリ削減の裏技
NotionのFormula 2.0は、単なる文字列操作ツールではない。適切に活用すれば、重いロールアッププロパティを排除し、ローカル評価(クライアントサイドでの軽量計算)に置き換えることが可能だ。
実例:多階層ロールアップの排除と数式による代替
例えば、「タスクのステータス」から「プロジェクトの完了率」を算出する際、従来のロールアップを多用する代わりに、子要素のID配列を文字列として連結し、Formulaでパースするというハブテクニックがある。
// Formula 2.0 の高度な活用例:リレーション先のプロパティを効率的に集約・判定する
let(
/ リレーション先のステータス配列を取得 /
statuses, prop(“Sub-Tasks”).map(current.prop(“Status”)),
total, statuses.length(),
completed, statuses.filter(current == “Done”).length(),
/ ゼロ除算ガードと進捗率の計算 /
if(total == 0, 0, round((completed / total) 1000) / 10)
)
【この手法の優位性】
ロールアッププロパティを何段も経由するとNotionのサーバーサイドクエリが発火するが、Formula内で直接リレーション先のプロパティにアクセス(`prop(“Relation”).prop(“Property”)`)する場合、Notionの最適化されたキャッシュレイヤーを利用できるケースが多く、実効レイテンシが劇的に改善する。
—
4. 完全自動構成:API & CLIによる非同期・非正規化パイプライン
データベースの数が増え、数式やリレーションだけではパフォーマンス限界に達した場合、最終兵器として「Notion APIを用いた非正規化の完全自動化(バッチ同期)」を導入する。
ユーザーが手動でデータを更新するのを待つのではなく、WebhookやCronジョブを用いて、バックグラウンドで子データベースの集計値を親データベースへ書き戻す(Denormalization)アーキテクチャだ。
以下に、Nodejs(TypeScript)を用いて多階層の数値を親DBへ安全にフラット書き込みするプロダクション品質のスクリプトを提示する。
/
- Notion Performance Optimizer: Rollup Denormalizer
- 概要: 多階層リレーションの集計負荷を軽減するため、
- 子タスクの進捗状況を計算し、親プロジェクトDBへ非同期で書き戻す。
/
import { Client } from “@notionhq/client”;
const notion = new Client({ auth: process.env.NOTION_API_KEY });
const PROJECT_DB_ID = process.env.PROJECT_DB_ID!;
const TASK_DB_ID = process.env.TASK_DB_ID!;
interface TaskMetrics {
total: number;
completed: number;
}
async function syncProjectMetrics() {
console.log(“🚀 Starting Notion Database Denormalization Pipeline…”);
try {
// 1. プロジェクト一覧の取得
const projects = await notion.databases.query({
database_id: PROJECT_DB_ID,
});
for (const project of projects.results) {
if (!(“properties” in project)) continue;
const projectId = project.id;
// 2. 紐づくタスク群をクエリ (リレーションプロパティ “Project” を想定)
const tasks = await notion.databases.query({
database_id: TASK_DB_ID,
filter: {
property: “Project”,
relation: {
contains: projectId,
},
},
});
// 3. メトリクスの算出
const metrics: TaskMetrics = tasks.results.reduce(
(acc, task) => {
if (!(“properties” in task)) return acc;
// ステータスプロパティの型安全な取得
const statusProp = task.properties[“Status”];
const statusName =
statusProp.type === “status” ? statusProp.status?.name : “Unknown”;
acc.total += 1;
if (statusName === “Done”) {
acc.completed += 1;
}
return acc;
},
{ total: 0, completed: 0 }
);
const progress =
metrics.total > 0
? Math.round((metrics.completed / metrics.total) 100)
: 0;
// 4. 親プロジェクトDBへ集計結果を書き戻し (ロールアップをバイパス)
await notion.pages.update({
page_id: projectId,
properties: {
// 事前に用意した数値プロパティ (Cache_Progress) へ書き込む
“Cache_Progress”: {
number: progress,
},
“Cache_TotalTasks”: {
number: metrics.total,
},
},
});
console.log(`[Synced] Project ID: ${projectId} -> Progress: ${progress}%`);
}
console.log(“✨ Denormalization Pipeline completed successfully.”);
} catch (error) {
console.error(“❌ Error during synchronization:”, error);
process.exit(1);
}
}
// 実行
syncProjectMetrics();
運用におけるベストプラクティス
- 実行頻度: GitHub ActionsのCronやAWS Lambda(EventBridge)を使い、10分〜1時間に1回、またはタスク更新時のWebhookトリガーで実行する。
- レートリミット対策: Notion APIの制限(平均3秒間に3リクエスト)を回避するため、コード内に指数バックオフ(Exponential Backoff)とキューイング機構を実装すること。
—
5. 現場で即実践すべき「黄金のチェックリスト」
最後に、数千・数万レコードを扱う巨大なNotion環境を崩壊させないための、アーキテクトとしての鉄則をまとめる。
1. リレーションの深さは「最大2レイヤー」を死守せよ
`A -> B -> C` までなら許容範囲だが、`D` が絡む場合は必ず中間DBまたはAPIによる非正規化を検討する。
2. 「動的ロールアップ」から「静的キャッシュ(数式/API)」へシフトせよ
リアルタイム性が厳密に求められないデータ(過去のスプリント実績、月次集計など)にロールアップを使うのは愚行である。
3. ビュー(View)のフィルターとソートを最適化せよ
データベース自体の構造だけでなく、大量の未完了アイテムを全てロードするようなビューはクライアントメモリを圧迫する。「過去30日以内の完了タスク」など、常にフィルタでデータセットを絞り込んだ状態をデフォルトにせよ。
Notionは、その柔軟性ゆえに設計者の力量がそのままパフォーマンスとして跳ね返ってくるツールである。ツールの限界を嘆く前に、背後にあるデータフローを疑い、美しくスケーラブルなアーキテクチャへとリファクタリングを敢行してほしい。開発チームのベロシティは、そこから再び加速し始める。