Advanced (更新: 2026/6/7)

Webキャッシュ戦略の決め方:Cache-Control・CDN・Redis無効化の実装

古い価格や情報漏えいを防ぐWebキャッシュ戦略を、HTTPヘッダー・CDN・Redis・無効化の順で実装。stale-while-revalidateとキャッシュ削除のコツも。

Webキャッシュ戦略の決め方:Cache-Control・CDN・Redis無効化の実装

「キャッシュ入れたら表示はめちゃくちゃ速くなりました」。そう報告した次の日、別のメンバーから「商品ページの価格、先週の値段のままなんですけど」と連絡が来ました。

調べたら、もっとひどかった。ログイン後のページがCDNに乗っていて、Aさんが見た画面に、別のBさんの名前と注文履歴が出ていたんです。背筋が凍りました。

キャッシュは速さを生む道具です。でも、置き方を一歩間違えると「古い情報を全力で配り続ける装置」と「他人の情報をばらまく装置」に化けます。僕はこれを一度やらかして、夜中に全キャッシュをパージして謝罪メールを書きました。

この記事は、その失敗から学んだ「どの層に、何を、何秒、どう消すか」の決め方です。Claude Codeに「キャッシュ入れといて」と丸投げする前に、これだけは自分で決めておく、という線引きをまとめました。

この記事の要点

  • キャッシュは速さより先に「誰に同じ内容を見せてよいか」を決める。ここを飛ばすと情報漏えいになる
  • 最初に入れるのはRedisではなくHTTPヘッダー(Cache-Control)。一番安く効く
  • 公開データは短めのmax-ages-maxage、ログイン後は問答無用でno-store
  • 一番難しいのは「入れる」ではなく「消す(無効化)」。更新時に何のキーやURLを消すかを先に書く
  • stale-while-revalidateで「古い値を一瞬返しつつ裏で更新」すると、速さと鮮度を両立できる

まず速さの話をやめて、データの性質を見る

キャッシュ設計を「Redisを入れるか、CDNを使うか」というツールの話から始めると、たいてい事故ります。順番が逆です。

僕が今やっているのは、新しいAPIや画面を足すとき、コードを書く前に次の4つを紙に書くことです。

  1. 誰に同じ内容を見せてよいか(全員同じ/ユーザーごとに違う)
  2. 何秒古くても、業務上は許されるか
  3. 更新したとき、どのキーやURLを消すか
  4. 事故ったとき、誰が、どの順番で戻すか

たとえばロゴ画像やハッシュ付きのJavaScript(app.a1b2c3.js みたいにファイル名に中身の指紋が入っているもの)は、1年キャッシュしても困りません。中身が変わればファイル名が変わるからです。

逆に、ログイン後の請求情報は、共有の保存場所に置いた瞬間アウト。ここで言う「共有の保存場所」とはCDNのように複数ユーザーで使い回す場所のことで、これを共有キャッシュと呼びます。ブラウザの中だけに置く場所はプライベートキャッシュ。この2つを混同すると、冒頭の「他人の注文履歴が見える」事故が起きます。MDNのHTTP cachingでも、この区別が最初の大前提として説明されています。

キャッシュは5つの層に分かれている

「キャッシュ」と一言で言っても、実は別々の場所に、別々の寿命で、5種類くらい存在します。全体像はこう考えると迷いません。

flowchart LR
  User[ブラウザ] --> Http[HTTPキャッシュ]
  Http --> SW[Service Worker]
  SW --> CDN[CDN エッジ]
  CDN --> App[アプリサーバー]
  App --> Redis[Redis]
  App --> DB[(データベース)]

それぞれの向き不向きを、僕がいつも参照している表にまとめておきます。これを CLAUDE.md に貼っておくと、Claude Codeが新しい画面を追加するときの判断がぶれません。

向いているデータ目安TTL消し方やりがちな事故
HTTPブラウザキャッシュ画像、CSS、JS、公開APIの短い応答1分〜1年ファイル名変更、ETagCache-ControlAPIまで長期保存して画面が古いまま
CDN・エッジ商品一覧、記事HTML、OGP画像30秒〜1日URL単位、タグ単位、デプロイ時パージログイン後HTMLを共有して情報漏えい
Service Workerオフライン画面、静的シェル、低頻度のJSONバージョン単位キャッシュ名の更新古いSWが残り、デプロイ後も旧JSを配る
Redis・アプリキャッシュDB集計、外部API結果、ランキング10秒〜1時間キー設計、更新イベント、TTLKEYSで本番Redisを詰まらせる
プロセス内メモリ設定値、機能フラグの短期コピー数秒〜数分再起動、明示的なclear複数台で値がバラバラになる

