1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Upstash RedisとVercel Edgeで無限スクロールキャッシュを実装する

1
Posted at

こんにちは、Futuristic Imagination LLCの佐藤です。

AIオウンドメディアを一人で18サイト、9言語で年間6,570記事も自動運用していると、とにかくシステムを効率化し、高速化しないとリソースが枯渇するんですよね。特に、Next.jsで構築しているサイト群では、ユーザー体験の向上とVercelの料金最適化が常に課題です。

今回は、そんな課題解決の一環として、無限スクロールの表示速度とコストを劇的に改善するために、Upstash RedisとVercel Edge Functionを組み合わせたキャッシュ戦略を実装した話をしたいと思います。実際に僕がどうやって実装したのか、コードを交えて具体的に解説していきます。

この記事でわかること:

  • 無限スクロールにおけるキャッシュの重要性とその課題
  • Upstash RedisをVercel Edge Functionから使う方法
  • Next.jsのApp Routerで、無限スクロールのページネーションにキャッシュを適用する具体的な実装方法
  • パフォーマンス向上とVercel料金最適化のポイント

無限スクロールのパフォーマンス課題とキャッシュの重要性

僕が運用しているメディアは、記事数が膨大です。トップページやカテゴリページでは無限スクロールを採用しているんですが、これって結構リソースを食うんですよね。

無限スクロールの各ページネーションリクエストごとにデータベースにアクセスしたり、複雑な処理を走らせたりすると、

  1. 表示速度の低下: ユーザー体験が悪くなる
  2. サーバーコストの増加: VercelのServerless Function実行時間が増大する
  3. APIレートリミット: データベースや外部APIの制限に引っかかる可能性がある

といった問題が発生します。

特にNext.jsのApp RouterでISR (Incremental Static Regeneration) を使っていても、動的なページネーションはStatic Generationでは賄いきれない部分があります。そこで、「よくアクセスされるページネーションの結果はキャッシュしてしまおう」という結論に至りました。

Upstash RedisとVercel Edge Functionの組み合わせが最適解な理由

キャッシュ戦略を考える上で、いくつか選択肢がありました。

  • Vercel KV: Vercelが提供するRedis互換のキーバリューストアで、Edge Functionから直接使えるのが魅力。ただ、個人で複数サイトを運用するには、もう少し柔軟性やコスト効率を考慮したい場面も。
  • Cloudflare Workers KV: Cloudflareのエッジで動作するKVストア。こちらも高速ですが、Vercel環境との連携を考えると一手間かかることも。
  • Upstash Redis: サーバーレスRedisとして有名で、Vercel Edge Functionからのアクセスも非常に高速です。無料で使える範囲も広く、スケールも柔軟。

僕の場合、Vercel環境でNext.jsを使っているので、Upstash RedisをVercel Edge Functionから使うのが最も手軽でパフォーマンスが良いと判断しました。

  • Edge Functionの高速性: ユーザーに近いエッジロケーションでキャッシュを処理できるため、非常に低いレイテンシでデータを返せます。
  • Upstash Redisのサーバーレス特性: 使った分だけ課金されるため、運用コストを最適化できます。また、永続化されるので、Serverless Functionがコールドスタートしてもキャッシュは失われません。

この組み合わせで、「補給不要の自販機型SaaS」を目指す僕のシステムにおいて、まさに最適な「補給」手段を提供してくれるわけです。

実装ステップ:無限スクロールキャッシュの実践

ここからは、具体的な実装方法をコードを交えて解説していきます。

1. Upstash Redisの準備

まずはUpstashでRedisインスタンスを作成します。

  1. Upstashのダッシュボードで新しいRedisデータベースを作成。
  2. 作成後、「.env」タブからUPSTASH_REDIS_REST_URLUPSTASH_REDIS_REST_TOKENを取得します。
  3. これらの環境変数をVercelのプロジェクトに設定します。Edge Functionからアクセスするためには、必ずVercelのプロジェクト設定に登録してください。

2. Upstash Redisクライアントのセットアップ

Vercel Edge FunctionからUpstash Redisにアクセスするためのクライアントを設定します。
通常、Node.js環境ではioredisなどを使いますが、Edge FunctionはNode.jsランタイムではないため、REST API経由でアクセスする必要があります。Upstashは公式でRESTクライアントを提供しているので、それを使います。

lib/redis.ts (例)

import { Redis } from '@upstash/redis';

