Logo
duck blog | 기술 블로그
Next.js キャッシング:フロントエンドが主導するキャッシュ戦略
Next.js キャッシング:フロントエンドが主導するキャッシュ戦略

Next.js キャッシング:フロントエンドが主導するキャッシュ戦略

Next.js キャッシング:フロントエンドが主導するキャッシュ戦略

フロントエンドもキャッシュを語れるべき

負荷テストやキャッシュ戦略の会議に入ると、大抵はバックエンドエンジニアの方がAPIキャッシュやCDNキャッシュの話を先に切り出します。フロントエンド開発者としては、いつも少し悔しい思いをしていました。

クライアントやフロントエンドサーバーの環境でも、トラフィックを分散させユーザー体験を引き上げるキャッシュ技法はたくさんあります。フロントエンドキャッシングがどれほど強力な武器になるか、チームに共有したいとずっと思っていました。

私たちが使うNext.jsは、単なる画面レンダリングツールではありません。ブラウザメモリからフロントエンドサーバーのディスクまで、多層キャッシュシステムを内蔵しており、この構造を正しく活用すること自体がフロントエンド主導のアーキテクチャ設計の核心です。

この記事では、Next.js 15・16のキャッシュメカニズムを整理した後、実際のキャッシュアーキテクチャ例を共有します。実際に経験した要件に仮想のシナリオを加えて、一つの事例としてまとめました。

NextJSのデフォルトキャッシュ動作(出典:NextJS Document)
NextJSのデフォルトキャッシュ動作(出典:NextJS Document)


フレームワークが自動で処理してくれるのに、キャッシュを理解すべき理由

Next.js公式ドキュメントのCachingセクションには、こんな記述があります。

「このページはNext.jsの内部動作を理解するのに役立ちますが、Next.jsを生産的に使用するために必須の知識ではありません。Next.jsのキャッシュヒューリスティクスのほとんどはAPI使用によって決定され、最小限の設定でも最高のパフォーマンスを発揮するようにデフォルト値が設定されています。」 — Next.js公式ドキュメント - Caching

ここでのニュアンスは「最適なキャッシュを組み込んであるので、ビジネスロジックに集中してください」ということです。

しかし実務は違います。単純なパフォーマンス向上だけを考えるなら、フレームワークのデフォルトに任せても構いません。問題は適切なタイミングで最新データをユーザーに見せなければならないビジネス環境です。

どのキャッシュが適用されているか把握する必要があり、ビジネス要件を満たすためにはどうコードを宣言し無効化すべきかを知る必要があります。

この記事では「フレームワークに任せて良いキャッシュ」と「コードで直接制御すべきキャッシュ」に分けて、各レイヤーを掘り下げていきます。

キャッシュレイヤー関与の必要度保存場所無効化方法
Request Memoization任せてOKサーバーメモリ(レンダリング中)自動消滅
Data Cache直接制御サーバーディスク / RedisrevalidateTag, revalidatePath
Full Route Cache直接制御サーバーディスクData Cache無効化時に連鎖更新
Router Cache直接制御ブラウザメモリrouter.refresh(), staleTimes設定

フレームワークに任せる領域:Request Memoization

1回のレンダリングサイクル内で、同じURL+同じオプションのGETリクエストは自動的に重複排除(Dedup)されます。コンポーネントツリーのどこから同じfetchを呼んでも、実際のネットワークリクエストは1回だけ発行され、残りはメモリにキャッシュされたレスポンスを再利用します。レンダリングが終わればキャッシュも消滅するので、別途の無効化は不要です。

これはNext.js固有の機能ではなく、ReactがfetchAPIを拡張して提供している機能です。

Pages Router時代との比較

Pages Routerでは、サーバーデータを取得するにはgetServerSidePropsgetStaticPropsでfetchし、ページコンポーネントにpropsで渡すかContextで配布する必要がありました。子コンポーネントが同じデータを必要としても、rootで一度取得してprops drillingするか、状態管理ライブラリを導入するしかありませんでした。

