Article

マイクロフロントエンドを3回失敗して4回目で成功した話

高田晃太郎
マイクロフロントエンドを3回失敗して4回目で成功した話

**DORAの年次レポートでは、デプロイ頻度が高くリードタイムが短い組織ほど変更失敗率が低い(おおむね0〜15%)**ことが示されてきました[1]。独立デプロイ性を高める手段の一つとして注目されるのがマイクロフロントエンドです[2]。とはいえ、実際に導入して成果に結び付けるのは容易ではありません。本稿ではCTOの視点から、導入時に陥りがちな失敗の構造と、機能した設計・実装パターンを、コードと運用の両面から具体的に整理します。キーワードは「境界の定義」「契約(コントラクト)テスト」「観測性」「安全なランタイム合成」です。

なぜ3回失敗したのか:原因は技術より境界と運用にあった

最初の取り組みの典型例は、レポジトリを分けただけで権限とリリースの境界を変えられなかったケースです。アプリケーションはドメインで縦に分けたつもりでも、デザインシステム、認証、カートなどの横断コンポーネントが一箇所に偏在し、結局は共通ライブラリの更新に全チームが巻き込まれる「疑似モノリス」に逆戻りします。リリースは週次のトレイン(複数変更をまとめて定期出荷する方式)のままで、バージョン衝突と回帰試験の待ち時間が積み上がり、変更が遅くなるだけという結果になりがちです。

二度目に多いのはUXを壊さないことを強調し過ぎて、iframeによる統合に舵を切る選択です。iframeはセキュリティや故障ドメインの分離が明快な一方で、グローバルナビの一貫性、フォーカスマネジメント、履歴管理、レスポンシブ調整が想定以上に難しく、結果としてイベントバスとCSSの調停に大半の時間を費やしがちです。ロード時間もフレームの多重読み込みで増え、LCP(Largest Contentful Paint)が3秒台後半に達することも珍しくありません[3]。

三度目にありがちなつまずきはWebpack Module Federationを導入しつつ、共有ライブラリの宣言、契約の検証、バージョニングの規律を軽視してしまうことです。Reactが二重にロードされてフックが壊れたり、マイナー更新で破壊的変更が混入したり、リモート間の暗黙的な依存が膨らみ、メンテナンス不能に陥ります。ここで重要なのは、技術スタックよりも境界の定義、契約の自動検証、運用ワークフローを先に設計するという原則です[3]。

試行1〜3の技術的つまずき:具体と反省

iframe統合で噴き出したUX課題と回避策の限界

親ウィンドウと子フレームの通信にはpostMessage(ウィンドウ間メッセージングAPI)を使うのが定石ですが、履歴やフォーカスの同期まで含めるとイベント定義が急増し、変更の伝播が複雑化します。以下は当時の連絡コードの一部です。

// parent.js
window.addEventListener('message', (e) => {
  if (e.origin !== new URL(process.env.CHECKOUT_ORIGIN).origin) return;
  if (e.data?.type === 'NAVIGATE') {
    history.pushState({}, '', e.data.path);
  }
});

function navigateChild(path) {
  const frame = document.getElementById('checkout-frame');
  frame.contentWindow?.postMessage({ type: 'NAVIGATE', path }, process.env.CHECKOUT_ORIGIN);
}

この方式は仕様上は問題がありませんが、ナビゲーションの二重管理、アクセシビリティ上のフォーカス移動、スクロール位置の整合など、UIの整合性維持における摩擦が大きく、一般的なチーム規模では継続運用が難しくなりがちです[3]。

Module Federationの共有設定不備で起きた二重ロード

Module Federation(ビルド境界をまたいだランタイム取り込み機構)の導入時、共有ライブラリの宣言が曖昧だと、ReactやRouterが重複読み込みされ、無効なフック呼び出しが頻発します。失敗時の設定ではrequiredVersionが空のままで、ビルド時の検証も効いていませんでした。

// webpack.config.js (bad)
new ModuleFederationPlugin({
  name: 'cart',
  filename: 'remoteEntry.js',
  exposes: { './CartApp': './src/App' },
  shared: { react: { singleton: true }, 'react-dom': { singleton: true } }
});

この曖昧さが、ホストとリモートでパッチレベルの差異を許容しすぎる原因となり、ランタイム互換性の崩壊を招きます。回避にはバージョンの釘付けと契約テストが不可欠です[3]。

4回目で成功した設計と実装:境界、契約、観測性を最初に置く

