# 컴포넌트 관리 시스템 최종 설계

---

## 🔒 확정 사항 (변경 금지)

| 항목 | 확정 내용 | 비고 |
|-----|---------|-----|
| **slot 저장 위치** | `custom_config.slot` | DB 컬럼 아님 |
| **component_url** | 모든 컴포넌트 **필수** | NULL 허용 안 함 |
| **멀티테넌시** | 모든 쿼리에 `company_code` 필터 필수 | action 실행/참조 조회 포함 |

⚠️ **위 3가지는 개발 중 절대 변경하지 말 것**

---

## 1. 현재 문제점 (복사본 문제)

### 문제 상황
- 컴포넌트 코드 수정 시 기존 화면에 반영 안 됨
- JSON에 모든 설정이 저장되어 있어서 코드 변경이 무시됨
- JSON 구조가 복잡해서 디버깅 어려움
- 어떤 파일을 수정해야 하는지 찾기 어려움

### 핵심 원인: DB에 "복사본"이 생김
- 화면 저장할 때 컴포넌트 설정 **전체**를 JSON으로 저장
- 그 순간 DB 안에 **"컴포넌트 복사본"**이 생김
- 나중에 코드(원본)를 고쳐도, 화면은 DB 복사본을 읽어서 **원본 수정이 안 먹음**

### 현재 구조 (문제되는 방식)
```json
{
  "componentType": "button-primary",
  "componentConfig": {
    "text": "저장",
    "variant": "primary",
    "backgroundColor": "#111",  // 기본값인데도 저장됨 → 복사본
    "textColor": "#fff",        // 기본값인데도 저장됨 → 복사본
    ...전체 설정...
  }
}
```
- 4,414개 레코드
- 모든 설정이 JSON에 통째로 저장 (= 복사본)

---

## 2. 해결 방안 비교

### 방안 A: 1개 레코드 (화면당 1개, components 배열)

```json
{
  "components": [
    { "type": "split-panel-layout", "url": "...", "config": {...} },
    { "type": "table-list", "url": "...", "config": {...} },
    { "type": "button", "config": {...} }
  ]
}
```

| 장점 | 단점 |
|-----|-----|
| 레코드 수 감소 (4414 → ~200) | JSON 크기 커짐 (10~50KB/화면) |
| 화면 단위 관리 | 버튼 하나 수정해도 전체 JSON 업데이트 |
| | 동시 편집 시 충돌 위험 |
| | 특정 컴포넌트 쿼리 어려움 (JSON 내부 검색) |

**결론: 비효율적**

---

### 방안 B: 다중 레코드 + URL (선택)

```sql
screen_layouts_v3
├── component_id
├── component_url = "@/lib/registry/components/split-panel-layout"
├── custom_config = { 커스텀 설정만 }
```

| 장점 | 단점 |
|-----|-----|
| 개별 컴포넌트 수정 가능 | 레코드 수 많음 (기존과 동일) |
| 부분 업데이트 | |
| URL로 바로 파일 위치 확인 | |
| 인덱스 검색 가능 | |
| 동시 편집 안전 | |

**결론: 효율적**

---

## 3. URL + overrides 방식의 핵심

### 핵심 개념
- **URL = 참조 방식**: "이 컴포넌트의 코드는 어디 파일이냐?"
- **overrides = 차이값**: "회사/화면별로 다른 값만"
- **DB는 복사본이 아닌 참조 + 메모**

### 저장 구조 비교

**AS-IS (복사본 = 문제):**
```json
{
  "componentType": "button-primary",
  "componentConfig": {
    "text": "저장",
    "variant": "primary",        // 기본값
    "backgroundColor": "#111",   // 기본값
    "textColor": "#fff",         // 기본값
    ...전체...
  }
}
```

**TO-BE (참조 + 차이값 = 해결):**
```json
{
  "component_url": "@/lib/registry/components/button-primary",
  "overrides": {
    "text": "저장",
    "action": { "type": "save" }
  }
}
```

### 왜 코드 수정이 전체 반영되나?

1. 코드(원본)에 defaults 정의: `{ variant: "primary", backgroundColor: "#111" }`
2. DB에는 overrides만: `{ text: "저장" }`
3. 렌더링 시 merge: `{ ...defaults, ...overrides }`
4. 코드의 defaults 수정 → 모든 화면 즉시 반영

