TalentX Tech Blog

Tech Blog

MyReferフロントエンドの技術的負債を4年かけて返済し、モダンSPAへ刷新した軌跡

こんにちは。フロントエンドをリードしている神長です。

TalentXの運営するプロダクトのうちの1つである「MyRefer」は、人事・リクルーター・ユーザーという3つのドメインにまたがり、約100ページのフロントエンドを持つプロダクトです。 長年の機能開発の過程で、フロントエンドのコードベースには深刻な技術的負債が蓄積し、開発生産性の低下が大きな課題となっていました。

この技術的負債を解消し、フロントエンドアーキテクチャを抜本的に刷新するために実施した約4年間にわたるモダナイゼーションの軌跡と、その過程で得られた技術的知見を解説します。

プロジェクト開始当初、コードベースが抱えていた主な課題は以下の通りでした。

1. ブラックボックス化した巨大ファイル

多くのページが単一ファイルで構成され、そのほとんどが1,000行を超えていました。例えば、あるページのコンポーネントは以下のような構造でした。

// Before: 典型的な巨大Class Component (概念図)
class Page extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      // ページの全状態がフラットに存在
      isLoading: true,
      tabIndex: 0,
      users: [],
      rawRecruiters: [],
      filteredRecruiters: [],
      selectedUserId: null,
      userDetail: {},
      isModalOpen: false,
      modalType: 'CREATE_USER',
      searchKeyword: '',
      error: null,
      // ...その他、数十個の状態が続く
    };
  }

  componentDidMount() { /* ... */ }
  componentDidUpdate(prevProps, prevState) { /* ... */ }

  // 多数のメソッドがコンポーネント内に混在
  handleTabChange = () => { /* ... */ };
  handleSearch = () => { /* ... */ };
  handleOpenCreateModal = () => { /* ... */ };
  fetchUsers = () => { /* ... */ };
  fetchRecruiters = () => { /* ... */ };
  
  render() {
    // 数百行に及ぶ巨大なJSX
    return (
      <div>
        {/* ...Tabs, Tables, Modalsが全てここに... */}
      </div>
    );
  }
}

この巨大なstateオブジェクトと無数のメソッドにより、たった一つの変数を追うためだけに、際限のないスクロールと検索を繰り返すのが日常でした。

2. コピー&ペーストによるUIの増殖

コンポーネントという設計思想が不在だったため、類似したUIがプロダクトの至る箇所にハードコードされていました。これにより「ボタンのデザインを全画面で統一する」といったごく自然な要求ですら、影響範囲の特定だけで1日を要する非効率な状況を生んでいました。

3. MPA構成によるパフォーマンスボトルネック

アーキテクチャはMPA (Multi-Page Application) であり、全てのルーティングをバックエンドのFuelPHPが担っていました。 例えば/corp/detailのようなパスにアクセスすると、FuelPHPはそのパス専用にバンドルされたReactアプリケーションを読み込んでレンダリングする、という仕組みでした。

この構成では、ページ遷移のたびに画面全体の再読み込みと、JavaScriptバンドルの再ダウンロード・評価が発生します。これにより、ユーザー体験を損なうパフォーマンス上の大きなボトルネックが生じていました。SPAのようなスムーズな画面遷移は実現できず、根本的なアーキテクチャの見直しが必要な状態でした。

フロントエンド刷新の全体像:Before/After

約4年をかけたモダナイゼーションの結果、フロントエンドの技術スタックは劇的に変化しました。

項目 Before (負債) After (刷新後)
フレームワーク/ライブラリ FuelPHP, jQuery, React15 (Class) React18(FC)
言語 JavaScript TypeScript
アーキテクチャ MPA (一部React) 完全なSPA
状態管理 巨大stateオブジェクト, Redux React Hooks, jotai
ビルドツール なし / 一部Webpack Vite

Phase 1: ページ単位での負債返済 - 基盤構築

最初の数年間は、ひたすらコードベースの質的改善に時間を費やしました。このフェーズでは、主にReactコンポーネントの刷新とTypeScriptの導入という、二つの大きな課題に取り組みました。

Class ComponentからHooksへ

Class ComponentsからFunctional Componentsへの移行は、単なる構文の書き換えではありませんでした。 特に、componentDidUpdate が持つ prevPropsprevState を利用した、命令的な条件分岐のロジックを、useEffect の宣言的な依存関係の仕組みで正確に再現するということがプロジェクトを通しての課題となりました。

具体例として、以下のような componentDidUpdate の典型的なロジックがありました。

