Files
vexplor/docs/COMPONENT_JSON_MANAGEMENT_ANALYSIS.md
DDD1542 192b678bce fix: 화면 복제 기능 개선 및 관련 버그 수정
- 화면 복제 기능을 개선하여 DB 구조 개편 후의 효율적인 화면 관리를 지원합니다.
- 그룹 복제 시 버튼의 `targetScreenId`가 새 화면으로 매핑되지 않는 버그를 수정하였습니다.
- 관련된 서비스 및 쿼리에서 `table_type_columns`를 사용하여 라벨 정보를 조회하도록 변경하였습니다.
- 여러 컨트롤러 및 서비스에서 `column_labels` 대신 `table_type_columns`를 참조하도록 업데이트하였습니다.
2026-01-28 11:24:25 +09:00

15 KiB

컴포넌트 JSON 관리 시스템 분석 보고서

1. 개요

WACE 솔루션의 화면 컴포넌트는 JSONB 형식으로 데이터베이스에 저장되어 관리됩니다. 이 방식은 스키마 변경 없이 유연하게 컴포넌트 설정을 확장할 수 있는 장점이 있습니다.


2. 데이터베이스 구조

2.1 핵심 테이블: screen_layouts

CREATE TABLE screen_layouts (
  layout_id SERIAL PRIMARY KEY,
  screen_id INTEGER REFERENCES screen_definitions(screen_id),
  component_type VARCHAR(50) NOT NULL,  -- 'container', 'row', 'column', 'widget', 'component'
  component_id VARCHAR(100) UNIQUE NOT NULL,
  parent_id VARCHAR(100),               -- 부모 컴포넌트 ID
  position_x INTEGER NOT NULL,          -- X 좌표 (그리드)
  position_y INTEGER NOT NULL,          -- Y 좌표 (그리드)
  width INTEGER NOT NULL,               -- 너비 (그리드 컬럼 수: 1-12)
  height INTEGER NOT NULL,              -- 높이 (픽셀)
  properties JSONB,                     -- ⭐ 컴포넌트별 속성 (핵심 JSON 필드)
  display_order INTEGER DEFAULT 0,
  layout_type VARCHAR(50),
  layout_config JSONB,
  zones_config JSONB,
  zone_id VARCHAR(100)
);

2.2 화면 정의: screen_definitions

CREATE TABLE screen_definitions (
  screen_id SERIAL PRIMARY KEY,
  screen_name VARCHAR(100) NOT NULL,
  screen_code VARCHAR(50) UNIQUE NOT NULL,
  table_name VARCHAR(100) NOT NULL,
  company_code VARCHAR(50) NOT NULL,
  description TEXT,
  is_active CHAR(1) DEFAULT 'Y',
  data_source_type VARCHAR(20),        -- 'database' | 'restapi'
  rest_api_endpoint VARCHAR(500),
  rest_api_json_path VARCHAR(100)
);

3. JSON 구조 상세 분석

3.1 properties 필드의 최상위 구조

interface ComponentProperties {
  // 기본 식별 정보
  id: string;
  type: "widget" | "container" | "row" | "column" | "component";
  
  // 위치 및 크기
  position: { x: number; y: number; z?: number };
  size: { width: number; height: number };
  parentId?: string;
  
  // 표시 정보
  label?: string;
  title?: string;
  required?: boolean;
  readonly?: boolean;
  
  // 🆕 새 컴포넌트 시스템
  componentType?: string;        // 예: "v2-table-list", "v2-button-primary"
  componentConfig?: any;         // 컴포넌트별 상세 설정
  
  // 레거시 위젯 시스템
  widgetType?: string;           // 예: "text-input", "select-basic"
  webTypeConfig?: WebTypeConfig;
  
  // 테이블/컬럼 정보
  tableName?: string;
  columnName?: string;
  
  // 스타일
  style?: ComponentStyle;
  className?: string;
  
  // 반응형 설정
  responsiveConfig?: ResponsiveComponentConfig;
  
  // 조건부 표시
  conditional?: {
    enabled: boolean;
    field: string;
    operator: "=" | "!=" | ">" | "<" | "in" | "notIn";
    value: unknown;
    action: "show" | "hide" | "enable" | "disable";
  };
  
