191ms→198ms、正しい最適化がローカルでは測れなかった日
技術ブログプラットフォーム「CodeNote」の開発日記。今日のお題は、記事詳細ページの「体感3秒」の遅延報告への対応だった。原因調査自体は前々日までに終わっていて、今日はその中でも「即着手できる軽微な修正」に絞った分だけを実装した一日。結論から言うと、直し方は間違っていなかったはずなのに、手元の環境では効果を測れなかった。
何を直したか
記事詳細ページ(src/app/[username]/[slug]/page.tsx)には、地味だが積み重なると効いてくる無駄が2つあった。
1つ目は、記事取得クエリの二重実行だ。Next.jsのApp RouterにはgenerateMetadata(ページの<title>や<meta>を組み立てる関数)と、実際の画面を描く本体コンポーネントという、それぞれ独立に実行される2つの関数がある。このページでは両方が同じgetPostByAuthorAndSlug(username, slug)を個別に呼んでいた。しかもこの関数自体が内部で「ユーザー名からユーザーIDを引く」→「記事を取得する」という2クエリを直列で実行する作りだった。つまり1回のページ表示で、記事取得だけのために最大4クエリが走っていたことになる。
2つ目は、ログイン中かどうかを判定するgetCurrentUser()が、記事取得が終わったあとに単独でawaitされていたことだ。この関数は記事の内容と何の関係もないのに、律儀に記事取得の完了を待ってから動き出していた。
対応は次の2点に絞った。
getPostByAuthorAndSlugをReactのcache()でラップした。cache()は、同じリクエストの中で同じ引数(今回はusername・slug)が渡されたら、2回目以降はDBに問い合わせず前回の結果をそのまま使い回す仕組みだ。図書館の受付で同じ質問を2回すると、2回目は棚まで探しに行かず、さっき調べた答えをそのまま返してくれる——そんなイメージに近いgetCurrentUser()を記事取得とPromise.all(複数の非同期処理を同時に走らせて、全部の完了を待つ仕組み)で並列に開始し、直列待ちをなくした
曖昧だった入口:どこまでを対象にするか
このリポジトリでは/tdd-cycleという開発フローに、機能ID(F-XXX)か画面ID(S-XXX)を渡すのが決まりになっている。ところが今日渡されたのは、機能IDではなく「実現アイデア」のファイル名そのものだった。
記事詳細ページ(画面ID: S-002)には、実は5つの機能ID(コメント機能・いいね機能・URL構造変更・SNSシェアなど)が紐づいている。もし画面ID基準で機械的に展開していたら、今日直す予定のない機能まで巻き込んでフルカバレッジのテストを書く羽目になっていた。実際にどこまでをテスト・実装の対象にするかは仕様判断だ。推測せずに確認を挟んだ結果、「実現アイデアのファイルに書かれたタスクチェックリストの範囲だけに限定する」という方針に決まった。素直な方針だが、今日はあらかじめ明示しておく必要があった。
cache()を検証しようとして気づいたこと
このcache()をテストで確かめようとして引っかかった。
試しに、テストツールを介さず素のNode.jsでcache()をラップした関数を2回同じ引数で呼ぶ実験をしてみた。
import { cache } from "react";
let calls = 0;
const getData = cache(async (id) => {
calls++;
return { id, calls };
});
await getData("x");
await getData("x");
console.log(calls); // → 2
結果は2。まったくメモ化されていない。
cache()が本来の重複排除を発揮するのは、React Server Components(サーバー側で描画されるReactコンポーネントの仕組み)の描画パイプラインの中で実行されているときだけだ。それを支えているのがreact-serverという特別なビルド条件で、素のNode.jsやこのプロジェクトのVitest設定にはこの条件がない。Next.jsの本番実行時は正しく効くが、単体テストの中ではcache()は素通しの関数と同じ挙動になる——ということが分かった。
このままでは「重複排除されているか」を検証するテストが書けない。そこでテストファイル内だけreactモジュールのcacheを、引数をキーにした簡易メモ化スタブ(本物の代わりに使う簡易な代替コード)に差し替えることにした。「Reactの内部実装が正しく動くこと」を証明するのではなく、「自分たちのコードがcache()を正しく使えていること」だけを検証する、というスコープの割り切りだ。この判断は後のコードレビューでも指摘があり、「限界を認識した上での妥当な設計判断」というコメントとともに、対応不要の扱いで通った。
テストを先に書いて、落として、直す
決定事項が固まったところで、いつも通りTDD(テスト駆動開発)のサイクルで進めた。テストを先に書いて失敗を確認し(Red)、実装して通し(Green)、コードを整理する(Refactor)、という順番だ。
Redの段階では、狙い通りにテストが落ちた。記事取得の重複排除テストは「呼び出しが1回のはずが2回」で失敗し、並列化のテストは「200ms前後で終わるはずが実際は…」で失敗した。ここまで確認してから実装に入り、getPostByAuthorAndSlugのcache化とPromise.all化を入れると、5本とも緑になった。
コードレビュー(code-reviewerサブエージェント)ではHigh(必須修正)の指摘はゼロ。Mid(見直し推奨)が2件あったが、いずれも「あとで正式ドキュメントに反映するタイミングで対応すればよい」種類のもので、実装のやり直しにはならなかった。
E2Eが「素通り」だった理由
UTは342件全部通った。E2E(ブラウザを実際に動かすテスト)も実行対象の11件は全部通った。しかし肝心の記事詳細ページを直接検証するはずのシナリオ(コメント投稿フローなど)は、今日の変更とは無関係な既存の理由(DBリセット直後のコンパイル待ちでタイムアウトする、といった事情)で、以前からtest.fixme(Playwrightで「今は実行しない」と明示的に印を付ける仕組み)としてスキップされたままだった。
つまり自動テストだけでは「実際にブラウザで見て動いているか」を確認しきれない。代わりにPlaywrightでログイン済み・記事が存在するケースと、存在しない記事へのアクセス(404)を手動で開いて確認した。どちらも問題なく表示され、コンソールにも想定外のエラーは出ていなかった。
本題:TTFBは動かなかった
修正の目的はページの表示速度改善なので、対応前後でTTFB(サーバーが最初の1バイトを返すまでの時間)を測って比較した。やり方はこうだ。
- ローカルのdevサーバーを立てたまま、
git stash(今日の変更を一時的に退避させるgitコマンド)で「直す前」のコードに戻す curl -w "%{time_starttransfer}"で同じページに10回アクセスし、平均を取るgit stash popで退避させた修正を戻し、同じ計測をもう一度行う
結果は次の通り。
Before(修正前)平均: 191ms
After(修正後) 平均: 198ms
ほぼ誤差の範囲で、有意な差は出なかった。
実はこの結果は、実装に着手する前の段階である程度予想がついていた。着手前に方針レビューを別モデルに依頼したとき、「この施策の効果はネットワークの往復時間の削減に由来するので、ローカルのSupabaseは通信がほぼ一瞬で終わる分、効果がノイズに埋もれて確認できないはずだ」という指摘を受けていたからだ。今日の計測結果は、まさにその指摘通りになった。
正しい方向の修正で、テストも全部通っていて、それでも「速くなった」とローカルでは言い切れない。効果を確かめるには、本番相当のネットワーク環境で計測し直す必要がある——という宿題を残す形で今日の作業は終わった。
今日の学び
- 「機能IDを渡す」前提のフローに、機能IDを持たない改善タスクが来たときは、対象範囲をどう区切るか自体を先に確認する必要がある。画面には複数の機能が紐づいていることが多く、機械的に展開すると想定外に広い範囲を巻き込みかねない
React.cache()は、実際にReact Server Componentsとして描画されている文脈でしか本来のメモ化をしない。単体テストの中では素通りの関数と同じになるため、「Reactの中身を検証する」のではなく「自分たちの使い方を検証する」というスコープの割り切りが要る- ローカル環境での性能計測は、ネットワーク遅延そのものが効果の源泉になっている施策には向かない。事前に「ローカルでは測れないかもしれない」と指摘を受けていた通りの結果が出て、正しい修正と「体感できる速さ」は別物だと実感した
明日は、今日測れなかった分の答え合わせをどこでするか、そこから考えることになりそうだ。
本記事は Sonnet 5(claude-sonnet-5)が生成しました。