キャッシュ戦略完全ガイド:CDN・Redis・API最適化でパフォーマンスを最大化

2024/03/07に公開

はじめに

Webアプリケーションのパフォーマンス向上において、キャッシュ戦略の最適化は避けて通れません。適切なキャッシュを活用することで、ページロードの高速化・サーバー負荷の軽減・ユーザーエクスペリエンスの向上を実現できます。

本記事では、CDN・Redis / Memcached・APIレスポンスキャッシュ・Next.js ISR / SSG・クライアントサイドキャッシュなど、さまざまなキャッシュ技術を駆使して、パフォーマンスを最大化する方法を解説します。キャッシュを適切に管理することで、よりスケーラブルで効率的なシステムを構築することが可能になります。

それでは、具体的な戦略を見ていきましょう。

CDN(Cloudflare / AWS CloudFront)による静的コンテンツの最適配信

CDN(Content Delivery Network)は、静的コンテンツ(HTML、CSS、JavaScript、画像など)を世界中のエッジサーバーに分散配置し、ユーザーの最寄りのエッジサーバーから配信することで、ロード時間を短縮し、オリジンサーバーの負荷を軽減します。

CDNの仕組み

CDNは、以下のような流れで動作します。

  1. ユーザーがWebサイトを開くと、ブラウザはCDNのエッジサーバーにリクエストを送る。
  2. エッジサーバーにキャッシュがある場合、そのままキャッシュされたコンテンツを提供(キャッシュヒット)。
  3. キャッシュがない場合(キャッシュミス)、オリジンサーバーからコンテンツを取得し、キャッシュに保存した後にユーザーへ提供。
  4. 次回以降、他のユーザーもエッジサーバーからキャッシュ済みコンテンツを取得可能。

主な利点

  • 遅延の低減
    • 地理的に分散したエッジサーバーにより、物理的に近いサーバーから配信されるため、レイテンシが低くなる。
  • サーバー負荷の軽減
    • オリジンサーバーへのリクエストが削減されるため、バックエンドの負荷が軽減し、スケールしやすくなる。
  • セキュリティ向上
    • DDoS攻撃対策、WAF(Web Application Firewall)、Bot対策などのセキュリティ機能を提供。

Cloudflare と AWS CloudFront の比較

項目 Cloudflare AWS CloudFront
キャッシュ制御 柔軟なルール設定が可能(Cache Rules, Page Rules) S3 や Lambda@Edge との統合が容易
DDoS対策 標準機能として無料で提供 AWS Shield(有料)と統合
TLS/SSL 自動で無料のSSLを提供 ACM(AWS Certificate Manager)で管理
価格 無料プランあり 使用量に応じた課金

Cloudflare を使った静的サイトの設定

  1. ドメインをCloudflareに登録
    Cloudflareの管理画面からドメインを追加し、DNS設定を変更。
  2. キャッシュ設定
    Cache Rulesを使用して、キャッシュポリシーを設定。
# Cache-Controlヘッダーの設定(静的ファイルのキャッシュ)
location ~* \.(css|js|jpg|png|gif|ico|svg|woff|woff2|ttf|otf)$ {
    expires 30d;
    add_header Cache-Control "public, max-age=2592000";
}

AWS CloudFront を使ったS3連携

例)CloudFrontをS3のオリジンとして設定

  1. S3バケットを作成し、静的ウェブサイトとして設定
  2. CloudFrontのオリジンにS3を指定
  3. キャッシュ動作のカスタマイズ
    Cache Policyを作成し、特定のリクエストヘッダーをキャッシュキーに含める。
{
  "name": "MyCustomPolicy",
  "minTTL": 60,
  "maxTTL": 86400,
  "defaultTTL": 3600,
  "parametersInCacheKeyAndForwardedToOrigin": {
    "headersConfig": {
      "headerBehavior": "whitelist",
      "headers": ["Authorization"]
    }
  }
}

Redis / Memcached によるデータベース負荷軽減

データベースへの負荷を軽減するために、頻繁にアクセスされるデータをメモリ上にキャッシュするのが Redis や Memcached の基本的な役割です。

例えば、データベースに対する 同じクエリが頻繁に実行 される場合、結果をキャッシュに保存することで、

  • データベースの負荷を大幅に削減
  • クエリ応答速度の向上 が可能になります。

Redis と Memcached の違い

特徴 Redis Memcached
データ構造 リスト、セット、ハッシュなど多彩 シンプルなキー・バリュー型
永続性 オプションでディスク保存可 メモリのみ(永続性なし)
スケーラビリティ マスター・スレーブ構成 シンプルな分散構成
トランザクション サポート(MULTI/EXEC) 非対応
メモリ管理 LRU(Least Recently Used)など選択可能 自動削除(LRUのみ)
用途 セッション管理、ランキング、キュー シンプルなデータキャッシュ
  • Redis: 多機能で永続化も可能なため、幅広い用途に適用できる
  • Memcached: 単純なキー・バリューキャッシュ用途に特化し、高速

具体的な適用例

頻繁にクエリされるデータ(ランキング・設定値)をキャッシュ

頻繁にアクセスされるランキングデータをキャッシュすることで、DB 負荷を軽減できます。

const { Pool } = require('pg');
const redis = require('redis');
const { promisify } = require('util');

const db = new Pool({ connectionString: 'postgres://user:password@localhost:5432/mydb' });
const redisClient = redis.createClient();
const getAsync = promisify(redisClient.get).bind(redisClient);
const setAsync = promisify(redisClient.set).bind(redisClient);

async function getLeaderboard(req, res) {
  const cacheKey = 'leaderboard';

  // Redis にキャッシュされているか確認
  const cachedData = await getAsync(cacheKey);
  if (cachedData) {
    return res.json(JSON.parse(cachedData));
  }

  // データベースからランキングデータを取得
  const result = await db.query('SELECT * FROM leaderboard ORDER BY score DESC LIMIT 10');
  
  // Redis にデータをキャッシュ(有効期限 10 分)
  await setAsync(cacheKey, JSON.stringify(result.rows), 'EX', 600);

  res.json(result.rows);
}

