🥊

Next.js (Turbopack) のバンドルサイズを元の半分まで削減した話

に公開1

こんにちは!アルダグラムでエンジニアをしている今町です。

今回は、弊社で運用している Next.js 製の Web アプリケーションの バンドルサイズを元の半分まで削減した話 を書いていきます。

単に「サイズが減って嬉しい」だけじゃなくて、どのページを開いても巨大な共通チャンクが必ずロードされる という構造的な問題を抱えていたので、そこを解きほぐしていく過程を書き残しておきます。Turbopack / webpack を問わず、Next.js をある程度の規模で運用している現場なら、どこかで一度は踏む地雷だと思うので、参考になれば幸いです。

背景:バンドルがクソデカすぎる問題 🔥

対象の Next.js アプリは、開発が進むにつれてバンドルサイズが肥大化しており、Next.js 公式の Turbopack 向けバンドルアナライザ next experimental-analyze(Next.js v16.1 以降で利用可能)で計測したところ、ざっくり以下のような状態でした(具体的な数値は伏せます)。

# Turbopack のモジュールグラフを使った公式のバンドルアナライザ
npx next experimental-analyze

# 結果をファイルに保存(Before / After で diff したいときに便利)
npx next experimental-analyze --output
# → .next/diagnostics/analyze に出力される

参考: Optimizing package bundling | Next.js

  • バンドルの合計サイズがすべてのページでかなり肥大化していた
  • その内訳としては、node_modules だけでなく 自前の共通コンポーネントが占める割合も大きかった

そして最大の問題がこれ 👇

全ページが巨大な共通チャンクを読み込んでいた

ほぼ全ページのサイズが ほぼ同じ値で横並び になっていました。どのページを開いても、同じ重さの JavaScript がロードされる、という状態です。

/cms        ██████████████░░  (最大)
/tasks      ████████████░░░░
_app        ████████████░░░░  ← 全ページ共通
/signin     ████████████░░░░
/open_app   ███████████░░░░░  (最小クラス)

ログイン画面(/signin)や利用規約のような静的ページ(/terms, /privacy)ですら、他の画面と同程度の JS を読み込んでいる 状態でした。ログインしないと使えない SaaS のログイン画面で、なぜかチャット機能のコードや画像エディタのコードが読み込まれている、というような問題があるとわかりました。

主な原因はざっくり以下の 2 つでした。

  1. import 方法の問題で Tree-shaking が効いていない
    • ライブラリをフル import してしまっている
    • バレル export(index.tsexport *)を経由することで、実際には使っていないコードまで一緒に引きずり込まれている
  2. 特定機能専用のコードが全ページ共通チャンクに混入している
    • 一部のページでしか使わない重いライブラリや自前コンポーネントが、依存チェーン経由で共通チャンクに吸い上げられてしまっている

バンドルサイズが大きいと、実際何が困るのか 🤔

「バンドルがデカい=悪」は感覚的にはわかるものの、改めて棚卸しすると結構色んなところで実害が出ます。

🐢 1. 初期表示が遅くなる(FCP / LCP の悪化)

ブラウザは JS を「ダウンロード → パース → 実行」してからコンポーネントを描画します。バンドルが大きいほど、白い画面の時間が長くなる ため、Core Web Vitals の LCP(Largest Contentful Paint)が悪化します。

📱 2. モバイル・低速回線ユーザーへの影響が大きい

モバイル回線(特に 3G・4G)では、数 MB の JS ダウンロードに 数十秒かかることもある 世界です。新興国向けのサービスや、現場の電波が弱い環境で使われるアプリでは、この影響が顕著に出ます。

⚙️ 3. TTI(操作可能になるまでの時間)が遅くなる

画面は見えていても、JS の実行が終わるまでボタンやリンクが 反応しない 状態になります。ユーザーからすると「壊れてる」「固まった」と感じる原因になります。

💸 4. 通信コスト・インフラコスト

  • ユーザー側:モバイルのデータ通信量を無駄に消費する
  • サーバー側:CDN の転送量が増え、コストが増加 する

🔄 5. キャッシュ効率の低下

