Skip to content

멀티브랜드(White-label) 프론트엔드 구현 전략 — 소스 하나로 place / hub 두 앱 빌드 ​

프론트엔드 소스코드 하나로 성격이 다른 웹 서비스 여러 개를 운영하는 구조. 구현체: src/constants/navigationItems.tsx(부품) · src/constants/recipe.tsx(조립), 소비처: router.tsx · GNB.tsx · Header.tsx · LoginPage.tsx · main.tsx.


0. 한눈에 ​

                        ┌── VITE_APP_MODE=place ──▶  room(회의실) 번들 ──▶ room.dunhill-industry.io
소스코드 1벌 ──(빌드)──┤                                                      │
                        └── VITE_APP_MODE=hub ────▶  portal(포털) 번들 ────▶ portal.dunhill-industry.io
                                                                              │
                                                        통합백엔드: hub-be.kakaocloud.io (1개)
  • 문제: 도메인 2개(place/hub)에 각각 다른 메뉴·브랜딩의 앱을 서비스하고 싶다. 백엔드는 통합 1개. 개발자는 1명. 환경변수 파일은 dev/prod 2개를 넘기고 싶지 않다.
  • 해결: 업계에서 검증된 white-label / multi-brand frontend 패턴. 페이지를 "부품"으로 나열하고, 앱별 "레시피"가 부품을 조립하며, 빌드 타임에 모드를 주입해 앱별 번들을 뽑는다.
  • 3요소로 굴러간다: ① 부품 카탈로그(navigationItems.tsx) → ② 앱별 레시피 + 모드 판별 유일 지점(recipe.tsx) → ③ 빌드 커맨드로만 주입하는 VITE_APP_MODE.

1. 문제 정의와 제약조건 ​

제약내용
도메인place-dev.kakaocloud.io(회의실 예약) / hub-dev.kakaocloud.io(크루정보·프로젝트)
백엔드hub-be-dev.kakaocloud.io 하나로 통합 — 어차피 사용자에겐 안 보임
인력개발자 1인 — 레포 분리/모노레포 운영 비용 감당 불가
환경변수.env.development / .env.production 2개 유지 — 모드 때문에 env 파일 증식 금지
요구place 는 회의실 메뉴만, hub 는 크루/프로젝트 메뉴만. 로그인 후 랜딩·브랜딩도 앱마다 다르게

2. 선택지 비교 — 왜 빌드 타임 분기인가 ​

선택지장점단점판정
A. 빌드 타임 분기 (채택)죽은 앱 코드가 번들에서 물리적으로 제거(tree-shaking). 이미지 이름(place-front/hub-front)이 인프라 멘탈모델과 일치빌드 2번, 이미지 2개✅ 1인 개발에서 빌드 2번은 스크립트 한 줄 차이
B. 런타임 분기 (이미지 1개)build once, deploy many — 파이프라인 최단순. 통합백엔드라 nginx 설정도 동일 가능두 앱 코드가 한 아티팩트에 공존. title 등도 런타임 분기나중에 배포가 귀찮아지면 전환 가능(§7)
C. 모노레포 분리 (apps/place, apps/hub)완전한 격리공유 컴포넌트 패키지화, 버전 관리 등 운영 비용❌ 1인 개발에 오버엔지니어링
D. 레포 복제시작이 빠름모든 수정을 2벌 반영 — 유지보수 지옥❌

A↔B 전환 비용이 낮은 게 이 설계의 핵심: 모드 판별을 코드 전체에서 딱 한 곳(recipe.tsx의 APP)에 격리했기 때문에, 나중에 B로 바꾸려면 그 한 줄의 판별 방식(빌드 상수 → 런타임 hostname)만 바꾸면 된다. 레시피 구조는 그대로.


3. 설계 — 부품 · 레시피 · 소비처 3층 구조 ​

navigationItems.tsx        recipe.tsx                     소비처들
┌─────────────────┐   ┌──────────────────────┐   ┌────────────────────────┐
│ 부품 카탈로그    │   │ PLACE 레시피          │   │ router.tsx  → 라우트    │
│ (NavItem 나열)   │──▶│ HUB 레시피            │──▶│ GNB.tsx     → 메뉴      │
│ 페이지 전부 선언 │   │ APP = 모드판별 유일지점│   │ Header/Login → 브랜딩   │
│ 소속 결정 안 함  │   │                      │   │ main.tsx    → title     │
└─────────────────┘   └──────────────────────┘   └────────────────────────┘

3-1. 부품 카탈로그 (navigationItems.tsx) ​

모든 페이지를 NavItem 상수로 선언만 한다. 어느 앱에 들어갈지는 여기서 결정하지 않는다.

tsx
export const CREW_INFO: NavItem = {
  path: '/crew-info',        // 라우트 경로 겸 GNB 링크 (단일 소스)
  label: '크루정보',
  icon: Users,
  element: <CrewInfoPage />,
  // requiredRoles: [...]    // 지정 시 해당 역할 보유자에게만 GNB 노출
}