### 디버깅 효율성

**URL 없을 때:**
```
1. component_type = "split-panel-layout" 확인
2. 어디에 파일이 있지? 매핑 찾기
3. 규칙 추론 또는 설정 파일 확인
4. 해당 파일로 이동
```
→ 3~4단계

**URL 있을 때:**
```
1. component_url = "@/lib/registry/components/split-panel-layout" 확인
2. 해당 파일로 바로 이동
```
→ 1단계

---

## 4. 최종 설계

### DB 구조

```sql
screen_layouts_v3 (
  layout_id SERIAL PRIMARY KEY,
  screen_id INTEGER,
  component_id VARCHAR(100) UNIQUE NOT NULL,
  component_url VARCHAR(200) NOT NULL,  -- 모든 컴포넌트 URL 참조 (권장)
  custom_config JSONB NOT NULL DEFAULT '{}',  -- slot, dataSource 등 포함
  parent_id VARCHAR(100),  -- 부모 컴포넌트 ID (컨테이너-자식 관계)
  position_x INTEGER DEFAULT 0,
  position_y INTEGER DEFAULT 0,
  width INTEGER DEFAULT 100,
  height INTEGER DEFAULT 100,
  display_order INTEGER DEFAULT 0,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);
```

**주요 컬럼:**
- `component_url`: 컴포넌트 코드 경로 (필수)
- `custom_config`: 회사/화면별 차이값 (slot 포함)
- `parent_id`: 부모 컴포넌트 ID (계층 구조)

### component_url 정책

**원칙: 모든 컴포넌트는 URL 참조가 가능해야 함**

| 구분 | 예시 | component_url | 설명 |
|-----|-----|--------------|------|
| 메인 | split-panel, tabs, table-list | `@/lib/.../split-panel-layout` | 코드 수정 시 전체 반영 |
| 공용 | button, text-input | `@/lib/.../button-primary` | 동일하게 URL 참조 |

**참고**: 
- 공용 컴포넌트도 URL로 참조하면 코드 수정 시 전체 반영 가능
- `NULL` 허용은 마이그레이션 단순화를 위한 선택적 옵션 (권장하지 않음)

### 데이터 저장/로드

**컴포넌트 파일에 defaults 정의:**
```typescript
// @/lib/registry/components/split-panel-layout/index.tsx
export const defaultConfig = {
  splitRatio: 30,
  resizable: true,
  minSize: 100,
};
```

**저장 시 (diff만):**
```json
// DB에 저장되는 custom_config
{
  "splitRatio": 50,
  "tableName": "user_info"
}
// resizable, minSize는 기본값과 같으므로 저장 안 함
```

**로드 시 (merge):**
```typescript
const fullConfig = { ...defaultConfig, ...customConfig };
// 결과: { splitRatio: 50, resizable: true, minSize: 100, tableName: "user_info" }
```

### Zod 스키마

```typescript
// 컴포넌트별 스키마 (defaults 포함)
const splitPanelSchema = z.object({
  splitRatio: z.number().default(30),
  resizable: z.boolean().default(true),
  minSize: z.number().default(100),
  tableName: z.string().optional(),
  columns: z.array(z.string()).optional(),
});

// 저장 시: schema.parse(config)로 검증
// 로드 시: schema.parse(customConfig)로 defaults 적용
```

---

## 5. 장점 요약

1. **코드 수정 → 전체 반영**
   - 컴포넌트 파일 수정하면 해당 URL 사용하는 모든 화면에 적용

2. **JSON 크기 감소**
   - 기본값과 다른 것만 저장
   - 디버깅 시 "뭐가 커스텀인지" 바로 파악

3. **새 기능 추가 시 자동 적용**
   - 코드에 새 필드 + default 추가
   - 기존 데이터는 그대로, 로드 시 default 적용

4. **디버깅 쉬움**
   - URL 보고 바로 파일 위치 확인
   - 매핑 파일 불필요

5. **유지보수 용이**
   - 컴포넌트별로 스키마 관리
   - Zod로 타입 안전성 확보

---

## 6. 회사별 설정 & 비즈니스 로직 처리

### 회사별 UI 차이 (색깔 등)