下の図のように、コンポーネントツリーの複数箇所からRequest A、B、Cをそれぞれ呼び出しても、最終的にMemoized RequestsはA、B、C各1回ずつしか実行されません。

コンポーネントツリーで発生する重複リクエストが自動的にマージされる過程
コンポーネントツリーで発生する重複リクエストが自動的にマージされる過程

App RouterのMemoizationのおかげで、各コンポーネントが必要なデータを直接fetchしても問題ありません。同じリクエストであれば、どうせ1回しか発行されません。

コードで見る

// api.ts
export async function getUser(id: string) {
  // Reactが同一URLのリクエストをインターセプトし、1回だけ実行します。
  const res = await fetch(`https://api.example.com/users/${id}`);
  return res.json();
}

// Layout.tsx(最上位)
export default async function Layout() {
  const user = await getUser('123'); // 実際のAPI呼び出し(1回)
  return <Profile />;
}

// Profile.tsx(子コンポーネント)
export default async function Profile() {
  const user = await getUser('123'); // メモリから即座に返却
  return <div>{user.name}</div>;
}

各子コンポーネントが同じfetchを独立して宣言しても問題ないので、コンポーネントの凝集度が向上します。


直接制御すべき領域 1:Data Cache

一度取得したデータをサーバー(ディスク/Redis)に保存し、ユーザーリクエスト全体で再利用するレイヤーです。フロントエンドサーバーの負荷を減らす第一の防衛線です。

v15/16での設計思想の変化

v14ではfetch呼び出し時に自動でキャッシュ(force-cache)が適用されていました。v15からはデフォルトがno-store(Dynamic by Default)に変更されました。明示的にオプションを宣言しない限り、毎回新しいデータを取得します。

キャッシュの宣言方法

fetchオプションのnextオブジェクトでキャッシュ期間とタグを宣言します。

export async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    next: { 
      revalidate: 3600, // Time-based:1時間ごとにバックグラウンドで更新
      tags: ['post-list'] // On-demand:手動キャッシュ無効化用タグ
    }
  });
  return res.json();
}

キャッシュの無効化方法

記事が新規作成または更新された時、Server ActionsやAPI Routesで無効化コードを呼び出します。

'use server'
import { revalidateTag, revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  await db.posts.insert(...);
  
  // Data Cacheの無効化トリガー
  revalidateTag('post-list'); // 'post-list'タグで紐づけたキャッシュを一括削除
  // revalidatePath('/posts'); // 特定ルートパス単位で削除する場合
}

マルチサーバー環境での注意点

Vercelであればフレームワークがグローバルなキャッシュ無効化を処理しますが、自社インフラ(AWS ECS、EKSなど)で複数のフロントエンドサーバーを運用する場合は注意が必要です。

デフォルトでは、Next.jsのData Cacheは各サーバーのローカルディスクに保存されます。サーバーAでrevalidateTagを呼び出しても、サーバーBやCのローカルキャッシュはそのまま残ります。ロードバランシングによっては、ユーザーごとに異なる古いデータを見てしまう問題が発生します。

解決策はCustom Cache Handlerを実装し、Redisのような中央ストアを使用することです。すべてのインスタンスが同じキャッシュを共有し、一括無効化が可能になります。

共有キャッシュなしで各インスタンスが独立してローカルキャッシュを運用する構造
共有キャッシュなしで各インスタンスが独立してローカルキャッシュを運用する構造

上図のように、共有キャッシュがなければInstance A、B、Cはそれぞれ独立してバックエンドにリクエストし、ローカルに保存します。Aでキャッシュを無効化しても、B、Cには依然として古いデータが残る構造です。

