wheatandcatの開発ブログ

技術系記事を投稿してます

Next.jsのrevalidateTagを使用してNeonの課金を改善した話

概要

前回の記事に続き、今回も KAWKAW の話。

www.wheatandcat.me

今回は、レビュー保存に使っている Neon の課金まわりを改善したので、その内容をまとめる。

PR

github.com

Neonとは

neon.com

Neon はサーバーレス前提で設計された PostgreSQL サービス。
起動している間だけ課金され、アクセスがない時間はコストが発生しにくいのが特徴。

  • 無料プランでは 1か月あたり 1 CPU・100 時間まで利用可能
  • DB ブランチ機能もあり、開発環境と本番環境の切り分けにも使いやすい
  • 今回の KAWKAW では主に「使っていない時間に止まる」性質に期待して採用した

発生していたコストの問題

モニタリングを確認したところ、DB が 24時間ずっと起動した状態 になっており、継続的に課金が発生していた(5分間アクセスが無ければ、インスタンスが落ちて課金されない仕組みになっている)。
これではサーバーレス DB の良さを活かせていないと感じ、改善を始めた。

KAWKAWの仕組みについて

KAWKAW での DB 利用はかなりシンプルで、基本的には以下の 2 つだけ。

  • 対象商品にレビューを投稿する API
  • 対象商品のレビュー一覧を取得する API

つまり、新しいレビューが投稿されるまでレビュー一覧は変化しない
そのため、取得時は毎回 DB に接続せず、キャッシュを返す構成にすれば DB アクセス数をかなり減らせると考えた。

Next.jsのrevalidateTagを使ったキャッシュ機構

nextjs.org

Next.js には Tag ベースのキャッシュ機構があり、以下のように fetch すると posts というタグ名でキャッシュされる。

await fetch("https://api.example.com/posts", {
  next: { tags: ["posts"] }
})

この状態では、2回目以降はキャッシュが返る。
ただし、これだけだと更新時にキャッシュが残り続けるため、revalidateTag で該当タグを無効化する必要がある。

import { revalidateTag } from "next/cache"

revalidateTag("posts")

実装

今回は外部 API の fetch というより、DB アクセスそのものを減らしたい ため、メソッド単位でキャッシュできる unstable_cache を使った。

レビュー一覧取得側

商品 ID ごとにタグ付きキャッシュを作成する。

apps/public/app/api/reviews/[productId]/route.ts

import { NextResponse } from "next/server";
import { unstable_cache } from "next/cache";
import { storage } from "@kawkaw/database";

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ productId: string }> }
) {
  const { productId } = await params;
  const getCachedReviews = unstable_cache(
    () => storage.getReviewsByProductId(productId),
    [`reviews-${productId}`],
    { tags: [`reviews-${productId}`] } // ← 商品IDごとにタグを作成
  );
  const reviews = await getCachedReviews();
  return NextResponse.json(reviews);
}

レビュー投稿側

レビュー投稿後に、対象商品のタグだけを無効化する。

apps/public/app/api/reviews/route.ts

import { NextResponse } from "next/server";
import { revalidateTag } from "next/cache";
import { storage, insertReviewSchema } from "@kawkaw/database";

export async function POST(req: Request) {
  const body = await req.json();
  const parsed = insertReviewSchema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json(
      { message: parsed.error.issues.map((i) => i.message).join(", ") },
      { status: 400 }
    );
  }

  const review = await storage.createReview(parsed.data);
  revalidateTag(`reviews-${parsed.data.productId}`); // ← 対象商品のキャッシュだけ無効化
  return NextResponse.json(review, { status: 201 });
}

この構成にすると、新しいレビューが投稿されるまで DB へアクセスせずにキャッシュを返せる

改善後のモニタリング

改善後は、アクセスがないタイミングでインスタンスがちゃんと停止するようになり、継続課金を抑えられるようになった。

まとめ

  • レビュー一覧のような「更新頻度が低い読み取り系」はタグ付きキャッシュと相性が良い
  • unstable_cacherevalidateTag を組み合わせることで、DB アクセス回数をかなり減らせる
  • サーバーレスな DB は「止まること」に価値があるので、定期的にモニタリングして改善していきたい