# 컴포넌트 관리 시스템 최종 설계 --- ## 🔒 확정 사항 (변경 금지) | 항목 | 확정 내용 | 비고 | |-----|---------|-----| | **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>(); // 사용 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 정의 | 🔲 대기 |