ヘッドレス CMS が速い理由を実測する: 検索と絞り込みを Contentful 側に任せる

ヘッドレス CMS が速い理由を実測する: 検索と絞り込みを Contentful 側に任せる

ヘッドレス CMS は速いと聞くことがあります。本記事では Contentful を例に、記事一覧の検索と絞り込みをアプリ側で行う方法と、ヘッドレス CMS 側で行う方法を比較し、処理完了までの時間を実測します。
2026.01.12

はじめに

ヘッドレス CMS は速いと聞くことがあります。しかし、何がどう速いのかは、概念的な説明になりがちです。

たとえば Web サイトで記事一覧を作るとき、検索や絞り込みを付けたい一方で、表示速度を落としたくないという要件が出てきます。さらに、記事数が将来増えても困らない設計にしたい、という要件もよく出てきます。このとき重要なのは、フィルタリング処理をどこで実行するか です。アプリ側で全件を受け取り、アプリ側でフィルタリングする方法のほか、「データを持つ側」でフィルタリングし必要な分だけ返してもらう方法 も考えられます。後者は、Web 設計におけるマイクロサービス化の考え方と相性が良いです。データを持つヘッドレス CMS が検索や絞り込みの責務を担い、呼び出し側は必要最小限を要求します。(これは SQL の WHERE 句をデータベースに任せる発想と似ています。)

get only required

本記事の狙いは Contentful を例として使い、フィルタリング処理を「データを持つ側」に任せるメリット を実測値で確認することです。

ヘッドレス CMS とは

ヘッドレス CMS とは、コンテンツの管理と表示を分離する CMS です。管理画面でコンテンツを編集し、Web アプリケーションは API を通じてコンテンツを取得して表示します。

Contentful とは

Contentful とは、ヘッドレス CMS を SaaS として提供するサービスです。管理画面で Content Model を定義し、Entry を作成して公開できます。Contentful の Content Delivery API (以下、CDA) とは、公開済みコンテンツを取得するための read-only の REST API です。アプリケーションは HTTP で JSON を取得し、クエリパラメータで検索や返却フィールドの制御ができます。

対象読者

  • ヘッドレス CMS のメリットを、実測ベースで理解したい方
  • 記事一覧に検索や絞り込みを付けたいが、設計の勘所が分からない方
  • 全件取得してフィルタリングする実装が、将来どの程度の負荷になり得るか不安な方

参考

検証方針

本記事では、CDA の次の 2 つの機能を使います。

  • フィルタリング: fields.title[match] によるフィールド単位の全文検索
  • フィールド選択: select によるレスポンスのフィールド絞り込み

ヘッドレス CMS の強みを確認するため、ベンチマークを行います。今回は処理完了までの時間を比較します。ここでの処理完了とは、HTTP 取得と JSON parse が終わり、配列として扱える状態になった時点までの時間を指します。

比較対象

比較する方法は 2 つです。

  • 手法 A
    全件取得してアプリ側でフィルタリングします。100 件をまとめて取得し、title をアプリ側で includes 判定します。一覧に不要な body も取得します。

  • 手法 B
    ヘッドレス CMS 側でフィルタリングして必要フィールドだけ取得します。fields.title[match] でヘッドレス CMS 側検索を行い、selecttitleslug だけを返します。

検証用データの投入

CDA は公開済みコンテンツを配信します。したがって、比較対象の Entry を publish 済みにしておく必要があります。

Content type の作成

Content type は blogPost とし、フィールドは次の 3 つにします。

  • title (Short text)
  • slug (Short text)
  • body (Long text)

content model

body は一覧では使いません。ただし差を出しやすくするため、検証用データでは body を大きくします。

セットアップ

TypeScript を実行するため、tsx を使います。

npm init -y
npm i contentful-management dotenv
npm i -D tsx typescript @types/node

環境変数の設定

.env を作成します。

CF_SPACE_ID=xxxxx
CF_ENV=master
CF_LOCALE=en-US

CF_MANAGEMENT_TOKEN=xxxxx
CF_CONTENT_TYPE=blogPost

CF_TOTAL=100
CF_FROM=1
CF_HIT_COUNT=50

CF_KEYWORD=NEEDLE
CF_BODY_CHARS=20000

CF_LOCALE は Space の Settings > Locales の内容に合わせてください。

Locale

また、CF_MANAGEMENT_TOKEN は Space Settings > CMA tokens から作成します。

CMA tokens