共通チャンクが巨大になるほど、少しコードを変えるだけでキャッシュが丸ごと無効化 されます。適切にコード分割されていれば、変更のないチャンクは引き続きキャッシュが効くため、毎リリースごとのユーザー体感が大きく変わります。

🛠 6. 開発環境への悪影響(Fast Refresh / HMR が遅くなる)

Next.js の Fast Refresh は、ファイルを保存したときに 変更されたモジュールとその依存モジュールだけ を再コンパイル・再適用する仕組みです。

つまり依存チェーンが、

変更したファイル
 └── import している A
      └── import している B
            └── import している C ...

のように 長く・広くなるほど、再コンパイルの範囲が広がり、Fast Refresh が遅く なります。

特に バレルファイル(index.ts で大量に re-export)が多いプロジェクト では、1 ファイルの変更が芋づる式に広範囲の再コンパイルを引き起こします。循環 import や複雑な依存関係があると、HMR の差分追跡が破綻して フルリロードにフォールバック することもあり、「コードを変えるとなぜかページごとリロードされる」という現象の原因になったりします。

Turbopack に移行しても、この構造的な依存の問題は解決しないので、バンドル最適化は Turbopack 導入とセットで向き合う価値があるテーマ です。

現状把握:共通チャンクの中身を見る 🔍

改修に入る前に、まずは next experimental-analyze の結果を使って 「全ページに読み込まれている最大チャンク」の中身 を覗きにいきます。これをやらないと、何を削ればどれくらい効くのかが全くわからないので、最初の投資として一番効果が大きい作業です。

なお Next.js 公式のバンドルアナライザは、treemap 上でモジュールをクリックすると そのモジュールがどの import チェーンで引き込まれているか を表示してくれるので、「このライブラリ、なんで共通チャンクに入ってるの?」を辿るのに非常に便利です。

最大の共通チャンクの中身(要点)

全ページで読み込まれる共通チャンクの中身を見てみたところ、本来ここに入るべきでないコードが大量に含まれている ことがわかりました。ざっくり分類するとこんな感じです。

  • 機能特化ライブラリが全ページ共通チャンクに入っている
    • 一部のページでしか使わないライブラリ(画像処理・エディタ系、アップロード系、ドラッグ&ドロップ系、アニメーション系 など)が、全ページに配られている
  • 特定画面用の自前コンポーネントが共通チャンクに引きずり込まれている
    • 一画面用のはずの Template やヘルパーが、依存の辿られ方次第で共通チャンクに混入している

このように、「import の書き方」と「バレル export」が噛み合うと、想定していない依存が共通チャンクに芋づる式に取り込まれていく、というのが根本の問題でした。

やったこと一覧 💪

今回の改修で実際に入れた変更を、やった順番に見ていきたいと思います。

1. lodash のフル import を個別 import に修正

// Before
import _ from 'lodash'
_.orderBy(...)

// After
import orderBy from 'lodash/orderBy'
orderBy(...)

どこか一箇所でもフル import が残っていると、共通チャンクに lodash 全体 が引きずり込まれてしまいます。影響範囲に対して修正コストが軽いので、まず最初に潰しておくべき対応のひとつです。

2. サードパーティ製のライブラリのバレル import は optimizePackageImports に任せる

@mui/material@mui/icons-material のような 「大量の名前付き export を持つライブラリ」をバレル import している箇所 は、Next.js の optimizePackageImports で自動的に最適化できます。

// next.config.js
const nextConfig = {
  experimental: {
    optimizePackageImports: ['your-package'],
  },
}

ポイントは、

  • Next.js 側で、バレル import を 実際に使っている名前だけの個別 import 相当 に書き換えてくれる
  • @mui/material など、有名どころのライブラリはデフォルトで最適化対象に含まれている(明示的にリストへ追加しなくてもよい。サポートパッケージ一覧は上記公式ドキュメントを参照)
  • それ以外のパッケージでも、optimizePackageImports のリストに追加するだけでよい

ということで、まず next experimental-analyze で本当にバンドルに混入しているのか、optimizePackageImports で解決しないのかを確認するのがおすすめです。

