ジャンプTOON Next.js App Router の活用〜得られた恩恵と課題〜

目次

  1. はじめに
  2. Colocation を意識した設計方針
  3. Parallel Routes と Intercepting Routes を用いた設計パターン
  4. サーバー側に処理を寄せたことによる恩恵と課題
  5. Next.js が抱える課題
  6. おわりに
  7. 参考文献

はじめに

ジャンプTOON のWeb版(以降、ジャンプTOON Web)の開発を担当している浅原昌大(@assa1605)です。

5 月にサービスを開始した「ジャンプTOON」は、オリジナル縦読みマンガ作品や人気作品のタテカラー版を連載する、ジャンプグループ発の新サービスです。

ジャンプTOON のフロントエンドには、Next.js を採用し開発をしています。 本記事では、Next.js の最新機能や設計パターン、Next.js を採用した恩恵と現在の課題について紹介します。

  • Colocation を意識した設計方針
  • Parallel Routes と Intercepting Routes を用いた設計パターン
  • サーバー側に処理を寄せたことによる恩恵と課題
  • Next.js が抱える課題

ジャンプTOON Web のアーキテクチャの全体像については以下の記事をご覧ください。

ジャンプTOON の Web アプリの全体像

Colocation を意識した設計方針

私たちのチームでは、Next.js App Router を採用しており、Colocation を意識した設計を行なっています。本章では、App Router と Colocation を用いたジャンプTOON Webの設計方針について紹介します。

App Router と Colocation

Colocation とは、関連するモジュールやデータを近くに配置することで保守性を高めようとする設計思想を指します。

Next.js の App Router は、この Colocation と密接な関連性があります。その理由として、Project Organization and File Colocation であるように、App Router から Colocation に適したディレクトリ構成が可能になりました。

また AppRouter は、React18 で導入された Suspense を最大限に活用しています。Suspense を用いる際には、データのフェッチをコンポーネントの近くで行うように設計するのが一般的であり、これも Colocation の概念に合致しています。このため、App Router では Colocation という考え方は非常に重要で、設計においても Colocation を意識する必要があります。

ディレクトリ構成やファイルの配置

私たちのチームでは、Colocation の考え方に基づいてディレクトリ構成やファイルの配置を決めている部分があります。

src 内は、ファイルベースルーティングに使用される app を含めた 7 つのディレクトリで構成されています。その中でも app ディレクトリと features ディレクトリでは Colocation を守るようにし、その他は機能を跨いで利用可能な汎用のタイプ分類になっています。

├── app/         # ルート 
├── features/   # 汎用の機能分類モジュール 
├── components/ # 汎用の UI コンポーネント
├── hooks/      # 汎用の hooks
├── styles/     # 汎用のスタイル
├── lib/        # アプリケーション向けに調整したライブラリの再エクスポート
└── config/     # アプリケーション全体で利用する設定ファイル

app ディレクトリでは、プライベートディレクトリを用いてページ固有の UI コンポーネントを page.tsx の近くに置く方針にしています。

複数のページにまたがる機能モジュールやドメインに依存したロジックなどは、features ディレクトリに置きます。この際、コンポーネントに必要な hooks、test、静的アセットなどはコンポーネントの側に置き、Colocation を守るようにしています。

また、features ディレクトリでは feature 同士で再利用することを可能としています。そのため、互いの feature でドメインにまつわるロジックが必要となったときに、どちらに置くか悩むことがあります。チーム内では何度か「domain ディレクトリを用意した方がいい?」という意見がでていました。しかし、domain ディレクトリを用意するとロジックと機能が分かれてしまうことになるため、Colocation を守ることが難しくなります。

そこで私たちは、ドメインロジック用に GraphQL のスキーマファイルと同じ名前のディレクトリを features に用意しています。これにより、features ディレクトリ内での Colocation は守りつつ、ドメインロジックの置き場に困らなくなります。また、スキーマと統一することで、既存のロジックを見落として重複実装することを防ぐことができます。