app.get('/leaderboard', getLeaderboard);

ポイント

  • leaderboard というキーでランキングデータを Redis にキャッシュ
  • DB へのクエリを減らし、負荷を軽減
  • キャッシュの有効期限(10 分)を設定して、古くなったデータを自動削除

計算コストの高いデータの一時保存

例えば、データ分析の結果や一時的な集計結果をキャッシュすることで、同じ計算を繰り返すのを防げます。

from flask import Flask, jsonify
import memcache

app = Flask(__name__)
cache = memcache.Client(['127.0.0.1:11211'])

def expensive_computation():
    return sum(range(1000000))  # 計算コストの高い処理

@app.route('/compute')
def compute():
    cache_key = "expensive_result"

    # キャッシュから取得
    cached_result = cache.get(cache_key)
    if cached_result:
        return jsonify({"result": cached_result, "cached": True})

    # 計算処理
    result = expensive_computation()

    # キャッシュに保存(有効期限 30 秒)
    cache.set(cache_key, result, time=30)

    return jsonify({"result": result, "cached": False})

if __name__ == '__main__':
    app.run(debug=True)

ポイント

  • 計算結果を expensive_result というキーで Memcached に保存
  • キャッシュがあれば、それを返して計算を省略
  • 30 秒でキャッシュが無効化され、最新の計算結果を取得可能

API のレスポンスキャッシュの適用(GraphQL / REST API の最適化)

APIのレスポンスキャッシュを適用することで、オリジンサーバーの負荷を軽減し、クライアントのレスポンス速度を向上させることができます。GraphQLとREST APIそれぞれで有効なキャッシュ戦略を解説し、サンプルコードも交えて説明します。

Apollo Client のキャッシュ機能

フロントエンドでApollo Clientを利用する場合、デフォルトで InMemoryCache を使用してクエリ結果をキャッシュします。これにより、同じデータへのリクエストが発生した際に、オリジンサーバーではなくローカルキャッシュからデータを取得できます。

import { ApolloClient, InMemoryCache, gql } from '@apollo/client';

// Apollo Client の設定
const client = new ApolloClient({
  uri: 'https://example.com/graphql',
  cache: new InMemoryCache(),
});

// キャッシュを有効活用したクエリ
client.query({
  query: gql`
    query GetUser {
      user(id: "1") {
        id
        name
      }
    }
  `,
}).then(response => console.log(response.data));

ポイント

  • InMemoryCache により、クエリ結果がキャッシュされ、同じデータをリクエストするとキャッシュが活用される。
  • cache-first ポリシーがデフォルトで適用され、キャッシュにデータがある場合はネットワークリクエストを行わない。

CDN を活用した API レスポンスキャッシュ

エッジサーバー(Cloudflare、Fastly、AWS CloudFront など)でキャッシュを行うことで、クライアントからのリクエストをオリジンサーバーに転送する回数を減らせます。

下記はGraphQL API でキャッシュを適用する CDN 設定例(Cloudflare Workers)

addEventListener("fetch", event => {
  event.respondWith(handleRequest(event.request));
});

async function handleRequest(request) {
  const cacheUrl = new URL(request.url);
  const cache = caches.default;

  let response = await cache.match(cacheUrl);
  if (!response) {
    response = await fetch(request);
    
    // キャッシュを設定(60秒間有効)
    response = new Response(response.body, response);
    response.headers.append("Cache-Control", "s-maxage=60");
    await cache.put(cacheUrl, response.clone());
  }

  return response;
}
  • s-maxage=60 を設定し、CDNで60秒間キャッシュする。
  • Cloudflare Workers でキャッシュを管理することで、オリジンサーバーの負荷を大幅に削減可能。

Redis でデータキャッシュ

Redisを使用して、リゾルバ単位でキャッシュを適用できます。特に、頻繁にアクセスされるデータ(例:ランキング、ニュースフィード)に有効です。

例)Apollo Server で Redis キャッシュを実装

import { ApolloServer, gql } from 'apollo-server-express';
import Redis from 'ioredis';

const redis = new Redis();

const typeDefs = gql`
  type User {
    id: ID!
    name: String!
  }

  type Query {
    user(id: ID!): User
  }
`;

const resolvers = {
  Query: {
    user: async (_, { id }) => {
      const cacheKey = `user:${id}`;
      const cachedData = await redis.get(cacheKey);

      if (cachedData) {
        return JSON.parse(cachedData);
      }

      const user = { id, name: "John Doe" }; // DB から取得するデータ(例)
      await redis.set(cacheKey, JSON.stringify(user), "EX", 60); // 60秒キャッシュ

      return user;
    },
  },
};

const server = new ApolloServer({ typeDefs, resolvers });

ポイント

  • redis.get(cacheKey) でキャッシュチェックし、ヒットした場合はDBアクセスを回避。
  • redis.set(cacheKey, JSON.stringify(user), "EX", 60) で60秒間キャッシュを保持。

ETag / Last-Modified ヘッダーを活用

REST APIでETag や Last-Modified を利用することで、リクエストごとにデータの変更をチェックし、変更がない場合はキャッシュを再利用できます。

import express from "express";
import crypto from "crypto";

const app = express();

app.get("/data", (req, res) => {
  const data = { message: "Hello, World!" };
  const etag = crypto.createHash("md5").update(JSON.stringify(data)).digest("hex");

  res.setHeader("ETag", etag);

  if (req.headers["if-none-match"] === etag) {
    res.status(304).end(); // 304: Not Modified
  } else {
    res.json(data);
  }
});

app.listen(3000, () => console.log("Server running on port 3000"));