  // 자동 입력
  autoFill?: {
    enabled: boolean;
    sourceTable: string;
    filterColumn: string;
    userField: "companyCode" | "userId" | "deptCode";
    displayColumn: string;
  };
}

3.2 컴포넌트별 componentConfig 구조

테이블 리스트 (v2-table-list)

{
  componentConfig: {
    tableName: "user_info",
    selectedTable: "user_info",
    displayMode: "table" | "card",
    
    columns: [
      {
        columnName: "user_id",
        displayName: "사용자 ID",
        visible: true,
        sortable: true,
        searchable: true,
        width: 150,
        align: "left",
        format: "text",
        order: 0,
        editable: true,
        hidden: false,
        fixed: "left" | "right" | false,
        autoGeneration: {
          type: "uuid" | "numbering_rule",
          enabled: false,
          options: { numberingRuleId: "rule-123" }
        }
      }
    ],
    
    pagination: {
      enabled: true,
      pageSize: 20,
      showSizeSelector: true,
      pageSizeOptions: [10, 20, 50, 100]
    },
    
    toolbar: {
      showEditMode: true,
      showExcel: true,
      showRefresh: true
    },
    
    checkbox: {
      enabled: true,
      multiple: true,
      position: "left"
    },
    
    filter: {
      enabled: true,
      filters: []
    }
  }
}

버튼 (v2-button-primary)

{
  componentConfig: {
    action: {
      type: "save" | "delete" | "navigate" | "popup" | "excel" | "quickInsert",
      
      // 화면 이동용
      targetScreenId?: number,
      targetScreenCode?: string,
      navigateUrl?: string,
      
      // 채번 규칙 연동
      numberingRuleId?: string,
      excelNumberingRuleId?: string,
      
      // 엑셀 업로드 후 플로우 실행
      excelAfterUploadFlows?: Array<{ flowId: number }>,
      
      // 데이터 전송 설정
      dataTransfer?: {
        targetTable: string,
        columnMappings: [
          { sourceColumn: string, targetColumn: string }
        ]
      }
    }
  }
}

분할 패널 레이아웃 (v2-split-panel-layout)

{
  componentConfig: {
    leftPanel: {
      tableName: "order_list",
      displayMode: "table" | "card",
      columns: [...],
      addConfig: {
        targetTable: "order_detail",
        columnMappings: [...]
      }
    },
    
    rightPanel: {
      tableName: "order_detail",
      displayMode: "table",
      columns: [...]
    },
    
    dataTransfer: {
      enabled: true,
      buttonConfig: {
        label: "선택 항목 추가",
        position: "center"
      }
    }
  }
}

플로우 위젯 (flow-widget)

{
  webTypeConfig: {
    dataflowConfig: {
      flowConfig: {
        flowId: 29
      },
      selectedDiagramId: 1,
      flowControls: [
        { flowId: 30 },
        { flowId: 31 }
      ]
    }
  }
}

탭 위젯 (v2-tabs-widget)

{
  componentConfig: {
    tabs: [
      {
        id: "tab-1",
        label: "기본 정보",
        screenId: 45,
        order: 0,
        disabled: false
      },
      {
        id: "tab-2",
        label: "상세 정보",
        screenId: 46,
        order: 1
      }
    ],
    defaultTab: "tab-1",
    orientation: "horizontal",
    variant: "default"
  }
}

3.3 메타데이터 저장 (_metadata 타입)

화면 전체 설정은 component_type = "_metadata"인 별도 레코드로 저장:

{
  properties: {
    gridSettings: {
      columns: 12,
      gap: 16,
      padding: 16,
      snapToGrid: true,
      showGrid: true
    },
    screenResolution: {
      width: 1920,
      height: 1080,
      name: "Full HD",
      category: "desktop"
    }
  }
}

4. 프론트엔드 레지스트리 구조

4.1 디렉토리 구조

