ERP-node/docs/DDD1542/COMPONENT_MANAGEMENT_FINAL_...

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" }
  }
}

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

  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 구조

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. 장점 요약

  1. 코드 수정 → 전체 반영

    • 컴포넌트 파일 수정하면 해당 URL 사용하는 모든 화면에 적용
  2. JSON 크기 감소

    • 기본값과 다른 것만 저장
    • 디버깅 시 "뭐가 커스텀인지" 바로 파악
  3. 새 기능 추가 시 자동 적용

    • 코드에 새 필드 + default 추가
    • 기존 데이터는 그대로, 로드 시 default 적용
  4. 디버깅 쉬움

    • URL 보고 바로 파일 위치 확인
    • 매핑 파일 불필요
  5. 유지보수 용이

    • 컴포넌트별로 스키마 관리
    • 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" }
    }
  }
}

실행 흐름:

  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에 저장, 실행은 공통 컨텍스트 매니저가 처리:

// 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 정의 🔲 대기