// 環境変数はVercelのUIまたはvercel.jsonで設定
// Vercel Edge Functionで利用する場合、環境変数はVercelのプロジェクト設定で設定する
// process.env.UPSTASH_REDIS_REST_URL と process.env.UPSTASH_REDIS_REST_TOKEN は必須
export const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

if (!process.env.UPSTASH_REDIS_REST_URL || !process.env.UPSTASH_REDIS_REST_TOKEN) {
  console.warn("UPSTASH_REDIS_REST_URL or UPSTASH_REDIS_REST_TOKEN is not set. Redis cache will be disabled.");
}

! を使っているのは、Next.jsのビルド時やVercelデプロイ時には環境変数が設定されていることを前提としているためです。ローカル開発時は.env.localに設定を忘れずに。

3. API Route (Edge Function) の実装

無限スクロールのページネーションデータを提供するAPI Routeを作成します。
ここでは、Next.jsのApp RouterにおけるAPI RouteをEdge Runtimeで動作させます。

app/api/posts/page/[pageId]/route.ts

import { NextRequest, NextResponse };
import { redis } from '@/lib/redis'; // 先ほど作成したredisクライアントをインポート

// Edge Runtimeで動作させるための設定
export const runtime = 'edge';

const CACHE_TTL_SECONDS = 3600; // キャッシュの有効期限 (1時間)

export async function GET(
  request: NextRequest,
  { params }: { params: { pageId: string } }
) {
  const pageId = params.pageId;
  const cacheKey = `posts:page:${pageId}`;

  try {
    // 1. キャッシュの確認
    const cachedData = await redis.get(cacheKey);
    if (cachedData) {
      console.log(`Cache hit for ${cacheKey}`);
      return NextResponse.json(cachedData, {
        headers: {
          'X-Cache-Status': 'HIT',
          'Content-Type': 'application/json',
        },
      });
    }

    // 2. キャッシュがない場合、データをフェッチ (DBや外部APIなど)
    console.log(`Cache miss for ${cacheKey}. Fetching data...`);
    // ここに実際のデータ取得ロジックを記述します
    // 例: await fetchPostsFromDatabase(pageId);
    // 今回はモックデータとして記事リストを返します
    const pageSize = 10; // 1ページあたりの記事数
    const startIdx = (parseInt(pageId) - 1) * pageSize;
    const endIdx = startIdx + pageSize;

    const allPosts = Array.from({ length: 100 }, (_, i) => ({
      id: `post-${i + 1}`,
      title: `記事タイトル ${i + 1}`,
      slug: `article-${i + 1}`,
      excerpt: `これは記事 ${i + 1} の要約です。`,
      date: new Date(Date.now() - i * 86400000).toISOString(), // 古い記事ほど日付が前になる
    }));

    const posts = allPosts.slice(startIdx, endIdx);
    const hasNextPage = endIdx < allPosts.length;

    const dataToCache = {
      posts,
      hasNextPage,
      currentPage: parseInt(pageId),
      totalPages: Math.ceil(allPosts.length / pageSize),
    };

    // 3. 取得したデータをキャッシュに保存
    // `redis.set(key, value, { ex: expiry_in_seconds })` でTTLを設定
    await redis.set(cacheKey, dataToCache, { ex: CACHE_TTL_SECONDS });
    console.log(`Data cached for ${cacheKey} with TTL ${CACHE_TTL_SECONDS}s`);

    return NextResponse.json(dataToCache, {
      headers: {
        'X-Cache-Status': 'MISS',
        'Content-Type': 'application/json',
      },
    });

  } catch (error) {
    console.error(`Error fetching posts for page ${pageId}:`, error);
    // エラー時も適切なレスポンスを返す
    return NextResponse.json({ error: 'Failed to fetch posts' }, { status: 500 });
  }
}

ポイント:

  • export const runtime = 'edge'; を設定することで、このAPI RouteがVercel Edge Functionで動作します。これにより、低レイテンシでのキャッシュアクセスが可能になります。
  • redis.get(cacheKey) でキャッシュをチェックし、存在すれば即座に返します。
  • キャッシュミスの場合、データの取得処理(実際のDBアクセスや外部API呼び出し)を行い、その結果を redis.set(cacheKey, data, { ex: TTL }) でキャッシュに保存します。exオプションで有効期限を秒単位で指定できるのが便利です。
  • エラーハンドリングも重要です。キャッシュサーバが落ちたり、ネットワークエラーが発生したりした場合でも、ユーザーに適切なレスポンスを返せるようにします。

4. クライアントサイドでの利用 (React Query/SWR + Intersection Observer)

