Last edited: March 7, 2026
클라우드 빌링 플랫폼을 개발하면서, 초기에는 GCP 하나만 지원했다. 그런데 곧바로 Datadog이 추가되었고, 향후 100개 이상의 클라우드 서비스를 엔티티로 확장할 계획이었다.
초기 코드는 이렇게 생겼다:
// ❌ Before: 조건 분기 지옥
if (vendor === 'gcp') {
return <GcpPurchasePage />
} else if (vendor === 'datadog') {
return <DatadogPurchasePage />
}
// vendor가 10개로 늘어나면?문제점:
if (entity === 'xxx') 같은 조건 분기 완전 제거┌─────────────────────────────────────────────────────────────────┐
│ URL: /$entity/cost-validation │
└──────────────────────┬──────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ TanStack Router │
│ Route File (thin wrapper, 3줄) │
└──────────┬───────────┬───────────────┘
│ │
▼ ▼
┌──────────────────────────────────────┐
│ App Registry │
│ │
│ createFeatureGuard() │
│ → 엔티티 접근 권한 검증 │
│ │
│ createFeaturePage() │
│ → resolveComponent() │
│ → lazy() + Map cache │
└───┬──────────┬───────────┬───────────┘
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌──────────┐
│GCP │ │Datadog │ │Common │
│features│ │features│ │features │
│/gcp/ │ │/datadog│ │/common/ │
│cost- │ │/cost- │ │dashboard │
│valid.. │ │valid.. │ │setting │
└────────┘ └────────┘ └──────────┘
entity entity resolve
=gcp =datadog .defaultsrc/config/app-registry/
├── types.ts # EntityId, FeatureDefinition, FeatureVisibility
├── apps/ # 엔티티별 config
│ ├── gcp.ts
│ └── datadog.ts
├── entities.ts # 엔티티 레지스트리
├── features/ # 기능별 정의
│ ├── dashboard.ts
│ ├── cost-validation.ts
│ ├── setting.ts
│ └── ...
├── features.ts # 기능 레지스트리 + visibility 필터링
├── resolve.tsx # 컴포넌트 resolve + lazy + cache
└── index.ts # Public API엔티티는 EntityConfig 객체 하나로 정의된다.
// apps/gcp.ts
export const gcpApp: EntityConfig = {
id: 'gcp',
label: 'Google Cloud Platform',
shortLabel: 'GCP',
defaultFeature: 'dashboard',
order: 1,
}EntityId 유니온 타입에 추가하고, entities.ts의 레지스트리에 등록하면 끝이다.
// types.ts
export type EntityId = 'gcp' | 'datadog' // ← 여기에 추가각 기능이 어떤 엔티티에서 보이는지를 선언적으로 정의한다.
export type FeatureVisibility =
| { mode: 'all' } // 모든 엔티티에서 노출
| { mode: 'only'; entities: EntityId\[\] } // 특정 엔티티에서만 노출
| { mode: 'except'; entities: EntityId\[\] } // 특정 엔티티 제외실제 사용 예시:
// 대시보드 — 모든 엔티티에서 보임
visibility: { mode: 'all' }
// SKU 마스터 — GCP에서만 보임
visibility: { mode: 'only', entities: \['gcp'\] }
// 정산 내역 — Datadog 제외
visibility: { mode: 'except', entities: \['datadog'\] }각 기능은 FeatureDefinition으로 정의하고, resolve 맵으로 엔티티별 컴포넌트를 연결한다.
// features/cost-validation.ts
export const costValidationFeature: FeatureDefinition = {
id: 'cost-validation',
translationKey: 'costValidation', // i18n 키 (타입 안전)
icon: ShoppingBasket,
visibility: { mode: 'all' },
order: 20,
resolve: {
gcp: () => import('@/features/gcp/cost-validation/pages/CostValidationPage')
.then(m => ({ default: m.CostValidationPage })),
datadog: () => import('@/features/datadog/cost-validation/pages/page')
.then(m => ({ default: m.CostValidationPage })),
},
}핵심: resolve 맵의 구조
resolve: Partial<Record<EntityId | 'default', () => Promise<{ default: React.ComponentType }>>>default 키로 공통 컴포넌트 지정 가능entity 키 > default 키모든 라우트 파일이 동일한 패턴으로 최소화된다.
// routes/$entity/cost-validation/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { createFeaturePage } from '@/config/app-registry'
export const Route = createFileRoute('/$entity/cost-validation/')\({
component: createFeaturePage('cost-validation'),
}\)조건 분기가 없다. createFeaturePage()가 URL의 $entity 파라미터를 읽어 적절한 컴포넌트를 resolve한다.
// resolve.tsx
const componentCache = new Map<string, React.LazyExoticComponent<React.ComponentType>>()
export const resolveComponent = (featureId: string, entityId: EntityId) => {
const cacheKey = `${featureId}:${entityId}`
const cached = componentCache.get(cacheKey)
if (cached) return cached
const feature = getFeature(featureId)
const loader = feature.resolve\[entityId\] ?? feature.resolve\['default'\]
const component = lazy(loader)
componentCache.set(cacheKey, component)
return component
}lazy() + Map 캐시로 동일 엔티티+기능 조합은 한 번만 로드된다.
React Compiler의static-components룰이lazy()를 렌더 중 호출로 감지하지만, Map 캐시가 안정적인 참조를 보장하므로eslint-disable로 허용했다.
FeatureVisibility가 only나 except인 기능은 URL 직접 입력으로 우회할 수 있다. beforeLoad 훅으로 차단한다.
export const createFeatureGuard = (featureId: string) =>
(\{ params \}: \{ params: \{ entity: string \} \}) => {
const feature = getFeature(featureId)
const entityId = params.entity as EntityId
if (!isFeatureVisible(feature, entityId) ||
!hasComponentForEntity(feature, entityId))
throw redirect\({ to: '/$entity/dashboard', params \})
}네비게이션 메뉴는 레지스트리에서 자동 생성된다. 하드코딩 없음.
// nav-items.ts
export const getNavItems = (entityId: EntityId): NavItem\[\] => {
const features = getFeaturesForEntity(entityId) // visibility 필터링 적용
const base = `/${entityId}`
return features
.filter(f => !f.id.includes('/')) // 자식 기능 제외
.map(feature => (\{
id: feature.id,
translationKey: feature.translationKey,
icon: <feature.icon className="h-4 w-4" />,
href: `${base}/${feature.id}`,
children: (feature as NavGroupDefinition).children?.map(...),
\}))
}getFeaturesForEntity('gcp')을 호출하면 GCP에서 보여야 할 기능만 필터링되어 네비게이션이 생성된다.
src/features/
├── common/ # 공통 기능 (dashboard, setting)
│ ├── dashboard/
│ └── setting/
├── gcp/ # GCP 전용
│ ├── cost-validation/
│ ├── revenue-validation/
│ ├── account/
│ ├── customer-contract/
│ └── ...
├── datadog/ # Datadog 전용
│ ├── cost-validation/
│ └── revenue-validation/
└── auth/ # 인증common/ — 모든 엔티티에서 공유 (resolve.default로 연결)gcp/, datadog/ — 엔티티별 도메인 로직 (같은 기능도 UI/API가 다를 수 있음)새로운 엔티티(e.g. AWS)를 추가할 때 필요한 작업:
Step 1. types.ts에 EntityId 추가
export type EntityId = 'gcp' | 'datadog' | 'aws'Step 2. apps/aws.ts 생성
export const awsApp: EntityConfig = {
id: 'aws',
label: 'Amazon Web Services',
shortLabel: 'AWS',
defaultFeature: 'dashboard',
order: 3,
}Step 3. entities.ts에 등록
const entityRegistry: Record<EntityId, EntityConfig> = {
gcp: gcpApp,
datadog: datadogApp,
aws: awsApp, // ← 추가
}Step 4. 기존 feature의 resolve에 AWS 컴포넌트 연결
// features/cost-validation.ts
resolve: {
gcp: () => import('@/features/gcp/cost-validation/...'),
datadog: () => import('@/features/datadog/cost-validation/...'),
aws: () => import('@/features/aws/cost-validation/...'), // ← 추가
}라우트 파일, 네비게이션, 레이아웃은 수정할 필요 없다. 전부 레지스트리에서 자동으로 처리된다.
App Registry와 Feature 모듈 간 순환 의존성을 ESLint로 차단한다.
import import
routes/ ──────────────► config/app-registry/ ◄──── ✕ ESLint 차단
│ │ │
│ import │ lazy import only │
└──────────────► features/ ◄─┘ features/features/ → config/app-registry/ import 금지 (ESLint import/no-restricted-paths)config/app-registry/ → features/ 는 resolve의 lazy import만 허용 (런타임 의존)| 항목 | Before | After (App Registry) |
|---|---|---|
| 엔티티 추가 | 라우트 + 네비 + 레이아웃 등 5~10개 파일 수정 | config 파일 2~3개만 추가 |
| 라우트 파일 | if/else 조건 분기 로직 포함 | 3줄 thin wrapper (조건 분기 제로) |
| 네비게이션 | 하드코딩된 메뉴 배열 | 레지스트리에서 자동 생성 + visibility 필터 |
| 메뉴 노출 제어 | 코드로 if 분기 | 선언적 visibility (all / only / except) |
| 컴포넌트 로딩 | 모든 엔티티 컴포넌트가 번들에 포함 | lazy() + cache로 해당 엔티티만 로드 |
| URL 직접 접근 | 보호 없음 | createFeatureGuard()로 redirect |
잘된 점:
FeatureVisibility 타입으로 메뉴 노출 규칙이 자기 문서화(self-documenting)배운 점:
lazy()를 렌더 중 호출하면 React Compiler의 static-components 룰에 걸림 → Map 캐시로 안정적 참조를 보장하는 방식으로 해결$vendor → $entity 리네이밍을 초기에 하길 잘함 — 도메인 용어가 정립되면 코드 이해도가 높아짐