この問題を解決するには、インスタンス間にRedisを配置し、Pub/Sub方式でrevalidateイベントを発行 → 各インスタンスが購読して一括キャッシュ無効化する形が理想的です。ただし、Redisインフラの追加、Pub/Sub購読管理、障害時のフォールバック処理など運用の複雑性が上がるトレードオフがあります。現在の環境では、この複雑性を受け入れるよりも、TTLベースで十分に防御可能な範囲で運用しています。


直接制御すべき領域 2:Full Route Cache

Data Cacheが「データの断片」をキャッシュするなら、Full Route Cacheは「ページレンダリング結果物(HTML + RSC Payload)」全体をサーバーディスクに保存します。Pages Router時代のSSGが進化した形態です。

SSGとの違い

RSC Payloadの存在:HTMLだけでなくReact Server Component Payloadも一緒にキャッシュします。クライアントでリンク遷移時、ページ全体をロードせずにこのペイロードだけを受け取り、スムーズな部分レンダリングを実行します。

PPRとの連携:ページ全体を静的に固める必要はありません。変化しないレイアウトシェルだけをキャッシュし、ユーザーのカートのような動的領域だけをリクエスト時に差し込みます。

Data Cacheとの連動:Data CacheがrevalidateTagで無効化されると、そのデータを参照していたFull Route Cacheもサーバー上で即座に再ビルドされます。

Full Route Cacheが崩れる瞬間(Auto Opt-out)

Next.jsはビルド時に可能な限り多くのページを静的にレンダリングしてFull Route Cacheに格納しようとします。しかし、開発者のコード1行がこのキャッシュを静かに無効化することがあります。

Dynamic API使用の罠:ページコンポーネントや子コンポーネントのどこかでcookies()headers()を呼び出したり、searchParams propを読み取った瞬間、Next.jsは「このページはリクエストごとにデータが変わる」と判断し、Full Route Cacheを放棄します。

動的ルートでのParams未定義:App Routerの動的ルート(app/posts/[id]/page.tsx)でgenerateStaticParamsを宣言しない場合、デフォルトでリクエスト時に動的レンダリングされます。

コードレビューが必須な理由:静的に高速ビルドされていたページが、同僚が追加した「現在のログインユーザーのCookie確認」ロジック1つでキャッシュが崩れる—これは実務で頻繁に起こります。「このランディングページは必ず静的キャッシュを使う」という合意があるなら、レビュー過程でDynamic APIが混入していないか追跡する必要があります。


直接制御すべき領域 3:Router Cache(クライアントメモリ)

何をキャッシュするのか

Router CacheはブラウザメモリにRSC Payloadを保存します。ユーザーが<Link>をクリックして別ページに遷移すると、サーバーから受け取ったReact Server Component Payload(そのページのレンダリング結果物)をブラウザメモリに保管します。その後、同じページを再訪問したり戻るボタンを押すと、サーバーに再リクエストせずメモリに保存されたPayloadを使って即座に画面を描画します。

簡単に言えば、「すでに訪問したページのレンダリング結果をブラウザが記憶し再利用するキャッシュ」です。

なぜ問題になるのか

実務で「保存を押したのに前の画面が出ます!」というバグ報告のほとんどは、このRouter Cacheが原因です。サーバーでrevalidateTagでData Cacheを無効化しても、ブラウザメモリに以前のRSC Payloadが残っていれば、ユーザーには古い画面が表示されます。サーバーはすでに最新データなのに、ブラウザがキャッシュされた以前のレンダリング結果を表示しているのです。

staleTimes:どれくらいキャッシュされるのか

Router Cacheが保存されたRSC Payloadをどれくらい保持するかは、ページタイプによって異なります:

  • Static Routes(静的ページ):5分間(300秒)キャッシュ維持。5分以内の再訪問はサーバーリクエストなしで即座に表示
  • Dynamic Routes(動的ページ):v14では30秒でしたが、v15/16からは0秒に変更。毎回サーバーから最新Payloadを取得