Next.jsのクライアントコンポーネントで、このAPIを無限スクロールとして利用します。
データフェッチにはreact-query (TanStack Query) や SWR といったライブラリを使うと非常に楽です。ここではSWRを例に挙げます。

components/PostList.tsx (例: クライアントコンポーネント)

'use client';

import React, { useRef, useState, useEffect, useCallback } from 'react';
import useSWRInfinite from 'swr/infinite'; // SWRの無限スクロール用フック

interface Post {
  id: string;
  title: string;
  slug: string;
  excerpt: string;
  date: string;
}

const fetcher = (url: string) => fetch(url).then(res => res.json());

export function PostList() {
  const [currentPage, setCurrentPage] = useState(1); // 現在のページ番号
  const { data, error, size, setSize, isValidating } = useSWRInfinite(
    (index) => `/api/posts/page/${index + 1}`, // 0-indexedなので+1する
    fetcher,
    {
      initialSize: 1, // 最初のページをロード
      revalidateOnFocus: false, // フォーカス時に再検証しない
      revalidateOnReconnect: false, // 再接続時に再検証しない
      persistSize: true, // ロードしたページサイズを維持
    }
  );

  // Intersection Observerで末尾要素を監視
  const observerTargetRef = useRef<HTMLDivElement>(null);

  const posts = data ? data.flatMap(page => page.posts) : [];
  const isLoadingInitialData = !data && !error;
  const isLoadingMore =
    isLoadingInitialData ||
    (size > 0 && data && typeof data[size - 1] === 'undefined');
  const isEmpty = data?.[0]?.posts?.length === 0;
  const isReachingEnd =
    isEmpty || (data && !data[data.length - 1]?.hasNextPage);

  const loadMore = useCallback(() => {
    if (!isReachingEnd && !isValidating) {
      setSize(size + 1);
    }
  }, [isReachingEnd, isValidating, setSize, size]);

  useEffect(() => {
    if (!observerTargetRef.current) return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && !isLoadingMore && !isReachingEnd) {
          loadMore();
        }
      },
      { threshold: 1.0 } // ターゲットが完全に画面に入ったら発火
    );

    observer.observe(observerTargetRef.current);

    return () => {
      if (observerTargetRef.current) {
        observer.unobserve(observerTargetRef.current);
      }
    };
  }, [isLoadingMore, isReachingEnd, loadMore]); // 依存配列にloadMoreを追加

  if (error) return <div>記事の読み込み中にエラーが発生しました。</div>;
  if (isLoadingInitialData) return <div>記事を読み込み中...</div>;
  if (isEmpty) return <div>まだ記事がありません。</div>;

  return (
    <div className="container mx-auto p-4">
      <h1 className="text-3xl font-bold mb-6">最新の記事</h1>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {posts.map((post) => (
          <article key={post.id} className="bg-white shadow-md rounded-lg p-6">
            <h2 className="text-xl font-semibold mb-2">{post.title}</h2>
            <p className="text-gray-600 mb-4">{post.excerpt}</p>
            <a href={`/posts/${post.slug}`} className="text-blue-500 hover:underline">
              続きを読む
            </a>
          </article>
        ))}
      </div>

      <div ref={observerTargetRef} className="text-center py-8">
        {isLoadingMore && <p>さらに記事を読み込み中...</p>}
        {isReachingEnd && <p>すべての記事を読み込みました。</p>}
      </div>
    </div>
  );
}

ポイント:

  • useSWRInfinite を使うことで、無限スクロールのデータフェッチと状態管理が非常にシンプルになります。getKey関数で次のページのURLを動的に生成します。
  • Intersection Observer を利用して、ユーザーがリストの末尾までスクロールしたことを検知し、自動的に次のページをロードします。これは、observerTargetRefで指定した要素がビューポートに入ったときに発火します。
  • isLoadingMoreisReachingEnd といったSWRInfiniteが提供する状態変数を使って、適切なUIフィードバックを行います。

5. キャッシュの無効化戦略

キャッシュは非常に強力ですが、新しい記事が追加されたり、既存の記事が更新されたりしたときに、古い情報が残り続けると困ります。そこで、キャッシュの無効化戦略も考慮する必要があります。