3-2. 앱별 레시피 (recipe.tsx) ​

부품을 골라 담고, 앱의 정체성(브랜딩·홈 경로)을 정의한다.

tsx
const PLACE: AppRecipe = {
  mode: 'place',
  title: '회의실 예약',            // document.title
  headerLabel: '공간관리',          // 헤더 "kakaocloud | ___"
  loginBrand: '🏢 Place',
  loginSubtitle: '회의실 예약 시스템',
  homePath: '/enter-reservation',   // ⚠️ 리터럴이어야 함 (§5 함정)
  navItems: [RESERVATION_VERTICAL, ..., ADMIN_MANAGERS],
}

// 모드 판별 유일 지점 — 코드 전체에서 여기서만 분기한다
export const APP: AppRecipe = import.meta.env.VITE_APP_MODE === 'hub' ? HUB : PLACE

3-3. 소비처는 APP만 읽는다 ​

  • router.tsx: APP.navItems → 라우트 생성, / → APP.homePath 리다이렉트, *(catch-all) → APP.homePath
  • GNB.tsx: APP.navItems.filter(canAccessNav) → 메뉴 렌더
  • Header.tsx / LoginPage.tsx: APP.headerLabel, APP.loginBrand 등 브랜딩
  • main.tsx: document.title = APP.title (index.html 의 <title>은 정적이라 런타임에 덮어씀)

원칙: 컴포넌트 안에 if (mode === 'hub') 분기를 만들지 않는다. 앱별로 다르게 하고 싶은 게 생기면 AppRecipe에 필드를 추가하고 소비처는 그 필드를 읽는다. 모드 분기가 코드 곳곳에 퍼지는 순간 white-label 구조는 무너진다.

부수 효과: 기존에 hidden: true로 어정쩡하게 숨겨두던 크루정보/프로젝트 페이지가 "hub 앱의 정식 메뉴"로 승격되면서 hidden 플래그 자체가 사라졌다. 레시피에 없으면 그 앱에 존재하지 않는 것이다.


4. 모드 주입 — env 파일이 아니라 빌드 커맨드로 ​

.env.place.production 같은 파일 증식 없이, package.json scripts 에서만 주입한다. env 파일은 dev/prod 2개 그대로.

jsonc
"scripts": {
  "dev:place":   "VITE_APP_MODE=place vite",
  "dev:hub":     "VITE_APP_MODE=hub vite",
  "build":       "npm run build:place",
  "build:place": "tsc -b && VITE_APP_MODE=place vite build",
  "build:hub":   "tsc -b && VITE_APP_MODE=hub vite build"
}
  • 양쪽 모두 명시적으로 지정한다(place도 생략하지 않음). 값이 명시돼야 Vite 가 import.meta.env.VITE_APP_MODE를 빌드 시 리터럴 상수로 치환하고, 그래야 §5의 tree-shaking 이 성립한다.
  • vite --mode hub 방식은 쓰지 않는다 — Vite 의 --mode는 .env.{mode} 파일 로딩 규칙을 바꿔버려 "env 파일 2개 유지" 제약과 충돌한다.
  • Docker: 현재 Dockerfile 은 빌드된 dist를 복사만 하므로, docker build 전에 어느 빌드를 돌렸는지가 이미지의 정체를 결정한다. npm run build:place → place-front:tag, npm run build:hub → hub-front:tag. nginx 의 /api 프록시 대상은 통합백엔드 하나라 이미지 구성은 동일하다.

5. Tree-shaking — 죽은 앱 코드가 번들에서 사라지는 원리와 함정 ​

동작 원리 (3단계) ​

① Vite 상수 치환:  import.meta.env.VITE_APP_MODE === 'hub'  →  "place" === 'hub'
② Rollup 죽은 가지 제거:  "place" === 'hub' ? HUB : PLACE  →  PLACE   (HUB 참조 소멸)
③ 모듈 tree-shaking:  HUB 만 참조하던 부품·페이지 모듈이 연쇄적으로 번들에서 탈락

process.env.NODE_ENV === 'production' 으로 React dev/prod 코드가 제거되는 것과 동일한, 검증된 메커니즘이다. JSX(element: <CrewInfoPage />)는 컴파일 시 /*#__PURE__*/ 주석이 붙어 미사용 시 제거 가능하다.

⚠️ 함정: 레시피에서 부품의 프로퍼티를 참조하지 말 것 (실측) ​

tsx
homePath: CREW_INFO.path   // ❌ place 빌드에 CREW_INFO 부품이 통째로 살아남음
homePath: '/crew-info'     // ✅ 리터럴로 두면 완전히 제거됨

처음 구현에서 HUB.homePath: CREW_INFO.path로 썼더니, place 번들에서 hub 부품이 전부 제거됐는데 CREW_INFO 하나만 살아남았다. Rollup 이 프로퍼티 접근(.path)을 getter 일 가능성 때문에 보수적으로 판단해 제거하지 못한 것. 레시피의 homePath는 부품 path와 중복되더라도 리터럴 문자열로 유지한다(recipe.tsx 에 주석으로 박아둠).

