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

19 KiB

컴포넌트 시스템 마이그레이션 계획서

1. 개요

1.1 목적

  • 현재 JSON 기반 컴포넌트 관리 시스템을 URL 참조 + Zod 스키마 기반으로 전환
  • 컴포넌트 코드 수정 시 모든 회사에 즉시 반영되는 구조로 개선
  • JSON 구조 표준화 및 런타임 검증 체계 구축

1.2 핵심 원칙

  1. 화면 동일성 유지: 마이그레이션 전후 렌더링 결과가 100% 동일해야 함
  2. 안전한 테스트: 기존 테이블 수정 없이 새 테이블에서 테스트
  3. 롤백 가능: 문제 발생 시 즉시 원복 가능한 구조

1.3 현재 상태 (DB 분석 결과)

항목 수치
총 레코드 7,170개
화면 수 1,363개
회사 수 15개
컴포넌트 타입 50개

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,
  component_id VARCHAR(100) UNIQUE NOT NULL,
  parent_id VARCHAR(100),
  position_x INTEGER NOT NULL,
  position_y INTEGER NOT NULL,
  width INTEGER NOT NULL,
  height INTEGER NOT NULL,
  properties JSONB,  -- 전체 설정이 포함됨
  display_order INTEGER DEFAULT 0,
  layout_type VARCHAR(50),
  layout_config JSONB,
  zones_config JSONB,
  zone_id VARCHAR(100)
);

2.2 신규 테이블: screen_layouts_v2 (테스트용)

CREATE TABLE screen_layouts_v2 (
  layout_id SERIAL PRIMARY KEY,
  screen_id INTEGER REFERENCES screen_definitions(screen_id),
  component_type VARCHAR(50) NOT NULL,
  component_id VARCHAR(100) UNIQUE NOT NULL,
  parent_id VARCHAR(100),
  position_x INTEGER NOT NULL,
  position_y INTEGER NOT NULL,
  width INTEGER NOT NULL,
  height INTEGER NOT NULL,
  
  -- 변경된 부분
  component_ref VARCHAR(100) NOT NULL,        -- 컴포넌트 URL 참조 (예: "button-primary")
  config_overrides JSONB DEFAULT '{}',        -- 기본값과 다른 설정만 저장
  
  -- 기존 필드 유지
  properties JSONB,                           -- 기존 호환용 (마이그레이션 완료 후 제거)
  display_order INTEGER DEFAULT 0,
  layout_type VARCHAR(50),
  layout_config JSONB,
  zones_config JSONB,
  zone_id VARCHAR(100),
  
  -- 마이그레이션 추적
  migrated_at TIMESTAMPTZ,
  migration_status VARCHAR(20) DEFAULT 'pending'  -- pending, success, failed
);

3. 마이그레이션 단계

3.1 Phase 1: 테이블 생성 및 데이터 복사

-- Step 1: 새 테이블 생성
CREATE TABLE screen_layouts_v2 AS 
SELECT * FROM screen_layouts;

-- Step 2: 새 컬럼 추가
ALTER TABLE screen_layouts_v2 
ADD COLUMN component_ref VARCHAR(100),
ADD COLUMN config_overrides JSONB DEFAULT '{}',
ADD COLUMN migrated_at TIMESTAMPTZ,
ADD COLUMN migration_status VARCHAR(20) DEFAULT 'pending';

-- Step 3: component_ref 초기값 설정
UPDATE screen_layouts_v2 
SET component_ref = properties->>'componentType'
WHERE properties->>'componentType' IS NOT NULL;

3.2 Phase 2: Zod 스키마 정의

각 컴포넌트별 스키마 파일 생성:

frontend/lib/schemas/components/
├── button-primary.schema.ts
├── text-input.schema.ts
├── table-list.schema.ts
├── select-basic.schema.ts
├── date-input.schema.ts
├── file-upload.schema.ts
├── tabs-widget.schema.ts
├── split-panel-layout.schema.ts
├── flow-widget.schema.ts
└── ... (50개)