僕が使っている戦略はいくつかあります。

  1. TTL (Time To Live) による自動無効化:
    先ほどのコードで CACHE_TTL_SECONDS を設定したように、一定時間が経過すると自動的にキャッシュが無効になります。ユーザーは古い情報を目にすることになりますが、次のリクエストで最新データが取得され、再度キャッシュされます。これは最もシンプルで実装コストが低い方法です。

  2. 記事更新時の明示的なキャッシュ削除:

    • 記事を更新したり、新規公開したりするたびに、対象のページネーションキャッシュキー(例: posts:page:1 など)をRedisから削除するAPI Routeを作成します。
    • このAPI Routeは、僕が自動記事生成に使っているNext.js + Gemini API + Vercel Cronのパイプラインから記事公開後に呼び出すようにしています。
    • 特定の記事が更新された場合、その記事が含まれる可能性のある全てのページネーションキャッシュ(例: 最初の数ページ分など)を削除することも検討します。

    app/api/revalidate-posts-cache/route.ts (例)

    import { NextRequest, NextResponse } from 'next/server';
    import { redis } from '@/lib/redis';
    
    export const runtime = 'edge'; // Edge Functionで動作
    
    export async function GET(request: NextRequest) {
      // 本番環境では、認証・認可の仕組みを必ず導入してください
      // 例: クエリパラメータに秘密のトークンを付与し、それを検証するなど
      const secret = request.nextUrl.searchParams.get('secret');
      if (secret !== process.env.REVALIDATE_TOKEN) {
        return NextResponse.json({ message: 'Invalid token' }, { status: 401 });
      }
    
      try {
        // 例: 最初の5ページのキャッシュを削除
        for (let i = 1; i <= 5; i++) {
          const cacheKey = `posts:page:${i}`;
          await redis.del(cacheKey);
          console.log(`Deleted cache for ${cacheKey}`);
        }
        // または、特定のタグやカテゴリに紐づくキャッシュを削除するロジックを実装
    
        return NextResponse.json({ revalidated: true, message: 'Post pagination caches revalidated' });
      } catch (error) {
        console.error('Error revalidating post caches:', error);
        return NextResponse.json({ revalidated: false, message: 'Failed to revalidate caches' }, { status: 500 });
      }
    }
    

    このrevalidate-posts-cacheエンドポイントは、記事が更新されるたびに(例えば、管理画面から記事を公開した際や、僕の自動生成パイプラインで新しい記事がデプロイされた際)HTTPリクエストを送ることで呼び出します。

パフォーマンスとコストへの影響

このUpstash RedisとVercel Edge Functionを組み合わせた無限スクロールキャッシュ戦略を導入した結果、僕のAIオウンドメディア群では以下のような効果が出ています。

  • 表示速度の劇的な向上: キャッシュヒット時には、数ミリ秒単位でデータを返せるようになり、ユーザー体験が大幅に向上しました。体感速度は段違いです。
  • Vercel Serverless Function実行時間の削減: データベースへの直接アクセス回数が減り、API Routeの実行時間が短縮されました。これはVercelの料金最適化に直結します。
  • データベース負荷の軽減: データベースへのクエリ数が大幅に減少し、スループットの安定に寄与しています。
  • Upstashのコスト効率: 無料枠でもかなり使えるため、現時点ではコストをほとんど気にせず運用できています。アクセスが増えても使った分だけ課金なので安心です。

「一人で大きな価値を生み出す完全自動化」を目指す上で、このようなインフラレベルの最適化は欠かせません。Next.js + Gemini API + Vercel Cronで毎日自動生成される記事が、高速にユーザーに届けられるのは大きなメリットです。

まとめ

Upstash RedisとVercel Edge Functionを組み合わせた無限スクロールキャッシュの実装は、Next.jsアプリケーションのパフォーマンス向上とコスト最適化に非常に有効な戦略です。特に、大量のコンテンツを扱うメディアサイトや、リアルタイム性がそこまで求められない一覧表示などには、積極的に導入を検討する価値があります。

僕自身、AIオウンドメディア18サイトの運用で得た知見や、Next.js + Gemini APIによる自動化パイプライン構築の経験から、今後もこうした実践的な技術情報を発信していきたいと思います。

今回紹介したようなNext.jsによる高速Webサイト構築や、Generative AIを活用したビジネス自動化、WordPressからNext.jsへの移行、SNS自動化、Gemini APIパイプライン構築など、幅広い受託開発サービスも提供しています。弊社のMediaForge AI (https://mediaforge-ai.vercel.app/) でも、AIによるコンテンツ自動生成・運用サービスを提供中です。

Webサイトのパフォーマンス改善や、Next.js App Routerでの開発にお悩みでしたら、ぜひ一度Futuristic Imagination LLC (https://www.futuristicimagination.co.jp) にご相談ください。御社の事業の自動化・効率化を、最新技術で強力にサポートさせていただきます。

ありがとうございました!

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?