WED Engineering Blog
😇

Next.js App Routerのfetchがキャッシュされないケースについて考える

2024/09/05に公開

WED株式会社でバックエンドだったりフロントエンドだったりのエンジニアをやっている宮崎です。
基本的にはONEという、レシートがお金に変わるサービスに関わる開発全般を行なっています。

Next.jsにおけるキャッシュ

皆さんApp Routerは使っていらっしゃるでしょうか?
私は自社サービスであるONEの管理画面に今年の4月に採用し、それ以来使用しています。
導入前まではPages Routerのみで動いていましたが、社外に向けた管理機能を作ることになり、社外向けの実装は全てApp Routerに寄せています。
レンダリング処理のうちサーバが占める割合が必然的に増え、それに伴って様々なキャッシュが勝手に働くようになって...
と、導入を決めたときには「これはいいな!」と思ったものでした。

Next.jsで働くキャッシュには下記のものがあります。

  • Request Memoization
  • Data Cache
  • Full Route Cache
  • Router Cache

公式ドキュメントにももちろん書いてありますし、解説している記事もたくさんあるので、詳細はそちらに任せるとして本記事に関係ある箇所をサラッと解説すると

Request Memoization

レンダリング時に、コンポーネントツリー上の同一とみなされるリクエストを一つにまとめる機能。
Next.jsが独自に拡張したfetch APIによるリクエストに適用される。
ツリー上のどのコンポーネントで複数回取得しようとまとめてくれるので、親で取得したデータを子、孫、...とバケツリレーしなくてもよくなります。

Data Cache

同一のリクエストに対するレスポンスをキャッシュしてくれる機能。
こちらもRequest Memoization同様、Next.js独自拡張のfetch APIによるものに適用される。
Request Memoizationとの違いは、1つのコンポーネントツリー内で適用されるのではなく、アプリケーション全体に適用されることにあります。

使ってみた

Prismaを同時に導入していたこともあり、App Router側の実装ではfetch APIを使う機会は相当に少ないものでした。
(なお、Prismaに関してもAccelerateによるキャッシュは可能ですが、課金が必要なのとそこまで速度を求める性質のプロダクトではないのとで、現時点で導入は考えていません。)
しかしながら2つのAPIのレスポンスを処理した、取得時以降ほぼ変わり得ない値を4ページで利用したいという要望が生まれ、それらの処理をまとめたAPIを1つ作成することとしました。
複数ページに同じ処理をコピペしたくないというDRY原則的な意味合いの方が大きく、キャッシュを効かせたいというのは副次的な目的ではありましたが。

ダメだった

結論から言うとキャッシュ効いてなかったです😇
そういえば .next/cache/fetch-cache をちゃんと眺めたことがなかったなと思い、色々いじりながら遊んでいたときに発覚しました。

なぜ効かなかったのか

公式ドキュメントにも書いてあるのでご存知の方も大勢いらっしゃるとは思いますが、fetchのキャッシュには無効化される条件があります。
下記のいずれかを使用していることです。

  • cookies
  • headers
  • (searchParams prop(これはキャッシュを無効化すると言うよりDynamic Renderingを引き起こすという意味だと思われる))

より具体的には、これらを呼び出した直後のfetchからキャッシュが効きません。
戻り値を利用しようがしまいが関係ないです。

SomeComponent.tsx
import { headers } from 'next/headers'

const SomeCompoent = async () => {
  const resA = await fetch('/some/api/a') // これはキャッシュされる
  const jsonA = await resA.json()

  headers()

  const resB = await fetch('/some/api/b') // これはキャッシュされない
  const jsonB = await resB.json()
  return (
    <div>
      <p>{jsonA.title}</p>
      <p>{jsonB.title}</p>
    </div>
  )
}

さて困りました。前述の通り、DRY原則を守りたかったという側面が強かったのでさほど問題はないですが、それでも狙った通りの挙動になっていないというのはエンジニアとして看過できないですね。
このプロダクトでは特定のAPI呼び出しの際、下記のような理由から、headers()を利用することが必須です。

  • APIの呼び出せる条件は、サイトへのログイン
  • 今回問題が発生したAPIは、内部処理としてヘッダからセッション情報を取得し、細かく権限を見ている

前者はAuth.jsのmiddleware設定、後者もまたAPIの処理内でAuth.jsからセッションを取得しています。

どうするか

例えばAuth.jsのmiddleware設定からAPIのパスを除外すれば、Auth.jsとは関係なく呼び出しが可能になります。
APIキーでも用意してあげてfetch呼び出し時に付加してあげれば、App RouterのServer Component上での出来事なのでクライアント側から見えることもないでしょう。CORSの設定もすればさらに安心です。
細かい権限チェックはどうなるでしょうか?
既存ではユーザ情報の取得はAPI内部でリクエストヘッダから行なっていましたが、それをコンポーネントに移動するしかなさそうに思えます。
取得したセッション情報を元に付加するAPIキーを使い分けることで、API内部で誰が呼んだのかを判断する方法に切り替える必要がありそうです。

もちろんキャッシュを諦めるというのも選択肢の一つです。そもそもにおいてheadersやcookiesを呼び出した後レスポンスのキャッシュがされないのは、これらの関数がリクエストによって異なる値を返し、それを利用したAPIも異なるレスポンスを返し得るからです。headersやcookiesが必要ということは、すなわちキャッシュできない、あるいはキャッシュすべきでないレスポンスが返ってくるということであり、無理を押してキャッシュを使うほどではない状況であることも多いのではないでしょうか。

反省

やや盲目的にNext.jsがよしなにやってくれるだろうと信じ込んでいたというのもありますが、特定の機能を使っているつもりで使えていなかったのは、エンジニアとしてなかなかに恥ずかしいことです。
今回のケースではキャッシュが必須要件ではなかったとはいえ、公式ドキュメントをしっかりと読み込むなり、挙動を自分の目できちんと確かめるなりしていれば防げていたものでもあります。
しかしながらミスや勘違いは誰にでも起き得ることです。(自己弁護)
本記事で取り上げているような技術スタックやユースケースは決して珍しいものではないと思われます。
願わくば、この記事が同じように勘違いしている方やキャッシュされない原因がわからない方の一助となることを。

WED Engineering Blog
WED Engineering Blog

Discussion