src/
├── app/ # Next.js
│   ├── layout.tsx
│   ├── loading.tsx
│   ├── page.tsx
│   ├── …
│   ├── _home-carousel/ # `/` でのみ使用する機能分類モジュール
│   │   ├── index.ts
│   │   ├── home-carousel.tsx
│   │   ├── home-carousel.css.ts
│   │   ├── home-carousel.stories.tsx
│   │   ├── use-home-carousel.ts
│   │   └── …
│   └── episode/ # URL `/episode` に対応
│       ├── page.tsx
│       ├── …
│       ├── _xxx/ # `/episode` でのみ使用する機能分類モジュール
│       │   └── …
│       └── …
├── features/ # 汎用の機能分類モジュール
│   ├── comic-viewer/
│      │      ├── iijan.svg
│   │   ├── comic-viewer.tsx
│   │   ├── comic-viewer.css.ts
│   │   ├── comic-viewer.stories.tsx
│   │   ├── use-comic-viewer-hotkeys.ts
│   │   └── …
│   └── series/ # schema に合わせた domain ロジック
│       ├── index.ts
│              ├── day-of-week.ts
│       ├── series-status-label.ts
│       ├── create-series-og-image-url.ts
│       └── …
├── components/ # 汎用の UI コンポーネント
│   ├── box/
│   │   ├── index.ts
│   │   ├── box.tsx
│   │   ├── box.css.ts
│   │   └── box.stories.tsx
│   └── button/
│ 
├── lib/ # アプリケーション向けに調整したライブラリの再エクスポート
│     └── schema
│           └── graphql
│                ├── series.graphql
│                ├── user.graphql
│                ├── feed.graphql
│                └── …

Fragment Colocation の活用

ジャンプTOON Web では、クライアント・バックエンド間の通信手段として GraphQL を採用しており、 Fragment Colocation を使用しています。

Fragment Colocation とは、コンポーネントが必要とするデータを Fragment に定義して、UI コンポーネントの近くに配置する手法です。これにより、コンポーネントファイル内部では、UI が必要とするデータが明確になりコードの読み手の認知負荷を下げることができます。

Fragment の命名に関しては、全体でユニークである必要があるため、私たちのチームでは以下の命名ルールを設けています。

  • app/ にあるコンポーネントの場合: RouteNameComponentNameFieldName
  • features/ にあるコンポーネントの場合: ComponentNameFieldName
コンポーネントとFragment 定義の例

1つ目のコンポーネント

export const HeaderComicFragment = graphql(`
  fragment HeadComic on Comic {
    name
    seriesThumbnailImageUrl
  }
`);

type Props = {
  comic: FragmentType;
};

export const Header: FC = ({
  comic
}) => {
  const {
    name,
    seriesThumbnailImageUrl,
  } = getFragmentData(HeaderComicFragment, comic);
  return (
     <Image 
       alt={name} 
       src={seriesThumbnailImageUrl} 
     />
  );
};

2つ目のコンポーネント

export const DetailComicFragment = graphql(`
  fragment DetailComic on Comic {
    title
    author {
       name
     }
    //...
  }
`);

type Props = {
  comic: FragmentType;
};

export const Detail: FC = ({
  comic
}) => {
  const {
    name
    author,
  } = getFragmentData(DetailComicFragment, comic);

  return (
   <>
      {title}
      {author.name}
   </>
  );
};
 

コンポーネントを作成したら、各コンポーネントで定義した Fragment を1つの page.tsx に集約させます。この時、私たちはデータフェッチと Page コンポーネントが 1対1の関係になるような構成にして、不要なデータフェッチを増やさない工夫をしています。

Query を呼び出す page.tsx の例
const PageDataQuery = graphql(`
  query ComicPageData($seriesId: ID!) {
    comic(id: $seriesId) {
      ...HeadComic
      ...DetailComic
    }
  }
`);

const fetchPageData = async (variables: ComicPageDataQueryVariables) =>
  toApiResult(await getClient().query(PageDataQuery, variables));

const Page = async () => {
  const { data } = await fetchPageData({ seriesId: "FURUBO" });

  return (
    <> 
      <Header series={data.series} /> 
      <Detail series={data.series} /> 
    </>
  );
};

export default Page;

Query 側は Fragment を記述するだけでよく、コンポーネントが必要とするデータを知らなくてよくなるため、関心を分けて実装することができます。

このように、全体的に Colocation を守ることによって、ファイル配置の方針やコードが統一され開発速度が向上し、コードの見通しもよくなります。