3.3 Phase 3: 차이값 추출

// 마이그레이션 스크립트 (backend-node)
async function extractConfigDiff(layoutId: number) {
  const layout = await getLayoutById(layoutId);
  const componentType = layout.properties?.componentType;
  
  if (!componentType) {
    return { status: 'skip', reason: 'no componentType' };
  }
  
  // 스키마에서 기본값 가져오기
  const schema = getSchemaByType(componentType);
  const defaults = schema.parse({});
  
  // 현재 저장된 설정
  const currentConfig = layout.properties?.componentConfig || {};
  
  // 기본값과 다른 것만 추출
  const overrides = extractDifferences(defaults, currentConfig);
  
  return {
    status: 'success',
    component_ref: componentType,
    config_overrides: overrides,
    original_config: currentConfig
  };
}

3.4 Phase 4: 렌더링 동일성 검증

// 검증 스크립트
async function verifyRenderingEquality(layoutId: number) {
  // 기존 방식으로 로드
  const originalConfig = await loadOriginalConfig(layoutId);
  
  // 새 방식으로 로드 (기본값 + overrides 병합)
  const migratedConfig = await loadMigratedConfig(layoutId);
  
  // 깊은 비교
  const isEqual = deepEqual(originalConfig, migratedConfig);
  
  if (!isEqual) {
    const diff = getDifferences(originalConfig, migratedConfig);
    console.error(`Layout ${layoutId} 불일치:`, diff);
    return false;
  }
  
  return true;
}

4. 컴포넌트별 분석

4.1 상위 10개 컴포넌트 (우선 처리)

순위 컴포넌트 개수 JSON 일관성 복잡도
1 button-primary 1,527 100% 낮음
2 text-input 700 95% 낮음
3 table-search-widget 353 100% 중간
4 table-list 280 84% 높음
5 file-upload 143 100% 중간
6 select-basic 129 100% 낮음
7 split-panel-layout 129 100% 높음
8 date-input 116 100% 낮음
9 unified-list 97 100% 높음
10 number-input 87 100% 낮음

4.2 발견된 문제점

문제 1: componentType ≠ componentConfig.type

-- 166개 불일치 발견
SELECT COUNT(*) FROM screen_layouts 
WHERE properties->>'componentType' = 'text-input' 
AND properties->'componentConfig'->>'type' != 'text-input';

해결: 마이그레이션 시 componentConfig.typecomponentType으로 통일

문제 2: 키 누락 (table-list)

-- 44개 (16%) pagination/checkbox 없음
SELECT COUNT(*) FROM screen_layouts 
WHERE properties->>'componentType' = 'table-list' 
AND properties->'componentConfig' ? 'pagination' = false;

해결: 누락된 키는 기본값으로 자동 채움 (Zod 스키마 활용)


5. Zod 스키마 예시

5.1 button-primary

// frontend/lib/schemas/components/button-primary.schema.ts
import { z } from "zod";

export const buttonActionSchema = z.object({
  type: z.enum([
    "save", "modal", "openModalWithData", "edit", "delete",
    "control", "excel_upload", "excel_download", "transferData",
    "copy", "code_merge", "view_table_history", "quickInsert",
    "openRelatedModal", "operation_control", "geolocation",
    "update_field", "search", "submit", "cancel", "add",
    "navigate", "empty_vehicle", "reset", "close"
  ]).default("save"),
  targetScreenId: z.number().optional(),
  successMessage: z.string().optional(),
  errorMessage: z.string().optional(),
});

export const buttonPrimarySchema = z.object({
  text: z.string().default("저장"),
  type: z.literal("button-primary").default("button-primary"),
  actionType: z.enum(["button", "submit", "reset"]).default("button"),
  variant: z.enum(["primary", "secondary", "danger"]).default("primary"),
  webType: z.literal("button").default("button"),
  action: buttonActionSchema.optional(),
});

export type ButtonPrimaryConfig = z.infer<typeof buttonPrimarySchema>;
export const buttonPrimaryDefaults = buttonPrimarySchema.parse({});