…が、現実には次に紹介する「自前のバレル export」との合わせ技になると、この自動最適化が 効き切らない ケースがあります。

3. 自前のバレル export を廃止する(ここが本丸)

今回のプロジェクトで 最も効果が大きかった のが、自前 UI ライブラリのバレル export 廃止です。src/ui/atoms/index.ts のような、いわゆる Atomic Design 的なバレル export を全廃しました。

// Before
import { Button, TextField } from '@/ui/atoms'

// After
import Button from '@/ui/atoms/Button'
import TextField from '@/ui/atoms/TextField'

その上で、不要になったバレル export ファイル(atoms/index.ts, molecules/index.ts, organisms/index.ts)自体を削除しています。

なぜ optimizePackageImports で解決しないのか

ざっくり書くとこういう構造です。

_app.tsx
 └── <あるコンポーネント>
      └── @/ui/molecules(barrel index.ts)
           ├── ComponentA ← 実際に使う
           ├── ComponentB
           ├── ComponentC
           ...
           └── ComponentZ

バレル index.ts は「全部 export」しているので、バンドラから見ると、_app.tsx の依存に molecules 配下の全コンポーネント が引き込まれる、と判断されがちです。そして、それらがさらに別のライブラリ(@mui/material 等)に依存していると、その依存も芋づるで引っ張られます。この状態だと、せっかく optimizePackageImports が効いても、自前 barrel の手前で Tree-shaking が失敗している ので、結局サードパーティ製のライブラリまで大量に引きずり込まれる、ということが起きます。

なので、一番確実な対応は バレル export そのものを消すこと でした。バレル export を廃止すると、

  • Tree-shaking が素直に効くようになる
  • サードパーティ製のライブラリの optimizePackageImports も結果的に効きやすくなる(ライブラリ由来のコードの大量混入が副次的に解消される)
  • 副次効果として、Fast Refresh / HMR の差分範囲も狭まり、開発体験も改善する

という、一石三鳥の対応になります。

また、再発防止として ESLint の no-restricted-imports ルールで 今後のバレル import を禁止 しておくのがおすすめです。これをやらないと必ずリバウンドします。

// eslint.config.js 抜粋
{
  rules: {
    'no-restricted-imports': ['error', {
      patterns: [
        {
          group: ['@/ui/atoms', '@/ui/molecules', '@/ui/organisms'],
          message: '個別パスで import してください(例: @/ui/atoms/Button)',
        },
      ],
    }],
  },
}

4. 依存の向きが逆転しているケースを解消する

一番厄介だったのがこのパターンです。

バンドルアナライザで import チェーンを辿っていくと、とある汎用 atom コンポーネントが、チャット画面用の template コンポーネントに依存してしまっている 箇所が見つかりました。本来は「template が atom を使う」という向きであるべきなのに、依存の向きが逆転していた、という状態です。

これが何を引き起こすかというと、

チャット以外のページ
 └── 汎用 atom コンポーネント(本来は軽いはず)
      └── チャット画面用 template  ← 逆向きの依存!
           └── チャット関連のライブラリ一式
                └── ...

というチェーンで、チャットを使わないページでその atom を読み込むだけで、チャット画面用の重いコードが芋づる式に引きずり込まれる という現象が起きます。

対応としては、

  • 汎用 atom 側から、チャット画面用 template への 逆向きの参照を解消
  • 両者で共有したいロジックは、依存関係的に下位にある共通ディレクトリへ移動

という方針で、依存の向きを正しく整え直しました。

この手の「特定機能のコードが共通チャンクに引きずり込まれる」現象は、ディレクトリ構造的に上位のコードが下位に混じり込んでいる ことが原因だったりします。バンドルアナライザの import チェーン表示で「なんでこれが入ってるの?」を地道に辿っていくと、こういう歪んだ依存が見つかるので、リファクタリングと合わせて片付けるのがおすすめです。

結果 ✨

最終的なバンドルサイズは、元の半分まで削減 できました(圧縮後の共通チャンク比)。全ページ共通で乗っていた巨大な共通チャンクがしっかりスリム化できたので、ログイン画面や静的ページの初期ロードが軽くなっています。