Fragment Colocation は、ジャンプTOON アプリ版でも採用されており、クライアント全体でこの考え方は浸透しています。

詳しくは、ジャンプTOON Flutter × GraphQL~宣言的なアプリ開発の工夫~をご覧ください。

Parallel Routes と Intercepting Routes を用いた設計パターン

Next.js の App Router からプライベートフォルダ以外にも、様々な特別な意味を持つファイルやフォルダが増えました。本章では、その中でも Next.js 13.3 から追加された機能である Parallel RoutesIntercepting Routes を用いた開発事例を紹介します。

Parallel Routes と Intercepting Routes

Parallel Routes は、複数のルートやコンポーネントを1つのレイアウトの中に並列してレンダリングすることができる機能です。

Parallel Routes の説明
引用元: https://nextjs.org/docs/app/building-your-application/routing/parallel-routes

Intercepting Routes は、クライアント遷移時にルートの変更をキャッチし、現在のレイアウトに新しいページをレンダリングすることができる機能です。この時、ブラウザの URL は遷移先のものに上書きされます。

Parallel Routes の図解
引用元: https://nextjs.org/docs/app/building-your-application/routing/intercepting-routes

これら2つを組み合わせることで、クライアント遷移した時は現在のルート上で別のルートを Modal で表示しつつ、直接 URLを訪れた時は、そのままページを表示するようなルーティングを簡単に実装することができます。

ジャンプTOON Web での活用事例

ジャンプTOON Web では、これらを作品購入フローで活用しています。作品購入フローでは、購入したい話を選択した後、コインが不足しているとコイン選択ページ、クレジットカード入力ページに遷移します。

作品購入フロー図

作品購入フロー図

私たちは、このコイン選択ページ、クレジットカード入力ページを Parallel Routes で実装し、Intercepting Routes を用いてページの Modal 表現を可能にしています。

そのため、作品購入ダイアログ → コイン選択ページ → クレジットカード入力ページで2回 Intercept させる構成となります。

ディレクトリ構成
app/
├── @modal # クライアント遷移時に表示             
│   ├── (...)me       
│   │     ├── purchase        
│   │     └── coin  
│   │          ├── page.tsx # コイン選択ページ  
│   │                └── [coinLineupId]
│   │                     └── page.tsx # クレジットカード入力ページ
│      └── default.tsx
├── me # 直接 URL を訪れた時に表示
│   └── purchase        
│        └── coin  
│            ├── page.tsx # コイン選択ページ
│            ├── loading.tsx  
│                  └── [coinLineupId]
│                       ├── page.tsx # クレジットカード入力ページ
│                       └── loading.tsx

実際の挙動は以下になります。


これにより、コイン不足時でも作品を選んでから閲覧までページ移動を伴わないため、シームレスな読書体験を提供することができます。
(*クレジットカードの 3D セキュア画面への移動は除く)

また、通常 Modal のような見せ方をすると、共有やブックマークといった URL を利用した操作をすることができません。しかし、ページとして存在しているためこれらの利点を活かしたまま Modal 表現が可能になります。さらには、ユーザーが誤ってページをリロードしても Modal のコンテキストが保持されて状態を失わないため、ユーザー体験の向上にも繋がります。

リロード時

コイン選択ページがモーダルで表示されている。リロードすると、コイン選択ページがモーダルではなくなりページ全体に表示されている。

ここで、2回目の Intercept させているクレジットカード入力ページは、「ページとして組み立てる必要はなさそう」と思う人もいるかもしれません。 実際に、Modal の中で UI を出し分けた方が、2つ目の Modal が開く時のチラつきを抑えることができるでしょう。

しかし、Modal の中で UI の出し分けをするのではなくページとして組み立てることで、メリットが2つあります。

1つ目は、UI を出し分けるロジックが不要になり、コードが簡潔になります。
2つ目は、React Server Components (以降: RSC) を使用することができ、その恩恵を受けることができます。

RSC による恩恵

  • async / await で簡潔に非同期処理が記述可能
  • JavaScript のバンドルサイズ削減
  • Next.js の cookie 関数で Cookie をサーバー側でセキュアに参照可能

また 3D セキュア認証後は、コインと話の購入処理を実行・待機する画面を別途用意する必要がありますが、クレジットカードを入力するページをそのまま再利用できるので、実装が楽になります。