ここでのStatic/Dynamicとは、Next.jsがビルド時に判断したページタイプのことです。generateStaticParamsで事前ビルドされたページはStatic、cookies()searchParamsを使用するページはDynamicに分類されます。

prefetchとの関係

<Link>コンポーネントはビューポートに表示されると、対象ページのRSC Payloadを自動的にプリフェッチします。このプリフェッチされたデータもRouter Cacheに保存されます。<Link prefetch={true}>を明示すると、Dynamicページもキャッシュにプリフェッチされるため、データの鮮度が重要なページでは注意が必要です。

キャッシュの無効化方法

クライアントコンポーネント内で手動で画面を更新する時に使用します。

'use client'
import { useRouter } from 'next/navigation';

export default function RefreshButton() {
  const router = useRouter();

  const handleRefresh = () => {
    // ブラウザのRouter Cache(RSC Payload)を破棄し、サーバーに最新データを再リクエスト
    // React stateは維持されつつ、サーバーデータのみ更新される
    router.refresh(); 
  };
  
  return <button onClick={handleRefresh}>最新データを取得</button>
}

補足:Server ActionでrevalidatePathを呼び出すと、Next.jsサーバーがクライアントブラウザにもRouter Cacheをクリアするシグナルを送信します。


実務適用例:多言語イベントページのキャッシュ設計

ここからが本題です。前述のキャッシュレイヤーを、実際のイベントページにどう適用するか設計してみました。実際に経験した要件に仮想のシナリオを加えた事例です。

要件

  • トラフィック:イベント開始時に毎秒数千件のアクセス
  • BO(Back Office)変更頻度:運営チームが1日に数十回、イベントバナー・商品リスト・当選者情報を修正
  • 多言語対応:韓国語、日本語、英語など最低3言語。URL構造は/ja/events/[id]/ko/events/[id]形式
  • パーソナライズ領域:ユーザー別の参加状況、クーポン適用状態、応募回数
  • インフラ制約:AWS ECSでマルチPod運用、中央キャッシュストア(Redisなど)未導入

要件整理
要件整理

設計結論

上記条件から導いた結論:

  1. Full Route Cache → 断念。多言語 × イベント数の組み合わせが多すぎ、BO変更も頻繁でビルド時の静的生成が非現実的
  2. On-demand Revalidation → 信頼不可。Redisなしのマルチpod環境では特定Podのみ無効化され、残りはstaleのまま
  3. Data CacheのTime-based TTL → 主軸。30秒TTLですべてのPodで均一に更新、バックエンド呼び出し50〜60%削減
  4. パーソナライズ領域 → Suspenseで分離。キャッシュ可能な静的領域を先にレンダリングし、ユーザー別データはストリーミング

この結論に至った過程を説明します。

ページを静的/動的に分割する

イベントページの静的領域(キャッシュ可能)と動的領域(キャッシュ不可)を分離した構造
イベントページの静的領域(キャッシュ可能)と動的領域(キャッシュ不可)を分離した構造

左側の静的領域(バナー、イベント説明、商品リスト)はサーバーキャッシュが可能で、BO変更時のみ更新すれば十分です。右側の動的領域(ユーザー参加状況、クーポン適用状態、リアルタイム在庫/応募)はユーザーごとに異なるためキャッシュ不可で、毎回APIを呼び出します。

この区分をコードレベルでどう反映するかが設計の核心です。

なぜFull Route Cacheを断念したのか

イベント詳細ページは一見、Full Route Cacheの良い候補に見えます。generateStaticParamsでイベントIDを事前ビルドすればいいですから。

多言語環境では事情が異なります:

  • locale × eventIdのすべての組み合わせをビルドする必要がある → イベントが蓄積されるほどビルド時間が爆発的に増加
  • BOから頻繁に変更されるイベント情報 → ビルド直後にすでにstale
  • ユーザーパーソナライズ領域が混在し、cookies()などDynamic APIの呼び出しが不可避 → 自動的にDynamic Renderingに切り替わる