frontend/lib/registry/
├── init.ts                      # 레지스트리 초기화
├── ComponentRegistry.ts         # 컴포넌트 등록 시스템
├── WebTypeRegistry.ts           # 웹타입 레지스트리
└── components/                  # 컴포넌트별 폴더
    ├── v2-table-list/
    │   ├── index.ts             # 컴포넌트 등록
    │   ├── types.ts             # 타입 정의
    │   ├── TableListComponent.tsx
    │   ├── TableListRenderer.tsx
    │   └── TableListConfigPanel.tsx
    ├── v2-button-primary/
    ├── v2-split-panel-layout/
    ├── text-input/
    ├── select-basic/
    └── ... (70+ 컴포넌트)

4.2 컴포넌트 등록 패턴

// frontend/lib/registry/components/v2-table-list/index.ts
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";

ComponentRegistry.register({
  id: "v2-table-list",
  name: "테이블 리스트",
  category: "display",
  component: TableListComponent,
  renderer: TableListRenderer,
  configPanel: TableListConfigPanel,
  defaultConfig: {
    tableName: "",
    columns: [],
    pagination: { enabled: true, pageSize: 20 }
  }
});

4.3 현재 등록된 주요 컴포넌트 (70+ 개)

카테고리 컴포넌트
입력 text-input, number-input, date-input, select-basic, checkbox-basic, radio-basic, textarea-basic, slider-basic, toggle-switch
표시 v2-table-list, v2-card-display, v2-text-display, image-display
레이아웃 v2-split-panel-layout, v2-section-card, v2-section-paper, accordion-basic, conditional-container
버튼 v2-button-primary, related-data-buttons
고급 flow-widget, v2-tabs-widget, v2-pivot-grid, v2-category-manager, v2-aggregation-widget
파일 file-upload
반복 repeat-container, repeater-field-group, simple-repeater-table, modal-repeater-table
검색 entity-search-input, autocomplete-search-input, table-search-widget
특수 numbering-rule, mail-recipient-selector, rack-structure, map

5. 백엔드 서비스 로직

5.1 레이아웃 저장 (saveLayout)

// backend-node/src/services/screenManagementService.ts

async saveLayout(screenId: number, layoutData: LayoutData, companyCode: string) {
  // 1. 기존 레이아웃 삭제
  await query(`DELETE FROM screen_layouts WHERE screen_id = $1`, [screenId]);
  
  // 2. 메타데이터 저장
  if (layoutData.gridSettings || layoutData.screenResolution) {
    const metadata = {
      gridSettings: layoutData.gridSettings,
      screenResolution: layoutData.screenResolution
    };
    await query(`
      INSERT INTO screen_layouts (
        screen_id, component_type, component_id, properties, display_order
      ) VALUES ($1, '_metadata', $2, $3, -1)
    `, [screenId, `_metadata_${screenId}`, JSON.stringify(metadata)]);
  }
  
  // 3. 컴포넌트 저장
  for (const component of layoutData.components) {
    const properties = {
      ...componentData,
      position: { x, y, z },
      size: { width, height }
    };
    
    await query(`
      INSERT INTO screen_layouts (...) VALUES (...)
    `, [screenId, componentType, componentId, ..., JSON.stringify(properties)]);
  }
}

5.2 레이아웃 조회 (getLayout)

async getLayout(screenId: number, companyCode: string): Promise<LayoutData | null> {
  // 레이아웃 조회
  const layouts = await query(`
    SELECT * FROM screen_layouts WHERE screen_id = $1
    ORDER BY display_order ASC
  `, [screenId]);
  
  // 메타데이터와 컴포넌트 분리
  const metadataLayout = layouts.find(l => l.component_type === "_metadata");
  const componentLayouts = layouts.filter(l => l.component_type !== "_metadata");
  
  // 컴포넌트 변환 (JSONB → TypeScript 객체)
  const components = componentLayouts.map(layout => {
    const properties = layout.properties as any;  // ⭐ JSONB 자동 파싱
    
    return {
      id: layout.component_id,
      type: layout.component_type,
      position: { x: layout.position_x, y: layout.position_y },
      size: { width: layout.width, height: layout.height },
      ...properties  // 모든 properties 확장
    };
  });
  
  return { components, gridSettings, screenResolution };
}