スクリーンショット:クレジットカード入力ページでローディング状態を示している。

サーバー側に処理を寄せたことによる恩恵と課題

Next.js の App Router では RSC をはじめ Server Actions が登場し、なるべくサーバー側で処理させる方針となりました。私たちのチームでは、これに則って RSC や Serve Actions を使用し、サーバー側に処理を寄せる方針にしています。 本章では、サーバー側に処理を寄せたことによる恩恵と web のパフォーマンス課題について紹介します。

RSC と Server Actions の使用と恩恵

RSC の登場によって、クライアント側でレンダリングしていた部分をサーバー側のみのレンダリングで済ませることができます。これにより、クライアントのバンドルサイズが減り、パフォーマンスの向上が見込めます。また、Server Actions を用いてサーバー側のみで機密情報などのデータを処理することにより、よりセキュアなアプリケーションにすることができます。

Server Actions の注意点と対策

ここでは、Server Actions を使用する際の注意点と対策をいくつか紹介します。

Server Actions を使用するには、関数内もしくは関数が入ったファイルのトップレベルで use server ディレクティブを宣言します。この時、Server Actions として使用される関数は非同期である必要があります。誤って同期関数にしておくとバグの温床になりますが、Eslint ではそのエラーに対するサポートがありません。そのため私たちは、Server Actions が非同期関数になっているかをチェックするカスタム Eslint を作成しています。

eslintrc.js
const config = {
  ...
  plugins: ["local-rules", "@typescript-eslint"],
  settings: {
    polyfills: nextPolyfills,
  },
  rules: {
    // Server action must return promise
    "local-rules/use-server-must-return-promise": "error",
     ...
    ],
  }
}
eslint-local-rules/index.js
require("ts-node").register({
  transpileOnly: true,
  compilerOptions: {
    module: "commonjs",
  },
});

module.exports = require("./rules").default;
eslint-local-rules/rules.ts
/* eslint-disable import/no-default-export */
import type { Rule } from "eslint";

/**
 * use-server を使用しているファイルでは、強制的に promise を返すため、
 * export されている関数が非同期であることをチェックする
 * @see https://github.com/vercel/next.js/pull/62821
 */

export default {
  "use-server-must-return-promise": {
    meta: {
      type: "problem",
      docs: {
        description:
          "Ensure exported functions in files with 'use server' directive return a Promise or are async",
        category: "Possible Errors",
        recommended: true,
      },
      schema: [],
    },
    create(context) {
      let hasUseServerDirective = false;
      return {
        Program(node) {
          // ファイル内に use server が含まれているかどうかをチェック
          hasUseServerDirective = node.body.some(
            (n) =>
              n.type === "ExpressionStatement" &&
              n.expression.type === "Literal" &&
              n.expression.value === "use server"
          );
        },
        ExportNamedDeclaration(node) {
          if (!hasUseServerDirective) {
            return;
          }
          if (
            !node.declaration ||
            node.declaration.type !== "VariableDeclaration"
          ) {
            return;
          }
          for (const declaration of node.declaration.declarations) {
            // 関数宣言以外は無視
            if (
              !declaration.init ||
              !(
                declaration.init.type === "ArrowFunctionExpression" ||
                declaration.init.type === "FunctionExpression"
              ) ||
              declaration.init.body.type !== "BlockStatement" ||
              declaration.id.type !== "Identifier"
            ) {
              continue;
            }

            let isAsync = declaration.init.async;
            // Promise を返しているかどうかをチェック
            let isPromiseReturned = declaration.init.body.body.some(
              (statement) =>
                statement.type === "ReturnStatement" &&
                statement.argument &&
                statement.argument.type === "CallExpression" &&
                statement.argument.callee.type === "Identifier" &&
                statement.argument.callee.name === "Promise"
            );
            if (!isPromiseReturned && !isAsync) {
              context.report({
                node,
                message:
                  "Exported function '{{name}}' must return a Promise or be async.",
                data: {
                  name: declaration.id.name,
                },
              });
            }
          }
        },
      };
    },
  },
} satisfies Record<string, Rule.RuleModule>;

Eslint 実行時

スクリーンショット: Server Actions。Eslint により警告が出ている。