ポイントは「上の層ほどユーザーに近く、速いが、消しにくい」こと。ブラウザに置いたものは、こちらから手を伸ばして消せません。だから上の層には「変わらないもの」を、下の層には「すぐ消したいもの」を置くのが基本の勘どころです。

実装1:まずExpressにCache-Controlを付ける(コピペで動く)

最初に入れるべきはRedisではなく、HTTPヘッダーのCache-Controlです。これはブラウザやCDNに「これは何秒保存していいよ」と伝える標準の指示書で、タダで、一番効きます。

主な指示(ディレクティブ)はこれだけ覚えれば足ります。詳細はMDNのCache-Controlが一次情報です。

  • max-age=60:60秒は新鮮とみなす(ブラウザ・共有どちらにも効く)
  • s-maxage=300:共有キャッシュ(CDN)でだけ300秒にする。max-ageを上書き
  • no-store:どこにも保存させない。個人情報・認証後はこれ
  • private:ブラウザにだけ保存OK、CDNには置くな
  • immutable:新鮮な間は再確認すらしない(ハッシュ付きアセット向け)
  • stale-while-revalidate=600:期限切れ後も600秒は古い値を返しつつ、裏でこっそり更新する

次のコードはそのまま server.js として動きます。

npm install express
node server.js
// server.js
const express = require("express");

const app = express();

// パスの種類ごとにキャッシュ方針を1か所で決める「門番」
function cacheControl(req, res, next) {
  const reqPath = req.path;

  // ハッシュ付きアセットは1年。immutableで再確認もさせない
  if (reqPath.startsWith("/assets/")) {
    res.set("Cache-Control", "public, max-age=31536000, immutable");
    return next();
  }

  // ログイン後・個人情報はどこにも保存させない
  if (reqPath.startsWith("/api/private/")) {
    res.set("Cache-Control", "no-store");
    return next();
  }

  // 公開APIはブラウザ60秒、CDN5分。期限後10分は古い値で耐える
  if (reqPath.startsWith("/api/public/")) {
    res.set(
      "Cache-Control",
      "public, max-age=60, s-maxage=300, stale-while-revalidate=600"
    );
    res.set("Vary", "Accept-Encoding");
    return next();
  }

  // 迷ったら no-cache(保存はするが毎回サーバーに確認)
  res.set("Cache-Control", "no-cache");
  next();
}

app.use(cacheControl);
app.use("/assets", express.static("public/assets"));

app.get("/api/public/products", (_req, res) => {
  res.json({
    items: ["book", "template", "consultation"],
    generatedAt: new Date().toISOString(),
  });
});

app.get("/api/private/me", (_req, res) => {
  res.json({ userId: "demo-user", plan: "team" });
});

app.listen(3000, () => {
  console.log("http://localhost:3000");
});

立ち上げたら、ヘッダーが効いているか1行で確認できます。

curl -I http://localhost:3000/api/public/products

肝は、公開データと個人データを「URLで分ける」こと。/api/private/ に必ず no-store を付けておけば、ブラウザもCDNも保存しません。Claude Codeに任せるときは「ログイン後のレスポンスには必ずno-store」「s-maxageを許すのは公開APIだけ」と一文添えると、レビューの見落としが激減します。HTTPヘッダーで足りない計測やボトルネック探しは、Claude Codeでパフォーマンス最適化のほうで詳しく扱っています。

実装2:Redisは「getOrSet」と「invalidate」の2関数から

DBや外部APIの負荷を下げたくなったら、ここで初めてRedisの出番です。ただしキー名とTTLを決めずに入れると「どこで古くなるのか誰も追えない」沼になります。

使うのはキャッシュアサイドという素直な型です。「先にRedisを見る、なければ本体から取って保存する」だけ。

npm install redis
// cache.js
const { createClient } = require("redis");

const redis = createClient({
  url: process.env.REDIS_URL || "redis://localhost:6379",
});

let connecting;

async function client() {
  if (redis.isOpen) return redis;
  if (!connecting) connecting = redis.connect();
  await connecting;
  return redis;
}

// あれば返す。なければloaderで取得してTTL付きで保存
async function getOrSet(key, ttlSeconds, loader) {
  const r = await client();
  const cached = await r.get(key);
  if (cached !== null) return JSON.parse(cached);

  const fresh = await loader();
  await r.set(key, JSON.stringify(fresh), { EX: ttlSeconds });
  return fresh;
}