```json
// A회사
{ "overrides": { "colorVariant": "blue" } }

// B회사
{ "overrides": { "colorVariant": "red" } }
```

- Zod로 허용 값 제한: `z.enum(["blue", "red", "primary"])`
- 임의의 hex 허용할지, 토큰만 허용할지 스키마로 강제

### 비즈니스 로직 연결 (제어관리 등)

**버튼에 함수/코드 직접 붙이면 안 됨** → 다시 복사본 문제 발생

**해결: 액션 정의(데이터)만 저장, 실행은 공통 엔진**

```json
{
  "component_url": "@/lib/registry/components/button-primary",
  "overrides": {
    "text": "제어실행",
    "action": {
      "type": "CONTROL_EXECUTE",
      "ruleId": "RULE_001",
      "params": { "targetTable": "user_info" }
    }
  }
}
```

**실행 흐름:**
1. 버튼 클릭
2. 공통 ActionRunner가 `action.type` 확인
3. `CONTROL_EXECUTE` → 제어관리 로직 실행
4. `ruleId`, `params`로 실제 동작

**장점:**
- 액션 시스템 버그 수정 → 전 회사 버튼 같이 개선
- 회사별로는 `ruleId`/`params`만 다르게 저장
- Zod로 `action` 타입/필수필드 검증 가능

---

## 7. 구현 순서

1. **DB 스키마 변경**
   - `screen_layouts_v3` 테이블 생성
   - `component_url`, `custom_config` 컬럼

2. **컴포넌트별 defaults 정의**
   - 각 컴포넌트 파일에 `defaultConfig` export

3. **저장 로직**
   - 저장 시 defaults와 비교하여 diff만 저장

4. **로드 로직**
   - 로드 시 defaults + customConfig merge

5. **마이그레이션**
   - 기존 데이터에서 component_url 추출
   - properties.componentConfig → custom_config 변환
   - (기존 데이터는 일단 전체 저장, 추후 diff로 변환 가능)

6. **프론트엔드 수정**
   - 컴포넌트 로딩 시 URL 기반으로 동적 import
   - config merge 로직 적용

---

## 8. 레코드 개수 원칙

### 핵심 원칙
**컴포넌트 인스턴스 1개 = 레코드 1개**

### 현재 문제 (split-panel에 몰아넣기)

```
split-panel-layout 1개 레코드에:
├── leftPanel 설정 (table-list 역할) → 박제
├── rightPanel 설정 (card 역할) → 박제
├── relation, binding 등등 → 박제
└── 전부 JSON으로 들어감
```

**문제점:**
- table-list 코드 수정해도 반영 안 됨 (JSON에 박제)
- 컨테이너 스키마가 계속 비대해짐
- URL 참조 체계와 충돌

### 올바른 구조 (레코드 분리)

```
레코드 1: split-panel-layout (컨테이너)
  └── component_url: @/lib/.../split-panel-layout  ← URL 필수 (코드 참조)
  └── parent_id: null
  └── custom_config: { splitRatio: 30 }

레코드 2: table-list (왼쪽)
  └── component_url: @/lib/.../table-list
  └── parent_id: "comp_split_001"
  └── custom_config: { 
        slot: "left",  ← slot은 custom_config 안에
        dataSource: {...}, 
        selection: { publishKey: "selectedId" } 
      }

레코드 3: card-display (오른쪽)
  └── component_url: @/lib/.../card-display
  └── parent_id: "comp_split_001"
  └── custom_config: { 
        slot: "right",  ← slot은 custom_config 안에
        dataSource: { where: { id: { fromContext: "selectedId" } } } 
      }
```

**주의**: 
- 컨테이너도 컴포넌트이므로 `component_url` 필수
- `slot`은 DB 컬럼이 아닌 `custom_config` 안에 저장

### 부모-자식 연결 방식

| 컬럼 | 위치 | 설명 |
|-----|-----|-----|
| `parent_id` | DB 컬럼 | 부모 컴포넌트 ID |
| `slot` | custom_config 내부 | 슬롯명 (left/right/header/footer) |

→ `parent_id`는 DB 컬럼, `slot`은 JSON 안에 → **일관성 유지**