また Server Actions の戻り値には、シリアライズ可能なデータ型を返す必要があります。ジャンプTOON Web では GraphQL クライアントに urql を採用していますが、urql クライアントから返される CombinedError に含まれる Response オブジェクトはシリアライズできません。そのため、シリアライズ可能な状態に整形する toApiResult 関数を作成して返却しています。

シリアライズ可能なデータへの変換
// *注意* 必要な関数や型は長くなるので省略してる部分があります

const createAPIError = (
  message: string,
  code: APIErrorCode
): APIError => ({
  name: "APIError",
  message,
  code,
  status: apiStatusCodes[code] ?? 500,
});

/**
 * Server Actions にも対応出来るようにシリアライズ可能な返り値に変換する
 */
export const toApiResult = >({
  data,
  error,
}: R): Result<NonNullable<R["data"]>, APIError> => {
  if (error) {
    return {
      errors: error.graphQLErrors.map(({ message, extensions }) =>
        createAPIError(message, (extensions as APIExtensions).code)
      ),
    };
  }

  return {
    data: data!,
  };
};

他には、Next.js 14 から実験的にサポートされた React の React Taint APIs を使用しています。React Taint APIs とは、React が experimental バージョンで提供する新しいセキュリティ保護機能の一つです。これにより、誤って Client Component にセキュリティ上の重要なデータが渡されることを防ぐことができます。

React Taint APIs を用いた Server Actions
"use server"

export const getSession = async (): Promise => {
  const cookie = cookies().get(cookieNames.session)?.value;

  if (!cookie) {
    return null;
  }

  const {
    userId,
    ...
  }: SessionCookie = JSON.parse(cookie);

  const session: Session = {
    userId,
    ...
  };

  // クライアントコードで使用するとエラー
  experimental_taintObjectReference(
    "Do not pass user session to the client",
    session
  );

  return session;
};

サーバー側で行うユーザー認証

ジャンプTOON Web では、ユーザーの認証において Firebase を使用しています。一般的には、Firebase SDK を使用して認証を行うことが多いでしょう。しかし、Firebase SDK はクライアント側で処理されるものであり、Server Actions と組み合わせることができません。そのため、Identity Platform API で提供される REST API を使用して認証を行なっています。

fetch-accounts.ts
import { serverConfig } from "~/config/server-config";
import {
  FirebaseAuthError,
  createFirebaseAuthError
} from "~/lib/firebase/auth/errors";
import { Result } from "~/lib/types";

const BASE_URL = "https://identitytoolkit.googleapis.com/v1/accounts";

type ErrorResponse = {
  error: {
    /** HTTP ステータスコード */
    code: number;
    /** 開発者向けのエラーメッセージ */
    message: string;
  };
};


/**
 * Identity Platform の accounts リソースへリクエストする
 * https://cloud.google.com/identity-platform/docs/reference/rest/v1/accounts
 */
export const fetchAccounts = async (
  method: string,
  request: Req
): Promise<Result<Res, FirebaseAuthError>> => {
  const url = `${BASE_URL}:${method}`;
  const init: RequestInit = {
    method: "POST",
    cache: "no-store",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(request)
  };
  const res = await fetch(`${url}?key=${serverConfig.firebase.apiKey}`, init);

  if (!res.ok) {
    try {
      const data: ErrorResponse = await res.json();
      const errors = [createFirebaseAuthError(data.error.message)];

      return { errors };
    } catch {
      const message = await res.text();
      const errors = [createFirebaseAuthError(message)];

      return { errors };
    }
  }

  return {
    data: await res.json()
  };
};
sign-in-with-phone-number.ts
import { fetchAccounts } from "./fetch-accounts";

type SignInWithPhoneNumberRequest = {
  /** sendVerificationCode から得られる sessionInfo */
  sessionInfo: string;
  /** ユーザーの電話に送信された SMS 認証コード */
  code: string;
  /** 渡された idToken のアカウントに電話番号をリンクする */
  idToken?: string;
};

type SignInWithPhoneNumberResponse = {
  /** 認証されたユーザーの ID トークン */
  idToken: string;
  /** 認証されたユーザーのリフレッシュトークン */
  refreshToken: string;
  /** ID トークンの有効期限 (秒) */
  expiresIn: string;
  /** ユーザーの uid */
  localId: string;
  /** 認証されたユーザーの電話番号 */
  phoneNumber: string;
  /** 認証されたユーザーに対して新しいアカウントが作成されたか */
  isNewUser?: true;
  /** 電話番号が別のユーザーにリンクされている場合に発生する電話番号認証の証明 */
  temporaryProof?: string;
};