// 更新時にまとめて消す。消すキーは呼び出し側が明示する
async function invalidate(keys) {
  const r = await client();
  if (keys.length > 0) await r.del(keys);
}

module.exports = { getOrSet, invalidate };

使う側は、取得と更新をセットで書くのがコツです。更新したら、その場で関連キーを消す。これを習慣にしないと無効化を忘れます。

// products.js
const { getOrSet, invalidate } = require("./cache");

async function loadProductsFromDb() {
  return [
    { id: "p1", name: "Prompt Templates", price: 500 },
    { id: "p2", name: "Claude Code Consultation", price: 15000 },
  ];
}

async function getPublicProducts() {
  return getOrSet("products:list:v1", 300, loadProductsFromDb);
}

async function updateProduct(productId, patch) {
  console.log("update db", productId, patch);
  // 先にDB更新→その直後に関連キーを消す
  await invalidate(["products:list:v1", `products:item:${productId}:v1`]);
}

module.exports = { getPublicProducts, updateProduct };

ひとつ強く言いたいのが、本番で KEYS products:* を絶対に打たないこと。全件スキャンなのでキー数が多いとRedis全体が固まります。僕は検証環境でこれをやって一瞬で詰まらせました。消す候補は配列で持つ、関連キーをRedis Setに登録しておく、どうしてもなら SCAN で少しずつ、のどれかにしてください。

最大の難問はキャッシュ無効化(消すほうが難しい)

「コンピュータサイエンスで難しいのはキャッシュ無効化と命名だ」という古い冗談があります。実際、現場で僕を一番悩ませてきたのも無効化です。入れるのは一瞬、消すのは設計がいる。

無効化で踏んだ地雷を、原因と対処でまとめます。

  • 更新したのに古いまま → DB更新は成功したのにキャッシュを消し忘れ。更新処理とinvalidateを必ず同じ関数に置く
  • 消したのにまた古い値が復活 → 期限切れ直前に古い値を読んで保存し直していた。stale-while-revalidateや更新イベント駆動に寄せる
  • 全部消したらサイトが重くなった → 全パージで一斉にDBへ殺到(キャッシュスタンピード)。範囲を絞り、TTLに揺らぎを足す

そこで、僕が運用メモに必ず置く「無効化ランブック(手順書)」がこれです。

  1. 影響範囲を決める。商品・記事・特定ユーザー・全体、のどれかを明記する
  2. DB更新を先に完了する。DB更新が失敗したらキャッシュは消さない
  3. Redisキーを消す。大量削除は SCAN か関連キーリストで
  4. CDNをURL単位かタグ単位でパージ。全パージは最終手段
  5. Service Workerはバージョンを上げ、旧キャッシュ削除を確認する
  6. curl -I、ブラウザのDevTools、Redisのヒット率で消えたか確かめる
  7. 事故時はTTLを短くするか no-store に寄せ、落ち着いてから段階的に戻す

順番が大事です。先にキャッシュを消してDB更新が失敗すると、空っぽの状態でアクセスが殺到します。だから「DBが先、キャッシュが後」。Claude Codeにこの手順を渡すときは、「このランブックを満たさない実装は完了扱いにしない」と完了条件に書き込むのが効きます。

stale-while-revalidateで速さと鮮度を両立する

「短いTTLにすると鮮度は上がるけど、期限切れの瞬間に遅くなる」。このジレンマを和らげるのが stale-while-revalidate です。

仕組みはシンプルで、期限が切れても指定秒数は「とりあえず古い値を即返す」。そのうえで裏側でこっそり新しい値を取りに行き、次のアクセスから差し替えます。ユーザーは待たされず、データもそこそこ新鮮。MDNでも max-age とセットで使う例が示されています。

Cache-Control: max-age=60, stale-while-revalidate=600

これは「60秒は新鮮、その後10分間は古い値を返しつつ裏で更新」という意味です。為替レートやランキング、記事一覧のように「数分古くても致命的ではない」データと相性が抜群です。

注意点をひとつ。stale-while-revalidate は「多少古くてもいい」前提の道具です。在庫数や決済金額のように一瞬の古さも許されないデータには使わないこと。そこは素直に no-store か短い max-age +確実な無効化でいきます。

Claude Codeにキャッシュ監査をやらせる

Claude Codeの本当の強みは、コードを書くこと自体より「このリポジトリのどこに、どんなキャッシュが、どんな寿命で散らばっているか」を横断して棚卸しできる点だと感じています。新規実装より、既存の事故を見つけるほうが価値が出やすい。

