역할 기반 라우팅에서 Episode 분리로

역할 기반 라우팅에서 Episode 분리로

-Banjang 웹 프론트엔드의 배포 단위를 기능 중심으로 재정립한 리팩터링 회고-

1. 들어가며 — 하나로 묶여 있던 앱

Banjang 웹 프론트엔드는 오랫동안 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

마이

모바일 하단 탭의 홈·구인·팀·대화·마이가 각각 하나의 웹 Episode와 1:1로 대응합니다. 어드민(episode-banjang-admin)은 사용자 앱의 하단 탭과 분리된 웹 백오피스 배포 단위로 둡니다. 이렇게 하면 화면 묶음과 배포 묶음이 같은 방향을 바라보게 됩니다.

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 집합으로 이동해야 하는가”를 관리하기보다 “이 화면을 어떤 역할에게 허용할 것인가”를 선언하는 형태가 됩니다. 배포 단위와 역할이라는 두 축이 분리되면서, 역할 추가도 라우터 복제보다 역할 정의와 화면 선택의 문제로 좁혀집니다.

5. 전환의 주체 — Shell과 keep-alive 덱

Episode가 여러 개가 되면 전환의 주체를 명확히 해야 합니다. Banjang에서는 웹 Episode가 전환을 직접 수행하지 않고, 목적지만 요청합니다. 판단과 실행은 모바일 Shell이 담당합니다. 웹에서는 브리지 메시지 REQUEST_EPISODE_NAVIGATION을 보내고, navigateBanjangEpisode가 이를 감쌉니다. 요청에는 대상 Episode와 그 안에서 이동할 경로가 포함됩니다.

모바일 Shell의 로딩 정책은 Home을 우선하는 방식입니다.

  1. 앱 시작 시 Home WebView만 생성합니다.

  2. 나머지 탭은 처음 선택되는 시점에 한 번씩 생성합니다.

  3. 한 번 생성된 WebView는 제거하지 않고, 전환할 때 활성 레이어만 바꿉니다(keep-alive).

따라서 모든 Episode가 시작부터 동시에 떠 있는 것이 아니라, 실제로 방문한 Episode만 WebView 덱에 남습니다. 준비 완료 신호도 WEB_APP_READYEPISODE_SCREEN_READY로 나눕니다. 전자는 브리지 통신 준비, 후자는 Home의 첫 데이터 페인트 완료를 나타냅니다. 로딩 상태를 한 단계로 뭉개지 않고 분리해두면 Shell이 어떤 시점에 무엇을 기다리는지 명확해집니다.

6. 로딩 전략 — 시도하고 되돌린 것까지

분리 초기의 성능 고민은 막연한 가설이 아니었습니다. 개발 환경에서 에피소드 전체를 띄우면 실행하는 데만 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은 여러 웹 Episode가 공통으로 필요로 하는 접착제를 모읍니다.

결국 아키텍처의 단위는 코드 폴더만으로 정해지지 않습니다. 배포 단위가 사용자 경험과 조직의 속도를 결정합니다. 홈, 구인, 팀, 대화, 마이처럼 서비스의 주요 경계가 안정적이라면, 그 경계에 맞춘 배포 단위도 오래 사용할 수 있습니다. 이번 작업은 잘 동작하던 구조를 부정하는 리팩터링이 아니라, 다음 단계의 개발과 배포를 위해 경계를 다시 그은 작업에 가깝습니다.

모바일 앱의 하단 내비게이터처럼 자주 바뀌지 않는 사용자 경험의 경계는 배포 경계를 정하는 좋은 기준이 될 수 있습니다. 홈, 구인, 팀, 대화, 마이라는 다섯 기능이 유지되는 동안 각 Episode도 독립적인 변경과 배포의 단위로 역할을 이어갈 수 있습니다. 구조를 나눈 결과보다 더 오래 남는 것은, 왜 이 경계를 선택했는지 설명할 수 있는 기준입니다.

Hazel

Site footer