CodeBuildのOOMをReact.lazyによるコード分割で解消した話
背景:CodeBuild が OOM で落ちる
React 製 SPA の CI を AWS CodeBuild で回しているのですが、ビルドが Out of Memory(OOM)で頻繁に落ちるようになりました。ローカルでは普通にビルドできるのに CI だけ落ちる、というのが厄介なところで、原因を追っていくと CodeBuild の実行環境のメモリ上限に対して、ビルドプロセス(バンドラー)が確保しようとするメモリが大きすぎることが分かりました。CodeBuild のメモリの設定もかなり少なかったのもありましたがね。
原因:ひとつの巨大なバンドルを一括で処理していた
SPA では基本的に全ページ分のコンポーネントを importして1つのエントリーポイントから読み込む構成になりがちです。ページ数やコンポーネント数が増えるにつれて、バンドラーが一度にパース・変換・Tree Shaking・minify しなければならない対象がどんどん膨らみ、ビルド時のメモリ消費のピークが上がっていきます。ローカルマシンはメモリに余裕があるので気づきにくいのですが、CodeBuild のビルド環境はスペックが限られているため、そのピークに耐えられずに落ちていました。
対応:ページ単位で React.lazy による遅延ロード(コード分割)
結論としては、ルーティングされる各ページのコンポーネントを React.lazy で動的 import に切り出し、チャンクを分割することで解消しました。
React.lazy と Suspense のしくみ
React.lazy は、コンポーネントを通常の静的 import ではなく import()(動的 import)で読み込むためのラッパーです。例えばページコンポーネントであれば、
const AboutPage = React.lazy(() => import('./pages/AboutPage'));
のように書き、実際にレンダリングされるタイミングまでそのコンポーネントのコードを読み込まないようにします。読み込みが完了するまでの間に表示する UI は Suspense の fallback で指定します。
<Suspense fallback={<Spinner />}><AboutPage /></Suspense>
ポイントは、この import() という構文自体が「ここでコード分割してよい」という合図になっている点です。Vite や webpack などのバンドラーはビルド時にこの動的 import を検出すると、そのモジュール(と依存先)を別のチャンクファイルとして切り出します。React 側の役割は「読み込みが終わるまで待って、終わったらレンダーする」という非同期処理のハンドリングだけで、チャンク分割そのものはバンドラーが担っています。
なぜ OOM が解消したのか
ページ単位でコードを分割すると、バンドラーは「全ページ分のコードをまとめて1つの巨大な塊として最適化する」処理から、「ページごとの比較的小さいチャンクを個別に処理する」処理に変わります。1回あたりに同時にメモリ上へ展開・保持する必要のあるコード量(AST・変換後のコード・ソースマップなど)のピークが下がるため、同じ総コード量であっても必要メモリの最大値を抑えられます。これが CodeBuild のメモリ上限内に収まり、OOM が起きなくなった理由です。単純に「全体のコード量を減らした」わけではなく、「一度に処理する単位を小さくした」ことが効いています。
実装のポイント
- ルーティング単位(ページ単位)で
React.lazyを適用する。コンポーネント単位で細かくやりすぎるとチャンク数が増えすぎて別の問題(後述)を招くため、まずはページ単位から始めるのが無難 - ルーターの各ルートを
Suspenseで包み、ローディング中の見た目(スピナーやスケルトン)を用意する - チャンクの読み込み失敗(ネットワークエラーなど)に備えて
ErrorBoundaryも併せて用意しておく
lazy 化のトレードオフ
メリット
- ビルド時のメモリ消費のピークが下がり、CI 環境のような限られたリソースでもビルドが通りやすくなる
- 初期表示に必要な JS の量が減り、初回ロードのパフォーマンスが向上しやすい(特にページ数が多いアプリで効果大)
- ユーザーが実際に訪れないページのコードはそもそもダウンロードされない
デメリット・注意点
- ページ遷移のたびに追加のネットワークリクエストが発生する。回線が細い環境では体感速度が悪化することもある
- Suspense の fallback(ローディング UI)の設計・実装コストが増える
チャンクを分割しすぎると、リクエスト数の増加や、細かいファイルごとのオーバーヘッド(HTTP ヘッダ等)が無視できなくなる。分割の粒度はページ単位など 適度な大きさ を意識する必要がある
- チャンク読み込み失敗時のエラーハンドリング(ErrorBoundary、リトライ処理)を別途考える必要がある
- SSR/SSG 環境では動的 import の扱いが素の CSR と異なるため、フレームワーク側の対応方法を確認する必要がある
- テストやデバッグ時に「非同期でコンポーネントが読み込まれる」という前提を意識する必要が出てくる(テストで待機処理が必要になることがある)
まとめ
CodeBuild の OOM は、SPA 全体を1つの巨大なバンドルとしてビルドしていたことが原因でした。ページ単位で React.lazy を使ってコード分割することで、ビルド時に一度に処理する単位が小さくなり、メモリのピークが下がって OOM が解消しました。ただし lazy 化はビルド時の負荷を下げる一方で、実行時にはネットワークリクエストの増加やローディング UI・エラーハンドリングの実装コストというトレードオフを伴います。すべてのコンポーネントを闇雲に分割するのではなく、ページ単位など適切な粒度を選ぶのが現実的な落としどころだと感じています。