18 KiB
18 KiB
컴포넌트 관리 시스템 최종 설계
🔒 확정 사항 (변경 금지)
| 항목 | 확정 내용 | 비고 |
|---|---|---|
| slot 저장 위치 | custom_config.slot |
DB 컬럼 아님 |
| component_url | 모든 컴포넌트 필수 | NULL 허용 안 함 |
| 멀티테넌시 | 모든 쿼리에 company_code 필터 필수 |
action 실행/참조 조회 포함 |
⚠️ 위 3가지는 개발 중 절대 변경하지 말 것
1. 현재 문제점 (복사본 문제)
문제 상황
- 컴포넌트 코드 수정 시 기존 화면에 반영 안 됨
- JSON에 모든 설정이 저장되어 있어서 코드 변경이 무시됨
- JSON 구조가 복잡해서 디버깅 어려움
- 어떤 파일을 수정해야 하는지 찾기 어려움
핵심 원인: DB에 "복사본"이 생김
- 화면 저장할 때 컴포넌트 설정 전체를 JSON으로 저장
- 그 순간 DB 안에 **"컴포넌트 복사본"**이 생김
- 나중에 코드(원본)를 고쳐도, 화면은 DB 복사본을 읽어서 원본 수정이 안 먹음
현재 구조 (문제되는 방식)
{
"componentType": "button-primary",
"componentConfig": {
"text": "저장",
"variant": "primary",
"backgroundColor": "#111", // 기본값인데도 저장됨 → 복사본
"textColor": "#fff", // 기본값인데도 저장됨 → 복사본
...전체 설정...
}
}
- 4,414개 레코드
- 모든 설정이 JSON에 통째로 저장 (= 복사본)
2. 해결 방안 비교
방안 A: 1개 레코드 (화면당 1개, components 배열)
{
"components": [
{ "type": "split-panel-layout", "url": "...", "config": {...} },
{ "type": "table-list", "url": "...", "config": {...} },
{ "type": "button", "config": {...} }
]
}
| 장점 | 단점 |
|---|---|
| 레코드 수 감소 (4414 → ~200) | JSON 크기 커짐 (10~50KB/화면) |
| 화면 단위 관리 | 버튼 하나 수정해도 전체 JSON 업데이트 |
| 동시 편집 시 충돌 위험 | |
| 특정 컴포넌트 쿼리 어려움 (JSON 내부 검색) |
결론: 비효율적
방안 B: 다중 레코드 + URL (선택)
screen_layouts_v3
├── component_id
├── component_url = "@/lib/registry/components/split-panel-layout"
├── custom_config = { 커스텀 설정만 }
| 장점 | 단점 |
|---|---|
| 개별 컴포넌트 수정 가능 | 레코드 수 많음 (기존과 동일) |
| 부분 업데이트 | |
| URL로 바로 파일 위치 확인 | |
| 인덱스 검색 가능 | |
| 동시 편집 안전 |
결론: 효율적
3. URL + overrides 방식의 핵심
핵심 개념
- URL = 참조 방식: "이 컴포넌트의 코드는 어디 파일이냐?"
- overrides = 차이값: "회사/화면별로 다른 값만"
- DB는 복사본이 아닌 참조 + 메모
저장 구조 비교
AS-IS (복사본 = 문제):
{
"componentType": "button-primary",
"componentConfig": {
"text": "저장",
"variant": "primary", // 기본값
"backgroundColor": "#111", // 기본값
"textColor": "#fff", // 기본값
...전체...
}
}
TO-BE (참조 + 차이값 = 해결):
{
"component_url": "@/lib/registry/components/button-primary",
"overrides": {
"text": "저장",
"action": { "type": "save" }
}
}
왜 코드 수정이 전체 반영되나?
- 코드(원본)에 defaults 정의:
{ variant: "primary", backgroundColor: "#111" } - DB에는 overrides만:
{ text: "저장" } - 렌더링 시 merge:
{ ...defaults, ...overrides } - 코드의 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 구조
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 정의:
// @/lib/registry/components/split-panel-layout/index.tsx
export const defaultConfig = {
splitRatio: 30,
resizable: true,
minSize: 100,
};
저장 시 (diff만):
// DB에 저장되는 custom_config
{
"splitRatio": 50,
"tableName": "user_info"
}
// resizable, minSize는 기본값과 같으므로 저장 안 함
로드 시 (merge):
const fullConfig = { ...defaultConfig, ...customConfig };
// 결과: { splitRatio: 50, resizable: true, minSize: 100, tableName: "user_info" }
Zod 스키마
// 컴포넌트별 스키마 (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. 장점 요약
-
코드 수정 → 전체 반영
- 컴포넌트 파일 수정하면 해당 URL 사용하는 모든 화면에 적용
-
JSON 크기 감소
- 기본값과 다른 것만 저장
- 디버깅 시 "뭐가 커스텀인지" 바로 파악
-
새 기능 추가 시 자동 적용
- 코드에 새 필드 + default 추가
- 기존 데이터는 그대로, 로드 시 default 적용
-
디버깅 쉬움
- URL 보고 바로 파일 위치 확인
- 매핑 파일 불필요
-
유지보수 용이
- 컴포넌트별로 스키마 관리
- Zod로 타입 안전성 확보
6. 회사별 설정 & 비즈니스 로직 처리
회사별 UI 차이 (색깔 등)
// A회사
{ "overrides": { "colorVariant": "blue" } }
// B회사
{ "overrides": { "colorVariant": "red" } }
- Zod로 허용 값 제한:
z.enum(["blue", "red", "primary"]) - 임의의 hex 허용할지, 토큰만 허용할지 스키마로 강제
비즈니스 로직 연결 (제어관리 등)
버튼에 함수/코드 직접 붙이면 안 됨 → 다시 복사본 문제 발생
해결: 액션 정의(데이터)만 저장, 실행은 공통 엔진
{
"component_url": "@/lib/registry/components/button-primary",
"overrides": {
"text": "제어실행",
"action": {
"type": "CONTROL_EXECUTE",
"ruleId": "RULE_001",
"params": { "targetTable": "user_info" }
}
}
}
실행 흐름:
- 버튼 클릭
- 공통 ActionRunner가
action.type확인 CONTROL_EXECUTE→ 제어관리 로직 실행ruleId,params로 실제 동작
장점:
- 액션 시스템 버그 수정 → 전 회사 버튼 같이 개선
- 회사별로는
ruleId/params만 다르게 저장 - Zod로
action타입/필수필드 검증 가능
7. 구현 순서
-
DB 스키마 변경
screen_layouts_v3테이블 생성component_url,custom_config컬럼
-
컴포넌트별 defaults 정의
- 각 컴포넌트 파일에
defaultConfigexport
- 각 컴포넌트 파일에
-
저장 로직
- 저장 시 defaults와 비교하여 diff만 저장
-
로드 로직
- 로드 시 defaults + customConfig merge
-
마이그레이션
- 기존 데이터에서 component_url 추출
- properties.componentConfig → custom_config 변환
- (기존 데이터는 일단 전체 저장, 추후 diff로 변환 가능)
-
프론트엔드 수정
- 컴포넌트 로딩 시 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에 저장, 실행은 공통 컨텍스트 매니저가 처리:
// 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 기반
// 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가 처리
구조
// 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 스키마
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 정의 | 🔲 대기 |