// Before: componentDidUpdate with complex conditional logic
componentDidUpdate(prevProps, prevState) {
  // 条件1: ユーザーIDが変わったら、フィルターの状態に関わらずデータを再取得
  if (this.props.userId !== prevProps.userId) {
    this.fetchData({ userId: this.props.userId, filter: this.props.filter });
    return; // 他の条件は実行しない
  }

  // 条件2: ユーザーIDは同じで、フィルターが変更された時だけ、ローカルで表示を更新
  if (this.props.filter !== prevProps.filter) {
    this.applyFilter(this.props.filter);
  }
}

100ページに及ぶ大規模な移行作業の中で、こうした複数の条件分岐を一つのuseEffectに集約してしまう、という誤りが生まれました。機械的な作業が続く中で、依存配列に userId と filter の両方を含めてしまったのです。

// After (デグレードが発生したコード)
useEffect(() => {
  // 本来、userId変更時のみ実行したいfetchDataが、filter変更時にも実行されてしまう
  fetchData({ userId, filter }); 
  applyFilter(filter);
}, [userId, filter]);

このコードは、ローカルのfilterが変更されただけでも、本来は不要なfetchData(APIリクエスト)をトリガーしてしまい、パフォーマンスのデグレードを引き起こしました。

この失敗経験は大規模なリファクタリングにおいて、いかにして一貫した品質を保つかという課題の難しさと、それを仕組みで解決することの重要性をチームに教えてくれる貴重な教訓となりました。

TypeScript化への地道な道のり

TypeScriptの導入は、コードの堅牢性を高める上で不可欠でしたが、その道のりは平坦ではありませんでした。 APIのドキュメントが十分に整備されていなかったので、APIがどのような構造のレスポンスを返すのか、信頼できる情報源が存在せず、型の恩恵を最大限に受けることが難しい状況でした。

そのため、私たちは手探りで型定義を進めるしかありませんでした。

// APIから返ってくるユーザー情報の型を定義したいが、ドキュメントがないこともある
interface User {
  id: number;
  name: string;
  // emailは必ず存在する? それともnullの場合がある?
  email: string | null;
  // created_at? createdAt? それとも...?
  createdAt: string; 
  // ... 他にも未知のプロパティがあるかもしれない
}

この未知の状態を解決するため、開発者ツールで実際のAPIレスポンスのJSONを展開し、あるいはコードにconsole.logを仕込んで、返ってくるデータの構造を目で確認しながら、一つ一つ丁寧にインターフェースを定義していく、という非常に地道な作業を繰り返しました。

このプロセスは多大な時間と労力を要しましたが、結果としてフロントエンド側にAPIレスポンスの「事実上の仕様書」をコードとして作り上げることにも繋がりました。また、この経験があったからこそ、原則としてany型を禁止するというルールを徹底することができました。安易にanyに逃げるのではなく、手間を惜しまず型を定義する文化が、この時期に醸成されたのです。

Phase 2: SPAへの段階的アーキテクチャ刷新

全てのページのコンポーネントがFC/TS化された後、我々はついにアーキテクチャの刷新に着手しました。

ステップ1:ドメイン単位でのSPA化とWebpackとの格闘

我々はまず、FuelPHPをベースとしながら、人事・リクルーター・ユーザーというドメインごとに独立したSPAを構築する「ハイブリッド構成」を選択しました。この時、ビルドツールとして採用したのはWebpackでした。 また、FuelPHPがルーティングするアーキテクチャに強引にフィットさせていたため、設定は極めて複雑化していました。

この肥大化した設定ファイルのメンテナンスコストは非常に高く、ビルド時間も長い、という課題を常に抱えていました。

ステップ2:完全なSPA化、ポリレポ化とS3へのデプロイ

3つのドメイン全てがWebpackベースの独立SPAとして安定稼働したことを確認し、私たちは最終フェーズへと移行しました。それは、フロントエンドのサービングからFuelPHPを完全に切り離し、単一のSPAを構築することです。

このタイミングで、私たちはビルドツールをWebpackからViteへと刷新しました。Viteの導入により、複雑だったビルド設定は劇的に簡素化され、開発体験は大幅に向上しました。

また、モノリシックだったリポジトリからフロントエンドのコードを完全に分離し、独立したリポジトリを構築しました。これにより、バックエンド(FuelPHP)とフロントエンドがそれぞれ独立して開発・デプロイを行えるポリレポ構成へと移行しました。 ホスティングもFuelPHPから完全に独立したことで、フロントエンドはもはやPHPサーバーで配信する必要がなくなりました。ビルド後に生成されるのは、静的なHTML、CSS、JavaScriptファイルだけです。