검증 방법 — 빌드 결과를 grep 으로 확인 ​

bash
npm run build:place
grep -o "/crew-info" dist/assets/*.js | wc -l   # 0 이어야 정상 (hub 부품 없음)
npm run build:hub
grep -o "/meetingroom" dist/assets/*.js | wc -l # 0 이어야 정상 (place 부품 없음)

실측 (2026-07-03): place 번들 375KB / hub 번들 349KB. 분리 전 단일 번들 390KB 에서 각각 상대 앱 코드만큼 빠졌다. 부품/레시피 파일을 나눠도(모듈 경계를 넘어도) tree-shaking 은 유지됨을 확인.


6. 함정과 가드 체크리스트 ​

#가드안 하면 생기는 문제
1모드 판별은 APP 한 곳 — VITE_APP_MODE 직접 읽기 금지분기가 코드 전체에 산개 → B 전환·앱 추가 시 전부 손대야 함
2양쪽 빌드 모두 모드를 명시적으로 지정미지정 시 상수 치환이 안 돼 죽은 가지가 번들에 잔류
3homePath 는 리터럴 (부품 .path 참조 금지)죽은 레시피의 부품이 tree-shaking 안 됨 (§5 실측)
4catch-all 라우트 * → APP.homePath상대 앱 딥링크(hub 에서 /meetingroom 등) 접근 시 빈 화면
5document.title은 런타임에 설정 (main.tsx)두 앱이 같은 index.html 을 쓰므로 탭 제목이 안 갈라짐
6프론트 분리는 UX 구분이지 보안 경계가 아님hub 어드민 API 를 place 도메인에서 호출해도 백엔드는 구분 못 함 — 접근 제어는 백엔드 역할(AdminRole) 검증이 최종 방어 (이미 그렇게 동작)
7OAuth redirect URI 를 도메인별로 등록hub 도메인에서 로그인 콜백 실패. place-dev/hub-dev 세션은 공유 안 됨(각각 로그인) — 내부 툴이라 수용. SSO 필요 시 그때 고민 (부모 도메인 쿠키는 범위 과다로 비추)
8dist 는 모드를 담고 있다직전에 hub 를 빌드해놓고 place 이미지를 말면 hub 앱이 place 도메인에 배포됨 — CI 에서는 빌드→이미지를 한 파이프라인으로 묶을 것
9경로 충돌 주의 — 두 앱이 한 path 공간을 나눠 씀같은 path 에 다른 페이지를 두면 레시피에 따라 다른 화면 → 혼란. path 는 전역 유일하게

7. 확장 가이드 ​

새 페이지를 hub 에 추가할 때 (3단계):

  1. pages/ 에 페이지 작성
  2. navigationItems.tsx 에 부품(NavItem) 선언
  3. recipe.tsx 의 HUB.navItems 배열에 추가 — 끝. 라우트·GNB·권한 노출이 전부 따라온다

세 번째 앱이 생기면: AppMode 에 리터럴 추가 → 레시피 하나 선언 → APP 삼항을 lookup 으로 교체 → build:앱이름 스크립트 추가.

런타임 분기(B)로 전환하려면: recipe.tsx 의 APP 판별식만 교체.

tsx
// 빌드 상수 대신 hostname 으로 — 소비처는 그대로 (단, tree-shaking 은 포기)
export const APP: AppRecipe = location.hostname.startsWith('hub') ? HUB : PLACE

앱별 favicon 이 필요해지면: 현재는 두 앱이 같은 favicon 을 쓴다. 갈라야 하면 AppRecipe 에 faviconHref 필드를 추가하고 main.tsx 에서 <link rel="icon"> 을 교체하는 방식으로 — 역시 레시피에 필드 추가로 해결하는 원칙 그대로.


부록 — 이번 구현에서 실제로 바뀐 것 ​

파일변경
src/constants/navigationItems.tsx신설 — 부품 카탈로그 (NavItem 선언 + canAccessNav)
src/constants/recipe.tsx신설 — PLACE/HUB 레시피 + APP 모드 판별
src/constants/navigation.tsx삭제 — 역할이 위 두 파일로 분해됨 (hidden 플래그도 소멸)
src/router.tsxAPP.navItems 기반 라우트 + /·* → APP.homePath
src/components/layout/GNB.tsxAPP.navItems + canAccessNav 로 메뉴 렌더
src/components/layout/Header.tsx서비스명 하드코딩 → APP.headerLabel
src/pages/auth/LoginPage.tsx브랜드 하드코딩 → APP.loginBrand / APP.loginSubtitle
src/main.tsxdocument.title = APP.title
src/vite-env.d.tsVITE_APP_MODE?: 'place' | 'hub' 타입 추가
package.jsondev:place / dev:hub / build:place / build:hub 스크립트