ポイント

  • クライアントは ETagIf-None-Match ヘッダーとして送信し、変更がなければ 304 Not Modified を返す。
  • 不必要なデータ転送を削減。

Cache-Control: max-age 指定によるブラウザキャッシュ

REST APIでブラウザに API レスポンスをキャッシュさせることで、不要なリクエストを防ぎます。

app.get("/data", (req, res) => {
  res.setHeader("Cache-Control", "public, max-age=3600"); // 1時間キャッシュ
  res.json({ message: "Hello, Cached World!" });
});

ポイント

  • max-age=3600 で、1時間はブラウザキャッシュを利用。

ユーザーセッションのキャッシュ戦略の改善

セッション情報を効率的にキャッシュすることで、認証処理の負荷を軽減し、アプリケーションのパフォーマンスを向上させることができます。ここでは、一般的な セッションキャッシュの方法 について詳しく解説し、それぞれの方法のサンプルコードを紹介します。

Redis を使用したセッション管理(サーバーサイドセッション)

概要

  • セッション情報を Redis に保存し、ユーザー認証時のデータ取得を高速化する。
  • ユーザーごとに一意のセッション ID を発行し、Redis に保存。
  • スケーラブルなセッション管理が可能。

メリット

  • ユーザーごとの状態を保持できる(Stateful)。
  • サーバーがセッション情報を管理するため、トークンの解析負荷がない。
  • 期限切れのセッションを Redis 側で自動管理できる。

デメリット

  • 負荷分散を考慮した場合、セッション情報を共有する仕組み(Redis クラスター等)が必要。
  • JWT と比較してスケーラビリティが低い。

下記のコードは Express.js を使用して Redis にセッションを保存するサーバー を構築するものです。

import express from 'express';
import session from 'express-session';
import Redis from 'ioredis';
import RedisStore from 'connect-redis';

const app = express();
const redisClient = new Redis();

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: 'your_secret_key', // 環境変数で管理
  resave: false,
  saveUninitialized: false,
  cookie: { secure: false, httpOnly: true, maxAge: 1000 * 60 * 60 } // 1時間
}));

app.get('/login', (req, res) => {
  req.session.user = { id: 1, name: "John Doe" };
  res.send("Logged in");
});

app.get('/profile', (req, res) => {
  if (!req.session.user) return res.status(401).send("Unauthorized");
  res.json(req.session.user);
});

app.listen(3000, () => console.log("Server running on port 3000"));

express-sessionconnect-redis を組み合わせることで、セッション情報を Redis に保存し、サーバーが再起動してもセッションを維持 できるようにしています。

このコードの動作は以下のようになります。

  • ユーザーが /login にアクセスすると、セッション情報が Redis に保存される。
  • ユーザーが /profile にアクセスすると、セッション情報がある場合に認証されたユーザー情報を返す。
  • Redis に保存されたセッション情報を connect-redis を使って管理し、メモリ上ではなく Redis を利用してスケールしやすくする。

セッション情報は Redis に以下のような形式で保存されます。

{
  "sess:U2FtMWJhMWVh...": {
    "cookie": {
      "originalMaxAge": 3600000,
      "expires": "2025-03-07T12:00:00.000Z",
      "httpOnly": true,
      "path": "/"
    },
    "user": {
      "id": 1,
      "name": "John Doe"
    }
  }
}

JWT(JSON Web Token)によるステートレス認証

概要

  • サーバー側でセッションを保持せず、トークンをクライアントに渡し、リクエストごとに認証する。
  • トークンには署名が含まれ、不正改ざんを防げる。

メリット

  • スケーラビリティが高い(サーバーレスやマルチインスタンスに適している)。
  • Redis などの外部ストレージが不要。
  • 認証情報を持ち回るため、レスポンス速度が速い。

デメリット

  • トークンの無効化が難しい(トークンが漏れた場合、期限が切れるまで有効)。
  • 署名の検証コストが発生する(毎回 JWT の解析が必要)。

下記のコードは Express + JSON Web Token(JWT)を用いたステートレス認証 を実装しています。

import express from 'express';
import jwt from 'jsonwebtoken';

const app = express();
const SECRET_KEY = "your_secret_key"; // 環境変数で管理

app.use(express.json());

app.post('/login', (req, res) => {
  const user = { id: 1, name: "John Doe" };
  const token = jwt.sign(user, SECRET_KEY, { expiresIn: '1h' });
  res.json({ token });
});

const authenticate = (req, res, next) => {
  const token = req.headers.authorization?.split(" ")[1];
  if (!token) return res.status(401).send("Unauthorized");

  try {
    req.user = jwt.verify(token, SECRET_KEY);
    next();
  } catch (err) {
    res.status(403).send("Invalid token");
  }
};

app.get('/profile', authenticate, (req, res) => {
  res.json(req.user);
});

app.listen(3000, () => console.log("Server running on port 3000"));

ポイント

  • ユーザーが /login にアクセスすると、JWT を発行 する。
  • /profile などの保護されたエンドポイントにアクセスする際は、JWT をAuthorization: Bearer <token> として送信 する。
  • JWT はサーバー側で状態を持たない(ステートレス)ため、Redis などの外部ストレージを使わず、スケールしやすい。

Hybrid Approach(JWT + Redis)によるバランス型アプローチ

概要

  • JWT を使って基本的な認証を行い、Redis にブラックリスト管理を行うことでトークンの無効化を実現。
  • ログアウト時やトークン無効化時に、Redis に保存する仕組みを導入することで JWT の欠点を補う。

メリット

  • JWT の スケーラビリティを活かしつつ、トークンの無効化が可能。
  • Redis の セッション管理の高速性を活かしつつ、全リクエストでの Redis 依存を減らせる。

デメリット

  • Redis の追加管理が必要。
  • ログアウト処理が増えるため、実装が複雑になる。
import express from 'express';
import jwt from 'jsonwebtoken';
import Redis from 'ioredis';