データ投入スクリプト (seed.ts)

import "dotenv/config";
import { createClient } from "contentful-management";

const {
  CF_SPACE_ID,
  CF_ENV = "master",
  CF_LOCALE = "en-US",
  CF_MANAGEMENT_TOKEN,
  CF_CONTENT_TYPE = "blogPost",
  CF_TOTAL = "100",
  CF_FROM = "1",
  CF_HIT_COUNT = "50",
  CF_KEYWORD = "NEEDLE",
  CF_BODY_CHARS = "20000",
} = process.env;

if (!CF_SPACE_ID || !CF_MANAGEMENT_TOKEN) {
  throw new Error("CF_SPACE_ID and CF_MANAGEMENT_TOKEN are required");
}

const TOTAL = Number(CF_TOTAL);
const FROM = Number(CF_FROM);
const HIT_COUNT = Number(CF_HIT_COUNT);
const BODY_CHARS = Number(CF_BODY_CHARS);

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

function makeBody(n: number): string {
  const chunk = "Lorem ipsum dolor sit amet. ";
  let s = "";
  while (s.length < n) s += chunk;
  return s.slice(0, n);
}

async function main(): Promise<void> {
  const client = createClient({ accessToken: CF_MANAGEMENT_TOKEN });
  const space = await client.getSpace(CF_SPACE_ID);
  const env = await space.getEnvironment(CF_ENV);

  const body = makeBody(BODY_CHARS);

  for (let i = FROM; i <= TOTAL; i++) {
    const slug = `post-${String(i).padStart(4, "0")}`;

    // `fields.title[match]` は全文検索であり、厳密な部分一致ではありません。
    // 本検証ではヒットを安定させるため、キーワードを title の先頭に置きます。
    const title = i <= HIT_COUNT ? `${CF_KEYWORD} post ${i}` : `post ${i}`;

    const entry = await env.createEntry(CF_CONTENT_TYPE, {
      fields: {
        title: { [CF_LOCALE]: title },
        slug: { [CF_LOCALE]: slug },
        body: { [CF_LOCALE]: body },
      },
    });

    await entry.publish();

    // 制限を回避するための待機です。
    await sleep(200);
    process.stdout.write(".");
  }

  process.stdout.write("\nDone.\n");
}

main().catch((e) => {
  console.error(e);
  process.exitCode = 1;
});

実行します。

npx tsx seed.ts
....................................................................................................
Done.

データが挿入されていることをコンソールで確認します。

seed.ts result

処理完了までの時間計測

A と B を同条件で実行し、処理完了までの時間を比較します。CDA にはレート制限がありますが、今回のような少数回の計測では問題になりにくいです。

環境変数の設定

.env に追記します。

CF_DELIVERY_TOKEN=xxxxx
CF_RUNS=5

CF_DELIVERY_TOKEN を取得するには、 Space Settings > API keys から API キーを作成します。Content Delivery API の値を使用します。

API keys

計測スクリプト (bench.ts)

全件取得はレスポンスサイズ上限に当たる場合があるため、skiplimit によるページングで取得します。

.env に次を追記します。

CF_LIMIT=100
CF_PAGE_SIZE=100

bench.ts は次の通りです。

import "dotenv/config";
import { performance } from "node:perf_hooks";

type EntryLike = {
  fields?: {
    title?: string;
    slug?: string;
  };
};

const {
  CF_SPACE_ID,
  CF_ENV = "master",
  CF_DELIVERY_TOKEN,
  CF_CONTENT_TYPE = "blogPost",
  CF_KEYWORD = "NEEDLE",
  CF_RUNS = "5",
  CF_LIMIT = "100",
  CF_PAGE_SIZE = "100",
} = process.env;

if (!CF_SPACE_ID || !CF_DELIVERY_TOKEN) {
  throw new Error("CF_SPACE_ID and CF_DELIVERY_TOKEN are required");
}

const RUNS = Number(CF_RUNS);
const LIMIT = Number(CF_LIMIT);
const PAGE_SIZE = Number(CF_PAGE_SIZE);

const baseUrl = `https://cdn.contentful.com/spaces/${CF_SPACE_ID}/environments/${CF_ENV}/entries`;
const authHeader = { Authorization: `Bearer ${CF_DELIVERY_TOKEN}` };

async function fetchJson(url: string): Promise<any> {
  const res = await fetch(url, { headers: authHeader });
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  return res.json();
}