そのまま使える監査プロンプトを置いておきます。

あなたはWebアプリのキャッシュ監査担当です。
このリポジトリでHTTPヘッダー、CDN前提、Service Worker、Redis、
プロセス内メモリを使っている箇所を調べてください。

出力:
1. キャッシュ層ごとの対象データ
2. TTLと無効化条件
3. 個人情報や認証後レスポンスが共有キャッシュに乗るリスク
4. 古いデータが表示される具体的な画面名
5. 最小の修正案と、修正後に実行する確認コマンド

制約:
- 推測した箇所は「推測」と明記する
- 既存の設計に合わせる
- 大きなリファクタリングは提案だけに止める

このプロンプトを使うと、「Redisを足しましょう」で終わらず、既存の Cache-Control、Service Worker、APIルート、DB更新処理を全部つないで見てくれます。なお、ブラウザ内でリクエストを横取りしてオフライン対応や静的シェルを管理するService Workerのキャッシュは、設計のクセが強くて事故も多いので、Service Worker入門のキャッシュ戦略に手順を独立してまとめてあります。

実アプリでの使い分け(4つ)

1. 教材・ECの公開商品一覧 商品名・価格・サムネは全員に同じ内容でいいので、CDNに短く(30秒〜数分)置き、DB集計はRedisに5分。更新ボタンを押したら、商品一覧キーとCDNの対象URLを両方消します。

2. 管理画面のダッシュボード 売上やPVの集計は毎秒正確でなくていいのでRedisに30秒〜5分。ただしユーザー権限や個人別の通知は privateno-store。ここを共有に置くと冒頭の漏えい事故になります。

3. ドキュメント・ブログ 記事HTMLはCDNで短めに、画像やハッシュ付きアセットは1年、オフライン用の殻はService Workerで。記事数が多いサイトでは、公開直後だけパージして、通常アクセスはCDNで受けると体感が安定します。

4. 外部API連携 為替・天気・SaaSのプラン情報などはAPI制限に当たりやすいので、Redisで短く吸収。ただし規約でキャッシュ禁止のAPIもあるので、Claude Codeのタスクに「公式規約を確認する」を必ず入れてください。

よくある質問

Q. キャッシュは結局どこから入れるべき? HTTPヘッダー(Cache-Control)からです。コードを足さずヘッダー1行で効く層が最もコスパがいい。Redisはその次、DBや外部APIが重いと分かってから入れます。

Q. no-cacheno-store の違いは? no-store は「どこにも保存するな」。no-cache は「保存はするが、使う前に毎回サーバーへ確認しろ」です。個人情報は no-store、更新を見逃したくない公開ページは no-cache が目安です。

Q. CDNとブラウザでTTLを変えられる? max-age がブラウザ・共有の両方、s-maxage が共有(CDN)だけに効きます。max-age=60, s-maxage=300 なら、ブラウザは60秒・CDNは300秒で運用できます。

Q. stale-while-revalidate は在庫や金額に使っていい? やめておきましょう。これは「数分古くても問題ない」データ向けです。在庫・決済額のように一瞬の古さも許されない値は、no-store か短い max-age +確実な無効化にします。

Q. 本番Redisで関連キーをまとめて消すには? KEYS は全件スキャンで固まるので禁止。消す候補を配列で持つ、関連キーをRedis Setに登録しておく、または SCAN で少しずつ消す、のいずれかにします。

実際に試した結果

Masaの検証アプリで上の順番どおり入れ直したら、静的アセットの再取得が目に見えて減り、公開APIはCDN向けの s-maxage で安定しました。Redisの getOrSet を入れたら、DBの無駄な読み込みがログで一目で分かるようになった。一方、Service Workerはバージョン削除を入れ忘れて、デプロイ後も古いCSSが残りました(これも一度やらかしました)。

結局いちばん効いたのは、複雑なテクニックではなく「速くする前に、どこで古くなるか・誰に見せていいかを紙に書く」ことでした。冒頭の情報漏えい以来、僕はキャッシュを足すとき必ず最初の4つの問いに答えてからコードを書いています。それだけで、事故の大半は実装前に消えます。

自分のプロジェクトに合わせたキャッシュ方針や CLAUDE.md の型まで一気に整えたいなら、教材・テンプレート一覧が早道です。チーム導入で権限境界やレビュー運用まで詰めたい場合は、Claude Code導入相談・研修で実リポジトリ前提に整理できます。

#キャッシュ戦略 #Cache-Control #Redis #CDN #stale-while-revalidate
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。