-Banjang Webフロントエンドのデプロイ単位を機能中心に再定義したリファクタリングの振り返り-
1. はじめに — ひとつにまとめられていたアプリ
Banjang Webフロントエンドは長い間、episode-banjangという単一のアプリとして運用されていました。ホーム、求人、チーム、マイ、通知、認証、請求など、複数の機能がひとつのビルド成果物に含まれており、ユーザーを区別する方法も単純でした。最上位ルーターの下に、作業者向けと現場管理者向けのルーターをそれぞれ置き、URLのプレフィックスで役割を分ける構成でした。
// AS-IS: pages/worker/router.tsx
export const router: RouteObject = {
path: 'wkr', // 작업자(worker)는 /wkr/* 로
element: (
<MobileLayout role="worker" ...>
<Outlet />
</MobileLayout>
),
children: [...],
};
// AS-IS: pages/leader/router.tsx — 구조는 동일
export const router: RouteObject = {
path: 'ldr', // 현장 관리자(leader)는 /ldr/* 로
...
};
URLプレフィックス(wkr/ldr)が役割を表し、役割に応じて画面一覧が分かれる方式です。機能自体は安定して動作していましたが、サービスが大きくなるにつれて、「どのコードを一緒にデプロイすべきか」という観点では限界が見え始めました。今回のリファクタリングの出発点は、ルーティングを変更することではなく、デプロイ単位を再定義することでした。
2. なぜ分離したのか — デプロイ単位を機能に合わせる
単一アプリでは、機能ごとの変更がすべてひとつのリリースにまとめられます。求人画面ひとつを修正しても、アプリ全体を再ビルドしてデプロイする必要があり、ユーザーの役割とは関係のない画面コードまで同じバンドルに含まれます。モバイルでも、React Native ShellがひとつのWebViewアプリを起動する以外に選択肢がなく、「ホームを先に表示し、残りは必要なときにロードする」といった戦略を適用するのは困難です。
そこで、基準を役割ではなく機能へ移しました。基本原則はシンプルです。デプロイ単位を機能ごとに分け、役割は各機能の内部で処理します。
|
Episode |
担当 |
|---|---|
|
episode-banjang-home |
ホーム |
|
episode-banjang-job |
求人 |
|
episode-banjang-team |
チーム |
|
episode-banjang-chat |
会話(Loopin) |
|
episode-banjang-my |
マイ |
モバイル下部タブのホーム・求人・チーム・会話・マイは、それぞれひとつのWeb Episodeと1対1で対応します。管理画面(episode-banjang-admin)は、ユーザーアプリの下部タブから分離したWebバックオフィスのデプロイ単位とします。これにより、画面のまとまりとデプロイのまとまりが同じ方向を向くようになります。
3. 新しい境界 — ShellとEpisode
Episodeの分離に合わせて、リポジトリの構造も整理します。各単位の詳細な定義は社内共通アーキテクチャドキュメントに委ね、ここではリファクタリングを理解するために必要な境界だけを見ていきます。
shell/
banjang-mobile-shell # RN 호스트. WebView, 네이티브 인증, 세션, 에피소드 전환 담당
banjang-episode-shell # 웹 에피소드들이 공유하는 UI·브라우저 런타임
shared/
banjang-bridge-contracts # Shell <-> Episode 메시지 계약
banjang-constants # 공통 상수
episodes/
episode-banjang-{home,job,team,my,chat} # + episode-banjang-admin
dramas/
banjang-view / banjang-state / banjangnote-stub
基本ルールは、Episodeが独立してビルド・デプロイされるMicroappであるということです。他のEpisodeのソースコードを直接importせず、複数のEpisodeで共通して必要な動作は、Shellまたはshared層を通じて共有します。以前は「どこまでが共通で、どこからが機能なのか」がチームの合意に依存していましたが、分離後はフォルダ構造とimportの境界がその原則をより明確に示します。
4. 役割の処理 — URL軸から画面選択へ
従来の/wkr、/ldrプレフィックスによるルーティングがなくなっても、役割の区別までなくなるわけではありません。役割はルーティングの上位軸から、各Episode内部での画面選択基準へと移ります。各Episodeはひとつのルーターを持ち、現在の役割はBanjangContext.displayTypeで確認します。
// TO-BE: shell/banjang-episode-shell/src/routing/BanjangRoleRoute.tsx
export const BanjangRoleRoute = ({ allowedRoles, redirectTo }: BanjangRoleRouteProps) => {
const { displayType } = useBanjangContext();
const roles = Array.isArray(allowedRoles) ? allowedRoles : [allowedRoles];
return roles.includes(displayType) ? <Outlet /> : <Navigate to={redirectTo} replace />;
};
アクセス制御はルートガードで処理し、役割によって最初の画面が異なる場合は、同じルーター内で表示するSceneを選択します。
// TO-BE: episodes/episode-banjang-job/src/pages/router.tsx
const JobRoleIndex = () => {
const { displayType } = useBanjangContext();
const selectedRoutes = displayType === 'leader' ? recruitmentRoutes : jobsRoutes;
return selectedRoutes.children?.find((route) => route.index)?.element ?? null;
};
たとえばJob Episodeでは、現場管理者には求人管理(recruitment)を、作業者には求人一覧(jobs)をインデックスとして表示します。URL構造はシンプルになり、「役割が変わったらどのURLの集合へ移動すべきか」を管理するのではなく、「この画面をどの役割に許可するか」を宣言する形になります。デプロイ単位と役割という2つの軸が分離されることで、役割の追加もルーターの複製ではなく、役割の定義と画面選択の問題へと絞り込まれます。
5. 遷移の主体 — Shellとkeep-aliveデッキ
Episodeが複数になると、遷移の主体を明確にする必要があります。Banjangでは、Web Episodeが遷移を直接実行するのではなく、目的地だけをリクエストします。判断と実行はモバイルShellが担当します。WebではブリッジメッセージREQUEST_EPISODE_NAVIGATIONを送信し、navigateBanjangEpisodeがこれをラップします。リクエストには対象のEpisodeと、その中で遷移するパスが含まれます。
モバイルShellのローディングポリシーは、Homeを優先する方式です。
-
アプリの起動時にはHome WebViewだけを生成します。
-
残りのタブは、最初に選択された時点で一度ずつ生成します。
-
一度生成したWebViewは削除せず、切り替え時にはアクティブなレイヤーだけを変更します(keep-alive)。
したがって、すべてのEpisodeが開始時から同時に表示されるのではなく、実際に訪問したEpisodeだけがWebViewデッキに残ります。準備完了シグナルも WEB_APP_READYと EPISODE_SCREEN_READYに分けます。前者はブリッジ通信の準備完了、後者はHomeの初回データペイント完了を示します。ローディング状態を一つの段階にまとめず分離しておけば、Shellがどの時点で何を待っているのかが明確になります。
6. ローディング戦略 — 試してから元に戻したものまで
分離初期のパフォーマンス上の懸念は、漠然とした仮説ではありませんでした。開発環境でEpisode全体を起動すると、実行するだけで3分近くかかるほど、起動速度の遅さを実感する問題がありました。リファクタリング期間中は衝突を避けるため、他のフロントエンド作業も一時的に停止していたため、開発環境を迅速に起動できるようにすることが最優先課題でした。
順次ローディング、preloading、タブを初めて押したときに該当するEpisodeを生成する方式などを順に比較し、その結果が前述したHome優先 + lazy生成 + keep-aliveポリシーです。同じ文脈で、root barrel importをファイル単位のdeep importに変更する実験も行いました。
確認は数日間にわたって繰り返しました。Episodeごとの初期ローディングHARを比較し、開発ビルドの [banjang-episode-timing] コンソールタイムラインで切り替えの各段階を追跡しました。特定のEpisodeだけを起動して測定の汚染を減らす EXPO_PUBLIC_BENCHMARK_EPISODEも使用しました。正式なベンチマークハーネスではありませんが、同じ条件で方向性を比較するための参考目盛りとしては十分でした。
観察の結果、import規約を全面的に変更して得られるメリットは大きくありませんでした。そこで、会社のbarrel importコンベンションは維持し、deep importは元に戻したうえで、必要性が明確な一部のパスにだけsubpath importを残しました。重要なのは変更そのものではなく、「私たちの環境ではどの選択が実際に意味を持つのか」を確認できた点です。
元に戻した作業も結果の一部です。「barrelは遅い」という仮説を実際のコードに適用して検証し、私たちの環境ではコンベンションを変えるほどの差はないという根拠を残しました。次に同じ議論が生じても、推測から始めるのではなく、そのときの観察と選択理由を基準にできます。
7. 分離の代償と返済
構造を分けると、得られるものだけでなく管理すべきものも増えます。今回のリファクタリングでも、そのコストは明確でした。
-
実行単位が増えたため、ローカルでは複数のViteサーバーとゲートウェイを同時に扱う必要があり、ビルド・デプロイパイプラインもEpisodeの数だけ管理対象になります。
-
初期段階では、担当者がすぐに作業へ入れるように、共通コードの整理よりも基盤環境のデプロイを優先しました。その過程で、認証・テーマ・ネットワーク・FCMといった横断的関心事がEpisodeごとに複製される局面がありました。これは後で共通化することを前提とした、計画的な一時対応でした。
-
デバッグの境界も増えます。問題がShellにあるのか、特定のEpisodeにあるのか、それともブリッジの送受信のどちら側にあるのかを、まず切り分けなければなりません。
このコストを返済する層が Episode Shellです。認証ルートは createAuthRoutes({ episodePath })でEpisodeごとの境界に合わせて生成し、ゲートへの入口は BanjangAppGate、役割の切り替えは useRoleSelectionが担います。ネットワーク・オフラインフォールバック・セッション同期も同じ共通層へ移します。担当者の作業基盤が安定した後は、初期に複製していたボイラープレートを整理し、各Episodeの入口にはそのドメインの画面が中心として残るようにしました。
この構造の効果は、新しい画面を追加するときに現れます。共通の接着剤はすでにShellにあるため、新規コードはドメインに集中できます。分離のコストは最初は支出に見えますが、共通層に返済しておけば、以降の作業で繰り返し発生するコストを削減する基盤になります。
8. 開発体験 — 実行方法も同時に整理する
デプロイ単位が増えた分、ローカル開発コマンドも目的別に分けました。すべてのEpisodeを常に一緒に実行するのではなく、作業の目的に応じて必要な範囲だけを起動できるように整理しています。
-
pnpm start:banjang-local-single [home|job|team|my]: 修正中のEpisodeだけをローカルで起動し、残りは共有開発環境を使用します。日常的な機能開発に必要な範囲を小さく保つためのコマンドです。
-
pnpm start:banjang-local-all: すべてのEpisodeとゲートウェイを同時に実行し、Shellとブリッジの統合フローを確認します。ブラウザーでも、keep-aliveポリシーに従うiframeデッキによって切り替えの流れを再現できます。
-
pnpm build:pkg home, pnpm storybook:pkg view: パッケージ単位の実行はrun-pkg.mjsにまとめ、コマンド形式を統一しました。
9. おわりに
今回のリファクタリングで最も大きく変わったのは、ルーターの形よりも「何を一つのデプロイ単位とみなすか」という基準です。役割を基準にアプリを分けていた構造から、機能ごとのEpisodeを独立したデプロイ単位とし、役割はその中での画面選択へと移しました。モバイルShellはEpisodeの切り替えとローディングポリシーを担い、Episode Shellは複数のWeb Episodeが共通して必要とする接着剤を集約します。
結局、アーキテクチャの単位はコードフォルダーだけで決まるものではありません。デプロイ単位がユーザー体験と組織のスピードを決めます。Home、求人、チーム、会話、マイといったサービスの主要な境界が安定しているのであれば、その境界に合わせたデプロイ単位も長く使えます。今回の作業は、うまく機能していた構造を否定するリファクタリングではなく、次の段階の開発とデプロイに向けて境界を引き直す作業に近いものでした。
モバイルアプリの下部ナビゲーターのように、頻繁には変わらないユーザー体験の境界は、デプロイ境界を定めるよい基準になり得ます。Home、求人、チーム、会話、マイという五つの機能が維持される間、各Episodeも独立した変更とデプロイの単位として役割を担い続けられます。構造を分けた結果よりも長く残るのは、なぜこの境界を選んだのかを説明できる基準です。
Hazel