function urlWith(params: Record<string, string | number>): string {
  const u = new URL(baseUrl);
  for (const [k, v] of Object.entries(params)) u.searchParams.set(k, String(v));
  return u.toString();
}

function median(values: number[]): number {
  const a = [...values].sort((x, y) => x - y);
  const mid = Math.floor(a.length / 2);
  return a.length % 2 ? a[mid] : (a[mid - 1] + a[mid]) / 2;
}

async function runA(): Promise<{ ms: number; hits: number }> {
  const t0 = performance.now();

  const itemsAll: EntryLike[] = [];

  for (let skip = 0; skip < LIMIT; skip += PAGE_SIZE) {
    const limit = Math.min(PAGE_SIZE, LIMIT - skip);

    const url = urlWith({
      content_type: CF_CONTENT_TYPE!,
      limit,
      skip,
      order: "sys.createdAt",
    });

    const data = await fetchJson(url);
    const items = (data.items ?? []) as EntryLike[];
    itemsAll.push(...items);
  }

  const hits = itemsAll.filter((e) => (e.fields?.title ?? "").includes(CF_KEYWORD!)).length;

  const t1 = performance.now();
  return { ms: t1 - t0, hits };
}

async function runB(): Promise<{ ms: number; hits: number }> {
  const url = urlWith({
    content_type: CF_CONTENT_TYPE!,
    limit: LIMIT,
    "fields.title[match]": CF_KEYWORD!,
    select: "sys.id,fields.title,fields.slug",
  });

  const t0 = performance.now();
  const data = await fetchJson(url);

  const items = (data.items ?? []) as EntryLike[];
  const t1 = performance.now();

  return { ms: t1 - t0, hits: items.length };
}

async function bench(
  name: string,
  fn: () => Promise<{ ms: number; hits: number }>,
): Promise<void> {
  const times: number[] = [];
  let hits = 0;

  for (let i = 0; i < RUNS; i++) {
    const r = await fn();
    times.push(r.ms);
    hits = r.hits;
  }

  console.log(`- ${name}`);
  console.log(`  - median: ${Math.round(median(times))} ms`);
  console.log(`  - runs: ${times.map((t) => Math.round(t)).join(", ")} ms`);
  console.log(`  - hits: ${hits}`);
}

async function main(): Promise<void> {
  console.log("# Results (ms)");
  await bench("A: 全件取得してアプリ側でフィルタリング", runA);
  await bench("B: ヘッドレス CMS 側でフィルタリング + select", runB);
}

main().catch((e) => {
  console.error(e);
  process.exitCode = 1;
});

実行します。

npx tsx bench.ts

出力は次のようになりました。

# Results (ms)
- A: 全件取得してアプリ側でフィルタリング
  - median: 28 ms
  - runs: 495, 30, 28, 28, 20 ms
  - hits: 50
- B: ヘッドレス CMS 側でフィルタリング + select
  - median: 10 ms
  - runs: 295, 11, 9, 10, 9 ms
  - hits: 50

結果

中央値を比較すると、B は A よりも短い時間で処理が完了しました。

取得方法 時間 (ms, median)
A: 全件取得してアプリ側でフィルタリング 28
B: ヘッドレス CMS 側でフィルタリング + select 10

中央値ベースでは、B は A の約 2.8 倍速くなりました。

なお、どちらの方法でも最初の 1 回目が大きな値になっています。DNS 解決や TLS ハンドシェイク、HTTP 接続の確立などが初回に集中するためです。本記事では、その影響を避けるため、中央値を採用しました。

追実験: 記事数を増やして伸び方を確認する

100 件だけの比較では、たまたま小さな差が出ただけなのか、件数が増えるほど差が広がる構造なのかを判断しにくいです。そこで追実験では、記事数を 500 件、1000 件に増やし、処理完了までの時間がどのように増えるかを観察します。狙いは、n を増やしたときに A の増え方が O(n) に近い形になるか、B の増え方が O(k) に近い形にとどまるかを、実測値で裏付けることです。(ここで n は総記事数、k は検索条件にヒットした件数とします。)

事前に決める条件

この観察を分かりやすくするため、ヒット件数 k を固定します。具体的には、title にキーワードを含む記事は常に 50 件とし、それ以外の記事だけを追加します。こうすると、記事数 n を増やしたときの増え方が比較しやすくなります。

追加データを投入する

