멀티브랜드(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 상수로 선언만 한다. 어느 앱에 들어갈지는 여기서 결정하지 않는다.
export const CREW_INFO: NavItem = {
path: '/crew-info', // 라우트 경로 겸 GNB 링크 (단일 소스)
label: '크루정보',
icon: Users,
element: <CrewInfoPage />,
// requiredRoles: [...] // 지정 시 해당 역할 보유자에게만 GNB 노출
}3-2. 앱별 레시피 (recipe.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 : PLACE3-3. 소비처는 APP만 읽는다
router.tsx:APP.navItems→ 라우트 생성,/→APP.homePath리다이렉트,*(catch-all) →APP.homePathGNB.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개 그대로.
"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__*/ 주석이 붙어 미사용 시 제거 가능하다.
⚠️ 함정: 레시피에서 부품의 프로퍼티를 참조하지 말 것 (실측)
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 으로 확인
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 | 양쪽 빌드 모두 모드를 명시적으로 지정 | 미지정 시 상수 치환이 안 돼 죽은 가지가 번들에 잔류 |
| 3 | homePath 는 리터럴 (부품 .path 참조 금지) | 죽은 레시피의 부품이 tree-shaking 안 됨 (§5 실측) |
| 4 | catch-all 라우트 * → APP.homePath | 상대 앱 딥링크(hub 에서 /meetingroom 등) 접근 시 빈 화면 |
| 5 | document.title은 런타임에 설정 (main.tsx) | 두 앱이 같은 index.html 을 쓰므로 탭 제목이 안 갈라짐 |
| 6 | 프론트 분리는 UX 구분이지 보안 경계가 아님 | hub 어드민 API 를 place 도메인에서 호출해도 백엔드는 구분 못 함 — 접근 제어는 백엔드 역할(AdminRole) 검증이 최종 방어 (이미 그렇게 동작) |
| 7 | OAuth redirect URI 를 도메인별로 등록 | hub 도메인에서 로그인 콜백 실패. place-dev/hub-dev 세션은 공유 안 됨(각각 로그인) — 내부 툴이라 수용. SSO 필요 시 그때 고민 (부모 도메인 쿠키는 범위 과다로 비추) |
| 8 | dist 는 모드를 담고 있다 | 직전에 hub 를 빌드해놓고 place 이미지를 말면 hub 앱이 place 도메인에 배포됨 — CI 에서는 빌드→이미지를 한 파이프라인으로 묶을 것 |
| 9 | 경로 충돌 주의 — 두 앱이 한 path 공간을 나눠 씀 | 같은 path 에 다른 페이지를 두면 레시피에 따라 다른 화면 → 혼란. path 는 전역 유일하게 |
7. 확장 가이드
새 페이지를 hub 에 추가할 때 (3단계):
pages/에 페이지 작성navigationItems.tsx에 부품(NavItem) 선언recipe.tsx의HUB.navItems배열에 추가 — 끝. 라우트·GNB·권한 노출이 전부 따라온다
세 번째 앱이 생기면: AppMode 에 리터럴 추가 → 레시피 하나 선언 → APP 삼항을 lookup 으로 교체 → build:앱이름 스크립트 추가.
런타임 분기(B)로 전환하려면: recipe.tsx 의 APP 판별식만 교체.
// 빌드 상수 대신 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.tsx | APP.navItems 기반 라우트 + /·* → APP.homePath |
src/components/layout/GNB.tsx | APP.navItems + canAccessNav 로 메뉴 렌더 |
src/components/layout/Header.tsx | 서비스명 하드코딩 → APP.headerLabel |
src/pages/auth/LoginPage.tsx | 브랜드 하드코딩 → APP.loginBrand / APP.loginSubtitle |
src/main.tsx | document.title = APP.title |
src/vite-env.d.ts | VITE_APP_MODE?: 'place' | 'hub' 타입 추가 |
package.json | dev:place / dev:hub / build:place / build:hub 스크립트 |