5.2 table-list

// frontend/lib/schemas/components/table-list.schema.ts
import { z } from "zod";

export const paginationSchema = z.object({
  enabled: z.boolean().default(true),
  pageSize: z.number().default(20),
  showSizeSelector: z.boolean().default(true),
  showPageInfo: z.boolean().default(true),
  pageSizeOptions: z.array(z.number()).default([10, 20, 50, 100]),
});

export const checkboxSchema = z.object({
  enabled: z.boolean().default(true),
  multiple: z.boolean().default(true),
  position: z.enum(["left", "right"]).default("left"),
  selectAll: z.boolean().default(true),
});

export const tableListSchema = z.object({
  type: z.literal("table-list").default("table-list"),
  webType: z.literal("table").default("table"),
  displayMode: z.enum(["table", "card"]).default("table"),
  showHeader: z.boolean().default(true),
  showFooter: z.boolean().default(true),
  autoLoad: z.boolean().default(true),
  autoWidth: z.boolean().default(true),
  stickyHeader: z.boolean().default(false),
  height: z.enum(["auto", "fixed", "viewport"]).default("auto"),
  columns: z.array(z.any()).default([]),
  pagination: paginationSchema.default({}),
  checkbox: checkboxSchema.default({}),
  horizontalScroll: z.object({
    enabled: z.boolean().default(false),
  }).default({}),
  filter: z.object({
    enabled: z.boolean().default(false),
    filters: z.array(z.any()).default([]),
  }).default({}),
  actions: z.object({
    showActions: z.boolean().default(false),
    actions: z.array(z.any()).default([]),
    bulkActions: z.boolean().default(false),
    bulkActionList: z.array(z.string()).default([]),
  }).default({}),
  tableStyle: z.object({
    theme: z.enum(["default", "striped", "bordered", "minimal"]).default("default"),
    headerStyle: z.enum(["default", "dark", "light"]).default("default"),
    rowHeight: z.enum(["compact", "normal", "comfortable"]).default("normal"),
    alternateRows: z.boolean().default(false),
    hoverEffect: z.boolean().default(true),
    borderStyle: z.enum(["none", "light", "heavy"]).default("light"),
  }).default({}),
});

export type TableListConfig = z.infer<typeof tableListSchema>;
export const tableListDefaults = tableListSchema.parse({});

6. 렌더링 로직 변경

6.1 현재 방식

// DynamicComponentRenderer.tsx (현재)
function renderComponent(layout: ScreenLayout) {
  const config = layout.properties?.componentConfig || {};
  return <Component config={config} />;
}

6.2 변경 후 방식

// DynamicComponentRenderer.tsx (변경 후)
function renderComponent(layout: ScreenLayoutV2) {
  const componentRef = layout.component_ref;
  const overrides = layout.config_overrides || {};
  
  // 스키마에서 기본값 가져오기
  const schema = getSchemaByType(componentRef);
  const defaults = schema.parse({});
  
  // 기본값 + overrides 병합
  const config = deepMerge(defaults, overrides);
  
  return <Component config={config} />;
}

7. 테스트 계획

7.1 단위 테스트

describe("ComponentMigration", () => {
  test("button-primary 기본값 병합", () => {
    const overrides = { text: "등록" };
    const result = mergeWithDefaults("button-primary", overrides);
    
    expect(result.text).toBe("등록");  // override 값
    expect(result.variant).toBe("primary");  // 기본값
    expect(result.actionType).toBe("button");  // 기본값
  });
  
  test("table-list 누락된 키 복구", () => {
    const overrides = { columns: [...] };  // pagination 없음
    const result = mergeWithDefaults("table-list", overrides);
    
    expect(result.pagination.enabled).toBe(true);
    expect(result.pagination.pageSize).toBe(20);
  });
});

7.2 통합 테스트

describe("RenderingEquality", () => {
  test("모든 레이아웃 렌더링 동일성 검증", async () => {
    const layouts = await getAllLayouts();
    
    for (const layout of layouts) {
      const original = await renderOriginal(layout);
      const migrated = await renderMigrated(layout);
      
      expect(migrated).toEqual(original);
    }
  });
});

