※こちらはバイセルテクノロジーズ Advent Calendar 2023の10日目の記事です。
前回の記事は、金澤さんのAuth0とEntra IDを扱うプロダクト同士を繋げるためのIstio設定あれこれでした。
はじめに
こんにちは、開発3部の神保です。
バイセルでは、お客様宅への出張訪問による買取が買取チャネルの主力となっています。現在開発3部の弊チームでは、この出張訪問買取で使用されるWebアプリケーション「Visit」の新規開発を進めています。
VisitのフロントエンドにはReactを採用しましたが、Next.js等のフレームワークは使用せず、Vite + ReactによるSPA (Single Page Application)構成を選択しました。
技術選定の過程では、社内での採用事例などからNext.jsも検討の対象となりましたが、最終的にはその採用を見送る結論に至りました。
今回の記事では、この結論に至るまでの検討内容についてまとめます。Reactのフレームワーク選定に悩む方の参考になれば幸いです。
なお、この記事ではCore Web Vitalsなどの詳細なパフォーマンス比較は行わず、定性的な観点からの議論が主となっていますので、あらかじめご了承ください。
出張訪問買取アプリ「Visit」
Visitは出張訪問買取を行うためのWebアプリケーションです。買取を行う査定士は、商品の査定から契約書を含む各種書類の作成までをVisit上で行います。
UIに表示される項目は、査定する商品情報や契約のお客様情報といったトランザクションデータがほとんどで、いわゆる認証付きの管理画面系アプリに分類されるものです。
Visitは現在、iPhoneおよびiPad上で利用するためのWebViewアプリとして開発が進められています。WebViewアプリの知見についてもまたどこかでお話しできたらと考えています。
フロントエンドのフレームワーク選定
Visitのフロントエンドには、社内の技術スタックやライブラリのエコシステムなどを考慮し、Reactを採用しました。
Reactでアプリケーションを作成するには、最近ではNext.jsやRemix等のフレームワークを利用するのがトレンドとなっています。一方で、初学者やシンプルなアプリケーションの開発者にとってはこうしたフレームワークはオーバースペックという見方もあり、Vite + ReactのようなSPA構成を好む方も多いようです。
React公式としては、新しいドキュメントをみる限りフレームワークの利用を強く推奨しているようです。ViteもReactアプリを作成する選択肢として同等に扱おうというIssueも上がっていますが、現状(2023.12執筆時点)では「前向きに検討する」との返信に留まっています。
こうしたReact開発チームのスタンスや、AppRouterに代表されるここ最近のNext.jsの勢いに加え、社内での採用事例も多かったことから、Visitでも当初はNext.jsの利用を検討していました。
Next.jsを採用して得られるもの
Next.jsを採用することで、パフォーマンスと開発体験の2つの観点でのメリットが得られます。
パフォーマンス観点で一番重要な機能はSSR (Server Side Rendering)やSSG (Static Site Generation)だと考えています。特にVisitはモバイル端末で利用されるため、端末の負荷をサーバーに寄せることによって、クライアント側のパフォーマンス向上は少なからず期待できるでしょう。
また、今更紹介するまでもないですが、フルスタックフレームワークであるNext.jsはAPI RoutesやMiddleware・Next/Imageに代表されるOptimizationなど、サーバーがあることによって実現できる様々な機能をサポートしています。
そのほかにも、ファイルベースルーティングやゼロコンフィグでの開発スタートなど、パフォーマンスのみならず開発体験の面でも魅力的な機能が多く提供されています。
長期的な視点でも、株式会社ヘンリーさんの記事にあるように、Next.jsを利用することでユーザー・開発者の双方が多くのメリットを享受できると思われます。
一方で課題もある
上記の通りNext.jsを採用することで得られる恩恵は多いですが、一方で課題もあると考えています。
例えば、直近でもKent C. DoddsによるNext.jsに対する批評が話題になっていました。彼の主張の100%を支持する訳ではありませんが、VercelへのロックインやNext.jsの機能の複雑さについては賛同できる部分が多くあります。
サーバーを運用するコスト
Next.jsに限らずですが、SSRをサポートするフレームワークはサーバーを持つことを前提としているため、当然サーバーを運用するコストが発生します。
単純なインフラ費用に加え、Cloud Runのようなサーバレスであっても、一般的なバックエンドサーバーと同様にリクエスト数の見積もり、負荷試験、SLO (Service Level Objectives)、SLA (Service Level Agreements)、SLI (Service Level Indicators)の策定、スケールアウトの検討、日々のメトリクス監視、サーバー起因のアラート対応など、様々な運用コストが発生します。
フロントエンドのためにこうした業務が発生することは、チームとしても負担になりかねません。特に、フロントエンドのメンバーがサーバーサイドやインフラの知識に長けていない場合は、これらのコストはさらに増加する可能性があります。実際に、自分が過去に所属していたチームや、社内でNext.jsを運用している他プロダクトでは、こうした悩みが運用上の課題として挙げられていました。
Vercelをホスティング先として利用すれば、このあたりのパフォーマンスチューニングを意識せずともサポートしてくれますが、Vercelの利用料金が次の障壁になるでしょう。
一方でSPAであれば、静的アセットの配信だけで完結します。Google CloudであればCloud StorageやApp Engineといった、容易に静的ホスティングが可能で自動でスケーリングしてくれるサービスがあるため、運用コストを大きく削減できます。
Static Exportsという選択肢
Next.jsの恩恵には与りたいが、サーバーを持ちたくないというニーズに対しては、Next.jsを静的なアセットとしてビルドするという選択肢があります。
これは先に述べたサーバーのランニングコストというデメリットを解消しつつ、ルーティングなどのNext.jsの一部機能を利用できる折衷案的なオプションです。しかし、Dynamic Routesを利用する場合には注意が必要です。
ドキュメントのSupported Featuresに「Dynamic Routes when using getStaticPaths
」とある通り、ルーティング先が有限でビルド時に決定できるケースであれば有効ですが、そうでない場合はアセットがposts/[postId].html
のようにビルドされてしまい、/posts/1
にアクセスしてもファイルが存在しないことになってしまいます。
したがって、完全に動的なルーティングを実現するためには、ファイルが見つからなかった場合にfallbackされる404.html
内でルーティング処理を行わなければなりません。
こうすると一度404.html
がクライアントに返されてから、正しいページにリダイレクトされるという特殊な挙動になってしまいます。その結果、二重にリクエスト処理が行われることによるユーザー体験の悪化や、router.events
が2回発火することによる予期せぬ動作につながる可能性があります。
一方で、ローカルではNext.jsのdevサーバーが通常通りルーティングを行うため、こうした動作が(特殊な開発環境を用意しない限り)再現できません。開発時には再現できない挙動が本番で発生することは大きな懸念点です。
もう一つの解決策として、ホスティングサーバー側でパスのリライトを行うという手法もあります。App Engineはこのような機能をサポートしており、app.yamlで設定ができます。
Cloud Storageにはそういった機能はありませんが、以下のようにLoad Balancer側でパスのリライトが可能です。
defaultService: your-project/frontend-bucket name: matcher1 routeRules: - matchRules: - pathTemplateMatch: /posts/*/{rest=**} priority: 1 service: your-project/frontend-bucket routeAction: urlRewrite: pathTemplateRewrite: /posts/[postId]/{rest}
ホスティング先がこうした機能をサポートしていない場合は、nginxサーバーを前段に置けば同様の処理が実現できます。
ただし、これらの方法は、フロントエンドのルーティングが変更されるたびにインフラの設定を変更しなければならない運用になってしまうことが課題といえるでしょう。
上記の通り、どうしてもハック的な方法に頼らざるを得ないため、完全に動的なルーティングを実現したいユースケースでは、Next.jsのStatic Exportsを利用するのは一定のデメリットを負うことになると考えています。
Visitの場合
Visitは冒頭に紹介した通り出張訪問買取で利用するアプリケーションであり、トランザクションデータを主として扱います。したがって、SSRやSSGによるメリットを享受できる範囲はかなり絞られます。また、買取業務で利用するというサービスの特性上、FCP (First Contentful Paint)をmsec単位で追求する必要も薄いと考えています。認証なしで閲覧可能なページもないため、SEOの観点でもSSRは不要です。
上述したモバイル端末でのパフォーマンス向上という利点はありますが、こちらも業務利用という観点から、ユーザーである査定士は会社の配布する端末を利用することが想定されるため、極端に低スペックな端末の利用は避けられると考えました。
結果的に残るメリットは、ファイルベースルーティングやコード分割のサポートなど、ユーザー体験というよりも開発者体験の面でのものとなってしまいます。
以上を鑑みたところ、Next.jsを採用して得られるメリットよりも、サーバーの面倒を見ていくコストや、Next.jsを無理にStatic Exportsで利用するデメリットが大きいという結論に至りました。
特に、Visitはフロントエンドとバックエンドで分かれたチーム構成で開発しているため、フルスタックな知識が求められる技術を運用していくのは最適な選択ではないと考えました。結果として、VisitではフレームワークなしでのReactを採用し、Cloud Storageで配信するという判断になりました。
SPAを採用してみて
Visitはまだ本格的なローンチこそされていませんが、これまで数ヶ月間開発を進めてきました。ここでは、実際にVite + Reactの構成を採用した後の課題や知見をいくつか紹介します。
バンドルツールの違い
モジュールバンドラに採用したViteのdevサーバーは、バンドルコストを削減することによって、非常に高速なHMR (Hot Module Replacement)を提供しています。これは、依存関係のみ事前バンドルを行い、ソースコードはブラウザのネイティブESMを利用してimport/exportすることで実現されているようです(参考)。体感でも、Next.jsでサポートされているwebpackと比較してとても快適に感じます(Turbopackの方がより高速とは言われています...)。
一方でViteはライブラリの対応状況がまだ進行中な部分もあります。例えば、画像回帰テストツールであるChromaticでは、SnapShot数を削減するためのTurboSnapをViteで有効にするには、experimentalなpluginを利用しなければなりません。ただ、現状のViteの人気やライブラリとしての将来性を考えると、こうしたエコシステムも今後ますます成熟していくことが期待できます。
ルーティングの実装
Visitのルーティングライブラリには、GitHub Stars等を参考に、React Routerを採用しました。
Next.jsのPages Routerと比較すると、React RouterではNested Routesを利用できるというメリットがありますが、その一方で型安全ではないという問題が多く指摘されています。Roconやreact-router-typingのような型問題に対処するライブラリがいくつか開発されていますが、Next.jsでのpathpidaのようなデファクトスタンダードとなるようなライブラリはないように思えます。結果として、Visitでもこれらのライブラリを参考に、ルーティングを型安全に扱うための機構を自前で実装することになりました。
コード分割に関しては、React.lazyを利用してページ単位でのSplittingを設定しています。 ただ、Viteに限らずですが、ビルドの成果物であるjsファイル名にハッシュが記載される都合上、デプロイ時にビルドファイルを置換してしまうとアセットが見つからず、アプリケーションエラーが発生してしまうという問題があります。
こちらの記事にあるようにいくつかの対策が考えられますが、Visitでは実装コストを考え、以下の一例のようにエラーをキャッチしてハンドリングを行うことで回避しています。
const LazyComponent = React.lazy(() => import("/hoge").catch(() => { // エラーページの表示やリロード等の処理 }));
Reactのドキュメントでも忠告されていた通り、フレームワークに頼らない分、このように自力で技術選定や技術的課題に対処することが求められました。
インフラ観点での留意点
SPAでは当然、ルート以外のURLに直アクセスされたときにもindex.html
を返す必要があります。Cloud Storageでは、Webサイトの構成で404時のfallbackとしてindex.html
を指定することで、この挙動が実現できます。
ただし、Cloud Storageでこのようにfallbackされたページは、正しく取得できるもののステータスコード404で返されてしまいます。アプリケーションへの影響はないのですが、ConsoleやNetwork上ではエラー表示されてしまうことと、パフォーマンス分析ツールであるLighthouseを実行できないことが問題でした。
これは、Static Exportsの章で紹介したLoad Balancerでのパスのリライトによって解決できます。インフラの変更を避けたい場合は、vite preview
であれば404が返されることはないので、ローカル等でビルド・プレビューすればLighthouseの実行が可能です。ちなみにApp Engineを利用する場合はそもそもfallbackの設定がないため、いずれにせよパスのリライト設定を行わなければなりません。
その他に、デプロイ先であるGoogle Cloudのサービスの差異もあります。
一例として、App Engineや、Next.jsのデプロイ先の第一候補であるCloud Runにはトラフィック管理機能があり、カナリアリリースやA/Bテストに利用できます。障害時に素早く前のリビジョンに切り戻す対応が可能な点も強みです。Cloud Storageにはここまで充実した機能はありませんが、オブジェクトのバージョニングによる前のバージョンへの復元機能はサポートされています。
こうしたインフラ面での差異も、要件によっては重要な技術選定の要素となる場合が考えられます。
まとめ
Next.jsの台頭により、 最近では管理画面系のプロダクトでもNext.jsを採用する事例が増えているように感じますが、Visitではコストと天秤にかけた結果、Vite + ReactのSPAを選択しました。
なお今回の結論はあくまでVisitの要件に照合した結果であって、いずれかの選択肢が優れているということを主張するものではありません。重要なのはプロダクトやチームの要件に合わせて技術選定を行うことだと考えています。
また、Next.js自体は複雑さに対する疑問は挙げられてはいるものの、フルスタックフレームワークとして急速に進化し続けている重要な技術であるため、フロントエンドエンジニアとしてキャッチアップは続けていかなければならないと感じています。
最後まで読んでいただきありがとうございました。明日の記事は福田さんのモノレポを導入して開発効率を上げるです。ぜひご覧ください!
また、BuySell Technologiesではフロントエンドエンジニアも絶賛募集中です。こちらも興味のある方はぜひご確認ください!