/**
 * 電話番号でログイン
 * https://cloud.google.com/identity-platform/docs/reference/rest/v1/accounts/signInWithPhoneNumber
 */
export const signInWithPhoneNumber = (req: SignInWithPhoneNumberRequest) =>
  fetchAccounts<SignInWithPhoneNumberRequest, SignInWithPhoneNumberResponse>(
    "signInWithPhoneNumber",
    req
  );

電話番号でサインインするフロー図
電話番号でサインインするフロー図

この方法は、従来の Firebase SDK と比べて以下の利点があります。

  • API key その他の秘匿情報や認証処理内容がサーバー側に隠蔽される
  • HttpOnly Cookie を利用することで対スクリプトインジェクションに対して一定の効力が期待できる
  • 認証が必要な URL に対してサーバーでリダイレクト処理が出来る

特に、「認証が必要な URL に対してサーバーでリダイレクト処理が出来る」においては、ユーザー体験に直結します。例えば、検索や SNS から認証が必要な話に直接遷移した時に、サーバー側でリダイレクト先をハンドリングできるため、画面を素早く表示できます。

購入していない有料話に遷移した時は作品詳細に自動リダイレクトしている様子


このように、クライアント側でやる仕事をサーバー側で行うことでさまざまな恩恵を得ることができます。

その一方で、サーバー側に処理を寄せたことで課題もあります。サーバー側で認証を行うということは、サーバー側で Cookie の参照を行い、リクエストヘッダー部分に Cookie を含める必要があります。 Next.js の cookies 関数を実行することになるため、自動的にページ全体が動的レンダリングに切り替わってしまいます。動的レンダリングに変わると、Full Route Cache が適用されず、デフォルトでページのレンダリング結果をキャッシュしてくれません。

悲しいことに私たちの全てのページが動的レンダリングになっています。動的レンダリングになっているページは、Build 結果から確認することができます。

Build 結果(一部省略)
Route (app)                                                  Size     First Load JS
┌ ƒ /                                                        18.8 kB         231 kB
├ ƒ /announcement/general                                    204 B           209 kB
├ ƒ /announcement/general/[announcementId]                   1.1 kB          194 kB
├ ƒ /announcement/important                                  205 B           209 kB
├ ƒ /announcement/important/[announcementId]                 1.1 kB          194 kB                                                                   
├ ƒ /fsa                                                     192 B           186 kB
├ ƒ /help                                                    2.3 kB          186 kB
├ ƒ /help/contact                                            4.01 kB         188 kB                            
├ ƒ /sct                                                     193 B           186 kB
├ ƒ /search                                                  13.9 kB         230 kB
├ ƒ /search/[keyword]                                        8.29 kB         220 kB
├ ƒ /series/[seriesId]                                       14.9 kB         259 kB
├ ƒ /series/ranking                                          5.08 kB         212 kB
├ ƒ /signin                                                  196 B           206 kB
├ ƒ /signup                                                  197 B           206 kB
├ ƒ /tba                                                     193 B           186 kB
├ ƒ /terms                                                   193 B           186 kB
└ ƒ /thanks                                                  193 B           186 kB
+ First Load JS shared by all                                ...
  ├ chunks/...js                                             ...
  ├ chunks/...js                                             ...
  └ other shared chunks (total)                              ...


ƒ Middleware                                                 ... kB

○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

利用規約などの静的であるべきページも動的レンダリングになっている理由は、ジャンプTOON Web のヘッダーに認証を必要とする UI があるからです。未ログイン時は、「ログイン・新規登録」、ログイン時は、「マイページ」と表示するには、認証済みである必要があります。

このヘッダーは全ページで表示しているため、静的であるべきページに関してもキャッシュができなくなっています。

トップページ

スクリーンショット: トップページ。マイページが枠で囲われ強調されている。

しかし、ジャンプTOON Web アプリケーションの全体像 でも記載されていますが、未ログイン時に限り HACK 的な実装で全てのページをキャッシュし、この問題を解決しています。

