まず背景から話す。うちは6人のフロントエンドチームで、去年の秋から15個のパッケージを持つモノレポを運用している。apps/に3つのNext.jsアプリ、packages/にUIコンポーネント、共有ユーティリティ、型定義の束。規模としては中規模 — 小さくもなく、Googleみたいに巨大でもない。
Lerna + Yarn Workspacesで2023年から回していたんだけど、去年の秋あたりから本格的に崩れ始めた。プッシュのたびにCIが28分かかる。誰かがpackages/uiにちょっとした変更を加えると、関係のないアプリも全部再ビルドされる。チームの誰かが「毎回コーヒー飲みながら待ってる」と言い出したあたりで、もう限界だと悟った。
1月中旬に「TurborepoかNxか、どちらかに移行しよう」という合意になって、私が評価担当になった。2週間、それぞれを実際のコードベースに当てて使い込んだ結果を正直に書く。
設計思想がそもそも違う — ツールを選ぶ前に整理すること
Turborepoを先に試した。インストールしてturbo.jsonを一つ書いて、turbo buildを実行するまで30分かかっていない。概念がシンプルで、「タスクの依存関係を定義して、変わっていないものはキャッシュする」これだけ。
// turbo.json — 最初に書いたほぼそのまま
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**", "public/**"]
},
"test": {
"dependsOn": ["^build"],
"cache": true
},
"lint": {
"cache": true
},
"type-check": {
"dependsOn": ["^build"],
"cache": true
}
}
}
^buildという記法が「このパッケージの依存先のbuildが先に終わること」を意味している。これを理解した瞬間、ほぼ全部わかった感じがした。
Nxは — まあ、違う。良い意味でも悪い意味でも。
Nxには「プロジェクトグラフ」という概念があって、全パッケージの依存関係をリアルタイムで可視化できる。nx graphを実行すると、ブラウザでインタラクティブな依存グラフが開く。最初に見たとき「おお」となった。ただ同時に、設定ファイルが複数階層に分散していて(nx.json、各パッケージ直下のproject.json、さらにルートのpackage.jsonとの関係)、全体像を把握するのに丸一日かかった。
で、ここが肝心なんだけど。Turborepoは「タスクランナー」として自分を定義している。コア機能は「依存関係を解決してキャッシュしながらタスクを実行する」こと。Nxはそれより広い「モノレポオーケストレーター」みたいな立ち位置で、コードジェネレーター、マイグレーションツール、プラグインエコシステム、実行環境の抽象化まで持っている。
どちらが優れているかじゃなく、何を求めているかによる。これは比較を始める前から言えたことだけど、2週間使うと「求めているもの」の輪郭がくっきりしてくる。
キャッシュ実装の差が、実は一番大事だった
モノレポツールの比較でキャッシュの話を避けたら何も語っていないのと同じなので、ここは丁寧に書く。
Turborepo 2.3のローカルキャッシュは速い。変更されていないパッケージのビルドが体感でほぼ瞬時に終わる。仕組みとしては入力ファイルのハッシュを計算して、~/.turbo/cacheに同じハッシュのビルド結果があればそれを展開する。透明性が高くて、--verbosity=2をつけると何をキャッシュしたか逐一表示される。
リモートキャッシュはVercelのサービスを使うのがデフォルトだけど、self-hostingオプションもある。うちはVercelを使っていないので、自前でMinIOを立ててturbo.jsonのremoteCacheに向けた。これが — 正直ちょっと面倒だった。公式ドキュメントの手順通りにやったつもりが、S3互換APIの認証設定で詰まって半日使った。最終的にはS3_ENDPOINT環境変数のパスの末尾スラッシュの有無の問題だったんだけど、エラーメッセージがそれを全く教えてくれなかった。半日返してほしい。
Nxのキャッシュは哲学が少し違う。Nx Cloud(公式の有償サービス)との統合が前提として設計されていて、チームでキャッシュを共有することが最初から想定されている。Nx 20.4ではローカルキャッシュのパスカスタマイズが改善されて(1月末のリリースノートで確認した)、CI環境でのキャッシュ永続化がやりやすくなった。
# 変更の影響を受けるパッケージだけを対象に実行
npx nx affected --target=build --base=main --head=HEAD
# Turborepoのフィルタリング(Gitの差分と連携)
turbo build --filter=...[HEAD^1]
ここで個人的に驚いたのが、nx affectedの精度だった。packages/utils/src/format.tsだけ変更したとき、Nxはそのファイルをimportしているパッケージだけを再ビルド対象にした。Turborepoも同様のことはできるんだけど、パッケージ単位での追跡で、ファイル単位の粒度はNxの方が細かい印象があった。
ただ、これは私が100%確認したわけじゃなくて、設定によって変わる可能性がある。依存グラフが複雑になったときの振る舞いは、実際に大きくなったコードベースで試さないと断言できない。
金曜午後にやらかした話
Turborepoのキャッシュ設定を本番CIに入れたのが、ある金曜の15時ごろだった。
問題はoutputsの設定にあった。Next.jsの.next/ディレクトリをoutputsに指定していたんだけど、public/以下の静的アセットを含め忘れた。結果、キャッシュが効いているビルドではアセットディレクトリが前回のスナップショットから復元されず、新しく追加した画像がステージング環境で消えた。
// 問題のある設定(当時の私が書いたもの)
"outputs": [".next/**"]
// 正しい設定
"outputs": [".next/**", "public/**"]
月曜の朝まで誰も本格的に確認しなかった(週末で良かった、本当に)。月曜の午前中に直して、チェックリストに「outputsの設定は必ずpublicを含める」と追加した。
これはTurborepoの問題ではなく私の設定ミスなんだけど、「キャッシュが効いているから全部OK」という油断が生んだ種類の事故だった。Nxだとproject.jsonでの設定がやや冗長で面倒に感じていたけど、あの冗長さが逆に「ちゃんと考えさせる」効果を持っているのかもしれない — とその後少し思った。痛い目を見てからそう思うのが悔しいけど。
Nxのプラグインエコシステムが本当に必要かどうか
これが「どちらを選ぶか」に直結する話。
Nxには公式プラグインが豊富にある。@nx/next、@nx/react、@nx/node、@nx/nest… さらにAngular、Remix、Astro用も揃っている。これらのプラグインを使うと、コードジェネレーターが使えてnx generate @nx/next:application my-appで新しいNext.jsアプリをモノレポに追加できる。設定ファイルの雛形も全部自動で作られる。
うちのチームでこれが刺さったのは、新メンバーのオンボーディングシナリオを考えたときだった。「コマンド一つでパッケージの雛形が作れる」のは、プロジェクト規模が大きくなるほど価値が出てくる。特に複数のフレームワークが混在するモノレポだと、「新しいマイクロサービスを追加するときの手順」が属人化しやすいので、Nxのジェネレーターはそのリスクを下げる。
一方でTurborepoは、スキャフォールディングについてはほぼ何も提供しない。PlopやHgen、あるいは自前のシェルスクリプトに頼ることになる。うちには既にPlopのセットアップがあったので、Turborepoの薄さが欠点に感じる場面は少なかった。
もう一つ実感したのが、Nxのデバッグのしやすさ。nx show project <name> --webでプロジェクトの設定をブラウザで全部確認できる。「なぜこのビルドがキャッシュされなかったのか」を追うのが視覚的にやりやすかった。TurborepoもVerboseログは出るけど、長いテキスト出力を読み解く必要がある。
ただし。
Nxのプラグインエコシステムには学習コストがある。executorとgeneratorの概念を理解しないといけないし、Nx本体とプラグインのバージョンが合っていないときのエラーはかなりわかりにくい。1月末に@nx/[email protected]を試したとき、peer dependencyの警告が大量に出て、整合させるのに1時間ほどかかった。「豊かなエコシステム」は同時に「バージョン管理の複雑さ」でもある。これ、移行前に誰かに教えてほしかった。
結局うちはTurborepoにした — 6人チームの正直な結論
2週間の比較が終わってチームで議論した結果、Turborepoにした。
技術的な観点だけで言えば、Nxの方が機能は豊富で、大規模チーム向きだと思う。数十チームが同じリポジトリで作業するような環境では、Nxのプロジェクトグラフ、アクセス制御、ジェネレーターの仕組みは明らかに強い。Nx Cloudの分散キャッシュも、大規模CIではコスト面でかなり効いてくる。
でも、うちは6人だ。
Turborepoを本番CIに入れた翌週、CIの平均時間が28分から11分になった。リモートキャッシュが安定し始めてからは、フルビルドが必要なケースが週に2〜3回まで減った。設定ファイルは実質turbo.json一つで、新メンバーが設定の全体像を把握するのに30分かからない。
Nxを選ばなかった理由の一つは「使わない機能の管理コストは0じゃない」という経験則による。コードジェネレーターは便利だけど、プロジェクト固有のテンプレートに合わせてカスタマイズするコストがある。エコシステムが豊かなほど、アップデートの追従コストも上がる。チームの認知負荷という観点で見ると、Turborepoの薄さは美点だった。
明確にNxを選ぶべきなのは、チームが10人を超えていて、複数のフレームワークが混在していて、新しいパッケージを月に何度も作るような状況だと思う。あと、そもそもNx Cloudを素直に使える環境ならリモートキャッシュの体験はTurborepoより楽だった — MinIOで格闘した半日を思うと特にそう感じる。
小中規模のチームでフロントエンド中心のスタックなら、Turborepoの「難しいことを難しく考えなくていい」設計思想は本当に助かる。CIが11分台に入った日、Slackに地味に「ついに28分切った」と書いたら、チーム全員からリアクションが来た。それが答えだと思っている。