そこで私たちは、ビルド成果物をAmazon S3のバケットにアップロードし、静的ウェブサイトホスティング機能を利用して配信する構成を選択しました。

これにより、フロントエンドはバックエンドのインフラから完全に分離され、スケーラビリティと可用性が高く、かつ低コストな運用が可能なモダンなフロントエンド基盤が完成しました。これが、私たちが目指した「完全なSPA化」であり、フロントエンドが真に自立した瞬間でした。

Phase 3: スケーラビリティを見据えたアーキテクチャ整備

完全なSPA化を達成した後、今後のプロダクトの成長を見据え、アーキテクチャの最終整備を行いました。

featureディレクトリ戦略

コードベースの見通しを改善するため、社内の他プロダクトで成功していた「featureディレクトリ戦略」 を採用しました。私たちのプロダクトでは、人事・リクルーター・ユーザーという明確なドメインが存在するため、まずトップレベルでドメインを分離し、その配下にそれぞれのfeaturesディレクトリを配置する構成としました。

これにより、各ドメインが自己完結した小さなアプリケーションのようになり、ドメイン間の依存関係を排除し、コードの凝集度を最大限に高めることができました。

/src
├── /corp       # 人事ドメイン
│   └── /features
│       ├── /settings
│       └── /analytics
├── /recruiter  # リクルータードメイン
│   └── /features
│       ├── /auth        
│       │   ├── /components
│       │   └── /hooks
│       ├── /settings
│       │   ├── /api
│       │   ├── /components
│       │   │   ├── SettingsTable.tsx
│       │   │   └── SettingsDetail.tsx
│       │   ├── /hooks
│       │   └── /pages
│       │       └── OfferListPage.tsx
│       └── /settings    
└── /user       # ユーザードメイン
    └── /features
        └── ...

この戦略は、機能ごとに関連ファイルをまとめるだけでなく、ドメインという大きな関心事でコードベースを分割することで、改修時の影響範囲の特定をさらに容易にしています。

成果と今後の展望

この4年間の取り組みにより、開発生産性とユーザー体験の両面で大きな成果が得られました。

開発生産性と開発者体験 (DX) の向上

  • UI改修スピードの劇的な向上: 1,000行を超えていたファイルは適切に分割され、再利用可能なコンポーネント指向の設計へと移行しました。これにより、かつて半日以上を要していた類似UIの修正が、1時間程度で完了するケースも珍しくありません。
  • リファクタリングの心理的安全性: TypeScriptによる型システムの恩恵は絶大です。実行時エラーを未然に防ぎ、特に大規模なリファクタリングを行う際の「壊してしまうかもしれない」という不安を解消してくれました。
  • 開発サイクルの高速化: ビルドツールをWebpackからViteへ移行したことで、開発サーバーの起動やHMR(Hot Module Replacement)が高速化し、コーディングから動作確認までのフィードバックループが大幅に短縮されました。また、CICD時間も半減しました。

ユーザー体験 (UX) の向上

  • シームレスなページ遷移の実現: 今回の最も大きな成果の一つが、MPAからSPAへのアーキテクチャ刷新です。ページ遷移のたびに発生していた画面全体の再読み込みがなくなり、ユーザーは文字通りストレスなく、スムーズに各機能間を移動できるようになりました。
  • 体感速度の向上: SPA化により、画面遷移時に必要なデータのみを非同期で取得する構成となりました。これにより、ページの表示速度、特にユーザーの体感速度が大幅に向上し、より快適な操作性を提供できるようになりました。

しかし、改善するところはまだまだあり、次のステップとして以下を挙げています。

  • 一部残っているReduxを、よりシンプルで宣言的な状態管理を実現する Jotai へとリプレイス
  • メンテナンスされていないライブラリの置換
  • data fetchをuseEffect + axios の形からSWRへ移行
  • lazy importを利用したコンポーネントの遅延読み込みによるパフォーマンス改善

まとめ

MyReferフロントエンドのモダナイゼーションは、「ビジネスを止めない」という制約の中で、巨大な技術的負債と向き合い続けた4年間の記録です。特に、MPAから複雑なWebpack設定を伴うハイブリッド構成を経て、最終的にViteベースの完全なSPAへと至る段階的なアーキテクチャ移行は、大規模プロダクトを安全かつ現実的に刷新するための有効な戦略だったと思います。 しかしこのプロジェクトは、私たちフロントエンドチームだけで成し遂げられたものではありません。移行の最中に予期せぬ不具合が発生した際には、迅速に調整いただいたPM、そして粘り強く協力いただいたバックエンドチームの皆さんには、この場を借りて心より感謝申し上げます。