ジャンプTOON Web では未ログイン時の時に限りミドルウェアで Cache-Control ヘッダーに public, max-age=0, s-maxage=60, stale-while-revalidate=60, stale-if-error=86400 を設定し、 60 秒間オリジンにリクエストが飛ばないようにしています。ただ、ハック的な実装になってしまうためできる限り Next.js に準拠した方法でこれを実現することが望ましいです。

引用元: https://developers.cyberagent.co.jp/blog/archives/49294/

インフラ構成とパフォーマンス課題

ジャンプTOON Web では、Next.js のサーバーのホスティング環境に Google Cloud Run を採用しており、CDN は Fastly を使用しています。一般的に Next.jsアプリケーションのデプロイ先として Vercel がありますが、見積もりと予算感からコストが高額と判断したため、採用を見送りました。

ジャンプTOON Web のインフラ構成

ジャンプTOON Web のインフラ構成

セルフホスティングをしているため、リクエスト数の見積もり、負荷試験、日々のメトリクス監視などがビジネス要件を満たせているかをより慎重に検証する必要があります。

その中でも負荷試験においては、Webのパフォーマンスが目標としていた数値に達していない問題に直面しました。ここでは、負荷試験の方法や結果の一部を抜粋して紹介します。

負荷試験の実施と結果

私たちのチームでは、負荷検証ツールとして JMeter を使用して試験を行いました。

【想定するシナリオ】

  • ホーム等を回遊して無料作品を閲覧するユーザ
  • 本棚に登録している作品を読むユーザ
  • コメント、いいじゃんとかコミュニティ系の機能にアクティブに参加するユーザ

【試験内容】

  • 想定するシナリオを24時間実施
  • DAU 60万想定で実施

【試験結果】
出来る限り純粋なアプリケーションの性能を可視化するために Fastly を除いた Cloud Run の結果を示します。

Latency p95: 292 ms
p99: 409 ms
Request Per Second (RPS) 108 req/s
サーバ台数 20 台(2core, 4GiB)

サーバー20台の時の RPS が108 req/sであることから、サーバー1台あたりの RPS は 108 req/s ÷ 20 = 5.4 req/s という結果となりました。

この結果は私たちの期待を大きく下回っているため、いくつかのチューニングを検討しています。

パフォーマンスを向上させるための今後の方針

私たちは、Web パフォーマンスのボトルネックとなっているのはキャッシュ率だと考えています。実際、Fastly でのキャッシュ率は30%前後となっており、オリジンへのリクエストが多くなっています。

スクリーンショット:Fastlyでのキャッシュ率

これは、前述した通りログイン時に全ページのレンダリング結果がキャッシュできていないのが要因としてありそうです。

そこで私たちは、 Next.js v15 のリリース候補版にある Partial Prerendering (以降: PPR) という技術に注目しています。PPR はページ全体を静的レンダリングにしつつ、部分的に動的レンダリングすることを可能にするレンダリングモデルです。

これにより、ログイン時でも Next.js 準拠の Static Rendering を通してレンダリング結果をキャッシュできる可能性があります。 私たちは、クライアントサイドで認証する実装に置き換えて、動的レンダリングにならないようにすることも検討していましたが、一旦この PPR の技術を待つことにしています。

Next.jsのドキュメントで紹介されてるECサイトにおける商品ページの構成例↓
Next.jsのドキュメントで紹介されてるECサイトにおける商品ページの構成例
引用元:https://rc.nextjs.org/learn/dashboard-app/partial-prerendering#what-is-partial-prerendering

2つ目のアプローチとして、Custom Next.js Cache Handler を使用することを検討しています。Custom Next.js Cache Handler を使用することで、Next.js のキャッシュを Redis などの外部データベースに移すことができます。まだ導入はできていませんが、これによりキャッシュの永続化が可能となり、バックエンドへのリクエストを減らすことができるためパフォーマンスの改善が見込めると考えています。

Next.js が抱える課題

本章では、私たちが Next.js を使用して開発してきた中で課題だと感じた部分を紹介します。

(※ 検証した Next.js のバージョン: 14.2.4)

HTTP ストリーミングを使用している箇所は、正しいステータスコードを返せない