5.3 ID 참조 업데이트 (화면 복사 시)

화면 복사 시 JSON 내부의 ID 참조를 새 ID로 업데이트:

// 채번 규칙 ID 업데이트
updateNumberingRuleIdsInProperties(properties, ruleIdMap) {
  // componentConfig.autoGeneration.options.numberingRuleId
  // componentConfig.action.numberingRuleId
  // componentConfig.action.excelNumberingRuleId
}

// 화면 ID 업데이트
updateTabScreenIdsInProperties(properties, screenIdMap) {
  // componentConfig.tabs[].screenId
}

// 플로우 ID 업데이트
updateFlowIdsInProperties(properties, flowIdMap) {
  // webTypeConfig.dataflowConfig.flowConfig.flowId
  // webTypeConfig.dataflowConfig.flowControls[].flowId
}

6. 장단점 분석

6.1 장점

장점 설명
유연성 스키마 변경 없이 새 컴포넌트 설정 추가 가능
확장성 새 컴포넌트 타입 추가 시 DB 마이그레이션 불필요
버전 호환성 이전 버전 컴포넌트도 그대로 동작
빠른 개발 프론트엔드에서 설정 구조 변경 후 바로 저장 가능
복잡한 구조 중첩된 설정 (예: columns 배열) 저장 용이

6.2 단점

단점 설명
타입 안정성 런타임에만 타입 검증 가능
쿼리 복잡도 JSONB 내부 필드 검색/수정 어려움
인덱싱 한계 전체 JSON 검색 시 성능 저하
마이그레이션 JSON 구조 변경 시 데이터 마이그레이션 필요
디버깅 JSON 구조 파악 어려움

7. 현재 구조의 특징

7.1 레거시 + 신규 컴포넌트 공존

// 레거시 방식 (widgetType + webTypeConfig)
{
  type: "widget",
  widgetType: "text",
  webTypeConfig: { ... }
}

// 신규 방식 (componentType + componentConfig)
{
  type: "component",
  componentType: "v2-table-list",
  componentConfig: { ... }
}

7.2 계층 구조

screen_layouts
├── _metadata (격자 설정, 해상도)
├── container (최상위 컨테이너)
│   ├── row (행)
│   │   ├── column (열)
│   │   │   └── widget/component (실제 컴포넌트)
│   │   └── column
│   └── row
└── component (독립 컴포넌트)

7.3 ID 참조 관계

properties.componentConfig
├── action.targetScreenId → screen_definitions.screen_id
├── action.numberingRuleId → numbering_rule.rule_id
├── action.excelAfterUploadFlows[].flowId → flow_definitions.flow_id
├── tabs[].screenId → screen_definitions.screen_id
└── webTypeConfig.dataflowConfig.flowConfig.flowId → flow_definitions.flow_id

8. 개선 권장사항

8.1 단기 개선

  1. 타입 문서화: 각 컴포넌트의 componentConfig 타입을 TypeScript 인터페이스로 명확히 정의
  2. 검증 레이어: 저장 전 JSON 스키마 검증 추가
  3. 마이그레이션 도구: JSON 구조 변경 시 자동 마이그레이션 스크립트

8.2 장기 개선

  1. 버전 관리: properties 내에 version 필드 추가
  2. 인덱스 최적화: 자주 검색되는 JSONB 필드에 GIN 인덱스 추가
  3. 로깅 강화: 컴포넌트 설정 변경 이력 추적

9. 결론

현재 시스템은 JSONB를 활용한 유연한 컴포넌트 설정 관리 방식을 채택하고 있습니다.

  • 70개 이상의 컴포넌트가 등록되어 있으며
  • screen_layouts.properties 필드에 모든 컴포넌트 설정이 저장됩니다
  • 레거시(widgetType)와 신규(componentType) 컴포넌트가 공존하며
  • 화면 복사 시 JSON 내부의 ID 참조가 자동 업데이트됩니다

이 구조는 빠른 기능 확장에 적합하지만, 타입 안정성쿼리 성능 측면에서 추가 개선이 필요합니다.