SODA Engineering Blog
🌟

[Frontend Rebirth] コンポーネント設計篇 - Server 層を足した話

に公開

こんにちは。Frontend Rebirth(フロントエンドを再構築していく)チームの Maple です。

前回の記事では、私たちが採用したアーキテクチャの全体像と、コロケーション原則を中心とした設計思想についてお伝えしました。その中で軸になっていたのが、Container / Presentational という古典的なコンポーネント設計のパターンです。

今回はその続編として、App Router への乗せ替えのタイミングで、Container / Presentational の 2層構成に Server 層を 1段足し、3層構成へと再設計した話をお伝えします。

ここで言う「Server 層」は、Next.js の React Server Component(RSC) として動作するコンポーネントを置く層のことです。サーバー側でデータ取得を行い、Suspense / ErrorBoundary の境界を配置する役割を担います。App Router の RSC の能力をきちんと活かすために、これまでの 2層に新しく 1段足した、というのが今回の話の中心です。

設計を決めるところで議論が発散し、4人のメンバーがそれぞれ別の案を持ち寄ってホワイトボードを囲みました。その時の絵と、何で揉めて、どう決着したかをそのまま書き残しておきます。あわせて、その後 server fetch を導入し、検索 API のリクエスト数を大幅に削減できた話もご紹介します。

なぜ Container / Presentational に Server 層を足すことになったのか

前回の記事でも触れたとおり、Container / Presentational は私たちが長らく採用してきたコンポーネント設計のパターンです。コンテナ側に状態取得とロジックを寄せ、プレゼンテーションは受け取った Props を表示するだけにする、という古典的な構成を、私たちは長く採用してきました。

問題は、運用して暫く経つとだんだん見えてきました。初期描画に必要なデータも、orval で生成した useGetV* 系の hooks でクライアントから取りに行く構造のままになっていました。検索結果ページのように meta description に商品件数を出したい場面では、generateMetadata の中で 1回 fetch して、レンダリング側でも別途 hook で fetch して、と同じ API を二重に叩く構造になりかけていました。

「Server Component を素直に使えば、ここの fetch は 1回で済みますし、HTML として返せるため初期描画も速くなる」というのはチーム全員が分かっていました。ただ、ディレクトリ設計と層構造をどう変えるかで意見が分かれていました。

ホワイトボードを囲んだ日

ある日の FigJam がこちらです。

4人がそれぞれ別の構造をスケッチしたホワイトボード

4人のメンバーがそれぞれ別の構造をスケッチしました。Server 層を新設する案、Container を Server 用と Client 用に分割する案、ページごとに RSC 利用の有無で 2層と 3層を使い分ける案——どれも一長一短で、その場では決着しませんでした。

議論を整理するうちに、論点は突き詰めると 3つに絞れることが見えてきました。先に結論として採用した構造をお見せし、そのあと 3つの論点を順にご紹介します。

採用した 3層構造

最終的に私たちは、user アプリを server → container → presentational の 3階層 で構成し、データ取得は Server 層、状態は Container 層、UI は Presentational 層、という分担に落ち着きました。

責務 ディレクティブ
Server サーバー側データ取得、Suspense / ErrorBoundary 境界の配置 なし(= RSC)
Container 状態管理、イベントハンドリング、ロジック "use client"
Presentational Props を受け取って描画するだけ、副作用なし なし

ディレクトリ構造はこのようになっています。

src/
├── app/
│   └── products/
│       ├── hooks/                       # API fetch、Server Actions
│       │   └── useProductsContainer/    # Container と 1:1 対応
│       │       ├── index.ts
│       │       └── index.test.ts
│       ├── constants/                   # ページ固有の定数
│       │   └── index.ts
│       ├── commons/
│       │   └── components/              # PC / SP 共通のコンポーネント
│       ├── pc/
│       │   └── components/
│       │       ├── server/              # サーバー側データ取得、Suspense 配置
│       │       │   └── index.tsx
│       │       ├── container/           # 状態・イベントハンドリング
│       │       │   ├── index.tsx
│       │       │   └── ProductSectionContainer/
│       │       │       └── index.tsx
│       │       └── presentational/      # 純粋な UI 描画
│       │           └── ProductCard/
│       │               ├── index.tsx
│       │               └── style.module.scss
│       ├── sp/
│       │   └── components/
│       │       ├── server/
│       │       ├── container/
│       │       └── presentational/
│       ├── page.tsx                     # server/ を呼び出すエントリポイント
│       └── layout.tsx
└── commons/                             # アプリ横断の共通コード
    ├── components/
    ├── hooks/
    ├── utils/
    └── lib/

揉めた 3つの論点

最終的に私たちは、user アプリを server → container → presentational の 3階層 で構成し、データ取得は Server 層、状態は Container 層、UI は Presentational 層、という分担に落ち着きました。ここに至るまでに揉めた論点を 3つ、ご紹介します。

論点A: Server 層を新設するか、Container を分割するか

意見は 3つに分かれました。

最初に出た反応は「層を増やすと書くファイルが増えて面倒」というものでした。これはそのとおりで、新規ページを作るたびに index.tsx を 4つ書くことになります。一方で、層を分けることで責務を完全に切り分けられるため、長期的な保守性ではこちらが優位だという結論になりました。