const app = express();
const SECRET_KEY = "your_secret_key"; // 環境変数で管理
const redisClient = new Redis();

app.use(express.json());

app.post('/login', (req, res) => {
  const user = { id: 1, name: "John Doe" };
  const token = jwt.sign(user, SECRET_KEY, { expiresIn: '1h' });
  res.json({ token });
});

const authenticate = async (req, res, next) => {
  const token = req.headers.authorization?.split(" ")[1];
  if (!token) return res.status(401).send("Unauthorized");

  const isRevoked = await redisClient.get(`blacklist:${token}`);
  if (isRevoked) return res.status(403).send("Token has been revoked");

  try {
    req.user = jwt.verify(token, SECRET_KEY);
    next();
  } catch (err) {
    res.status(403).send("Invalid token");
  }
};

app.post('/logout', async (req, res) => {
  const token = req.headers.authorization?.split(" ")[1];
  if (!token) return res.status(400).send("Token required");

  await redisClient.setex(`blacklist:${token}`, 3600, "revoked"); // 1時間後に自動削除
  res.send("Logged out");
});

app.get('/profile', authenticate, (req, res) => {
  res.json(req.user);
});

app.listen(3000, () => console.log("Server running on port 3000"));

ポイント

  • JWT を使用した認証
    • ユーザーが /login すると JWT を発行し、クライアントに渡す。
    • クライアントは以降のリクエストで JWT を Authorization ヘッダーに含めて送信。
    • サーバーは JWT を検証し、正当なリクエストかを判断。
  • Redis を活用したトークンの無効化
    • /logout でログアウト処理を行うと、JWT を Redis のブラックリストに追加。
    • /profile などの保護されたエンドポイントでは、ブラックリストに含まれる JWT を拒否。

この方式により、JWT の課題である「トークンの無効化が難しい問題」を解決 しています。

キャッシュの TTL(Time To Live)の適切な設定

TTL(Time To Live)は、キャッシュデータの有効期間を制御する重要な概念であり、適切に設定することでパフォーマンス向上やコスト削減を実現できます。一方で、適切な TTL 設定をしないと、古いデータの配信やキャッシュヒット率の低下につながるため、データの特性に応じた最適な値を選択することが重要です。

TTL 設定の考え方

キャッシュの TTL を決める際には、以下のポイントを考慮します。

  • データの更新頻度
    • 頻繁に更新されるデータは TTL を短く設定することで、新鮮なデータを保持できる。
    • 変更が少ないデータは長い TTL を設定し、不要なキャッシュミスを減らす。
  • データの重要度
    • 認証情報やリアルタイムデータなどのクリティカルな情報は短い TTL を設定。
    • 静的リソースや履歴データのような不変データは長い TTL を設定。
  • パフォーマンスとコストのバランス
    • TTL を長くするとキャッシュヒット率が向上し、API や DB への負荷を軽減できる。
    • ただし、TTL が長すぎると古いデータを保持し続けるリスクがある。

データ種別ごとの推奨 TTL

データ種別 推奨 TTL 理由
静的コンテンツ(CSS, JS, 画像) 数時間〜数日 頻繁な更新がないため、長めに設定して負荷を軽減
ユーザープロフィール情報 数分〜数時間 変更は比較的少ないが、リアルタイム性も考慮する必要がある
API レスポンス(頻繁に更新されるもの) 数秒〜数分 最新の情報を反映する必要があるため短めに設定
認証トークン(JWT など) 数時間〜1日 セキュリティと利便性のバランスを考慮して設定

TTL を動的に調整する方法

TTL は固定値として設定することもできますが、柔軟に調整することでより適切なキャッシュ戦略を構築できます。

主な手法として3種類挙げられます。

  • スライディングエクスパイア(Sliding Expiration)
  • イベントベースのキャッシュクリア(Cache Invalidation)
  • CDN のキャッシュ無効化 API を活用
  1. スライディングエクスパイア(Sliding Expiration)
  • アクセスがあるたびに TTL を延長する方式。
  • キャッシュされたデータにアクセスがあると TTL をリセットし、一定時間経過するまでキャッシュを保持する。
  • セッション管理や一時的なデータに適している。

メリット

  • 頻繁に使われるデータはキャッシュに残り続ける
    • 例えば、ログインしているユーザーのセッションデータをキャッシュする場合、アクティブなユーザーはずっとキャッシュを保持できる。
  • 不要なデータのキャッシュを削除できる
    • しばらくアクセスのなかったデータは、自動的にキャッシュが消えるため、ストレージを節約できる。

デメリット

  • キャッシュの管理が複雑になる
    • アクセスのたびに TTL を延長するため、通常の TTL 方式よりもロジックが増える。
  • 特定のデータがキャッシュに長期間残る可能性がある
    • アクセスが途絶えない限り、キャッシュがクリアされないため、古いデータが残るリスクがある。

例)ユーザーセッション管理

ログインしたユーザーのセッション情報をキャッシュに保存するとします。

  • アクセスが続く限りセッション情報はキャッシュに残る
  • 一定時間アクセスがなければセッションが削除される
const cacheKey = `session_${userId}`;
const sessionTTL = 600; // 10 分

// 1. ユーザーのセッションをキャッシュから取得
const sessionData = cache.get(cacheKey);

if (sessionData) {
  // 2. アクセスがあったので TTL をリセット(キャッシュ延命)
  cache.set(cacheKey, sessionData, sessionTTL);
  return sessionData;
}

// 3. セッションがなければ、新しいセッションを作成
const newSession = createSession(userId);
cache.set(cacheKey, newSession, sessionTTL);
return newSession;
  • これにより、ユーザーがアクティブにアクセスしている間はセッションが保持される。
  • ユーザーが 10 分間アクセスしなかった場合、キャッシュが削除され、再ログインが必要になる。

例)API レスポンスのキャッシュ