**장점:**
- table-list 코드 수정 → 전체 반영 ✅
- card-display 코드 수정 → 전체 반영 ✅
- 컨테이너는 레이아웃만 담당 (설정 폭발 방지)
- 재사용/확장 용이

### 연결 방식

**연결 정보는 각 컴포넌트의 custom_config에 저장**, 실행은 공통 컨텍스트 매니저가 처리:

```json
// table-list의 custom_config
{ "selection": { "publishKey": "selectedId" } }

// card-display의 custom_config
{ "dataSource": { "where": { "id": { "fromContext": "selectedId" } } } }
```

- **저장**: 각 컴포넌트 custom_config에 바인딩 정보
- **실행**: 공통 ScreenContext가 publish/subscribe 처리

---

## 9. 마이그레이션 전략

### 2단계 전략 (반자동 + 검증)

**1단계: 자동 변환**
```
split-panel-layout 레코드에서:
├── properties.componentConfig.leftPanel → 왼쪽 컴포넌트 레코드 생성
├── properties.componentConfig.rightPanel → 오른쪽 컴포넌트 레코드 생성
├── properties.componentConfig.relation → 바인딩 설정으로 변환
└── 원본 → 컨테이너 레코드 (레이아웃만)
```

**2단계: 검증/수동 보정**
- 특이 케이스 (커스텀 필드, 중첩 구조) 확인
- 사람이 검증 후 보정

**이유**: "완전 자동"은 예외가 많고, "완전 수동"은 시간이 너무 듦

---

## 10. publish/subscribe 바인딩 설계

### 스코프
**화면(screen) 단위**가 기본

**이유**: 같은 key(selectedId)가 다른 화면에서 섞이면 사고

### 구현 방식 (React)

**권장: ScreenContext 기반**

```typescript
// ScreenContext + 내부 store
const ScreenContext = createContext<Map<string, any>>();

// 사용
const { publish, subscribe } = useScreenContext();

// table-list에서
publish("selectedId", row.id);

// card-display에서
const selectedId = subscribe("selectedId");
```

**장점:**
- 화면 언마운트 시 상태 자동 폐기
- 디버깅 쉬움 ("현재 화면 컨텍스트 값" 표시 가능)

---

## 11. ActionRunner 설계

### 원칙
- 버튼에는 **"실행할 일의 데이터"만** 저장
- 실행은 **공통 ActionRunner**가 처리

### 구조

```typescript
// action.type은 enum으로 고정 (Zod 검증)
const actionTypeSchema = z.enum([
  "OPEN_SCREEN",
  "CRUD_SAVE",
  "CRUD_DELETE",
  "CONTROL_EXECUTE",
  "FLOW_EXECUTE",
  "API_CALL",
]);

// payload는 타입별 스키마로 분기
const actionSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("OPEN_SCREEN"), screenId: z.number(), filters: z.record(z.any()).optional() }),
  z.object({ type: z.literal("CRUD_SAVE"), tableName: z.string() }),
  z.object({ type: z.literal("CONTROL_EXECUTE"), ruleId: z.string(), params: z.record(z.any()).optional() }),
  z.object({ type: z.literal("FLOW_EXECUTE"), flowId: z.number() }),
  // ...
]);
```

### 초기 action.type 목록

| type | 설명 | payload |
|-----|-----|---------|
| `OPEN_SCREEN` | 화면 이동 | `{ screenId, filters? }` |
| `CRUD_SAVE` | 저장 | `{ tableName }` |
| `CRUD_DELETE` | 삭제 | `{ tableName }` |
| `CONTROL_EXECUTE` | 제어관리 실행 | `{ ruleId, params? }` |
| `FLOW_EXECUTE` | 플로우 실행 | `{ flowId }` |
| `API_CALL` | 외부/내부 API 호출 | `{ endpoint, method, body? }` (보안/허용 목록 필수) |

---

## 12. 구현 우선순위

### 순서 (권장)

| 순서 | 단계 | 설명 |
|-----|-----|-----|
| 1 | **데이터 모델/스키마 확정** | component_url 정책, parent_id + slot 위치 |
| 2 | **프론트 렌더링 파이프라인** | 로드 → merge → Zod → 렌더링 |
| 3 | **바인딩 컨텍스트 + ActionRunner** | publish/subscribe + 공통 실행 엔진 |
| 4 | **화면 디자이너 저장 포맷 변경** | "박제 JSON" 방지 (저장 시 차단) |
| 5 | **마이그레이션 스크립트** | 기존 데이터 → 새 구조 변환 |