「ページごとに 2層と 3層を使い分ける」案は、読み手が毎回「このページはどちらのルートか」を判断するコストが乗ります。私たちは長期保守性とレビュー時の判断速度を優先し、全ページで 3層構成に統一する選択をしました。

論点B: ディレクティブをどう設計するか

ここはディレクティブの意味そのものを誤解していたメンバーもいて、議論が長引きました。最終的には次の振り分けで合意しています。

  • Server 層: ディレクティブなし(= RSC)
  • Container 層: "use client"
  • Presentational 層: 指定しない

Presentational は親から server / client が決まるリーフコンポーネントです。自分で宣言する意味がないため、ディレクティブを書かないルールにしました。書き手が迷わないことと、レビューでの判断が速いことを優先したルールになっています。

論点C: server fetch がないページにも Server 層は要るか

server fetch を使わないページでは Server 層を省略してよい、という案も出ました。ただ、省略を許すと層構造が崩れ、読み手に「ここは省略ルートか」と毎回考えさせるコストが乗ります。私たちは Container を空通しで書く方針に統一しました。あわせて 3層チェックの CI も導入し、構造が崩れない仕組みにしています。

server fetch でキャッシュを効かせる

3層構成が決まったあとに着手したのが、Server 層での fetch 実装です。私たちの server fetch の基本方針は、「各セクションのコンポーネントが、表示に必要なデータを自分の中で fetch する」 という形に置いています。複数セクションでデータを共有したい時だけ、page.tsx で React の cache() を使ってまとめて取り、各セクションに配る、という使い分けです。

「秒単位の鮮度が必須ではない」画面は Data Cache に乗せ、高速性を取りに行く方針に切り替えました。実装としては next オプションを通せるようにして、呼び出し側で next: { revalidate } を 1行付けるだけで済むようにしています。

export const getSearchProducts = async ({ searchParams }: Props) => {
  const response = await getV3Search(searchParams, {
    next: { revalidate: DEFAULT_REVALIDATE_SECONDS },
  });

  if (!isSearchSuccessResponse(response)) {
    throw new Error("Failed to fetch search products");
  }

  return response.data.search.products;
};

Server 層で fetch する具体例

各セクションが自前で fetch する形は、トップページの最新記事セクションが分かりやすい例です。BannerSectionContainer(Client Component)に渡す initial データを、Server コンポーネントの中で直接 await して取得しています。

import { getV1MarketBanners } from "@/packages/api-client";
import { BannerSectionContainer } from "@/src/app/_app/components/container/BannerSectionContainer";

export const BannerSectionServer = async () => {
  const response = await getV1MarketBanners({
    department: "all",
    releasePattern: "new",
    tab: "media",
  });
  if (response.status !== 200) {
    throw new Error(`BannerSectionServer fetch failed: ${response.status}`);
  }
  
  const banner = response.data.banners?.[0]
  
  return <BannerSectionContainer banners={banners} />;
};

運用してみて、一番効いた効果

ここまで設計とキャッシュ実装の話を書いてきましたが、半年運用してみて一番効いたのは、検索 API を叩く件数が目に見えて減ったこと でした。

リプレイス前の構造では、検索結果ページを 1回開くたびに、メタデータ生成用にサーバー側で 1回、レンダリング用にクライアント側で 1回、と検索 API を 2回叩く構造になりかけていました。cache() で同一リクエスト内に統合した時点で、まず 1ユーザーあたり 1回に減ります。

さらに revalidate: 60 を入れたことで、同じ検索パラメータで来た複数ユーザーのリクエストは Data Cache を共有するようになりました。実際に backend に届く件数は、極端に言えば「60秒に 1回 × 検索パラメータの種類分」まで圧縮されます。検索 API は私たちのアプリの中でも重い処理に分類されるため、件数そのものが減ること自体で、インフラ側の余裕が大きく変わってきます。

「backend へのリクエスト数そのものが減る」という事実は、レイテンシ改善以上にアプリ全体のコストに効いてきます。RSC への移行を検討されている方には、レイテンシよりも先に「叩く件数が減る」という観点を見積もっていただきたいと思いました。

また、スニダン(スニーカーダンク)はスニーカー・トレカ・アパレルなどを扱うマーケットプレイスで、ユーザーの大半は「商品を探す」「相場を見る」「ランキングを眺める」といった、ページに表示されている情報を見るために訪れる タイプのサービスです。この特性が、Next.js の server fetch と非常に噛み合いました。

まとめ

Container / Presentational の 2層に Server 層を 1段足すという、コンポーネント設計上は地味な変更でした。それでも、その地味な変更を「どう書くか」のルールまで含めて整えた結果、レビューの判断速度が上がり、backend への API リクエスト数も減り、初期描画も速くなりました。

また、私たちが今回の設計で最もこだわったのは、「各層は 100% その責務しか持たない」と言い切れる状態を作る ことでした。「Presentational には絶対にロジックが入らない」「Container にだけロジックが集まる」という 100% の責務分離が、Container / Presentational パターンの最大の価値です。Server 層を 1段足した今回の変更でも、その思想を一切ぶらすことなく引き継げたこと。これが今回の設計における最大の成果だと思っています。

SODA Engineering Blog
SODA Engineering Blog

Discussion