検索結果のキャッシュを考えてみます。

const cacheKey = `search_results_${query}`;
const cacheTTL = 300; // 5 分

// キャッシュを取得
const cachedResults = cache.get(cacheKey);

if (cachedResults) {
  // キャッシュがあれば、TTL を延長
  cache.set(cacheKey, cachedResults, cacheTTL);
  return cachedResults;
}

// キャッシュがない場合は API からデータを取得
const results = fetchSearchResults(query);
cache.set(cacheKey, results, cacheTTL);
return results;
  • 検索結果のキャッシュが5 分間維持される。
  • もしユーザーが3 分後に同じ検索をしたら、さらに 5 分延長される。
  • しかし、5 分間アクセスがなければキャッシュは削除され、次回の検索時に新しいデータが取得される。
  1. イベントベースのキャッシュクリア(Cache Invalidation)
  • データの更新が発生した際に、関連するキャッシュを削除する方式。
  • API や DB のデータ更新時に該当キャッシュを破棄することで、常に最新のデータを提供可能。

通常、キャッシュには TTL(Time To Live) を設定して、一定時間経過後に自動的に削除されるようにします。しかし、データが変更された場合、TTL の期限が切れるまで 古いデータが残り続ける 可能性があります。

例えば:

  • ユーザーがプロフィールを更新したのに、キャッシュが古いままで反映されない
  • 商品の在庫が変わったのに、キャッシュされた情報が古くて誤った在庫数を表示する
  • 通知システムで最新の通知を受け取るべきなのに、キャッシュのせいで古い通知が表示される

こうした問題を解決するために、データが変更されたら、そのタイミングで関連するキャッシュを削除する(無効化する) という仕組みを導入します。

function updateUserProfile(userId, newProfileData) {
  // 1. データベースを更新
  database.updateUserProfile(userId, newProfileData);

  // 2. 変更があったのでキャッシュを削除
  cache.delete(`user_profile_${userId}`);
}

キャッシュクリアのベストプラクティス

  • 変更が発生したデータに関連するキャッシュのみ削除する
    • 必要以上にキャッシュを削除すると、キャッシュヒット率が下がるため注意。
  • キャッシュ削除後、すぐに新しいデータをキャッシュする(プリフェッチ)
    • 削除した後に即座に新しいデータを取得&キャッシュすることで、キャッシュミスを減らす。
  • タグベースのキャッシュ管理を活用する
    • 例: category_123 というタグを持つキャッシュを一括削除することで、関連データの削除を簡単に管理。
  1. CDN のキャッシュ無効化 API を活用
  • CDN(Content Delivery Network)を利用している場合、CDN のキャッシュを明示的に削除することで即座に最新データを提供可能。
  • Cloudflare、AWS CloudFront、Fastly などでは、API を使ってキャッシュを無効化できる。

例(Cloudflare のキャッシュ削除 API の利用)

curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
     -H "Authorization: Bearer {api_token}" \
     -H "Content-Type: application/json" \
     --data '{"purge_everything":true}'

適用例としては下記が挙げられます。

  • ブログ記事やニュース記事の即時更新
  • EC サイトの商品情報の更新
  • マーケティングキャンペーンページの更新

TTL 設定のベストプラクティス

  • デフォルトの TTL を適切に設定する
    • 変更頻度の低いデータは長めに設定し、動的データは短めに設定。
  • ユーザー体験とパフォーマンスのバランスを取る
    • キャッシュが短すぎると毎回データを取得するため負荷が増加。
    • キャッシュが長すぎると古いデータを提供してしまう可能性がある。
  • TTL をデータごとにカスタマイズする
    • すべてのデータに一律の TTL を設定するのではなく、データの特性に応じて適切な値を設定。
  • キャッシュクリアの仕組みを適切に設計する
    • データ変更時に明示的にキャッシュを削除する仕組みを導入する。

コンテンツのプリフェッチ(Next.js ISR / SSG の活用)

Next.js では、静的サイト生成(SSG)やインクリメンタル静的再生成(ISR)を利用することで、Webページの表示速度を最適化しつつ、サーバー負荷を軽減できます。これにより、ユーザーは高速なページ読み込みを体験でき、開発者は効率的なコンテンツ配信を実現できます。

SSG(Static Site Generation)とは?

SSG は、ビルド時にあらかじめ HTML を生成し、それを CDN(Content Delivery Network)から配信する 仕組みです。データが頻繁に変わらないページに適しており、以下のようなメリットがあります。

SSG の特徴

  • ビルド時 (next build) に getStaticProps を使ってデータを取得
  • 生成された HTML が CDN にキャッシュされ、高速に配信される
  • サーバー負荷が低く、スケーラビリティが高い
  • ページの内容が変更された場合、再デプロイが必要

SSG の適用例

  • ブログ記事(記事の内容は頻繁に更新されない)
  • ドキュメントサイト(静的な情報が中心)
  • 製品ページ(数日〜数週間ごとに更新される商品情報)

SSG の実装例

getStaticProps を使用して SSG を実現できます。

export async function getStaticProps() {
  const res = await fetch('https://api.example.com/posts');
  const posts = await res.json();

  return {
    props: { posts }, // ビルド時にデータを取得し、ページに渡す
  };
}

API からデータを取得し、ビルド時に静的 HTML として保存します。

ISR(Incremental Static Regeneration)とは?

ISR は SSG の拡張版 で、ページを静的に生成しつつ、一定時間ごとに最新データを反映できる 機能です。これにより、サイトの更新性とパフォーマンスのバランスをとることができます。

  • ISR の特徴

    • getStaticProps に revalidate オプションを指定することで、一定時間後にページを再生成
    • ビルド後も静的ページを最新の状態に保てる
    • CDN キャッシュのメリットを活かしつつ、リアルタイム性も確保
    • 完全な SSR(サーバーサイドレンダリング)ほどの負荷をかけずに、動的なデータを扱える
  • ISR の適用例

    • ニュースサイト(記事が一定時間ごとに更新される)
    • ECサイトの商品ページ(在庫数や価格が変更される)
    • ランキングやトレンドを表示するページ
  • ISR の実装例
    Next.js では、getStaticProps に revalidate を設定するだけで ISR を有効化できます。

