🚀

150万MAUのNuxt.js製サービスを機能開発を止めずに1ヶ月&1人でNext.jsに置き換えた話

2021/07/26に公開
3

Nuxt.js で開発されていたAI受診相談ユビーのフロントエンドを Next.js で作り直しました。

まだまだ仮説検証を繰り返すフェーズのスタートアップのため、機能開発を止めて一気に置き換えることはできず、機能ごとに少しずつ置き換えてリリースをしました。結果、5人のプロダクト開発チームによる機能開発と並走して、全体の移行を1人で1ヶ月の短期間で終わらせることができたので、その意思決定や過程、工夫を紹介します。

移行前の課題

まず前提として、移行前の Nuxt.js による実装は 2018 年に立ち上がったもので、当時 toC の Web サービスを持っていなかった Ubie が ほぼ 1 人の小さいチームで PoC 的に作り始めたものでした。また、当時の Next.js は今ほど多機能ではないプレーンなフレームワークでした。

これらを踏まえて、当時の状況で MVP を最速で作るための技術選定や設計は良いものでしたし、Nuxt.js は現在でも良いフレームワークだと思っています。

しかし、150万 MAU を突破してコードベースも約6万行になり、チーム・プロダクト共に大きくなった現在では、重視すべき事柄が大きく変わっていてプロダクト開発の足枷となる部分がありました。

具体的には、Nuxt.js に起因しない部分も含め、以下のような問題点がありました。

型が効かせにくい

単一ファイルコンポーネントを採用していたため、template 内で TypeScript の恩恵を十分に受けることができていませんでした。

Vetur Terminal Interface を CI に組み込むなどして最大限のビルド時型チェックを行っていましたが、どうしても漏れがあり、実際に不正な props による致命的な不具合が何度か発生してしまっていました。

アーキテクチャが荒野

MVP を作ることに特化したアーキテクチャのままではいけないという課題感はずっとあり、2020 年ごろからクリーンアーキテクチャ風の設計を導入し始めました。しかしやり切れずに 2 つの設計が混在した状態で、ディレクトリ構成は以下のようになっていました。

src
├── api
├── constants
├── controllers
├── models
├── services
├── store
├── utils
├── v2
│   ├── adapters
│   │   ├── controllers
│   │   ├── gateways
│   │   └── transformers
│   ├── domains
│   ├── infrastructure
│   └── useCases
└── views
    ├── components
    ├── hooks
    ├── layouts
    ├── locales
    ├── middleware
    ├── pages
    ├── plugins
    ├── seo
    ├── serverMiddleware
    └── static

詳細は省きますが、荒野であることは伝わると思います。

大量の永続化されたグローバルステート

複数のコンポーネントにまたがるステートを、リアクティブ性が必要かどうかに関わらず全て Vuex に保存していました。そしてそれを vuex-persistedstate で全て Local Storage に永続化していました。

その結果、それぞれのステートのライフタイムや影響範囲がわかりにくい状態でした。

他に Nuxt.js(Vue.js) を使っているプロダクトがない

Ubie の他のプロダクトでは全て React を使っています。そのため、他のチームから異動しようとすると Vue.js のキャッチアップが必要になり、社内メンバーの流動性を阻害していました。

Next.js を選択した理由

他のオプションもいくつかありました。

  • 生の React を使う
  • Nuxt.js のまま、単一ファイルコンポーネントをやめて TSX(JSX) で書く

その中で、以下の観点から Next.js で作り直す選択をしました。

  • 荒野だったアーキテクチャを整えると結局ほぼゼロからの実装になり、Nuxt.js のままでもあまりコードを使い回せない
  • グロース期に入ってきて、パフォーマンスの重要度が上がってきた
  • toC の Web サービスなので SEO のために SSR/SSG も重要
  • 社員が増えて、社内メンバーの流動性もますます重要に
  • Nuxt.js(Vue.js) 自体は TSX で書けるが、ライブラリを含むエコシステムの TypeScript 意識が React の方が比較的高い


比較表

具体的な移行作業

機能開発をブロックせずに少しずつ置き換えてリリースするため、以下のような工夫をしました。

インフラ層でのルーティング

部分的に置き換えてリリースするには、 Nuxt.js で実装されている部分と Next.js で実装されている部分が共存する必要があり、両方のサーバーをホストしていました。そして、インフラ層で使っている Istio のルーティング制御で path ごとに向き先を変えていました。


簡略化したイメージ図

Nuxt.js と Next.js を跨ぐ際にそれぞれのランタイムのダウンロードが発生するためパフォーマンスは一時的に低下することになりましたが、期間限定の負債であるため許容しました。

ハマったところとしては、NuxtLink コンポーネントや Router を使ってページ遷移をすると、Nuxt.js 内でのクライアントサイドルーティングになり、Next.js で実装したページにはアクセスできません。そのため、Next.js と Nuxt.js を跨いだページ遷移では生の <a> タグを用いる必要がありました。

Local Storage を通したマイグレーション

vuex や Redux、Recoil などで持ったステートは、当然そのままでは Next.js と Nuxt.js の間で共有することはできません。また、Nuxt.js 版では前述の通り永続化された vuex が膨らんでいたため、Next.js 版ではリアクティブにしたいステートのみを Recoil、それ以外を Local Storage や Session Storage に入れるよう設計し直していました。

そのため、Nuxt.jsプラグインを用いてクライアントサイド初期化時に、Next.js 側が Local Storage に保存したステートを読んで vuex に書き込むことで、ステートのマイグレーションを実装しました。Next.js 側でも逆のことを _app.tsx で行っています。