// app/[locale]/events/[id]/page.tsx
export default async function EventPage({ params }: Props) {
  const { locale, id } = await params;
  
  // localeに基づいてAccept-Languageヘッダーやクッキーを参照する瞬間
  // → Dynamic Renderingに切り替わる
  const event = await getEvent(id, locale);
  // ...
}

Full Route Cacheはこのページでは使えません。

なぜOn-demand Revalidationを信頼できないのか

理想的なシナリオであれば、BO修正 → Webhook → revalidateTag → 即時反映です。私たちの環境では以下の理由で機能しません:

  1. Webhookがロードバランサーを経由して1つのPodにしか到達しない → 他のPodのキャッシュはそのまま
  2. 全Podに個別Webhookを送る方法もあるが、オートスケーリングでPod数が変動する場合、ターゲット管理が複雑
  3. Custom Cache Handler + Redis導入前は根本的な解決が困難

それでもタグは事前に付けておきます。将来Redisを導入すれば、コード変更なしでOn-demandが有効になるためです。

残った選択肢:Time-based TTL中心の設計

消去法で残ったのはData CacheのTime-based Revalidationです。データの変更頻度とビジネスで許容される遅延を基準にTTLを分けます。

項目revalidate理由
イベント基本情報(タイトル、バナー、商品リスト)30秒BO変更頻度高、最大30秒遅延SLA
コメント・レビュー300秒変更頻度低、5分遅延許容可能
在庫・応募数量no-storeリアルタイムの正確性が必須
ユーザーパーソナライズ(参加状況、クーポン)no-storeユーザー別に異なる、キャッシュ不可

コードで見ると:

// lib/api/event.ts
export async function getEvent(id: string, locale: string) {
  const res = await fetch(`${API_BASE}/events/${id}?lang=${locale}`, {
    next: {
      revalidate: 30, // 30秒TTL
      tags: [`event-${id}`, `event-${id}-${locale}`] // 将来のRedis導入時に活用
    }
  });
  return res.json();
}

export async function getEventComments(id: string, locale: string) {
  const res = await fetch(`${API_BASE}/events/${id}/comments?lang=${locale}`, {
    next: {
      revalidate: 300, // 5分TTL
      tags: [`event-comments-${id}`]
    }
  });
  return res.json();
}

export async function getEventStock(id: string) {
  // 在庫はキャッシュしない
  const res = await fetch(`${API_BASE}/events/${id}/stock`, {
    cache: 'no-store'
  });
  return res.json();
}

TTL 30秒の意味:

  • 毎秒3,000リクエスト基準で、30秒間のバックエンド呼び出しはPodあたり1回。89,999件はキャッシュ応答
  • BO修正反映の最大遅延:30秒(全Pod均一)
  • トレードオフ:TTLを短くするほどバックエンド呼び出し頻度が増加

パーソナライズ領域はSuspenseで分離

静的領域はData Cacheから即座にレスポンスし、動的領域はストリーミングで後から埋めます。

// app/[locale]/events/[id]/page.tsx
import { Suspense } from 'react';

export default async function EventPage({ params }: Props) {
  const { locale, id } = await params;
  const event = await getEvent(id, locale); // Data Cacheヒット(30秒TTL)

  return (
    <main>
      {/* 静的領域 - キャッシュから即座にレンダリング */}
      <EventBanner event={event} />
      <EventDescription description={event.description} />
      
      {/* 動的領域 - ストリーミングで後から到着 */}
      <Suspense fallback={<StockSkeleton />}>
        <EventStock eventId={id} />
      </Suspense>
      
      <Suspense fallback={<ParticipationSkeleton />}>
        <ParticipationStatus eventId={id} />
      </Suspense>
      
      <Suspense fallback={<CommentsSkeleton />}>
        <EventComments eventId={id} locale={locale} />
      </Suspense>
    </main>
  );
}