export async function getStaticProps() {
  const res = await fetch('https://api.example.com/products');
  const products = await res.json();

  return {
    props: { products },
    revalidate: 60, // 60秒ごとにページを再生成
  };
}

ページが 60 秒ごとに最新データに更新 されるため、リアルタイム性が求められるコンテンツに適しています。

ISR の仕組み

  1. 最初のリクエスト
    初回リクエスト時に Next.js が getStaticProps を実行し、ページを生成
    そのページが CDN にキャッシュ され、すべてのユーザーに提供される

  2. 再生成のタイミング
    revalidate の時間が経過すると、新しいリクエストが来たときにバックグラウンドで再生成が行われる
    新しいリクエストは古いページを受け取り、新しいページが完成したら以降のリクエストに適用

SSG / ISR の活用によるパフォーマンス向上

Next.js の SSG や ISR を適切に活用することで、以下のようなメリットを得られます。

  1. ページの読み込み速度を高速化
    静的ページを CDN から配信することで、表示が速くなる
  2. サーバー負荷を軽減
    リクエストのたびに API を呼ぶ SSR と比べて、処理負荷を大幅に減らせる
  3. SEO(検索エンジン最適化)に有利
    事前に HTML を生成するため、クローラーがコンテンツを適切に評価できる
  4. リアルタイム性を確保しつつ、高速な配信を実現
    ISR を使うことで、更新頻度の高いデータも適切に配信できる

クライアントサイドキャッシュの最適化(Service Worker の適用)

Service Worker は、ブラウザとネットワークの間に存在するスクリプトで、以下のような特徴を持ちます。

  • バックグラウンドで動作 し、ページの再読み込みとは独立して機能する
  • ネットワークリクエストをインターセプト(傍受)し、キャッシュや API リクエストを管理できる
  • PWA(プログレッシブウェブアプリ)の必須技術 であり、オフラインサポートや高速化を実現する

今回はReact で Service Worker を適用する方法を Workbox を活用しつつご紹介します。

Workbox をインストール

npm install workbox-window

プロジェクトの public/ ディレクトリに serviceWorker.js を作成し、Service Worker の登録やキャッシュ戦略を設定します。

public/serviceWorker.js
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate, CacheFirst, NetworkFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';

// ビルド時にキャッシュするリソース
precacheAndRoute(self.__WB_MANIFEST || []);

// 静的リソース(CSS, JS, 画像)をキャッシュ
registerRoute(
  ({ request }) => request.destination === 'style' || request.destination === 'script' || request.destination === 'image',
  new CacheFirst({
    cacheName: 'static-resources',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 50, // キャッシュの上限数
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30日間保持
      }),
    ],
  })
);

// API レスポンスのキャッシュ(stale-while-revalidate)
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new StaleWhileRevalidate({
    cacheName: 'api-cache',
  })
);

// 古いキャッシュを削除
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.filter((cacheName) => cacheName !== 'static-resources' && cacheName !== 'api-cache')
          .map((cacheName) => caches.delete(cacheName))
      );
    })
  );
});

React のエントリーポイント src/main.tsx で Service Worker を登録します。

src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { registerSW } from './serviceWorkerRegistration';

const root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

// Service Worker を登録
registerSW();

React で Service Worker を管理するための serviceWorkerRegistration.ts を作成します。

src/serviceWorkerRegistration.ts
import { Workbox } from 'workbox-window';

export function registerSW() {
  if ('serviceWorker' in navigator) {
    const wb = new Workbox('/serviceWorker.js');

    wb.addEventListener('installed', (event) => {
      if (event.isUpdate) {
        console.log('新しいバージョンの Service Worker がインストールされました。');
      }
    });

    wb.addEventListener('waiting', () => {
      console.log('Service Worker が待機中です。');
    });

    wb.addEventListener('activated', () => {
      console.log('Service Worker がアクティブになりました。');
    });

    wb.register();
  }
}

キャッシュを手動で削除するコンポーネントを作成します。

src/components/ClearCacheButton.tsx
import React from 'react';

const ClearCacheButton: React.FC = () => {
  const clearCache = async () => {
    if ('caches' in window) {
      const cacheNames = await caches.keys();
      await Promise.all(cacheNames.map((name) => caches.delete(name)));
      alert('キャッシュが削除されました!');
    }
  };

  return <button onClick={clearCache}>キャッシュをクリア</button>;
};

export default ClearCacheButton;

このボタンを App.tsx に追加すると、キャッシュを削除できます。

src/App.tsx
import React from 'react';
import ClearCacheButton from './components/ClearCacheButton';

const App: React.FC = () => {
  return (
    <div>
      <h1>React + Service Worker + Workbox</h1>
      <ClearCacheButton />
    </div>
  );
};

export default App;
  • キャッシュの更新が反映されない問題を防げる
  • 開発者がデバッグしやすくなる
  • 特定のデータだけをクリアできる
    • 画像のキャッシュだけクリアして、API のレスポンスは保持する
    • 特定の API のレスポンスだけクリアする
    • 重要なデータ(認証トークンなど)は消さずに、CSS や JS のキャッシュだけ削除

などの対応でキャッシュクリアボタンを実装してみるのもいいかと思います。

適切なキャッシュポリシーの設定

  1. stale-while-revalidate(キャッシュ優先 + バックグラウンド更新)
  • 仕組み: キャッシュから即座にレスポンスを返しつつ、バックグラウンドで最新データを取得
  • 適用例: API レスポンス、CDN から取得する静的ファイル