有効だったパターンは、アーキテクチャより先に組織の責任境界を固定し、それに合わせて技術の境界を引き直すことでした。事業の縦軸(例:検索・詳細・カート・チェックアウト)を単位にプロダクトチームを分け、プラットフォームは認証・デザインシステム・共通計測といった有界コンテキスト(DDDで定義される機能境界)だけを提供します。UI統合はランタイム合成を採用し、主要経路はModule Federation[3]、安定APIの共有はWeb Components[4]でフレームワーク非依存に、そしてアセットのバージョン固定はImport Maps[5]で行うのが実践的です。

ホストアプリの合成と厳格な共有設定

ホストはルーティングでリモートを遅延読み込みし、共有ライブラリはメジャー互換を必須にします。以下がホスト側の設定例です。

// host/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
  entry: './src/bootstrap',
  experiments: { topLevelAwait: true },
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        product: 'product@https://cdn.example.com/product/v2/remoteEntry.js',
        cart: 'cart@https://cdn.example.com/cart/v3/remoteEntry.js'
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.2.0', strictVersion: true, eager: false },
        'react-dom': { singleton: true, requiredVersion: '^18.2.0', strictVersion: true },
        'react-router-dom': { singleton: true, requiredVersion: '^6.23.0' }
      }
    })
  ]
};

リモート側は公開ポイントを小さく保ち、UIと副作用を分離します。

// cart/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
  entry: './src/bootstrap',
  plugins: [
    new ModuleFederationPlugin({
      name: 'cart',
      filename: 'remoteEntry.js',
      exposes: { './App': './src/App' },
      shared: {
        react: { singleton: true, requiredVersion: '^18.2.0', strictVersion: true },
        'react-dom': { singleton: true, requiredVersion: '^18.2.0' }
      }
    })
  ]
};

ホストはルート単位で遅延ロードし、通信障害や互換性エラーの際にフェールセーフを提供します[3]。

// host/src/routes.tsx
import React, { Suspense, lazy } from 'react';
import { createBrowserRouter } from 'react-router-dom';

const Product = lazy(() => import('product/App'));
const Cart = lazy(() => import('cart/App'));

function Fallback({ message }) {
  return <div role="status">{message ?? '読み込み中...'}</div>;
}

function ErrorBoundary({ children }) {
  const [error, setError] = React.useState(null);
  return (
    <React.Suspense fallback={<Fallback />}>
      <ErrorCatcher onError={setError}>
        {error ? <Fallback message="一時的な不具合が発生しました" /> : children}
      </ErrorCatcher>
    </React.Suspense>
  );
}

export const router = createBrowserRouter([
  { path: '/', element: <ErrorBoundary><Product /></ErrorBoundary> },
  { path: '/cart', element: <ErrorBoundary><Cart /></ErrorBoundary> }
]);

ErrorCatcherは予期せぬ例外をホスト側で捕捉し、オブザーバビリティ(観測性)に必要なコンテキストを付加して送信します。

// host/src/ErrorCatcher.tsx
import React from 'react';
import { captureException } from './observability';

export const ErrorCatcher = ({ children, onError }) => {
  return (
    <ErrorBoundaryImpl onError={onError}>
      {children}
    </ErrorBoundaryImpl>
  );
};

class ErrorBoundaryImpl extends React.Component { 
  constructor(props) { super(props); this.state = { hasError: false }; }
  static getDerivedStateFromError() { return { hasError: true }; }
  componentDidCatch(error, info) {
    captureException(error, { componentStack: info.componentStack, app: 'host' });
    this.props.onError?.(error);
  }
  render() { return this.state.hasError ? null : this.props.children; }
}

Web ComponentsとImport Mapsでフレームワーク非依存を担保

共通UIはWeb Components(ブラウザ標準のカスタム要素)で提供し、各フレームワークから透過的に利用可能にします。これにより、ReactとSvelteが混在しても共通ボタンやモーダルの振る舞いを一元化できます[4]。

// design-system/src/button.ts
export class DsButton extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.shadowRoot!.innerHTML = `<style>button{all:unset;padding:8px 12px;border-radius:6px;background:#111;color:#fff}</style><button part="button"><slot/></button>`;
  }
}
customElements.define('ds-button', DsButton);

ライブラリのバージョン拘束はImport Maps(ESMの解決マップ)で行い、CDN経由の解決を安定化します[5]。

<script type="importmap">{
  "imports": {
    "react": "https://cdn.example.com/npm/react@18.2.0/esm/index.js",
    "react-dom": "https://cdn.example.com/npm/react-dom@18.2.0/esm/index.js"
  }
}</script>