8. 롤백 계획

8.1 즉시 롤백

-- 마이그레이션 실패 시 원래 properties 사용
UPDATE screen_layouts_v2 
SET migration_status = 'rollback'
WHERE layout_id = ?;

8.2 전체 롤백

-- 기존 테이블로 복귀
DROP TABLE screen_layouts_v2;
-- 기존 screen_layouts 계속 사용

9. 작업 순서

Step 1: 테이블 생성 및 데이터 복사

  • screen_layouts_v2 테이블 생성
  • 기존 데이터 복사
  • 새 컬럼 추가

Step 2: Zod 스키마 정의 (상위 10개)

  • button-primary
  • text-input
  • table-search-widget
  • table-list
  • file-upload
  • select-basic
  • split-panel-layout
  • date-input
  • unified-list
  • number-input

Step 3: 마이그레이션 스크립트

  • 차이값 추출 함수
  • 렌더링 동일성 검증 함수
  • 배치 마이그레이션 스크립트

Step 4: 테스트

  • 단위 테스트
  • 통합 테스트
  • 화면 렌더링 비교

Step 5: 적용

  • 프론트엔드 렌더링 로직 수정
  • 백엔드 저장 로직 수정
  • 기존 테이블 교체

10. 예상 일정

단계 작업 예상 기간
1 테이블 생성 및 복사 1일
2 상위 10개 스키마 정의 3일
3 마이그레이션 스크립트 3일
4 테스트 및 검증 3일
5 나머지 40개 스키마 5일
6 전체 마이그레이션 2일
7 프론트엔드 적용 2일
총계 약 19일 (4주)

11. 주의사항

  1. 기존 DB 수정 금지: 모든 테스트는 screen_layouts_v2에서만 진행
  2. 화면 동일성 우선: 렌더링 결과가 다르면 마이그레이션 중단
  3. 단계별 검증: 각 단계 완료 후 검증 통과해야 다음 단계 진행
  4. 롤백 대비: 언제든 기존 시스템으로 복귀 가능해야 함

12. 마이그레이션 실행 결과 (2026-01-27)

12.1 실행 환경

테이블: screen_layouts_v2 (테스트용)
백업: screen_layouts_backup_20260127
원본: screen_layouts (변경 없음)

12.2 마이그레이션 결과

상태 개수 비율
success 5,805 81.0%
skip 1,365 19.0% (metadata)
pending 0 0%
fail 0 0%

12.3 데이터 절약량

항목 수치
원본 총 크기 5.81 MB
config_overrides 총 크기 2.54 MB
절약량 3.27 MB (56.2%)

12.4 컴포넌트별 결과

컴포넌트 개수 원본(bytes) override(bytes) 절약률
text-input 1,797 701 143 79.6%
button-primary 1,527 939 218 76.8%
table-search-widget 353 635 150 76.4%
select-basic 287 660 172 73.9%
table-list 280 2,690 2,020 24.9%
file-upload 143 1,481 53 96.4%
date-input 137 628 111 82.3%
split-panel-layout 129 2,556 2,040 20.2%
number-input 115 646 121 81.2%

12.5 config_overrides 구조

{
  "_originalKeys": ["text", "type", "action", "variant", "webType", "actionType"],
  "text": "등록",
  "action": {
    "type": "modal",
    "targetScreenId": 26
  }
}
  • _originalKeys: 원본에 있던 키 목록 (복원 시 사용)
  • 나머지: 기본값과 다른 설정만 저장

12.6 렌더링 복원 로직

function reconstructConfig(componentRef: string, overrides: any): any {
  const defaults = getDefaultsByType(componentRef);
  const originalKeys = overrides._originalKeys || Object.keys(defaults);
  
  const result = {};
  for (const key of originalKeys) {
    if (overrides.hasOwnProperty(key) && key !== '_originalKeys') {
      result[key] = overrides[key];
    } else if (defaults.hasOwnProperty(key)) {
      result[key] = defaults[key];
    }
  }
  
  return result;
}