ユーザーの視点では、バナーと説明が先に表示され、残り数量や参加状況は少し遅れて埋まります。体感のロード速度が向上しつつ、キャッシュ可能な領域は確実にキャッシュを活用します。

バックエンドでキャッシュするのと何が違うのか?

「バックエンドでRedisキャッシュしても結果は同じでは?」という疑問が浮かぶかもしれません。データの鮮度だけで見れば似ていますが、フロントエンドのData Cacheには別の利点があります。

1. ネットワーク往復がなくなる — バックエンドがRedisで1msのキャッシュヒットを達成しても、フロントエンド → バックエンド間のリクエスト(DNS、TCP、シリアライゼーション、転送)は毎回数ms〜数十msかかります。フロントエンドのローカルキャッシュはこの往復が0msです。

2. バックエンドのコネクション負荷が減る — 毎秒数千件のTCPコネクションを受けること自体がコストです。フロントエンドで吸収すれば、バックエンドは本当に動的なリクエスト(在庫確認、応募処理)にリソースを集中できます。

3. レンダリングコストも節約できる — バックエンドキャッシュは元データのみキャッシュします。フロントエンドのData Cacheはfetchレスポンスのキャッシュと同時に、RSCレンダリング結果も再利用します。

4. 消費コンテキスト別のTTL分離が可能 — 同じAPIでもイベントメインでは30秒、一覧ページでは5分のように、呼び出し元に応じてTTLを変えられます。

5. バックエンド障害時のバッファになる — バックエンドがダウンしてもTTL内のキャッシュが残っていれば、ユーザーに正常なレスポンスを返せます。

フロントエンドキャッシングはバックエンドキャッシングの代替ではなく、手前にもう一枚積む防御層です。

実際のサーバー負荷の変化

キャッシュ未適用時

  • 毎秒3,000リクエスト × API 3本 = バックエンドに毎秒9,000呼び出し
  • フロントエンドサーバーも毎回RSCレンダリング → CPU負荷が高い

Data Cache適用後(Pod 3台基準)

  • イベント基本情報:30秒TTL → Podあたり30秒に1回。3台なら30秒間で合計3回
  • コメント:300秒TTL → Podあたり5分に1回
  • 在庫・数量:依然として毎回呼び出し
  • 結果:バックエンドAPI呼び出し量約50〜60%削減

Pod別ローカルキャッシュのため、Pod数分だけ重複呼び出しが発生します。それでも毎秒3,000件が30秒間で3回に減る効果は十分で、将来Redis導入時にはこの重複さえも1回に統合されます。


まとめ

Next.jsを使う環境であれば、ブラウザ(Router Cache)からフロントエンドサーバー(Data Cache)まで、直接制御できるキャッシュレイヤーが多数あります。多言語イベントページのようにFull Route Cacheの活用が難しく、Redisもないため On-demand Revalidationさえ制限された環境でも、Time-based Data Cache + Suspenseストリーミングの組み合わせだけでバックエンド負荷の50%以上をフロントエンド側で吸収できます。

もちろん限界も明確です。現在の環境ではBO修正の反映まで最大TTL分の遅延が発生し、Pod数分だけキャッシュが重複します。これは将来Custom Cache Handler + Redis導入で解決する課題です。

重要なのは、Redisなしでもフロントエンドキャッシングだけでかなりのトラフィック防御が可能だということです。キャッシュシステムを理解し適切に設計すれば、パフォーマンスとユーザー体験を同時に実現できます。

댓글

새 글 알림 받기

실무에서 바로 써먹을 수 있는 개발 팁과 경험담을 받아보세요

#실무 개발 경험담#최신 기술 트렌드#성능 최적화 노하우#개발 팁과 인사이트

개인정보는 뉴스레터 발송 목적으로만 사용되며, 언제든 구독을 해지할 수 있습니다.