import { StaleWhileRevalidate } from 'workbox-strategies';

registerRoute(
  ({ request }) => request.destination === 'script' || request.destination === 'style',
  new StaleWhileRevalidate({
    cacheName: 'static-resources',
  })
);

メリット: ページの表示速度が速く、かつデータが自動更新される。

  1. network-first(ネットワーク優先 + フォールバックキャッシュ)
  • 仕組み: ネットワークからデータを取得し、失敗時はキャッシュから提供
  • 適用例: ユーザーが最新の情報を必要とする API データ
import { NetworkFirst } from 'workbox-strategies';

registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-cache',
    networkTimeoutSeconds: 5, // 5秒以内に応答がなければキャッシュを使用
  })
);

メリット: 可能な限り最新データを取得しつつ、オフライン時にもデータを表示できる。

  1. cache-first(キャッシュ優先 + フォールバックネットワーク)
  • 仕組み: キャッシュを優先して使用し、キャッシュがない場合はネットワークから取得
  • 適用例: 変更が少ないリソース(画像、フォント、CSS)
import { CacheFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';

registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'image-cache',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 50, // キャッシュする最大エントリ数
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30日間有効
      }),
    ],
  })
);

メリット: サイトの表示速度を優先

キャッシュの自動クリアルールの策定

キャッシュ管理は、パフォーマンスの最適化と最新データの提供のバランスを取るために重要です。適切なルールを設定することで、不要なキャッシュの蓄積を防ぎ、効率的なデータ配信を実現できます。

キャッシュの期限管理

キャッシュの保持期間を適切に設定することで、無駄なキャッシュを防ぎつつ、最新データをユーザーに届けることができます。

  • Cache-Control ヘッダーの設定

    • max-age=<秒数>:指定秒数の間、キャッシュを有効にする
    • must-revalidate:期限が切れたキャッシュは必ず再検証する
    • no-cache:キャッシュされるが、利用前に必ずサーバーと整合性を確認する
    • no-store:キャッシュを完全に無効化
  • ETag の活用

    • サーバー側でコンテンツのハッシュを生成し、変更がない場合はキャッシュを再利用
    • クライアントは If-None-Match を送信し、変更がなければ 304 Not Modified を受け取る

バージョニングを活用

キャッシュのクリアを適切に行うため、アセットの URL にバージョン情報を組み込む方法が有効です。

  • ファイル名にバージョンを付与

  • 変更があった際に異なるURLを提供し、古いキャッシュを参照させない

    • 例:
      • /static/js/app.v1.2.3.js
      • /static/css/style.v2.1.0.css
  • CDN のキャッシュクリアAPIを活用し、新しいバージョンを即座に反映

    • 例: Cloudflare の cache purge 機能を利用

クエリキャッシュの導入(React Query / Apollo Client の活用)

React アプリケーションにおいて、データフェッチとキャッシュ管理を効率化するには、React Query または Apollo Client を活用するのが有効です。これにより、ネットワークリクエストの最適化 や パフォーマンス向上 を実現できます。

React Query の活用

React Query は、REST API や GraphQL を含むあらゆるデータ取得のキャッシュ管理を簡単に行えるライブラリです。

  1. useQuery を利用したデータフェッチ
    React Query の useQuery フックを使うことで、データ取得とキャッシュ管理を自動化できます。
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';

const fetchData = async () => {
  const { data } = await axios.get('https://jsonplaceholder.typicode.com/posts');
  return data;
};

