Files
sam-docs/frontend/v2/01-dynamic-multi-tenant-page-system.md
유병철 65b6a27479 docs: [frontend] 동적 멀티테넌트 페이지 시스템 JSONB 저장 방식 확정
- JSONB vs JSON 비교 및 채택 근거 추가
- DB 테이블 구조 제안 (page_configs)
- JSONB가 config 설계에 미치는 영향 정리
- 백엔드 협의 필요 사항 업데이트
- 문서 버전 1.1 → 1.2
2026-03-11 22:33:38 +09:00

895 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 동적 멀티테넌트 페이지 시스템 설계
> 작성일: 2026-03-11
> 상태: 초안 (백엔드 논의 필요)
> 관련 문서:
> - `[VISION-2026-02-19] dynamic-rendering-platform-strategy.md`
> - `[PLAN-2026-02-06] multi-tenancy-optimization-roadmap.md`
> - `[DESIGN-2026-02-11] dynamic-field-type-extension.md`
---
## 1. 핵심 목표
```
현재: 테넌트(업종)별 페이지를 하드코딩 → 신규 테넌트마다 개발 필요
목표: 백엔드 기준관리에서 설정 → JSON API → 프론트 동적 렌더링
결과: 프론트엔드 코드 변경 0줄로 새 테넌트 대응
```
---
## 2. 전체 아키텍처
```
┌─────────────────────────────────────────────────────────┐
│ 백엔드 어드민 (mng) │
│ ┌───────────────────────────────────────────────────┐ │
│ │ 기준관리 페이지 │ │
│ │ 레이아웃 / 섹션 / 항목 / 속성 등록 │ │
│ └───────────────┬───────────────────────────────────┘ │
│ │ 저장 │
│ ↓ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ DB (테넌트별 페이지 config) │ │
│ └───────────────┬───────────────────────────────────┘ │
│ │ API │
└──────────────────┼──────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ 프론트엔드 (Next.js) │
│ │
│ ┌────────────┐ ┌─────────────────────────────────┐ │
│ │ 정적 페이지 │ │ 동적 페이지 │ │
│ │ - 로그인 │ │ - catch-all route │ │
│ │ - 회원가입 │ │ - JSON config → 동적 렌더링 │ │
│ │ - 404 등 │ │ - pageType별 렌더러 선택 │ │
│ └────────────┘ └─────────────────────────────────┘ │
│ │ │ │
│ └──── 공유 컴포넌트 ───────┘ │
│ (ui/, molecules/, organisms/) │
└──────────────────────────────────────────────────────────┘
```
---
## 3. 규칙 정의
### 규칙 1: 기준관리 → 백엔드 어드민
| 항목 | 내용 |
|------|------|
| 현재 | 프론트 `ItemMasterDataManagement` 등에서 기준관리 |
| 변경 | 백엔드 어드민(mng) 페이지로 이동 |
| 이유 | 프론트 번들 크기 감소, 설정 변경 = 배포 불필요 |
| 담당 | 🔵 백엔드 |
```
Before: 프론트 기준관리 UI → 프론트 API 호출 → DB 저장
After: 백엔드 어드민 UI → 직접 DB 저장 → API로 config 전달
```
---
### 규칙 2: 페이지 정보를 JSON API로 제공
| 항목 | 내용 |
|------|------|
| 방식 | 메뉴 API처럼 페이지 config도 JSON API로 제공 |
| 엔드포인트 | `GET /api/v1/page-configs/{slug}` (제안) |
| 응답 | 페이지 타입, 레이아웃, 섹션, 필드, 검증규칙, API 매핑 등 |
| 담당 | 🔵 백엔드 API 설계 |
**페이지 config JSON 구조 (제안)**:
```jsonc
{
"pageId": "sales-order-list",
"pageType": "list", // list | detail | form | dashboard | document
"title": "수주 관리",
"slug": "sales/order-management",
// --- 규칙 11: API 엔드포인트 매핑 ---
"api": {
"list": "/api/v1/orders",
"detail": "/api/v1/orders/:id",
"create": "/api/v1/orders",
"update": "/api/v1/orders/:id",
"delete": "/api/v1/orders/:id"
},
// --- 규칙 4: 레이아웃 > 섹션 > 항목 > 속성 ---
"layout": {
"sections": [
{
"sectionId": "filters",
"sectionType": "filter",
"fields": [
{
"fieldId": "status",
"type": "select",
"label": "상태",
"options": [
{ "value": "all", "label": "전체" },
{ "value": "pending", "label": "대기" },
{ "value": "confirmed", "label": "확정" }
],
"defaultValue": "all"
},
{
"fieldId": "dateRange",
"type": "dateRange",
"label": "기간"
}
]
},
{
"sectionId": "table",
"sectionType": "dataTable",
"columns": [
{ "key": "orderNo", "label": "수주번호", "width": 120 },
{ "key": "clientName", "label": "거래처명", "width": 150 },
{ "key": "amount", "label": "금액", "type": "currency", "align": "right" },
{ "key": "status", "label": "상태", "type": "badge" }
],
"actions": ["view", "edit", "delete"],
"pagination": true
}
]
},
// --- 규칙 12: 검증 규칙 ---
"validation": {
"quantity": { "required": true, "min": 1, "message": "1 이상 입력하세요" },
"clientId": { "required": true, "message": "거래처를 선택하세요" }
},
// --- 규칙 13: 필드 간 의존성 ---
"dependencies": [
{
"type": "visibility",
"when": { "field": "itemType", "equals": "motor" },
"show": ["motorSpec", "voltage"]
},
{
"type": "computed",
"target": "amount",
"formula": "quantity * unitPrice"
},
{
"type": "cascade",
"source": "category1",
"target": "category2",
"api": "/api/v1/categories/:parentId/children"
}
],
// --- 규칙 14: 권한 ---
"permissions": {
"fieldLevel": {
"unitPrice": { "view": ["admin", "sales_manager"], "edit": ["admin"] }
},
"actionLevel": {
"delete": ["admin"],
"export": ["admin", "sales_manager"]
}
}
}
```
> ⚠️ **백엔드 논의 필요**: JSON 구조의 세부 스펙 확정
#### 2-2. 백엔드 저장 방식: JSONB (확정)
> ✅ **확정**: 페이지 config는 PostgreSQL **JSONB** 타입으로 저장
| 항목 | JSON | JSONB (채택) |
|------|------|:---:|
| 저장 형태 | 텍스트 그대로 | 바이너리 (파싱된 형태) |
| 읽기 속도 | 매번 파싱 필요 | 이미 파싱됨 → **빠름** |
| 인덱싱 | ❌ 불가 | ✅ **GIN 인덱스 가능** |
| 내부 검색 | ❌ 전체 꺼내서 비교 | ✅ **특정 키/값으로 쿼리** |
| 부분 수정 | ❌ 전체 교체 | ✅ **특정 키만 업데이트** |
**JSONB가 필요한 이유 — 우리 시스템과의 연관**:
```sql
-- 1. 테넌트별 특정 타입 페이지만 조회 (인덱싱)
SELECT * FROM page_configs
WHERE tenant_id = 282
AND config->>'pageType' = 'list';
-- 2. 특정 필드 타입을 쓰는 페이지 검색 (내부 검색)
SELECT * FROM page_configs
WHERE config @> '{"layout":{"sections":[{"fields":[{"type":"reference"}]}]}}';
-- 3. 기준관리에서 섹션 하나만 수정 (부분 수정)
UPDATE page_configs
SET config = jsonb_set(config, '{layout,sections,0,title}', '"수정된 섹션명"');
```
**JSONB 채택이 config 구조 설계에 미치는 영향**:
| 영향 | 설명 |
|------|------|
| **구조 단순화** | 하나의 큰 JSONB에 전체 config를 담아도 부분 쿼리/수정 가능 → 테이블 분리 최소화 |
| **테넌트 분기** | JSONB 인덱스로 테넌트+pageType 조합 쿼리가 빠름 → 별도 테이블 불필요 |
| **기준관리 UI** | 섹션 하나만 수정해도 전체 config를 다시 저장할 필요 없음 → UX 향상 |
| **프론트 영향** | **없음** — 프론트는 동일한 JSON을 받아서 렌더링, 저장 방식 무관 |
```
DB 테이블 구조 (제안):
page_configs
├── id (PK)
├── tenant_id (FK, 인덱스)
├── slug (UNIQUE per tenant, 인덱스)
├── config (JSONB) ← 페이지 config 전체
├── created_at
└── updated_at
GIN 인덱스: config에 대해 생성 → 내부 검색 고속화
복합 인덱스: (tenant_id, slug) → 테넌트별 페이지 조회 최적화
```
> ⚠️ **백엔드 논의 필요**: JSONB 기반 테이블 설계 세부 확정 (위 제안 구조 검토)
---
### 규칙 3: 정적 페이지 vs 동적 페이지 분류
| 분류 | 정적 페이지 | 동적 페이지 |
|------|------------|------------|
| 정의 | 테넌트 무관, 고정 UI | 테넌트 config 기반 동적 생성 |
| 예시 | 로그인, 회원가입, 404, 500 | 수주관리, 품목관리, 공정관리 등 |
| 라우팅 | 기존 파일 기반 라우트 | catch-all `[...slug]` |
| 컴포넌트 | 직접 코딩 | JSON → 동적 렌더러 |
| 변경 빈도 | 거의 없음 | 테넌트별/설정별 수시 변경 |
**정적 페이지 목록 (확정)**:
| 경로 | 페이지 | 이유 |
|------|--------|------|
| `/login` | 로그인 | 인증 전 접근, 공통 UI |
| `/signup` | 회원가입 | 인증 전 접근, 공통 UI |
| `/404` | Not Found | 에러 페이지 |
| `/500` | Server Error | 에러 페이지 |
| `/settings/*` | 설정 | 시스템 설정은 공통 |
> ⚠️ **논의 필요**: 설정 페이지 중 일부(구독, 결제)도 동적 대상인지?
> ⚠️ **논의 필요**: 대시보드는 동적 페이지? 위젯 기반 별도 시스템?
---
### 규칙 4: 계층 구조 — 레이아웃 > 섹션 > 항목 > 속성
```
Page (pageType에 의해 렌더러 결정)
└─ Layout (전체 레이아웃: single-column, two-column, tabs 등)
└─ Section (논리적 그룹: 기본정보, 상세정보, 테이블 등)
└─ Field (개별 입력 항목: input, select, date 등)
└─ Attribute (필드의 속성: label, placeholder, validation 등)
```
| 계층 | 역할 | 기준관리 등록 항목 | 프론트 컴포넌트 |
|------|------|-------------------|----------------|
| Layout | 전체 배치 | 레이아웃 타입 선택 | `DynamicPageLayout` |
| Section | 논리적 그룹 | 섹션 추가/순서/조건부 표시 | `DynamicSection` |
| Field | 개별 항목 | 필드 타입/라벨/기본값 | `DynamicFieldRenderer` (14종) |
| Attribute | 필드 속성 | 검증규칙/옵션/의존성 | props로 전달 |
---
### 규칙 5: 컴포넌트 책임 분리
```
┌─────────────────────────────────────────────────┐
│ 상위: 데이터 처리 컴포넌트 (Layout, Section) │
│ - API 호출 / 데이터 가공 │
│ - 조건부 표시 로직 │
│ - props 전달 / 이벤트 핸들링 │
│ - Zustand store 구독 │
└──────────────────┬──────────────────────────────┘
│ props (순수 데이터)
┌─────────────────────────────────────────────────┐
│ 하위: 순수 기능 컴포넌트 (Field, Attribute) │
│ - UI 렌더링만 담당 │
│ - 외부 의존성 없음 │
│ - value + onChange 패턴 │
│ - 테스트 용이 │
└─────────────────────────────────────────────────┘
```
| 구분 | 상위 (Layout/Section) | 하위 (Field/Attribute) |
|------|----------------------|----------------------|
| 역할 | 데이터 처리, 조건 분기 | 순수 렌더링 |
| 상태 | Zustand 구독 | props only |
| API | 호출 가능 | 호출 안 함 |
| 예시 | `DynamicSection`, `DynamicListPage` | `Input`, `Select`, `DatePicker` |
| 테스트 | 통합 테스트 | 단위 테스트 |
---
### 규칙 6: Zustand 기반 상태 관리
```
┌────────────────────────────────────────────────┐
│ pageConfigStore (Zustand) │
│ │
│ state: │
│ configs: Map<slug, PageConfig> │
│ currentPage: PageConfig | null │
│ loading: boolean │
│ │
│ actions: │
│ fetchPageConfig(slug) → API 호출 + 캐시 │
│ invalidateConfig(slug) → 캐시 무효화 │
│ subscribeToPage(slug) → 실시간 구독 │
└────────────────────────────────────────────────┘
│ 구독
┌────────────────┐ ┌────────────────┐
│ DynamicListPage │ │ DynamicFormPage │ ...
└────────────────┘ └────────────────┘
```
| 항목 | 설명 |
|------|------|
| Store 위치 | `src/stores/pageConfigStore.ts` (신규) |
| 캐시 전략 | 메모리(Zustand) → localStorage → API |
| 변경 감지 | 해시 비교 (메뉴 갱신과 동일 방식) |
| 테넌트 격리 | 기존 `TenantAwareCache` 패턴 재사용 |
---
### 규칙 7: 테넌트 + 하위 구성요소별 화면 분기
```
테넌트 A (셔터 제조업)
├─ 메뉴: 품목관리, 생산관리, 출하관리
├─ 품목 폼: 셔터 규격 필드 포함
└─ 생산 공정: 셔터 전용 공정 단계
테넌트 B (건설업)
├─ 메뉴: 프로젝트관리, 공사관리, 기성관리
├─ 프로젝트 폼: 현장정보 필드 포함
└─ 공사 공정: 건설 전용 단계
같은 테넌트 내에서도:
├─ 부서 A → 메뉴 5개, 필드 20개 표시
└─ 부서 B → 메뉴 3개, 필드 12개 표시
```
| 분기 기준 | 설명 | 예시 |
|----------|------|------|
| 테넌트 (company) | 업종별 전체 화면 구성 | 셔터업 vs 건설업 |
| 부서 (department) | 같은 테넌트 내 부서별 | 영업팀 vs 생산팀 |
| 역할 (role) | 같은 부서 내 역할별 | 관리자 vs 일반 |
| 사용자 (user) | 개인 설정 | 즐겨찾기, 컬럼 순서 |
> ⚠️ **백엔드 논의 필요**: 분기 우선순위 및 상속 정책
> (테넌트 설정 → 부서 설정으로 오버라이드 → 사용자 설정으로 오버라이드?)
---
### 규칙 8: 정적/동적 컴포넌트 공유
```
src/components/
├── ui/ ← 공유 (정적+동적 모두 사용)
│ ├── Input.tsx
│ ├── Select.tsx
│ ├── DatePicker.tsx
│ └── ...
├── molecules/ ← 공유
│ ├── FormField.tsx
│ ├── SearchFilter.tsx
│ └── ...
├── organisms/ ← 공유
│ ├── DataTable.tsx
│ ├── MobileCard.tsx
│ └── ...
├── dynamic/ ← 동적 전용 (신규)
│ ├── renderers/
│ │ ├── DynamicListPage.tsx
│ │ ├── DynamicDetailPage.tsx
│ │ ├── DynamicFormPage.tsx
│ │ └── DynamicDashboardPage.tsx
│ ├── sections/
│ │ ├── DynamicSection.tsx
│ │ ├── DynamicFilterSection.tsx
│ │ └── DynamicTableSection.tsx ← 기존 이동
│ ├── fields/
│ │ └── DynamicFieldRenderer.tsx ← 기존 이동 (14종)
│ └── store/
│ └── pageConfigStore.ts
└── static/ ← 정적 전용 (기존 유지)
├── auth/LoginPage.tsx
└── auth/SignupPage.tsx
```
| 레이어 | 공유 여부 | 예시 |
|--------|----------|------|
| ui/ | ✅ 100% 공유 | Input, Select, Button |
| molecules/ | ✅ 100% 공유 | FormField, StatusBadge |
| organisms/ | ✅ 대부분 공유 | DataTable, SearchFilter |
| dynamic/renderers/ | ❌ 동적 전용 | DynamicListPage |
| 기존 도메인 컴포넌트 | ❌ 정적 전용 (점진적 전환) | OrderSalesDetailEdit |
---
### 규칙 9: 페이지 타입 분류 체계
| pageType | 용도 | 핵심 구성 요소 | 기존 대응 패턴 |
|----------|------|--------------|---------------|
| `list` | 목록 조회 | 필터 + 테이블 + 페이지네이션 + 액션 | UniversalListPage |
| `detail` | 상세 보기 | 읽기전용 섹션 + 수정/삭제 버튼 | IntegratedDetailTemplate |
| `form` | 등록/수정 | 입력 섹션 + 저장/취소 | DynamicItemForm (범용화) |
| `dashboard` | 대시보드 | 위젯/카드 그리드 | CEODashboard |
| `document` | 문서/프린트 | 프린트 레이아웃 + 결재란 | ContractDocument 등 |
```
pageType 결정 흐름:
API 응답의 pageType 값
├─ "list" → <DynamicListPage config={...} />
├─ "detail" → <DynamicDetailPage config={...} />
├─ "form" → <DynamicFormPage config={...} />
├─ "dashboard" → <DynamicDashboardPage config={...} />
├─ "document" → <DynamicDocumentPage config={...} />
└─ 미지원 → <FallbackPage /> (에러 표시)
```
---
### 규칙 10: 동적 라우팅 전략
```
src/app/[locale]/(protected)/
├── (static-pages)/ ← 정적 페이지 그룹
│ ├── settings/
│ └── ...
└── [...slug]/ ← 동적 페이지 catch-all
└── page.tsx ← 아래 로직 수행
```
**catch-all page.tsx 동작 흐름**:
```
1. URL에서 slug 추출 (예: ["sales", "order-management"])
2. slug로 pageConfigStore에서 config 조회 (캐시 우선)
3. 캐시 없으면 → API 호출: GET /api/v1/page-configs/sales/order-management
4. config.pageType으로 렌더러 선택
5. 렌더러에 config 전달 → 동적 페이지 렌더링
```
| 라우트 우선순위 | 경로 | 설명 |
|---------------|------|------|
| 1 (최우선) | `/login`, `/signup` | 정적 페이지 (파일 존재) |
| 2 | `/settings/*` | 정적 그룹 (파일 존재) |
| 3 (폴백) | `/*` (나머지 전부) | catch-all → 동적 처리 |
> Next.js 라우팅 규칙: 구체적 경로 > catch-all → 충돌 없음
> ⚠️ **논의 필요**: 기존 정적 페이지를 동적으로 전환 시, 해당 파일 삭제 후 catch-all로 자연스럽게 이관
---
### 규칙 11: API 엔드포인트 동적 매핑
#### 11-1. API 호출 유형
| API 유형 | config 키 | 용도 |
|---------|----------|------|
| `list` | `api.list` | 목록 조회 (GET) |
| `detail` | `api.detail` | 상세 조회 (GET) |
| `create` | `api.create` | 등록 (POST) |
| `update` | `api.update` | 수정 (PUT/PATCH) |
| `delete` | `api.delete` | 삭제 (DELETE) |
| `export` | `api.export` | 엑셀 다운로드 (GET) |
| `custom` | `api.custom[actionName]` | 커스텀 액션 |
#### 11-2. 백엔드 API 제공 방식 (3가지 방향)
동적 페이지는 **데이터를 어디서 가져올지도 동적**이어야 합니다.
백엔드가 API를 어떤 방식으로 제공하느냐에 따라 3가지 방향이 있습니다.
| 방향 | 설명 | 장점 | 단점 |
|------|------|------|------|
| **A. 개별 API** | 페이지마다 전용 API 존재, config에 경로 명시 | 기존 API 재사용, 복잡한 로직 처리 가능 | 새 페이지마다 백엔드 개발 필요 |
| **B. 범용 Entity API** | 하나의 엔드포인트가 entityType으로 분기 | 새 페이지 추가 시 백엔드 코드 변경 없음 | 복잡한 비즈니스 로직 처리 어려움 |
| **C. 하이브리드 (권장)** | 단순 CRUD는 범용 API, 복잡한 로직은 전용 API | 양쪽 장점 모두 취함 | 두 방식 공존에 따른 관리 비용 |
**방향 A: 개별 API (config에 경로 포함)**
```jsonc
{
"pageType": "list",
"slug": "sales/order-management",
"api": {
"list": "/api/v1/orders",
"detail": "/api/v1/orders/:id",
"create": "/api/v1/orders",
"delete": "/api/v1/orders/:id"
}
}
```
→ 기존에 이미 만들어둔 API를 그대로 config에 연결
→ 견적 계산, 세금 처리 등 **비즈니스 로직이 있는 페이지에 적합**
**방향 B: 범용 Entity API**
```jsonc
{
"pageType": "list",
"slug": "master/equipment",
"entityType": "equipment"
}
```
```
// 범용 API 1개로 모든 entity 처리
GET /api/v1/entities/{entityType}
GET /api/v1/entities/{entityType}/{id}
POST /api/v1/entities/{entityType}
PUT /api/v1/entities/{entityType}/{id}
DELETE /api/v1/entities/{entityType}/{id}
```
→ 백엔드에서 entityType에 따라 테이블/모델 동적 매핑
→ 단순 CRUD(거래처, 설비, 자재 등) **마스터 데이터 페이지에 적합**
**방향 C: 하이브리드 (권장)**
```
┌──────────────────────────────────────────────────┐
│ 단순 CRUD 페이지 (거래처, 설비, 자재 등) │
│ → 방향 B: 범용 entity API │
│ → config에 entityType만 지정 │
│ → 새 페이지 추가 시 백엔드 코드 변경 없음 │
│ → 동적 시스템의 최대 효과 │
└──────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────┐
│ 비즈니스 로직 페이지 (견적, 생산, 세금계산서) │
│ → 방향 A: 전용 API 경로를 config에 명시 │
│ → 계산/검증/워크플로우 등 복잡한 로직 처리 │
│ → 기존 API 재사용으로 마이그레이션 용이 │
└──────────────────────────────────────────────────┘
```
#### 11-3. 프론트 처리 방식 (어느 방향이든 동일)
```
프론트는 API 제공 방식에 무관하게 동일한 패턴으로 처리:
1. config에서 API 경로 결정
├─ api.list 있으면 → 그 경로 사용 (방향 A)
└─ entityType 있으면 → `/api/v1/entities/${entityType}` 생성 (방향 B)
2. buildApiUrl(경로, params) ← 기존 유틸 재사용
3. Server Action에서 API 프록시 호출
4. 응답을 config.columns 기준으로 렌더링
```
```typescript
// 프론트 API 경로 결정 유틸 (예시)
function resolveApiUrl(config: PageConfig, action: 'list' | 'detail' | 'create' | 'update' | 'delete') {
// 방향 A: 전용 API 경로가 있으면 사용
if (config.api?.[action]) {
return config.api[action];
}
// 방향 B: entityType으로 범용 API 생성
if (config.entityType) {
const base = `/api/v1/entities/${config.entityType}`;
if (action === 'list' || action === 'create') return base;
return `${base}/:id`;
}
throw new Error(`No API config for action: ${action}`);
}
```
#### 11-4. API 응답 구조 통일
어느 방향이든 **응답 구조는 통일**되어야 프론트가 범용 처리 가능:
| API 유형 | 응답 구조 |
|---------|----------|
| list | `{ data: [...], meta: { total, current_page, per_page, last_page } }` |
| detail | `{ data: { ... } }` |
| create | `{ data: { id, ... }, message: "..." }` |
| update | `{ data: { id, ... }, message: "..." }` |
| delete | `{ message: "..." }` |
> ⚠️ **백엔드 논의 필요**:
> - 범용 entity API 도입 여부 및 범위
> - 기존 API 중 응답 구조가 통일되지 않은 것 정리
> - 전용 API와 범용 API의 분류 기준 합의
---
### 규칙 12: 검증(Validation) 규칙
| 검증 타입 | JSON 표현 | 프론트 변환 |
|----------|----------|------------|
| 필수값 | `{ "required": true }` | `z.string().min(1)` |
| 최솟값 | `{ "min": 1 }` | `z.number().min(1)` |
| 최댓값 | `{ "max": 100 }` | `z.number().max(100)` |
| 정규식 | `{ "pattern": "^\\d{3}-\\d{2}$" }` | `z.string().regex()` |
| 커스텀 메시지 | `{ "message": "올바른 형식이 아닙니다" }` | 에러 메시지 |
| 이메일 | `{ "type": "email" }` | `z.string().email()` |
| 전화번호 | `{ "type": "phone" }` | `z.string().regex()` |
```
JSON validation config
↓ 런타임 변환
Zod 스키마 자동 생성
react-hook-form zodResolver에 주입
폼 검증 자동 적용
```
---
### 규칙 13: 필드 간 의존성
| 의존성 타입 | 설명 | 예시 |
|------------|------|------|
| `visibility` | 조건부 표시/숨김 | 품목타입=모터 → 전압 필드 표시 |
| `computed` | 자동 계산 | 수량 × 단가 = 금액 |
| `cascade` | 연쇄 선택 | 대분류 → 중분류 → 소분류 |
| `setValue` | 값 자동 설정 | 거래처 선택 → 담당자 자동 입력 |
| `disable` | 조건부 비활성화 | 상태=확정 → 수량 수정 불가 |
```
기존 자산 활용:
DynamicItemForm의 DisplayCondition → visibility 타입으로 범용화
DynamicItemForm의 ComputedField → computed 타입으로 범용화
```
> ✅ **확정**: 복잡한 계산식(견적 할인율 등)은 **백엔드에서 전부 처리**하여 결과만 전달
---
### 규칙 14: 권한 통합
#### 14-1. 현재 권한 시스템 검증 결과
**현재 권한 시스템으로 동적 페이지도 컨트롤 가능** (검증 완료)
현재 권한 시스템이 **메뉴 ID 기반 + URL 패턴 매칭**으로 동작하므로, 페이지가 정적이든 동적이든 해당 URL이 menu 테이블에 등록되어 있으면 권한 관리 페이지에서 동일하게 컨트롤됩니다.
```
현재 (정적 페이지):
백엔드 menu 테이블에 URL 등록 → 권한 매트릭스 체크박스 on/off
→ PermissionGate가 URL 매칭 → 접근 허용/차단
동적 페이지도 동일:
백엔드 menu 테이블에 동적 페이지 URL(slug) 등록
→ 권한 매트릭스에서 동일하게 체크박스 on/off
→ PermissionGate가 URL 매칭 → 동일하게 동작
```
#### 14-2. 권한 레벨별 동적 페이지 호환성
| 권한 레벨 | 현재 지원 | 동적 페이지 호환 | 사용 컴포넌트 |
|----------|:---:|:---:|------|
| 페이지 접근 (view) | ✅ | ✅ | `PermissionGate` (URL 매칭) |
| 생성 (create) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
| 수정 (update) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
| 삭제 (delete) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
| 승인 (approve) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
| 내보내기 (export) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
| 관리 (manage) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
| **필드 단위 권한** | ❌ | ❌ | 현재 미지원 → **v2 고려사항** |
#### 14-3. 권한 적용 흐름
```
권한 적용 흐름 (정적/동적 공통):
1. 페이지 접근: PermissionGate → URL longest prefix 매칭 → view 권한 확인
2. 액션 권한: usePermission() → canCreate/canDelete 등 → 버튼 표시/숨김
3. 필드 권한: 현재 미지원 (v2에서 config.permissions.fieldLevel 추가 시 구현)
```
> ⚠️ **백엔드 논의 필요**: 동적 페이지 URL(slug)을 menu 테이블에 자동 등록하는 방안
> (기준관리에서 페이지 생성 시 → menu 테이블에도 자동 연동?)
---
### 규칙 15: 캐싱 & 성능 전략
```
요청 흐름:
1차 캐시 (Zustand 메모리)
↓ miss
2차 캐시 (localStorage, 테넌트별 격리)
↓ miss
3차 (API 호출)
↓ 응답
1차 + 2차 캐시 갱신
```
| 전략 | 방법 | 갱신 주기 |
|------|------|----------|
| 초기 로드 | 로그인 시 전체 config 프리페치 | 1회 |
| 변경 감지 | 해시 비교 (메뉴 갱신과 동일) | 30초~5분 |
| 강제 갱신 | 관리자가 기준관리 변경 시 push | 즉시 |
| 캐시 무효화 | 테넌트 전환 시 전체 클리어 | 즉시 |
---
### 규칙 16: 비즈니스 로직 처리
> ✅ **확정**: 복잡한 계산 수식은 **백엔드에서 전부 처리**하여 결과만 전달
| 로직 복잡도 | 처리 방식 | 예시 |
|------------|----------|------|
| 단순 계산 | config formula (프론트) | 수량 × 단가 = 금액 |
| 복잡한 계산 | **백엔드 API** | 견적 할인, 세금, 재고 검증 등 |
```jsonc
// config에서 로직 지정
{
"businessLogic": {
// 단순: 프론트 formula (기존 ComputedField 재사용)
"amount": { "type": "formula", "expression": "quantity * unitPrice" },
// 복잡: 백엔드 위임 (확정)
"totalDiscount": {
"type": "api",
"endpoint": "/api/v1/quotes/:id/calculate-discount",
"trigger": "onFieldChange",
"watchFields": ["quantity", "unitPrice", "discountRate"]
}
}
}
```
프론트는 단순 사칙연산(ComputedField)만 담당하고, 그 외 모든 비즈니스 로직은 백엔드 API로 위임합니다.
---
### 규칙 17: 점진적 마이그레이션 전략
| Phase | 범위 | 예상 기간 | 상태 |
|-------|------|----------|------|
| **Phase 0** | 인프라 구축 | 2-3주 | ⏳ 준비 |
| | - catch-all 라우터 | | |
| | - pageConfigStore | | |
| | - DynamicListPage/FormPage 렌더러 | | |
| | - 백엔드 page-config API | | |
| **Phase 1** | 신규 테넌트/페이지만 동적 | 2-4주 | ⏳ |
| | - 새로 추가되는 페이지는 동적으로 생성 | | |
| | - 기존 페이지는 그대로 유지 | | |
| **Phase 2** | 단순 CRUD 페이지 전환 | 4-6주 | ⏳ |
| | - 리스트+상세만 있는 단순 페이지 | | |
| | - 거래처관리, 설비관리 등 | | |
| **Phase 3** | 복잡한 비즈니스 페이지 전환 | 6-8주 | ⏳ |
| | - 견적, 수주, 생산 등 로직 있는 페이지 | | |
| | - 로직 블록 구축 병행 | | |
| **Phase 4** | 기존 정적 → 동적 완전 전환 | 지속적 | ⏳ |
| | - 남은 하드코딩 페이지 점진적 전환 | | |
```
전환 판단 기준:
[쉬움] 순수 CRUD (리스트+폼) → Phase 2에서 전환
[보통] CRUD + 단순 계산 → Phase 2~3
[어려움] 복잡한 비즈니스 로직 → Phase 3
[마지막] 문서/프린트, 대시보드 → Phase 4
```
---
## 4. 이미 있는 자산 → 재사용 매핑
| 기존 자산 | 현재 용도 | 동적 시스템에서의 역할 |
|----------|----------|---------------------|
| DynamicFieldRenderer (14종) | 품목 폼 필드 | → 모든 동적 폼 필드 |
| DynamicTableSection | 품목 BOM 테이블 | → 모든 동적 테이블 |
| DisplayCondition | 품목 조건부 표시 | → 범용 visibility 규칙 |
| ComputedField | 품목 자동 계산 | → 범용 computed 규칙 |
| UniversalListPage | 리스트 페이지 템플릿 | → DynamicListPage 기반 |
| IntegratedDetailTemplate | 상세 페이지 템플릿 | → DynamicDetailPage 기반 |
| TenantAwareCache | 캐시 격리 | → pageConfigStore 캐시 |
| menuRefresh (해시 비교) | 메뉴 갱신 | → config 변경 감지 |
| buildApiUrl | URL 빌더 | → 동적 API 호출에 재사용 |
---
## 5. 논의 현황 정리
### 확정 사항
| 항목 | 확정 내용 | 비고 |
|------|----------|------|
| API 제공 방식 | 하이브리드 (C) — 단순 CRUD는 범용, 복잡 로직은 전용 | 범용 API 세분화 가능성 있음 |
| 복잡한 계산 수식 | 백엔드에서 전부 처리, 결과만 전달 | 프론트는 단순 사칙연산만 |
| 권한 관리 호환성 | 현재 권한 시스템으로 동적 페이지 컨트롤 가능 | 메뉴 ID + URL 패턴 매칭 방식 |
| 기존 동적 필드 재사용 | DynamicFieldRenderer 14종 등 90%+ 재사용 가능 | 기준관리 UI가 mng로 이동해도 렌더링 컴포넌트 유지 |
| DB 저장 방식 | PostgreSQL **JSONB** 사용 | 인덱싱/부분수정/내부검색 가능, 프론트 영향 없음 |
### 협의 필요 사항
| 항목 | 현재 상태 | 논의 포인트 |
|------|----------|------------|
| JSON config 세부 구조 | 제안 구조 작성됨 (규칙 2 참조) | 회의에서 세부 항목 결정 후 확정 |
| 정적/동적 페이지 분류 | 초안 목록 작성됨 (규칙 3 참조) | 어떤 페이지를 정적으로 남길지 최종 확정 |
| 테넌트 하위 분기 정책 | 개념 정리됨 (규칙 7 참조) | 테넌트→부서→역할 오버라이드 정책, config를 최종 결과물로 줄지 프론트가 조합할지 |
| 동적 라우팅 전략 | catch-all 방식 제안 (규칙 10 참조) | 기존 정적 페이지와의 공존/전환 전략 |
| 범용 entity API 범위 | 하이브리드 방향 합의 | 페이지 렌더링 분기에 따라 범용 API 세분화 가능 |
| page-config API 스펙 | 미정 | `GET /api/v1/page-configs/{slug}` 응답 구조 |
| 기준관리 어드민 UI | 미정 | mng에서 레이아웃/섹션/필드 등록 화면 설계 |
| API 응답 통일 | 미정 | list/detail/create/update/delete 응답 포맷 표준화 |
| 캐시 무효화 | 미정 | 기준관리 변경 시 프론트 캐시 갱신 방법 (polling vs push) |
| 프리페치 범위 | 미정 | 로그인 시 전체 config vs 페이지 접근 시 개별 로드 |
| 검증/의존성 JSON 스펙 | 제안 구조 작성됨 (규칙 12, 13 참조) | 세부 스펙 확정 |
| 마이그레이션 순서 | Phase 0~4 제안 (규칙 17 참조) | 어떤 페이지부터 동적 전환할지 |
| 동적 페이지 → menu 자동 등록 | 미정 | 기준관리에서 페이지 생성 시 menu 테이블 자동 연동 방안 |
| 필드 단위 권한 | 현재 미지원 | v2 고려사항 (필요 시 추가 개발) |
---
## 6. 기존 자산 재사용 현황
### 즉시 재사용 가능 (코드 변경 없음)
| 자산 | 현재 용도 | 동적 시스템 역할 | 재사용도 |
|------|----------|----------------|:---:|
| DynamicFieldRenderer (14종) | 품목 폼 필드 | 모든 동적 폼 필드 | 100% |
| DynamicTableSection | 품목 BOM 테이블 | 모든 동적 테이블 | 99% |
| DisplayCondition (9개 연산자) | 품목 조건부 표시 | 범용 visibility 규칙 | 100% |
| ComputedField | 품목 자동 계산 | 범용 단순 계산 | 100% |
| Reference Sources 프리셋 | 거래처/품목 등 조회 | 새 source 추가만으로 확장 | 100% |
| TenantAwareCache | 캐시 격리 | pageConfigStore 캐시 | 100% |
| menuRefresh (해시 비교) | 메뉴 갱신 | config 변경 감지 | 100% |
| buildApiUrl | URL 빌더 | 동적 API 호출 | 100% |
| PermissionGate / usePermission | 정적 페이지 권한 | 동적 페이지 권한 (동일) | 100% |
### 범용화 필요 (약간의 리팩토링)
| 자산 | 변경 사항 |
|------|----------|
| useDynamicFormState | API URL을 파라미터로 받도록 |
| useFormStructure | 품목 전용 API → 범용 API 경로 |
| types.ts | `ItemFieldResponse``DynamicFieldResponse` 리네이밍 |
### 신규 개발 필요
| 자산 | 역할 |
|------|------|
| DynamicListPage | 동적 리스트 페이지 렌더러 (UniversalListPage 기반) |
| DynamicDetailPage | 동적 상세 페이지 렌더러 (IntegratedDetailTemplate 기반) |
| DynamicDashboardPage | 동적 대시보드 렌더러 |
| pageConfigStore | 페이지 config Zustand 스토어 |
| catch-all route | `[...slug]/page.tsx` 동적 라우터 |
| resolveApiUrl | API 경로 결정 유틸 (개별/범용 분기) |
---
## 7. 관련 문서
| 문서 | 위치 | 내용 |
|------|------|------|
| 동적 렌더링 플랫폼 비전 | `claudedocs/architecture/[VISION-2026-02-19]` | 전체 비전 및 자산 현황 |
| 멀티테넌시 최적화 로드맵 | `claudedocs/architecture/[PLAN-2026-02-06]` | 테넌트 격리/최적화 8 Phase |
| 동적 필드 타입 설계 | `claudedocs/architecture/[DESIGN-2026-02-11]` | 4-Level 구조, 14종 필드 |
| 동적 필드 구현 현황 | `claudedocs/architecture/[IMPL-2026-02-11]` | Phase 1~3 프론트 구현 완료 |
| 백엔드 API 스펙 | `claudedocs/item-master/[API-REQUEST-2026-02-12]` | 동적 필드 타입 백엔드 요청서 |
---
**문서 버전**: 1.2
**마지막 업데이트**: 2026-03-11
**다음 단계**: 백엔드 회의 → 협의 필요 항목 확정 → v2.0 작성 → `sam-docs/frontend/v2/`에 최종본 등록