100 件のデータがある前提で、101 件目以降を追加投入して 1000 件に増やします。seed.ts はすでに CF_FROMCF_TOTAL で作成範囲を指定でき、CF_HIT_COUNT でキーワードを入れる件数を固定できます。

.env を次のように変更します。

CF_TOTAL=1000
CF_FROM=101
CF_HIT_COUNT=50

実行します。

npx tsx seed.ts

この手順では、101 件目以降はキーワードを含まない title で作られます。そのため、ヒット件数は 50 件のままになります。

計測を 500 件、1000 件で実行する

bench.tsCF_LIMITCF_PAGE_SIZE を読み取り、A はページングして合計 CF_LIMIT 件を取得します。B は fields.title[match] により 50 件だけ返るため、CF_LIMIT を大きくしても返却件数は増えません。

まず 500 件として計測します。

CF_LIMIT=500
CF_PAGE_SIZE=100
npx tsx bench.ts
# Results (ms)
- A: 全件取得してアプリ側でフィルタリング
  - median: 146 ms
  - runs: 2126, 125, 122, 158, 146 ms
  - hits: 50
- B: ヘッドレス CMS 側でフィルタリング + select
  - median: 10 ms
  - runs: 240, 10, 9, 11, 10 ms
  - hits: 50

次に 1000 件として計測します。

CF_LIMIT=1000
CF_PAGE_SIZE=100
npx tsx bench.ts

結果は次のようになりました。

# Results (ms)
- A: 全件取得してアプリ側でフィルタリング
  - median: 225 ms
  - runs: 2460, 242, 225, 224, 215 ms
  - hits: 50
- B: ヘッドレス CMS 側でフィルタリング + select
  - median: 10 ms
  - runs: 555, 12, 10, 8, 9 ms
  - hits: 50

追実験の結果

結果を表とグラフにまとめます。

記事数 n A: 全件取得してアプリ側でフィルタリング (ms, median) B: ヘッドレス CMS 側でフィルタリング + select (ms, median)
100 28 10
500 146 10
1000 225 10

graph

A は記事数を 100 件から 500 件、1000 件へ増やすと、中央値が 28 ms、146 ms、225 ms へ増加しました。つまり、この実験条件では、記事数 n を増やすほど A の処理完了までの時間が増えたことになります。なお、A では CF_PAGE_SIZE=100 としているため、取得対象が 500 件のときは 5 回、1000 件のときは 10 回の HTTP リクエストを行っています。

一方で B は、記事数を 100 件、500 件、1000 件へ増やしても、中央値が 10 ms 前後 (10 ms、10 ms、10 ms) でした。つまり、この実験条件では、記事数 n を増やしても B の処理完了までの時間はほとんど変わりませんでした。

考察: なぜデータを持つ側に任せると速くなりやすいのか

増え方の違いが生まれた理由として考えられること

A の処理時間が増えた理由としては、次の要因が考えられます。

まず、A は記事数 n を増やすと取得するデータ量が増えます。さらに、今回の実装では 100 件単位でページングしているため、n が大きいほど HTTP リクエスト回数も増えます。取得データ量の増加とリクエスト回数の増加は、いずれも処理完了までの時間が増える方向に働く可能性があります。

一方で B は、ヒット件数 k が 50 件で固定されており、select により返すフィールドも titleslug に絞っています。この条件では、n を増やしてもアプリ側が受け取る JSON のサイズが増えにくく、処理完了までの時間も増えにくかった可能性があります。

オーダー記号の説明との対応関係

この実験結果は、背景で述べた O(n)O(k) の説明と矛盾しません。

ただし、今回測ったのは処理完了までの時間であり、ネットワークや接続確立の定数項も含まれます。そのため、本記事では O(n)O(k) を厳密な証明として主張しません。ここで言えるのは、少なくとも本実験条件では、A は n を増やすと時間が増え、B は n を増やしても時間がほとんど増えなかった、という観測結果です。

まとめ

ヘッドレス CMS が速いと言われる理由の 1 つは、検索や絞り込みの処理をデータを持つ側に任せられることです。今回の検証では、全件取得してアプリ側でフィルタリングする方法は記事数 n を増やすほど処理時間が増えました。 一方で、ヘッドレス CMS 側でフィルタリングし select で返却フィールドを絞る方法は、記事数を増やしても処理時間がほとんど増えませんでした。 記事一覧に検索や絞り込みを付ける場合は、まず API 側のフィルタリングとフィールド選択を検討すると安全です。

この記事をシェアする

FacebookHatena blogX

関連記事