const Posts = () => {
  const { data, error, isLoading } = useQuery({
    queryKey: ['posts'],  // クエリの識別キー(キャッシュ管理に使用)
    queryFn: fetchData,   // データ取得関数
    staleTime: 1000 * 60, // 1分間は「フレッシュ」なデータとみなす
    cacheTime: 1000 * 300 // 5分間キャッシュを保持
  });

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <ul>
      {data.map((post: { id: number; title: string }) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
};

export default Posts;
  1. staleTimecacheTime の調整
  • staleTime: キャッシュが 古いとみなされるまでの時間。この時間内なら、再フェッチせずキャッシュを使用。
  • cacheTime: キャッシュが メモリに残る時間。この時間を過ぎるとキャッシュが削除される。
  1. invalidateQueries でキャッシュを手動更新
    データの変更があった際に、特定のクエリを無効化し、最新データを取得できます。
import { useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';

const fetchPosts = async () => {
  const { data } = await axios.get('https://jsonplaceholder.typicode.com/posts');
  return data;
};

const Posts = () => {
  const queryClient = useQueryClient();
  const { data, isLoading } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts });

  const refreshData = () => {
    queryClient.invalidateQueries({ queryKey: ['posts'] }); // キャッシュを無効化し、データを再取得
  };

  if (isLoading) return <p>Loading...</p>;

  return (
    <div>
      <button onClick={refreshData}>更新</button>
      <ul>
        {data.map((post: { id: number; title: string }) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
};

export default Posts;

Apollo Client の活用

Apollo Client は GraphQL API に特化したデータ管理ライブラリで、InMemoryCache を活用したクエリキャッシュ が強力です。

Apollo Client のセットアップ

import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client';

const client = new ApolloClient({
  uri: 'https://your-graphql-endpoint.com/graphql',
  cache: new InMemoryCache(),
});

const App = () => (
  <ApolloProvider client={client}>
    <YourComponent />
  </ApolloProvider>
);

export default App;

useQuery を利用したデータフェッチ

import { gql, useQuery } from '@apollo/client';

const GET_POSTS = gql`
  query GetPosts {
    posts {
      id
      title
    }
  }
`;

const Posts = () => {
  const { data, loading, error } = useQuery(GET_POSTS, {
    fetchPolicy: 'cache-first', // キャッシュを優先して使用
  });

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <ul>
      {data.posts.map((post: { id: number; title: string }) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
};

export default Posts;

Apollo Client では fetchPolicy を指定することで、データ取得の挙動を制御できます。

fetchPolicy 説明
cache-first キャッシュがあれば使用、なければネットワークから取得
network-only 常にネットワークから取得
cache-and-network キャッシュを即座に使用しつつ、ネットワークでも取得
no-cache キャッシュを使用せず、都度ネットワークから取得

GraphQL ミューテーション後に refetchQueries を使うことで、特定のデータを最新の状態にできます。

import { gql, useMutation } from '@apollo/client';

const CREATE_POST = gql`
  mutation CreatePost($title: String!) {
    createPost(title: $title) {
      id
      title
    }
  }
`;

const GET_POSTS = gql`
  query GetPosts {
    posts {
      id
      title
    }
  }
`;

const CreatePost = () => {
  const [createPost] = useMutation(CREATE_POST, {
    refetchQueries: [{ query: GET_POSTS }], // クエリを再取得
  });

  const handleCreate = async () => {
    await createPost({ variables: { title: 'New Post' } });
  };

  return <button onClick={handleCreate}>新規投稿</button>;
};

export default CreatePost;

React Query と Apollo Client

項目 React Query Apollo Client
主な用途 REST API / GraphQL GraphQL API
キャッシュ管理 useQuery, cacheTime, staleTime InMemoryCache
データ更新 invalidateQueries refetchQueries
データ取得制御 ` fetchPolicy (cache-first など)

キャッシュヒット率の分析と最適化

キャッシュ戦略を適用した後、その効果を定量的に評価し、最適化を行うことが重要です。ここでは、キャッシュヒット率の計測方法について詳しく解説します。

クライアント側の計測

キャッシュヒット率(Cache Hit Ratio)は、「キャッシュから取得できたリクエストの割合」を示す指標です。キャッシュの有効性を確認し、最適な設定を行うために、以下の方法で測定します。

  1. ブラウザ・Service Worker を利用した計測
  • ブラウザのPerformance APIを活用
    • performance.getEntriesByType("resource") を使用し、リソースがキャッシュから取得されたかを確認。
    • transferSize === 0 のリソースはキャッシュヒットと判断できる。
performance.getEntriesByType("resource").forEach((entry) => {
  console.log(entry.name, entry.transferSize === 0 ? "Cache Hit" : "Cache Miss");
});
  • Service Worker のログを分析
    • Service Worker で fetch イベントをフックし、cache.match(event.request) の結果をロギング。
    • Cache Hit したリクエストと Cache Miss のリクエストを比較。
self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((cachedResponse) => {
      if (cachedResponse) {
        console.log("Cache Hit:", event.request.url);
        return cachedResponse;
      } else {
        console.log("Cache Miss:", event.request.url);
        return fetch(event.request);
      }
    })
  );
});
  1. GraphQL のキャッシュヒット率計測(Apollo Client)

cache.readQuery() を利用し、クエリのキャッシュヒットをチェック。

const { cache } = useApolloClient();

const data = cache.readQuery({
  query: GET_USER_PROFILE,
  variables: { id: "123" },
});

console.log(data ? "Cache Hit" : "Cache Miss");
  • Apollo DevTools を使用し、cache タブからどのデータがキャッシュに保持されているかを確認。
  • useQueryfetchPolicy を "cache-first" に設定し、キャッシュヒット時のレスポンス速度を測定。

サーバー側でのキャッシュヒット率の集計

クライアント側だけでなく、サーバーやCDNでキャッシュヒット率をログに記録し、集計することで、システム全体の傾向を把握できます。

  1. Nginx や Apache のログを解析
  • Nginx の $upstream_cache_status をログに記録し、キャッシュヒット率を集計。
  • HIT/MISS/BYPASS の割合を分析し、キャッシュ設定を最適化。
log_format cache_status '$remote_addr - $upstream_cache_status - $request';
access_log /var/log/nginx/cache.log cache_status;
  1. GraphQL や API レイヤーでキャッシュ状況を記録
  • Apollo Server や REST API で、キャッシュヒット(cache.get())の回数を記録。
  • Redis やメモリキャッシュ(LruCache)のヒット率を Datadog などで監視。
const redis = require("redis");
const client = redis.createClient();

const cacheKey = `user:${userId}`;
client.get(cacheKey, (err, data) => {
  if (data) {
    console.log("Cache Hit");
  } else {
    console.log("Cache Miss");
  }
});
  1. CDN(Cloudflare, AWS CloudFront)のログを分析
  • Cloudflare のキャッシュヒット率は cf-cache-status ヘッダーで確認可能。
  • AWS CloudFrontx-cache ヘッダーで HIT/MISS を記録し、ログを集計。

さいごに

キャッシュを適切に設計・運用することで、アプリケーションのパフォーマンスは飛躍的に向上します。CDNによる静的コンテンツの配信最適化、データベース負荷軽減のためのRedis / Memcached、APIレスポンスキャッシュの活用、Next.js ISR / SSGのプリフェッチ、クエリキャッシュの導入など、それぞれの手法にはメリットと適用シナリオがあります。

しかし、キャッシュには適切なTTL設定や自動クリアルールの策定が欠かせません。過剰なキャッシュはデータの整合性を損なう可能性があり、逆にキャッシュ不足はパフォーマンス低下を招きます。キャッシュヒット率の分析やモニタリングを行いながら、最適なキャッシュ戦略を構築しましょう。

本記事が、あなたのアプリケーションのパフォーマンス改善とスケーラビリティ向上の一助となれば幸いです。

記事に関するお問い合わせ📝

記事の内容に関するご質問、ご意見などは、下記よりお気軽にお問い合わせください。
ご質問フォームへ

技術支援などお仕事に関するお問い合わせ📄

技術支援やお仕事のご依頼に関するお問い合わせは、下記よりお気軽にお問い合わせください。
お問い合わせフォームへ

関連する技術ブログ