12.7 검증 결과

  • button-primary: 1,527개 전체 검증 통과 (100%)
  • text-input: 1,797개 전체 검증 통과 (100%)
  • table-list: 280개 전체 검증 통과 (100%)
  • 기타 모든 컴포넌트: 전체 검증 통과 (100%)

12.8 다음 단계

  1. Zod 스키마 파일 생성 완료
  2. 백엔드 API에서 config_overrides 기반 응답 추가 완료
  3. 프론트엔드에서 V2 API 호출 테스트
  4. 실제 화면에서 렌더링 테스트
  5. screen_layouts 테이블 교체 (운영 적용)

13. Zod 스키마 파일 생성 완료 (2026-01-27)

13.1 생성된 파일 목록

frontend/lib/schemas/components/
├── index.ts              # 메인 인덱스 + 복원 유틸리티
├── button-primary.ts     # 버튼 스키마
├── text-input.ts         # 텍스트 입력 스키마
├── table-list.ts         # 테이블 리스트 스키마
├── select-basic.ts       # 셀렉트 스키마
├── date-input.ts         # 날짜 입력 스키마
├── file-upload.ts        # 파일 업로드 스키마
└── number-input.ts       # 숫자 입력 스키마

13.2 주요 유틸리티 함수

// 컴포넌트 기본값 조회
import { getComponentDefaults } from "@/lib/schemas/components";
const defaults = getComponentDefaults("button-primary");

// 설정 복원 (기본값 + overrides 병합)
import { reconstructConfig } from "@/lib/schemas/components";
const fullConfig = reconstructConfig("button-primary", overrides);

// 차이값 추출 (저장 시 사용)
import { extractConfigDiff } from "@/lib/schemas/components";
const diff = extractConfigDiff("button-primary", currentConfig);

13.3 componentDefaults 레지스트리

50개 컴포넌트의 기본값이 componentDefaults 맵에 등록됨:

  • button-primary, v2-button-primary
  • text-input, number-input, date-input
  • select-basic, checkbox-basic, radio-basic
  • table-list, v2-table-list
  • tabs-widget, v2-tabs-widget
  • split-panel-layout, v2-split-panel-layout
  • flow-widget, category-manager
  • 기타 40+ 컴포넌트

14. 백엔드 API 추가 완료 (2026-01-27)

14.1 수정된 파일

파일 변경 내용
backend-node/src/utils/componentDefaults.ts 컴포넌트 기본값 + 복원 유틸리티 신규 생성
backend-node/src/services/screenManagementService.ts getLayoutV2() 함수 추가
backend-node/src/controllers/screenManagementController.ts getLayoutV2 컨트롤러 추가
backend-node/src/routes/screenManagementRoutes.ts /screens/:screenId/layout-v2 라우트 추가

14.2 새로운 API 엔드포인트

GET /api/screen-management/screens/:screenId/layout-v2

응답 구조: 기존 getLayout과 동일

차이점:

  • screen_layouts_v2 테이블에서 조회
  • migration_status = 'success'인 레코드는 config_overrides + 기본값 병합
  • 마이그레이션 안 된 레코드는 기존 properties.componentConfig 사용

14.3 복원 로직 흐름

1. screen_layouts_v2에서 조회
2. migration_status 확인
   ├─ 'success': reconstructConfig(componentRef, configOverrides)
   └─ 기타: 기존 properties.componentConfig 사용
3. 최신 inputType 정보 병합 (table_type_columns)
4. 전체 componentConfig 반환

14.4 테스트 방법

# 기존 API
curl "http://localhost:8080/api/screen-management/screens/1/layout" -H "Authorization: Bearer ..."

# V2 API
curl "http://localhost:8080/api/screen-management/screens/1/layout-v2" -H "Authorization: Bearer ..."

두 응답의 components[].componentConfig가 동일해야 함


작성일: 2026-01-27 작성자: AI Assistant 버전: 1.1 (마이그레이션 실행 결과 추가)