はじめに
ZOZOTOWN開発本部の武井と申します。ZOZOTOWNのフロントエンドリプレイスプロジェクトを主に担当しております。ZOZO DEVELOPERS BLOG でも「ZOZOのリプレイスプロジェクトで得られる唯一無二の経験。大規模サービスを進化させるやりがいとは」というインタビュー記事を掲載しておりますので、もしよろしければこちらも併せてご覧ください。
さて、本題です。現在ZOZOTOWNではオンプレミスかつ、モノリスだった既存システムをマイクロサービスAPIに責務を分割したり、インフラをクラウドに移行したりしています。しかし、いわゆるWebのUIを構築するためのシステムは現在も既存システムに新機能開発や機能改修を行なっており、リプレイスに着手できていませんでした。
そこで、まずホーム画面から段階的にリプレイスすべく設計・開発を昨年から行ない、無事リリースできました。ZOZOTOWNのソースファイルを見るとNext.jsで提供されていることがフロントエンドエンジニアの方にはお分かりいただけると思います。
本記事では、ホーム画面のリプレイスをどのようなシステム構成で実現したのかの事例と、Next.jsのアプリケーションをプロダクションレディにするナレッジや設定内容の一部などをご紹介します。
目次
背景
ZOZOTOWNのリプレイスは開発効率を上げる、運用コストを下げる、人材獲得の強化を目的として掲げています。その手段としてAPIのマイクロサービス化をしています。これと同時並行でフロントエンドに新フレームワークを導入しリプレイスする計画がありました。過去の弊社瀬尾の発表資料では下記の図で示され、数年前から検討はされていました。
歴史が長く、アクセス数の多いサービスを、稼働させたままリプレイスするのは一筋縄ではいきません。さらに、我々は既存のサービスの成長を止めずに、リプレイスする方針で取り組んでいます。こうしたリプレイスを実現するベースを構築する必要がありました。ベースの構築にどのような困難があったのか、セッションオフロードとカナリアリリースを例にあげて紹介します。
セッションオフロード
サービスを止めないリプレイスを実現するために、ストラングラーパターンというレガシーシステムを徐々にモダナイズするためのアーキテクチャパターンを採用しています。具体的にはリプレイスしたパスへのリクエストはモダンシステムに、置き換え前のパスはレガシーシステムにパスルーティングします。最終的には、リプレイス前のシステムへのパスルーティングはなくなり、リプレイス完了となります。
フロントエンドのHTMLの配信においても前述したパスルーティングを用いて、パスごとに段階的なリリースを計画していました。しかし、このパスルーティングを実現できない事情が既存システムにはありました。既存システムはIISのユーザーセッション機能を利用しており、セッションがWebサーバーに紐づいています。つまり、ユーザーはセッションが続いている限り以前接続したサーバーに接続されます。いわゆるスティッキーセッションです。これでは、パスルーティングを機能させることができません。この問題を解消するために、セッション情報をAmazon ElastiCache for Redisにオフロードする取り組みなどが必要でした。セッションをオフロードすることで、サーバーとセッションが分離でき、ストラングラーパターンによる置き換えが可能になりました。
セッションオフロードの詳細は、杉山の記事をご参照ください。
カナリアリリース
ZOZOTOWNはUIや機能改修によってビジネス指標に大きく影響が生じるサイトです。これを考慮し、UIや機能要件はリプレイス前と可能な限り互換性を維持し、挙動は変えないという方針を定めています。リリース前に念入りにQAテストを実施しますが、リリースでの不具合の発生や他システムに影響を及ぼすリスクは存在します。このリスクを軽減するために、一部のユーザーだけに絞り新システムを提供し、段階的にリリースするカナリアリリースを実施しています。このカナリアリリースについてはAkamai Application Load Balancerの加重ルーティングという機能を利用して実現しています。
加重ルーティングの詳細は、秋田の記事をご参照ください。
これらの例以外にも、リプレイスサービスを構築するためにCI/CD戦略、BFF API、サービスメッシュ、プログレッシブデリバリーなどの施策を実施しベースが整ってきました。こうした背景から満を持してフロントエンドのリプレイスが始動しました。
リプレイスは、全く別のものに一気に刷新するのではなく、このようにサービスを構築するためのベースを構築し、それぞれの機能ごとにマイルストーンを設定し段階的に置き換えていくことが有効です。
フロントエンドリプレイスPhase1
リプレイスをどのページから着手するか検討した結果、ホーム画面1を選択しました。理由は下記の通りです。
- ホーム画面で利用しているAPIは、大部分がBFF(Backend For Frontend)から提供されており、レガシーシステムへの依存が比較的少ない
- アクセス数や機能が多く、開発や運用のナレッジを蓄積しやすい
- サービスの象徴的ページであり、開発のモチベーションが湧きやすい
さらにリプレイスの付加価値としてSPA(Single Page Application)化することでページ遷移を高速にし、UXの向上を考えました。具体的にはホーム画面では、下記の図のような商品のカテゴリーや性別を切り替えるタブUIがあります。
このタブUIでのページ遷移時にページ全体を読み込まず、商品データだけを動的に切り替えるSPAを実装しました。
次にフロントエンドフレームワークについてです。フレームワークはNext.jsを採用しました。選定理由は下記の通りです。
- Reactベースのフレームワーク2
- ゼロコンフィグで開発を始められる
- ページごとのレンダリング手法を柔軟に切り替えるができる
- 数々のパフォーマンス最適化など新機能が毎年リリースされており、とてもアクティブに開発されている
- 利用している開発者が多く3、コミュニティーが盛んでWebに情報が多い
- HeadlessCMSと相性が良い4
Next.js以外の新たに導入したライブラリを紹介します。
ライブラリ名 | 説明 |
---|---|
Emotion | CSSinJSライブラリ |
SWR | データ取得とそれに関連する操作を提供する React Hooksライブラリ |
MSW | APIモッキングライブラリ |
Recoil | 状態管理ライブラリ |
openapi-typescript-code-generator | OpenAPI定義からクライアントコードを生成するライブラリ |
それぞれの選定意図については記事の本題ではないため紹介のみとします。Emotionの選定意図は、菊地の記事をご覧ください。
これらの新フレームワークや新技術の導入とインフラを構築することで、フロントエンドリプレイスの礎を作るマイルストーンを社内ではフロントエンドリプレイスPhase1と呼んでいます。以降も複数のマイルストーンをおき、2024年を目処にフロントエンドのリプレイスをすべて完了させる計画です。
システム構成
リプレイスに際して構築したシステムは下記のような構成です。
まずCDNを経由してユーザーのブラウザにコンテンツが配信されます。弊社ではCDNにAkamaiを採用しております。このAkamaiでは、(1)キャッシュ、パスルーティングとあるように、コンテンツのキャッシュや、ユーザーのリクエストパスから適切なサービスにルーティングをするパスルーティングなどを行なっています。具体的にはホーム画面のパスをリプレイス後のシステムへ、ホーム画面以外のパスは既存システムにリクエストをルーティングしています。
次にリプレイス後のパブリッククラウドのシステムですが、AWS上に構築しており、コンテナアプリ基盤にマネージドKubernetesサービスであるEKSを採用しています。また、複数サービスを単一Kubernetesクラスタで稼働させる、いわゆるマルチテナントクラスタ方式です。このクラスタにマイクロサービス群と、BFF API、そして今回新設したNext.jsのSSRを実行するサーバーが稼働しています。
最後に(2)データ取得、セッション共有とあるように、リプレイス後のシステムと既存オンプレミスのIISサーバーとセッションデータ共有や、まだリプレイスが完了していないデータストアからデータ取得を可能にしています。これによりあらゆる機能要件を満たすことができます。
なお、実際には認証サービスや、APIルーティングを行うAPI Gatewayなどのサービスとも通信していますが、ここでは省略しております。
以上がシステムの全体像です。
Next.jsのアプリケーションをプロダクションレディにするナレッジ
Next.jsの機能はシンプルなため、Reactを使ったプログラミングに習熟していれば、スムーズに開発を進めることができました。Web上に開発に関するナレッジが多く集積されている点が大きな要因と思われます。一方で、Next.jsのアプリケーションのサーバー負荷への考慮、ロギングやエラーハンドリングなどのプロダクションレディにするための情報がWeb上に少ないように感じました。なのでここからはそれらのナレッジについて紹介したいと思います。
要件を実現するためのレンダリング選定
Next.jsのアプリケーションにおいて、SSR(Server Side Rendering)するか否かというのはとても重要な決断です。 アプリケーションの性質や要件によれば、SSRせずSG(Static Generation)やCSR(Client Side Rendering)も可能です。その場合は静的ファイルを配信するのみとなりインフラの管理コストは低くなります。
一方、SSRする場合はNode.jsの実行環境を必要とするため、アプリケーションを監視するエンジニアのオンコール体制の構築、サーバーコスト、パフォーマンス的な懸念等々の管理コストが発生してしまいます。可能であればSSRしたくはありませんが、下記のような機能要件やSEOを考慮してSSRすることは不可避でした。
- メタタグにブランド数、商品名、OGP画像などの動的データを含めたい
- ファーストビューに表示されるUIはローディングなど挟まず表示したい
- セールやキャンペーンの開始や終了のタイミングに合わせて時限式に切り替わるUIを提供したい
3の要件についてクライアントサイドのJavaScriptで、時限式に切り替わる実装をする選択もあります。しかし、クライアントのJavaScriptはソースファイルが公開されます。そのため将来のキャンペーンやセール情報が露見してしまう可能性があります。したがって、クライアントではなくSSRするという結論になりました。
Next.jsの性能試験でレンダリングと
スループットの関係性を調査
GoやJavaのAPIサーバーは運用実績があり、性質や運用についてのノウハウがあります。一方でNode.jsを運用するのは初めての試みでした。加えてNode.jsはシングルスレッドのランタイム環境という特性があります。そのため、CPUバウンドなタスクを実行する場合、サーバー処理をブロックしてしまいパフォーマンス低下の可能性があります。具体的には、SSRの処理がCPUバウンドな処理で知られており5、この事象が起きてしまえばインフラコストが高くついてしまうことや、パフォーマンス要件を満たせない懸念があると考えました。本番にリリースしてからパフォーマンス要件が満たせないことになれば問題ですので、Next.jsアプリケーションの性能試験を実施しました。
性能試験は、Gatling Operatorというツールを用いて、本番に近いインフラやサーバーをセットアップし、リクエストを送りその結果をモニタリングして計測します。パフォーマンス要件の基準はLighthouse の TTFB の基準値を参考に600ms
以内とし、この状態で秒間どの程度のリクエストを捌けるかスループットも計測します。SSRするコンポーネントの規模によって、パフォーマンスやスループットの目処をつけておきたかったため3パターン実装しました。スペックのcore数が2core以上なのはNext.jsアプリケーション以外にもサービスメッシュとしてistio proxy
を実行しているためです。
結果は下記のとおりです。
項目 | レスポンスタイム (95percentile) |
スループット (req/sec) |
スペック |
---|---|---|---|
高負荷 ネストが深く子要素が多いコンポーネント |
364ms | 5 req/sec | CPU: 3core メモリ: 5GiB |
中負荷 ネストの深さ子要素数がホームと同程度のコンポーネント |
169ms | 30 req/sec | CPU: 2core メモリ: 5GiB |
低負荷 Next.js の初期設定のまま |
61ms | 60req/sec | CPU: 2core メモリ: 5GiB |
高負荷の場合はパフォーマンス基準を安定的に満たし、スループットは5req/sec
と効率が悪い結果となりました。やはり前述した通り、CPUバウンドなSSRになってしまうとインフラのコストパフォーマンスは悪くなりそうです。しかし、中程度の負荷であれば、スループットも性能はまずまずという結果も得られました。この結果から、負荷を考慮したSSR実装をすることに加え、負荷増加が考えられるリリースをする際には負荷試験を行ない事前に検知するなど対策すればスケーラブルに運用できるという判断をしました。以上の性能試験から、SSRという選択肢ありきで安心して開発に着手できました。
CDNキャッシュを用いた最適化
性能試験の結果から、Node.jsをスケーラブルに運用できることが分かりました。しかしながら、性能は常に最適な状態に保つことが望ましいため、CDNでキャッシュを使用することでパフォーマンスを向上し、コスト削減を実現できます。HTMLをSSRした結果を一定期間CDNでキャッシュすることにより、オリジンサーバー上でNode.jsサーバーの負荷を大幅に軽減できます。ただし、パフォーマンス要件を満たすためにキャッシュを有効にできない場合もあります。ホーム画面の要件に関してはキャッシュが可能であるため、キャッシュを有効にしています。具体的には、下記のようにレスポンスヘッダーのCache-Control
を使用して、キャッシュの保持期間を制御できます。
Cache-Control: s-maxage=seconds
検証の際には、時間の文字列をHTMLに埋め込んでおくと、キャッシュできているか検証しやすいので、実装しておくのがおすすめです。Next.jsでは下記のように書けます。
import { GetServerSideProps, InferGetServerSidePropsType } from 'next' export default function Index({ time }: InferGetServerSidePropsType<typeof getServerSideProps>) { return ( <script type="application/json" data-type="cacheTimeDisplay" data-time={time} /> ) } export const getServerSideProps: GetServerSideProps<{ time: string }> = async ({ res, }) => { const second = '10' res.setHeader( 'Cache-Control', `s-maxage=${second}` ) return { props: { time: new Date().toISOString(), } } }
このようにしてレスポンスヘッダーをページごとに異なる時間を設定することで、キャッシュを最適化していくことができます。ただし、CDNキャッシュを利用する際には注意が必要です。特に、SSRするHTMLには個人を特定できるようなパーソナルな情報を含めないようにすることが重要です。ユーザーごとに異なるパーソナル情報はAPIから取得し、クライアントサイドでレンダリングするようにする必要があります。パーソナル情報をSSRしてしまうと、CDNキャッシュを利用できなくなってしまうためです。もし誤ってパーソナル情報をキャッシュさせてしまった場合、重大な情報漏えいが起こる可能性もあるため、キャッシュを用いる際には慎重に実装する必要があります。
URLに対して複数のキャッシュを作成する
通常、1つのURLに対して1つのキャッシュが作成されますが、Cache ID Modificationという機能を使うことで、複数のキャッシュを1つのURLに対して作成できます。例えば、ホーム画面にはカルーセルバナーがあり、このバナーは指定なし、レディース、メンズ、キッズの4つのパターンがあります。
これをSSRするには、4つのキャッシュを作成する必要があります。これを実現するために、Cache ID Modification機能を使用しています。Cache IDは、CDNの管理画面で設定でき、Cookieやリクエストヘッダーなどを指定できます。この場合、Cookieに性別を保存し、このCookieをCache IDに設定しました。これにより、4つの異なるキャッシュが作成され、適切なカルーセルバナーがSSRされます。
カスタムサーバーでルーティングのカスタマイズと
ロギングの実現
zozo.jpのホーム画面はデスクトップ向けにはhttps://zozo.jp/
、モバイルでサイトhttps://zozo.jp/sp/
とURLが異なります。そのため、モバイルデバイスでhttps://zozo.jp/
にアクセスした場合は、https://zozo.jp/sp/
にリダイレクトされるような実装が入っています。例えばこのようなルーティングをカスタマイズしたい場合に利用できるのがカスタムサーバーという機能です。この機能を使えばNode.jsサーバーのモジュールとしてNext.jsを利用できます。Node.jsの組み込みモジュールでも実装は可能ですが、WebフレームワークのFastifyを利用しました。理由はパフォーマンスの良さ、TypeScriptとの相性、ロギングのしやすさなどです。
Fastifyを利用する場合Next.jsカスタムサーバーは下記のように書けます。
import Next from 'next' import Fastify from 'fastify' import type { FastifyPluginCallback } from 'fastify' type Option = { isDev: boolean } const isDev = NODE_ENV !== 'production' const app = Fastify({ /** * Dev の際は Next.js のサーバーの起動までのタイムアウトを設定します。 * */ pluginTimeout: isDev ? 120_000 : 0, }) export const nextJsCustomServerPlugin: FastifyPluginCallback<Option> = async ( serve, option, done ) => { const app = Next({ dev: option.isDev }) const handle = app.getRequestHandler() await app.prepare().catch((err) => { serve.log.error('error', err) done(err) }) serve.all('/*', async (req, reply) => { await handle(req.raw, reply.raw) reply.sent = true }) serve.setNotFoundHandler(async (req, reply) => { await app.render404(req.raw, reply.raw) reply.sent = true }) done() } app.register(nextJsCustomServerPlugin, { isDev }) app.listen(PORT, HOST, () => { app.log.info( `started server` ) })
FastifyにはHooksというAPIがあり、リクエストからレスポンスまでのライフサイクルイベントをフックにして処理を実行できます。前述したリダイレクトの実装などはリクエストをフックにして下記のように書けます。
app.addHook('onRequest', (req, reply, done) => { const isNextAssetsPath = req.url.startsWith('/_next/') const isSpPath = req.url.startsWith(`/sp/`) const isMobileDevice = req.headers['user-agent']?.includes('Mobile') && !req.headers['user-agent']?.includes('iPad') if (isNextAssetsPath || isPublicPath) { done() } else if (isMobileDevice && !isSpPath) { reply.redirect(302, path.join('/sp', req.url)) } else if (!isMobileDevice && isSpPath) { reply.redirect(302, req.url.slice('/sp'.length)) } else { done() } })
次にロギングについて紹介します。弊社はサーバーアクセスログをJSON Linesという形式で標準出力しています。JSON形式であればjqなどのツールを用いてデータ加工や集計を簡単に扱うことができるためです。このJSON形式のログの標準出力にはpinoというライブラリを用いています。pinoはFastifyとの相性は抜群で、使い方はloggerにpinoを指定するだけです。下記はリクエストのlatency
などの情報をアクセスログに出力する例です。
import Fastify from 'fastify' import pino, { LoggerOptions } from 'pino' // ログ出力も下記のようにオプションを使って柔軟にカスタマイズ可能 const pinoOptions: LoggerOptions = { timestamp: () => `,"timestamp":"${new Date(Date.now()).toISOString()}"`, formatters: { level(label) { const severity = label.toUpperCase() return { severity } }, }, } const app = Fastify({ logger: pino(pinoOptions) }) app.addHook('onResponse', (req, reply, done) => { const latency = reply.getResponseTime() / 1000 req.log.info({ latency }) done() })
下記のようにJSONが出力されます。
{"severity":"INFO","timestamp": "2023-03-09T09:27:48.963Z","latency":"0.2675443229675293"}
Sentryでのエラーログ集積とソースマップのアップロード
アプリケーションのエラートラッキングツールにはSentryを利用しています。Sentryはnextjs向けのSDKとして@sentry/nextjsを提供しており、このSDKを利用して実装できます。カスタムサーバーを使っているため、カスタムサーバー向けに@sentry/nodeも併用して利用します。また、どの環境で起きたエラーか特定をしやすくするために、enviroment
変数にカスタムサーバー
、Next.jsのサーバーサイド
、Next.jsのクライアントサイド
の3つの環境の値を設定しています。
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN if (dsn) { SentryNode.init({ dsn, environment: '[CUSTOM SERVER]' // next.js の SSR のエラーの場合は [NEXTJS SERVER] CSR の場合は[NEXTJS BROWSER]と指定する }) }
次に、ソースマップについてです。ソースマップファイルをSentryにアップロードすることで、Next.jsでビルドしたJavaScriptコードのエラーではなく、ビルド前のソースコードでエラーの該当箇所を示してくれる機能があります。詳しくはSource Mapsをご覧ください。この機能を活用しないと、エラーの原因を突き止めるのは困難です。活用するためには、next.jsのコンフィグファイルを編集し、next build
の際にソースマップファイルをアップロードする必要があります。下記がnext.config.js
設定例です。
const { withSentryConfig } = require('@sentry/nextjs') const buildConfig = { sentry: { hideSourceMaps: !!isProdOrStg, // 環境によってはソースマップの参照をソースコードに含めない。 widenClientFileUpload: true, // next build の際に Sentry にクライアントのソースマップファイルをアップロードするフラグ }, } const sentryWebpackPluginOptions = { silent: false, dryRun: (isLocal || isGitHubAction) ? true : false, // ソースマップのアップロードの有無のフラグ、環境によっては、アップロードを実行しない。例えば、ローカル環境や、GitHub Action などでは実行しないように設定ができる release: process.env.BUILD_ID ?? undefined, org: '<ORG NAME>', authToken: process.env.SENTRY_AUTH_TOKEN, project: '<PROJECT NAME>', debug: false, } module.exports = withSentryConfig(buildConfig, sentryWebpackPluginOptions)
注意点としては、ソースマップをユーザーに公開したくない場合はhideSourceMaps
を有効にすることです。ソースマップから開発コードの復元が可能でセキュリティーの観点からこれを避けたかったため、本番環境ではこの設定を有効にしています。逆に開発環境ではデバッグを効率的に行うため、hideSourceMaps
を無効にしてソースマップ機能を有効にしています。
ソースマップアップロードを可能にするDockerイメージ作成
前述の通りNext.jsのSSRサーバーはKubernetes上で稼働します。そのためには、Next.jsのDockerコンテナアプリケーションをビルドする必要があります。Next.jsのドキュメントにDockerfileのサンプルが提示されていますので、これをベースに作成します。加えて、こちらのベストプラクティス集も参考にしました。Next.jsのドキュメントだとnode-alpine
のイメージが使われていますが、node-slim
6をベースに構築しました。下記がDockerfileの一部です。
# <version>は任意のversionを当てはめてください。 FROM node:<version>-slim AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN apt-get update RUN npm ci FROM node:<version>-slim AS builder RUN apt-get update WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . # ビルド際に環境変数をアプリケーションが参照できるようにする ARG NEXT_PUBLIC_SENTRY_DSN ENV NEXT_PUBLIC_SENTRY_DSN $NEXT_PUBLIC_SENTRY_DSN ARG SENTRY_AUTH_TOKEN ENV SENTRY_AUTH_TOKEN $SENTRY_AUTH_TOKEN RUN npm run build # ソースマップをSentryにアップロードすれば不要なため.map拡張子のファイルを削除するnode.jsスクリプトを実行 RUN node script/removeSourceMap.js # devDependencies のファイルを削除して軽量化 RUN npm prune --production FROM node:<version>-slim AS runner # 略...
Next.jsのDockerfileの特徴的な点は、イメージ作成時にCSS、JavaScriptなどのアセットファイルのビルドを実行することです。そのため、アプリケーションのサーバーの起動時ではなく、イメージ作成時に必要な環境変数を設定する必要があります。これを実現するために、ARG、ENV命令を使用します。例ではNEXT_PUBLIC_SENTRY_DSN
、SENTRY_AUTH_TOKEN
を記述してます。注意点としては、環境変数をブラウザに公開する場合の名称です。Next.jsではNEXT_PUBLIC_
というプレフィックスをつければ公開される環境変数になります。例では、SENTRY_AUTH_TOKEN
はSentryにソースマップをアップロードするためにSDKで必要な値のため、公開は不要のためプレフィックスはつけません。
また、ソースマップについても注意が必要です。前述の通り、Next.jsの設定ファイルにhideSourceMaps:true
と設定することで、ソースマップファイルへの参照は消されますが、ソースマップファイル自体は残り続けます。そのため、ファイル自体も削除する必要があります。以下は、Node.jsスクリプトを使用して.map
拡張子のファイルを削除する例です。
const fs = require('fs') const path = require('path') const FileType = { file: 'file', directory: 'directory', unknown: 'unknown', sourceMap: '.map', } const getFileType = (path) => { try { const stat = fs.statSync(path) if (stat.isFile()) { return FileType.file } if (stat.isDirectory()) { return FileType.directory } return FileType.unknown } catch (e) { return FileType.unknown } } const getFileList = (dirPath) => { const ret = [] const paths = fs.readdirSync(dirPath) paths.forEach((p) => { const filePath = path.resolve(dirPath, p) if (getFileType(filePath) === FileType.file) { ret.push(filePath) } if (getFileType(filePath) === FileType.directory) { ret.push(...getFileList(filePath)) } return }) return ret } const sourceMapFileList = getFileList('./.next/static').filter( (p) => path.extname(p) === FileType.sourceMap ) sourceMapFileList.forEach((filePath) => { fs.unlink(filePath, (err) => { if (err) throw err }) })
最後にnpm pruneを実行して、不要なファイルを削除しDockerイメージの軽量化を図ります。これによってnode_module
の中にあるTypeScript
やライブラリの型定義など、ビルド以降は利用しないライブラリのファイルを削除します。
ナレッジの紹介は以上です。
効果と今後の課題
新システムのリリースは、リクエストの1%、20%、50%、100%と徐々にルーティングさせるカナリアリリースによって、各段階で検証し、不具合がないことを確認できました。これにより、インフラやフロントエンドの技術的なベースの構築、Next.jsの開発ナレッジの獲得が達成されました。また、ホーム画面ではSPA化によって、タブの切り替えなどのユーザー体験が向上しました。しかし、課題として残る点もあります。1点目はパフォーマンスです。CDNでキャッシュされることで、TTFBの値は200ms(95percentile)
以内でレスポンスできています。この数値は、600msという遅いと判断される基準よりもかなり余裕がある数値です。一方で、Web Vitalsの他の基準である、FCP、LCP、CLSなどの数値はまだまだ改善の余地があります。現在は、レンダリングの最適化や画像サイズの最適化などについて検討中です。2点目は、コスト最適化です。ホーム画面はSSRのキャッシュが要件的に可能でしたが、不可能なページも今後発生する可能性があります。この場合は、CDNでのSSRを実現できないか検討しています。
まとめ
本記事では、ZOZOTOWNのホーム画面のリプレイスをどのようなシステム構成で段階的に実現したのかの事例を紹介しました。加えて、最適なレンダリング選択やCDNでのキャッシュ、カスタムサーバー、Sentryへのソースマップのアップロードなどについて説明しました。いずれもNext.jsのアプリケーションをプロダクションレディにするナレッジです。
歴史が長く、アクセス数の多いサービスを段階的にリプレイスするためには、事前にマイルストーンを設定し、インフラ的なベースの構築を進行していくことが重要です。さらに、こうしたインフラのベースの仕組みや意図についてアプリケーション開発者が理解し、Next.jsなどのフレームワーク固有の設定ナレッジを蓄積していくことで運用が可能になります。これまでフロントエンド開発では活用していなかったNext.jsはもちろんCDNやNode.js、Kubernetesなどを用いる術を今回得ました。これらのツールを活用し、より高い品質のサービスを提供していきたい考えです。
ZOZOでは、そんなサービスを一緒に作り上げてくれる方を募集中です。ご興味のある方は、下記のリンクからぜひご応募ください。
-
ホーム画面とは具体的には
https://zozo.jp/
、https://zozo.jp/shoes/
、https://zozo.jp/cosme/
の画面をさします。↩ - 既にZOZOTOWNはReactを用いて開発しており、開発者のノウハウも蓄積されていたため。↩
- state of js 2022のデータを見ても近年利用率1位を維持しています。↩
- ZOZOではmicroCMSを活用していますが、技術スタックの相性の良さから、これまで以上にmicroCMSを使って効率的に開発できるようになりました。↩
- Server Rendering vs Static Rendering↩
- node-slimは、Debian Linuxをベースとした軽量なDockerイメージです。これは、Alpine Linuxをベースとしたnode-alpineよりもわずかにイメージサイズは大きいです。しかし、Debianベースの方が広範なツールやライブラリを利用でき、汎用性が高いためnode-slimを採用しています。↩