Import Mapsを使うと、バンドラを跨いだ解決の一貫性が保たれ、Module Federationのshared設定と矛盾しない運用が可能になります[5]。

CDN公開とバージョン固定、そして安全なランタイム解決

各リモートはCDNにコンテントハッシュ付きで公開し、マニフェストでルーティングします。ホストは署名付きマニフェストを検証してから読み込むと安全です。

# .github/workflows/deploy-remote.yml
name: deploy-remote
on: [push]
jobs:
  build-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci && npm run build
      - run: node scripts/emit-manifest.mjs  # {version, url, sha256, signed}
      - uses: cloud/cdn-upload@v1
        with:
          path: dist
          cacheControl: public,max-age=31536000,immutable
// host/src/remote-loader.ts
type Entry = { url: string; sha256: string };
async function loadRemote(entry: Entry) {
  const res = await fetch(entry.url, { integrity: `sha256-${entry.sha256}` });
  const src = await res.text();
  const blob = new Blob([src], { type: 'text/javascript' });
  const url = URL.createObjectURL(blob);
  return import(/* webpackIgnore: true */ url);
}

この仕組みにより、ホストは信頼できるバイナリのみを動的に取り込み、万一の改ざんがあっても検証で弾けます[6]。合わせて変更範囲を狭めることで、障害の爆発半径を小さくできます。

効果検証と運用:数値、観測性、セキュリティ

速度と安定性の定量改善

一般に、バンドル分割と重複依存の排除、遅延ロードを組み合わせると、LCPやTTI(Time to Interactive)が1秒前後改善し、初期JSペイロードも約30〜60%の削減が見込めます。マイクロフロントエンドにより各チームが独立にデプロイできるため、デプロイ頻度は月間数回から数十回規模へと増やしやすく、RUM(実ユーザー監視)でのエラー率やMTTR(平均復旧時間)の改善も期待できます[1,3]。これらはプロダクトごとに差がありますが、独立デプロイと小さな変更単位が寄与する傾向は多くの事例で確認されています。

観測性とSLOの運用

各リモートはアプリケーション名とバージョン、ルート、ユーザーセッションを必ずイベントに付与し、トレースIDをホストから伝搬させます。SLOはLCPのp75を2.5秒以下、フロントエンドのエラー率を1%未満、合成監視の可用性を99.9%といった目標を例として設定し、エラーバジェットの消費が閾値を超えた場合は自動でリリースを停止する仕組みが有効です[8]。権限はチーム単位に委譲しつつ、プラットフォームがSLOとガードレールで横断統治する運用が安定性に寄与します。

契約と互換性の保証

ビルド時にはコンポーネントの入力・出力に型レベルの契約を設け、公開APIの破壊的変更はCIで弾きます。JSONスキーマとStorybookのスナップショット、さらにE2Eの合成テストで、ホストとリモートの互換性を自動検証します。Semantic Versioningの規律を守り、requiredVersionとマニフェストの両方でメジャー互換を強制することが、安定運用の要になります[3,5]。

セキュリティ面では、Content Security Policyで外部スクリプトの読み込み先をCDNドメインに限定し、Subresource Integrityとマニフェスト署名の二重で保護します[7,6]。依存関係は継続的にスキャンし、重大度の高いものは自動でPRとリリースを生成して速やかに修正します。

まとめ:小さく始め、境界と契約から設計する

振り返ると、失敗の本質は新しい技術を選んだことではなく、組織と運用の境界を変えないまま統合方式だけを差し替えた点にあります。成功に近づく順序は、まず責任境界を定義し、契約と観測性をコードに織り込み、最後に技術を選ぶこと。導入に迷っているなら、トラフィックの少ない縦切りの1ルートだけを対象に、Module FederationとWeb Componentsの併用で小さな実験を始めるのが現実的です[3,4]。短いリードタイムと独立デプロイがもたらすフィードバックの速さは、チームの意思決定とプロダクトの速度を確実に変えます。あなたの組織では、どの境界から変えていくのが最も効果的でしょうか。今日のバックログに、その一歩を具体的に書き込んでみてください。

参考文献

  1. Atlassian. DORA metrics: Four keys to measuring DevOps performance
  2. Wikibooks 日本語版. HTML/マイクロフロントエンド
  3. freeCodeCamp. How Microfrontends Work – From Iframes to Module Federation
  4. web.dev. Custom elements v1 – reusable web components
  5. MDN Web Docs. script type=“importmap”
  6. MDN Web Docs. Subresource Integrity (SRI)
  7. MDN Web Docs. Content Security Policy (CSP)
  8. Google Cloud Blog. A practical guide to setting SLOs