Language: 日本語
「if (flag) で隠した未公開機能、JS バンドルに漏れていませんか?」 ビルド時フラグと Tree-shaking で成果物を削ぎ落とすフロントエンド設計
開発中の未リリース機能や、管理者・社内限定の機能をフィーチャーフラグで制御するとき、**「クライアント側の実行時 `if` 文(`if (flags.secretFeature)`)で分岐して非表示にしているから安心」** と思っていませんか?
開発中の未リリース機能や、管理者・社内限定の機能をフィーチャーフラグで制御するとき、「クライアント側の実行時 if 文(if (flags.secretFeature))で分岐して非表示にしているから安心」 と思っていませんか?
実は、ブラウザで実行時にフラグを判定している場合、画面上は見えなくなっていても、配信された JavaScript バンドルの中に未公開機能のソースコード、API エンドポイント、内部データ構造が 100% 丸ごと残っています。
ブラウザの DevTools でフラグの値を true に書き換えるだけで隠し機能が暴かれてしまったり、未リリースの巨大なコードがバンドルを肥大化させて初期表示速度を落とす原因になります。
Trunk-Based Development で main や develop ブランチを常にデプロイ可能な状態に保ちつつ、「フラグが OFF のコードは、ビルド成果物から 1 バイトも残さず物理的に消去する」 というビルド時フィーチャーフラグ基盤を設計・導入しました。
この記事では、Webpack / Vite / Next.js の Dead Code Elimination(DCE / Tree-shaking)を確実に発動させる 5 層のアーキテクチャと、「本当に成果物から消えたか」を CI が自ら証明する永続カナリア(Canary Assertion) の仕組みを解説します。
得られること
- 実行時フラグとビルド時フラグの違いと、情報漏洩・バンドル肥大化を防ぐメカニズムがわかる
- TypeScript 定義から boolean リテラルを自動生成し、Dead Code Elimination を確実に発動させる 5 層構造が学べる
- 「フラグ OFF のコードが消去されたこと」を CI で機械検証する永続カナリア(
canaryOn/canaryOff)の設計が身につく!
なぜ今、あえて「静的フィーチャーフラグ」を推すのか?
昨今のフロントエンド開発では、LaunchDarkly や Unleash などの「動的(実行時)フィーチャーフラグ」が広く使われています。管理画面からボタンひとつで即座に機能を ON/OFF できるのは確かに魅力的です。
しかし、要件を冷静に見つめ直すと、「本当にすべての機能に動的フラグが必要なのか?」 という疑問が浮かびます。
- 「開発中の大型機能を、本番リリース日まで完全に隠しておきたい」
- 「環境(dev / stg / prd)やテナントごとにビルド単位でクリーンに出し分けたい」
こうした要件に対して動的フラグを使ってしまうと、「本番で動いてはいけない未完成コードや内部 API が JS バンドルに丸ごと混入する」「全ユーザーのバンドルサイズが無駄に太る」「実行時の状態分岐でテストや型が複雑化する」 という重い代償を払い続けることになります。
要件を見極め、「そもそも成果物に 1 行も含めない」と割り切れる静的(ビルド時)フィーチャーフラグ は、セキュリティ・バンドルサイズ・認知負荷のすべての観点において圧倒的にクリーンで推せる選択肢です。
5 層で守るビルド時フィーチャーフラグのアーキテクチャ
フラグが安全に消去され、型安全に運用されるために、以下の 5 層のパイプライン を構築しました。
【 5 層のビルド時フラグアーキテクチャ 】
1. 単一の TypeScript ソース定義(フラグ名・型・各環境の真偽値を一元管理)
↓
2. 環境別の codegen(ビルド時に環境ごとの boolean リテラル定数を生成)
↓
3. コンパイル定数として参照(if (FLAGS.NEW_CHECKOUT) { ... })
↓
4. フラグ配下のディレクトリ分離(src/features/<flag-name>/ へ実装を隔離)
↓
5. 成果物マーカー検証 CI(生成された dist/*.js を grep して完全消去を機械検証 ⚡️)
1. 単一の TS 定義と codegen による定数化
フラグの定義は 1 箇所の TypeScript ファイルに集約します。ビルド時にターゲット環境(dev / stg / prd)に応じた boolean リテラル(true または false)を codegen で出力します。
// codegen が生成する環境別定数ファイル(prd 環境の例)
export const FLAGS = {
NEW_CHECKOUT: false, // prd では静的に false リテラルとして埋め込まれる
CANARY_ON: true,
CANARY_OFF: false,
} as const;
もし未知の環境名が渡された場合は、デフォルト値に倒さず fail-fast で即座に throw します。フォールバックで既定値を入れてしまうと、「本番環境で意図せず未公開機能が ON になる」という事故の抜け穴になるからです。
2. Dead Code Elimination の発動
コード内ではこの定数を使って分岐します。
import { FLAGS } from '@/config/flags';
if (FLAGS.NEW_CHECKOUT) {
// prd ビルド時、FLAGS.NEW_CHECKOUT は静的に `false` に置換される
// バンドラ(Webpack/esbuild/Terser)は到達不能コード(Dead Code)と判定し、
// このブロック内のコードおよび import されたモジュールを成果物から完全削除する!
renderNewCheckout();
}
「検証自体が壊れていないか」を自らテストする永続カナリア
この仕組みで最も恐ろしいのは、「CI のマーカー検証スクリプトがバグっていて、コードが漏洩しているのに Green でパスしてしまうこと」 です。テストの不備は誰にも気づかれません。
そこで、システム内に canaryOn と canaryOff という 2 つの永続的なカナリアフラグ を常設しました。このフラグは将来も絶対に削除しません。
// 永続カナリアフラグの定義
export const CANARY_FLAGS = {
CANARY_ON: true, // 常に true
CANARY_OFF: false, // 常に false
} as const;
# CI ワークフローでのカナリア自己検証スクリプト
echo "🔍 ビルド成果物の Tree-shaking を検証中..."
# 1. CANARY_ON のマーカーが成果物(dist/*.js)に「存在すること」をアサート
if ! grep -q "__CANARY_ON_MARKER__" dist/**/*.js; then
echo "❌ エラー: CANARY_ON のコードが成果物に見つかりません! バンドラ設定が壊れています。"
exit 1
fi
# 2. CANARY_OFF のマーカーが成果物に「存在しないこと」をアサート
if grep -q "__CANARY_OFF_MARKER__" dist/**/*.js; then
echo "❌ エラー: CANARY_OFF のコードが成果物に漏洩しています! Dead Code Elimination が効いていません。"
exit 1
fi
echo "✅ カナリア検証成功: Tree-shaking は正常に機能しています。"
CI は毎回のビルドで、「ON のマーカーが残り、OFF のマーカーが完全に消去されていること」 を自らテストします。これにより、バンドラやコンパイラのバージョンアップで Tree-shaking の挙動が壊れた場合でも、即座に検知できます。
ts-morph と ESLint による import 境界の二重防壁
ビルド時フラグを運用する上で、もう 1 つ重大な事故経路が見つかりました。
「フラグ配下のディレクトリ(src/features/new-checkout/)を、他の共通コンポーネントから直接 import してしまう事故」 です。
もし他所から直接 import が残っていると、ビルド時にフラグ配下のファイルがプルーニング(削除)された際、本番起動時に MODULE_NOT_FOUND エラーでクラッシュ してしまいます。
この事故を構造的に防ぐため、以下の 2 段階の import 境界 を敷きました。
| ツール | 役割 | 検証タイミング |
|---|---|---|
ESLint (no-restricted-imports) | エディタ上での即時フィードバックと警告 | コーディング中・pre-commit |
ts-morph (AST 解析スクリプト) | パス解決に基づく迂回不可能な境界強制 | CI パイプライン(PR 必須ゲート) |
- 命名規約の強制:
フラグ名には kebab-case(ハイフン必須。例:
new-checkout)を義務付け、単一語の通常ドメイン名との衝突を codegen レベルで防止。 - ts-morph による厳格な依存検査: フラグ配下のディレクトリへの依存は、専用のディスパッチャ(wiring)からのみ許可し、他からの参照を AST レベルで完全に拒絶。
このカプセル化がもたらす最大の価値(ライフサイクル完結)
機能が本番で 100% 稼働してフラグが不要になったとき、ディスパッチャの 1 行とフラグ定義を消すだけで、不要な分岐がすべてコンパイルエラーとして表面化 します。コードベースに「過去のフラグの残りカス」が一切溜まりません。
まとめ: 要件に応じた「静的フラグ」という武器
なんでもかんでも動的フラグ SaaS に放り込んで「後から切り替えられるようにしておく」のは、一見柔軟に見えて設計の放棄になりがちです。
- 動的フラグ: ユーザーごとの A/B テストや、即時キルスイッチが必要なコア機能に絞る
- 静的フラグ: 未リリースの大型機能や環境・テナント単位の切り替えに適用し、成果物を物理的にクリーンに保つ
要件を見極めて静的フラグを採用し、ts-morph によるディレクトリ封じ込めと DCE(Tree-shaking) を組み合わせる。これこそが、Trunk-Based Development を最も安全かつ軽量にスケールさせる実践的なアプローチです。