### 핵심
- 렌더링이 먼저 되어야 검증 가능
- 저장 로직을 마지막에 수정해야 "새 박제" 방지

---

## 13. 주의사항

- 기존 화면은 **동일하게 렌더링**되어야 함
- 마이그레이션 시 데이터 손실 없어야 함
- 새 테이블(v1)에서 테스트 후 전환
- **company_code 필터 필수** (멀티테넌시)
- action.type `API_CALL`은 **허용 목록 필수** (보안)

---

## 14. 구현 진행 상황

### 완료된 작업

| 단계 | 내용 | 상태 |
|-----|-----|-----|
| 1-1 | `screen_layouts_v1` 테이블 생성 | ✅ 완료 |
| 1-2 | 복합 인덱스 생성 (company_code, screen_id) | ✅ 완료 |
| 1-3 | 기존 데이터 마이그레이션 (4,414개) | ✅ 완료 |
| 1-4 | **split-panel 자식 분리** (leftPanel/rightPanel → 별도 레코드) | ✅ 완료 |
| 1-5 | **repeat-container 자식 분리** (children → 별도 레코드) | ✅ 완료 |
| 2-1 | 백엔드 `getLayoutV1` API 구현 | ✅ 완료 |
| 2-2 | 프론트엔드 `getLayoutV1` API 추가 | ✅ 완료 |
| 2-3 | Zod 스키마 및 merge 함수 | ✅ 완료 |

### 마이그레이션 결과

```
총 레코드: 4,691개
├── 루트 컴포넌트: 4,414개
└── 자식 컴포넌트: 277개 (parent_id 있음)

slot 분포:
├── left: 136개
├── right: 135개
└── child_0~3: 6개

박제 제거:
├── split-panel의 leftPanel/rightPanel: 0개 (완료)
├── repeat-container의 children: 0개 (완료)
└── tabs 내부 components: 13개 (추후 처리)
```

### 샘플 구조 (screen 1383 - 수주등록)

```
comp_lspd9b9m (split-panel-layout)
├── comp_lspd9b9m_left (table-list)
│   ├── slot: "left"
│   └── tableName: "sales_order_mng"
└── comp_lspd9b9m_right (table-list)
    ├── slot: "right"
    └── tableName: "sales_order_detail"
```

### DB 스키마

```sql
CREATE TABLE screen_layouts_v1 (
  layout_id SERIAL PRIMARY KEY,
  screen_id VARCHAR(50) NOT NULL,
  component_id VARCHAR(100) NOT NULL,
  component_url VARCHAR(200) NOT NULL,  -- 🔒 필수
  custom_config JSONB NOT NULL DEFAULT '{}',  -- slot 포함
  parent_id VARCHAR(100),
  position_x INTEGER NOT NULL DEFAULT 0,
  position_y INTEGER NOT NULL DEFAULT 0,
  width INTEGER NOT NULL DEFAULT 100,
  height INTEGER NOT NULL DEFAULT 100,
  display_order INTEGER DEFAULT 0,
  company_code VARCHAR(20) NOT NULL,  -- 🔒 멀티테넌시
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(company_code, screen_id, component_id)
);

-- 인덱스
CREATE INDEX idx_v1_company_screen ON screen_layouts_v1(company_code, screen_id);
CREATE INDEX idx_v1_company_parent ON screen_layouts_v1(company_code, parent_id);
CREATE INDEX idx_v1_component_url ON screen_layouts_v1(component_url);
```

### API 엔드포인트

```
GET /api/screen-management/screens/:screenId/layout-v1
```

### 남은 작업

| 단계 | 내용 | 상태 |
|-----|-----|-----|
| 3-1 | 바인딩 컨텍스트 (ScreenContext) 구현 | 🔲 대기 |
| 3-2 | ActionRunner 공통 엔진 구현 | 🔲 대기 |
| 4 | 화면 디자이너 저장 포맷 변경 | 🔲 대기 |
| 5 | 컴포넌트별 defaultConfig 정의 | 🔲 대기 |