これにより、Nuxt.js 側ではマイグレーション以外のロジックを変更する必要がなくなり、Next.js 側での新規実装に専念することができましたし、Nuxt.js 側の負債を Next.js に伝播させずにすみました。

CSS Modules の採用

React で CSS を定義する方法として、CSS-in-JS などの選択肢もありましたが、CSS Modules を採用しました。

これにより、template(JSX) と CSS は Nuxt.js の実装からほぼコピペで移植でき、大幅に工数を削減できました。一方で、破綻した DOM 構造と CSS 設計の負債を引き継ぐことになりました。

しかし、DOM も CSS もコンポーネント内にスコープが閉じているため、移行後に少しずつ返済できる負債だと考え、ここでは許容することにしました。

コミュニケーションコストの削減

ゼロから Next.js 実装を立ち上げるにあたって、チームで進めると、作業範囲の重複による手戻りや設計のブレなどを防ぐため、厚くコミュニケーションを取って足並みを揃える必要があります。

そのコミュニケーションコストを防ぐため、移行作業はほぼ全て1人で行いました。ただし、設計などは同期で密に壁打ちできる相手がいるとやりやすいため、壁打ち相手として専任の業務委託を採用し、手伝ってもらいました。

しかし、普通に壁打ち相手を採用すると、結局オンボーディングなどでコミュニケーションコストが増えてしまいます。Ubie の場合は、以前に業務委託をしてくれていてフロントエンドに詳しくて仲が良い友人がたまたまいて、お願いすることができました。既にドメイン知識があってコミュニケーションコストが限りなく低い人を採用できたのはとてもラッキーでした。

また、過度に議論をしないことも意識しました。根本のアーキテクチャなどは叩きを作って意見収集しましたが、命名規則のように後からどうにでもなる部分は独断で決めて進め、短期間でやり切ることを優先しました。

この進め方は、チームメンバーが信頼して任せてくれたからこそ実現できました。このプロジェクトを始めるときも、僕がプロダクト開発チームを1ヶ月間だけ抜けてやり切ると急に宣言して、10分ほどで動機と見積もりを説明するだけで受け入れてくれて、とてもスムーズに進みました。

プロダクト開発チームとの調整

1週間のスプリントごとに、バックログに積まれたチケットをベースに相談し、変更が入りそうな機能の洗い出しを行いました。そして、変更が入らない部分から順に小さく置き換えてリリースを繰り返すことで、機能開発のブロックや手戻りをほぼゼロに抑えることができました。

置き換えリリースが1週間で収まらないと機能開発をブロックすることになるので、なるべく小さくちぎってリリースすることが重要でした。

ゴールデンウィークブースト

週ごとにプロダクト開発チームと足並みを揃えているとはいえ、機能開発が集中していて常に誰かが触っているところがいくつかあり、そこは並行して移行するのは難しいです。

これは計画段階で予期できていたため、移行期間を 4月~5月頭に設定し、最後にゴールデンウィークが来るようにしました。ゴールデンウィークはプロダクト開発チームの作業が止まるため、そのタイミングで残った機能の移行をやり切りました。

健全でないようにも聞こえますが、今年のゴールデンウィークは緊急事態宣言下で、大学の課題くらいしかやることがなかったのでよかったです。ワクチンを打ってから代休をとって夏を満喫する予定です。

おわりに

プロダクト開発チームの機能開発と並行して、短期間で Nuxt.js から Next.js への移行をやり切った話を書きました。

一時的なパフォーマンス低下に繋がる実装や壁打ち相手の新規採用など、手段にこだわらず進めた結果、ほぼ計画通りに終わらせることができ、すでに以下のような効果が出ています。

  • 6万行のコードベースを3.5万行に削減
  • 社内異動・新規入社による4人の新メンバーが1-2日で自走
  • LightHouse スコアが40程度から70程度に
  • 毎月発生していたステートの不整合によるバグが0件に

全リプレイスと言うとどうしても数ヶ月単位のプロジェクトをイメージしますが、条件と進め方によっては短期間で大きな結果を得られることがわかったため、今後も必要に応じて大胆な変更をしていこうと思っています。

まだまだ改善の余地がたくさんあるので、一緒に取り組んでくれる方も募集しています!

Ubie Discovery 採用サイト

Ubie テックブログ

Discussion

porokyu32porokyu32

初めまして。
とても参考になる記事でした。ありがとうございます!
1点質問があります。
「インフラ層でのルーティング」この部分においてLocalの開発環境はどのような構成をされていたのでしょうか?

Yuku KotaniYuku Kotani

ありがとうございます!

ローカルでは雑にプロキシサーバーを立てて、インフラ層と同様のルーティングをしていました。

import http from 'http';
import httpProxy from 'http-proxy';

function isNextPath(path) {
  return (
    path.startsWith('/feature-a') ||
    path.startsWith('/feature-b')
  );
}

const proxy = httpProxy.createProxyServer({});
proxy.on('error', function (err, req, res) {
  res.end();
});

const server = http.createServer(function (req, res) {
  if (req.url && isNextPath(req.url.split('?')[0])) {
    proxy.web(req, res, { target: 'http://127.0.0.1:3001' });
  } else {
    proxy.web(req, res, { target: 'http://127.0.0.1:3002' });
  }
});

console.log('listening on http://localhost:8000');
server.listen(8000);

ルーティングの管理コストが二重に発生しますが、移行期間の1ヶ月だけということでそこは耐えました。

porokyu32porokyu32

ありがとうございます!
プロキシサーバー立ててたんですね!
参考にさせてもらいます🙏