改善効果の分類

カテゴリ 効果 主な内訳
フル import の修正 共通チャンクから除外 lodash など
サードパーティ製のライブラリ最適化 optimizePackageImports に寄せる MUI などの大量 export を持つライブラリ
自前バレル export 廃止 Tree-shaking 改善による間接効果(大) atoms / molecules / organisms / templates の index.ts 全廃
依存チェーン解消 共通チャンクからの除外 特定画面用 Template の逆向き参照の解消

学び:やってみて思ったこと 🎓

今回の改修を通して感じたことをいくつか書いてみます。

バンドルサイズ改善は「計測 → 仮説 → 潰す」の繰り返し

感覚で「これが重そう」と入っていくとハマります。next experimental-analyze --outputBefore / After のスナップショットを撮る のが本当に大事で、「入れたつもりの改善が効いていない」「別の依存から復活している」というケースが結構あります。

バレル export は、アプリ内部だと負債になりやすい

書き始めは便利な index.ts ですが、アプリケーション内部のモジュール間で使うと

  • tree-shaking が効きにくくなりやすい
  • Fast Refresh の再評価範囲が広がりがち
  • 循環依存を生みやすい

といった副作用が出やすく、中規模以上だと地味に効いてきます。ライブラリの公開 API としてのバレルは有用ですが、アプリ内部のバレル import は ESLint で早めに制限しておく のが、コスパの良い予防策かなと思っています。

Turbopack との関係

Turbopack は webpack より高速な開発体験を提供してくれますが、「不要なモジュールが共通チャンクに混入している」という構造問題はバンドラを変えても残ります。Turbopack 移行と同時にバンドル棚卸しをやっておくと、Turbopack の恩恵(HMR の速さなど)がより顕著に体感できるのでおすすめです。

Turbopack 向けバンドルアナライザがかなり便利

今回の調査で大きく効いたのが、next experimental-analyze(Next.js v16.1 以降)です。公式ドキュメント記載の特徴として、Turbopack のモジュールグラフと統合 されており、モジュールをクリックすると import チェーン がそのまま辿れたり、ルート / 環境 (client/server) / ファイル種別でフィルタできたりします。

従来の @next/bundle-analyzer(webpack 向け)は「どのモジュールが入っているか」はわかるものの、「なぜ入っているか」を辿る体験は Turbopack 版の方が圧倒的に良かった です。今回の逆向き依存みたいなケースは、この import チェーン辿りがなければ発見がかなり大変だったはず。Turbopack 移行と合わせて触ってみるのはおすすめです。

ぶっちゃけ:調査も改修もほぼ AI にやってもらいました 🤖

最後にぶっちゃけた話をしておくと、今回の調査・原因特定・修正まで、ほぼ全部 Claude Code にやってもらいました。自分がやったのは、ざっくり以下くらいです。

  • next experimental-analyze を回して、出力ファイル(.next/diagnostics/analyze)を Claude Code に渡す
  • 「共通チャンクがデカい原因を調べて、改善案を出して」みたいな指示を投げる
  • 出てきた仮説(バレル export、逆向き依存など)をレビューして、修正方針を決定する
  • それぞれの改善をステップバイステップで実施し、ステップ毎で、Before / After のバンドルサイズを比較する

特に今回みたいに import チェーンを延々と辿る系の調査 は、人間がやると集中力が持たない上に見落としが出やすいんですが、AI にやらせると 機械的に全チェーンを舐めてくれる ので、逆向き依存みたいな歪な依存もサクッと見つかってきます。

「バンドル肥大化、なんとなく気になってるけど腰が重い…」みたいな状態の方、バンドルアナライザの出力を AI に丸投げして壁打ち してみるのはおすすめです 💡 今回の改修もそれで一気に進みました。


というわけで、Next.js のクライアントバンドルを元の半分まで削減した話でした。同じように「なんかバンドルサイズ、デカいな…」と思っている方の参考になれば嬉しいです 🙌

最後まで読んでいただきありがとうございました!

アルダグラム Tech Blog

Discussion