Next.js で HTTP ストリーミングを使用している箇所は、ステータスコードを正しく返すことができません。例えば、存在しない動的ページにアクセスした時に Next.js の notFound 関数を使って 404 にしても 200 のステータスコードが返されることになります。 これは、ストリーミングが開始されるとステータスコードを含むヘッダーが、レスポンスボディの前にクライアントに送信されるため、途中でステータスコードを変えることができないという HTTP レスポンスの仕様によるものです。

正しいステータスコードで返せないことにより、障害が起きた時の調査が難航したりモニタリング時にノイズになったりするため、早めに解決されるべき問題です。

この問題は Next.js 側でも議論されているので、近いうち対応されることを祈っています。

discussions: https://github.com/vercel/next.js/discussions/53225

RSC のエラー詳細情報が取れない

App Router では、RSC 内で throw したエラーの詳細を観測する事ができません。なぜなら、RSC で throw したエラーは自動でクライアント側で動作する error.js でキャッチされ、機密情報がブラウザ側に渡らないように Next.js が配慮しているからです。

私たちは、Datadog を用いてエラーを監視していますが、RSC が引き起こすエラーの原因を突き止めるのが難しいという課題があります。

しかし直近(2024/8/5)では、こちらの Pull request で onRequestError という機能が拡張がされています。 これがリリースされると RSC 内で throw したエラーの詳細を観測することができるかも知れません。

動的ルートを使用した時に親階層の loading.js が無視される

動的ルートを使用した時に親階層の loading.js が無視される問題があります。loading.js は、Suspense に基づいて即座にローディングの状態を作成する事ができるファイルです。

ジャンプTOON Web では、ページ全体のスケルトンの表示に loading.js を使用していました。

しかし、動的ルートを使用した以下のようなディレクトリ構成では、search/ から search/[keyword] に遷移したときに、 search/[keyword]/loading.tsx のスケルトンではなく、search/loading.tsx のスケルトンが表示されてしまいます。

app/
 ├─ search
 │  ├── loading.tsx
 │  └── [keyword]
 │        └── loading.tsx

そのため、全てのページで loading.js は使用しない方針にし、 <Page><Suspense> で囲んだコンポーネントを export することでこの問題を一時的に解決しています。

page.tsx

type Props = {
  searchParams: {
    /** 現在のページ数 */
    page?: string | string[];
  };
};

const Page = async ({ searchParams }: Props) => { 
  const pageParam = Array.isArray(searchParams.page)
    ? searchParams.page[0] 
    : searchParams.page; 
  return <div>ページ番号:{pageParam}</div>; 
}; 

const WrappedPage = (props:Props) => { 
  return ( 
   <Suspense fallback={<PageLoading/>}> 
     <Page {...props} /> 
   </Suspense> 
  ); 
}; 

export default WrappedPage;

本来 loading.js を使用すると page.js が自動的に <Suspense> でラップされるため、このような置き換えは不要なのですが、こちらの discussions でもあるように何か問題があるようです。

In the same folder, loading.js will be nested inside layout.js. It will automatically wrap the page.js file and any children below in a Suspense boundary.

引用元: https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming

課題をいくつか紹介しましたが、Next.js では上記の問題以外にもいくつかのバグや予想外の動きをする現象と遭遇します。

具体的には、下記にある問題に直面し HACK 的なコードを入れて解決してきました。(解決方法は Issues に記載されているため、詳細なコード例は省略します)

そのため、Next.js のバージョンを上げる際は、意図しない動作を防ぐためにこれらのバグをすべて調査する必要があります。

私たちは、Next.js が原因であると判明したバグには PACKAGE: (Next.js) というアノテーションを付ける方針にしています。また、アノテーションタグのハイライトを管理する vscode extension (Todo Tree)を利用することで、振り返りやすくしています。

スクリーンショット: アノテーションをハイライトする様子

おわりに

最後までお読みいただき、ありがとうございます。

Next.js の App Router には課題を感じるところはありつつも、得られた恩恵も沢山あります。
ジャンプTOON Web では、これからも Next.js を使用していく方針です。 これから Next.js を採用しようと考えているチームの人たちの参考になれば、幸いです。

また本記事では、 Next.js の App Router に焦点をおいてについてお伝えしましたが、ジャンプTOON の Web アプリの全体像 の方もぜひお読みいただけると嬉しいです。

参考文献