# 화면 복제 로직 V2 마이그레이션 계획서 > 작성일: 2026-01-28 ## 1. 현황 분석 ### 1.1 현재 복제 방식 (Legacy) ``` 테이블: screen_layouts (다중 레코드) 방식: 화면당 N개 레코드 (컴포넌트 수만큼) 저장: properties에 전체 설정 "박제" ``` **데이터 구조:** ```sql -- 화면당 여러 레코드 SELECT * FROM screen_layouts WHERE screen_id = 123; -- layout_id | screen_id | component_type | component_id | properties (전체 설정) -- 1 | 123 | table-list | comp_001 | {"tableName": "user", "columns": [...], ...} -- 2 | 123 | button | comp_002 | {"label": "저장", "variant": "default", ...} ``` ### 1.2 V2 방식 ``` 테이블: screen_layouts_v2 (1개 레코드) 방식: 화면당 1개 레코드 (JSONB) 저장: url + overrides (차이값만) ``` **데이터 구조:** ```sql -- 화면당 1개 레코드 SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = 123; -- { -- "version": "2.0", -- "components": [ -- { "id": "comp_001", "url": "@/lib/registry/components/table-list", "overrides": {...} }, -- { "id": "comp_002", "url": "@/lib/registry/components/button-primary", "overrides": {...} } -- ] -- } ``` --- ## 2. 현재 복제 로직 분석 ### 2.1 복제 진입점 (2곳) | 경로 | 파일 | 함수 | 용도 | |-----|------|------|-----| | 단일 화면 복제 | `screenManagementService.ts` | `copyScreen()` | 화면 관리에서 개별 화면 복제 | | 메뉴 일괄 복제 | `menuCopyService.ts` | `copyScreens()` | 메뉴 복제 시 연결된 화면들 복제 | ### 2.2 screenManagementService.copyScreen() 흐름 ``` 1. screen_definitions 조회 (원본) 2. screen_definitions INSERT (대상) 3. screen_layouts 조회 (원본) ← Legacy 4. flowId 수집 및 복제 (회사 간 복제 시) 5. numberingRuleId 수집 및 복제 (회사 간 복제 시) 6. componentId 재생성 (idMapping) 7. properties 내 참조 업데이트 (flowId, ruleId) 8. screen_layouts INSERT (대상) ← Legacy ``` **V2 처리: ❌ 없음** ### 2.3 menuCopyService.copyScreens() 흐름 ``` 1단계: screen_definitions 처리 - 기존 복사본 존재 시: 업데이트 - 없으면: 신규 생성 - screenIdMap 생성 2단계: screen_layouts 처리 - 원본 조회 - componentIdMap 생성 - properties 내 참조 업데이트 (screenId, flowId, ruleId, menuId) - 배치 INSERT ``` **V2 처리: ❌ 없음** ### 2.4 복제 시 처리되는 참조 ID들 | 참조 ID | 설명 | 매핑 방식 | |--------|-----|----------| | `componentId` | 컴포넌트 고유 ID | 새로 생성 (`comp_xxx`) | | `parentId` | 부모 컴포넌트 ID | componentIdMap으로 매핑 | | `flowId` | 노드 플로우 ID | flowIdMap으로 매핑 (회사 간 복제 시) | | `numberingRuleId` | 채번 규칙 ID | ruleIdMap으로 매핑 (회사 간 복제 시) | | `screenId` (탭) | 탭에서 참조하는 화면 ID | screenIdMap으로 매핑 | | `menuObjid` | 메뉴 ID | menuIdMap으로 매핑 | --- ## 3. V2 마이그레이션 시 변경 필요 사항 ### 3.1 핵심 변경점 | 항목 | Legacy | V2 | |-----|--------|-----| | 읽기 테이블 | `screen_layouts` | `screen_layouts_v2` | | 쓰기 테이블 | `screen_layouts` | `screen_layouts_v2` | | 데이터 형태 | N개 레코드 | 1개 JSONB | | ID 매핑 위치 | 각 레코드의 컬럼 | JSONB 내부 순회 | | 참조 업데이트 | `properties` JSON | `overrides` JSON | ### 3.2 수정해야 할 함수들 #### screenManagementService.ts | 함수 | 변경 내용 | |-----|----------| | `copyScreen()` | screen_layouts_v2 복제 로직 추가 | | `collectFlowIdsFromLayouts()` | V2 JSONB 구조에서 flowId 수집 | | `collectNumberingRuleIdsFromLayouts()` | V2 JSONB 구조에서 ruleId 수집 | | `updateFlowIdsInProperties()` | V2 overrides 내 flowId 업데이트 | | `updateNumberingRuleIdsInProperties()` | V2 overrides 내 ruleId 업데이트 | #### menuCopyService.ts | 함수 | 변경 내용 | |-----|----------| | `copyScreens()` | screen_layouts_v2 복제 로직 추가 | | `hasLayoutChanges()` | V2 JSONB 비교 로직 | | `updateReferencesInProperties()` | V2 overrides 내 참조 업데이트 | ### 3.3 새로 추가할 함수들 ```typescript // V2 레이아웃 복제 (공통) async copyLayoutV2( sourceScreenId: number, targetScreenId: number, targetCompanyCode: string, mappings: { componentIdMap: Map; flowIdMap: Map; ruleIdMap: Map; screenIdMap: Map; menuIdMap?: Map; }, client: PoolClient ): Promise // V2 JSONB에서 참조 ID 수집 collectReferencesFromLayoutV2(layoutData: any): { flowIds: Set; ruleIds: Set; screenIds: Set; } // V2 JSONB 내 참조 업데이트 updateReferencesInLayoutV2( layoutData: any, mappings: { ... } ): any ``` --- ## 4. 마이그레이션 전략 ### 4.1 전략: V2 완전 전환 ``` 결정: V2만 복제 (Legacy 복제 제거) 이유: 깔끔한 코드, 유지보수 용이, V2 아키텍처 일관성 전제: 기존 화면들은 이미 screen_layouts_v2로 마이그레이션 완료 (1,347개 100%) ``` ### 4.2 단계별 계획 #### Phase 1: V2 복제 로직 구현 및 전환 ``` 목표: Legacy 복제를 V2 복제로 완전 교체 영향: 복제 시 screen_layouts_v2 테이블만 사용 작업: 1. copyLayoutV2() 공통 함수 구현 2. screenManagementService.copyScreen() - Legacy → V2 교체 3. menuCopyService.copyScreens() - Legacy → V2 교체 4. 테스트 및 검증 ``` #### Phase 2: Legacy 코드 정리 ``` 목표: 불필요한 Legacy 복제 코드 제거 영향: 코드 간소화 작업: 1. screen_layouts 관련 복제 코드 제거 2. 관련 헬퍼 함수 정리 (collectFlowIdsFromLayouts 등) 3. 코드 리뷰 및 정리 ``` #### Phase 3: Legacy 테이블 정리 (선택, 추후) ``` 목표: 불필요한 테이블 제거 영향: 데이터 정리 작업: 1. screen_layouts 테이블 데이터 백업 2. screen_layouts 테이블 삭제 (또는 보관) 3. 관련 코드 정리 ``` --- ## 5. 상세 구현 계획 ### 5.1 Phase 1 작업 목록 | # | 작업 | 파일 | 예상 공수 | |---|-----|------|---------| | 1 | `copyLayoutV2()` 공통 함수 구현 | screenManagementService.ts | 2시간 | | 2 | `collectReferencesFromLayoutV2()` 구현 | screenManagementService.ts | 1시간 | | 3 | `updateReferencesInLayoutV2()` 구현 | screenManagementService.ts | 2시간 | | 4 | `copyScreen()` - Legacy 제거, V2로 교체 | screenManagementService.ts | 2시간 | | 5 | `copyScreens()` - Legacy 제거, V2로 교체 | menuCopyService.ts | 3시간 | | 6 | 단위 테스트 | - | 2시간 | | 7 | 통합 테스트 | - | 2시간 | **총 예상 공수: 14시간 (약 2일)** ### 5.2 주요 변경 포인트 #### copyScreen() 변경 전후 **Before (Legacy):** ```typescript // 4. 원본 화면의 레이아웃 정보 조회 const sourceLayoutsResult = await client.query( `SELECT * FROM screen_layouts WHERE screen_id = $1`, [sourceScreenId] ); // ... N개 레코드 순회하며 INSERT ``` **After (V2):** ```typescript // 4. 원본 V2 레이아웃 조회 const sourceLayoutV2 = await client.query( `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1 AND company_code = $2`, [sourceScreenId, sourceCompanyCode] ); // ... JSONB 변환 후 1개 레코드 INSERT ``` #### copyScreens() 변경 전후 **Before (Legacy):** ```typescript // 레이아웃 배치 INSERT await client.query( `INSERT INTO screen_layouts (...) VALUES ${layoutValues.join(", ")}`, layoutParams ); ``` **After (V2):** ```typescript // V2 레이아웃 UPSERT await this.copyLayoutV2( originalScreenId, targetScreenId, sourceCompanyCode, targetCompanyCode, { componentIdMap, flowIdMap, ruleIdMap, screenIdMap, menuIdMap }, client ); ``` ### 5.2 copyLayoutV2() 구현 방안 ```typescript private async copyLayoutV2( sourceScreenId: number, targetScreenId: number, sourceCompanyCode: string, targetCompanyCode: string, mappings: { componentIdMap: Map; flowIdMap?: Map; ruleIdMap?: Map; screenIdMap?: Map; menuIdMap?: Map; }, client: PoolClient ): Promise { // 1. 원본 V2 레이아웃 조회 const sourceResult = await client.query( `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1 AND company_code = $2`, [sourceScreenId, sourceCompanyCode] ); if (sourceResult.rows.length === 0) { // V2 레이아웃 없으면 스킵 (Legacy만 있는 경우) return; } const layoutData = sourceResult.rows[0].layout_data; // 2. components 배열 순회하며 ID 매핑 const updatedComponents = layoutData.components.map((comp: any) => { const newId = mappings.componentIdMap.get(comp.id) || comp.id; // overrides 내 참조 업데이트 let updatedOverrides = { ...comp.overrides }; // flowId 매핑 if (mappings.flowIdMap && updatedOverrides.flowId) { const newFlowId = mappings.flowIdMap.get(updatedOverrides.flowId); if (newFlowId) updatedOverrides.flowId = newFlowId; } // numberingRuleId 매핑 if (mappings.ruleIdMap && updatedOverrides.numberingRuleId) { const newRuleId = mappings.ruleIdMap.get(updatedOverrides.numberingRuleId); if (newRuleId) updatedOverrides.numberingRuleId = newRuleId; } // screenId 매핑 (탭 컴포넌트 등) if (mappings.screenIdMap && updatedOverrides.screenId) { const newScreenId = mappings.screenIdMap.get(updatedOverrides.screenId); if (newScreenId) updatedOverrides.screenId = newScreenId; } // tabs 배열 내 screenId 매핑 if (mappings.screenIdMap && Array.isArray(updatedOverrides.tabs)) { updatedOverrides.tabs = updatedOverrides.tabs.map((tab: any) => ({ ...tab, screenId: mappings.screenIdMap.get(tab.screenId) || tab.screenId })); } return { ...comp, id: newId, overrides: updatedOverrides }; }); const newLayoutData = { ...layoutData, components: updatedComponents, updatedAt: new Date().toISOString() }; // 3. 대상 V2 레이아웃 저장 (UPSERT) await client.query( `INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at) VALUES ($1, $2, $3, NOW(), NOW()) ON CONFLICT (screen_id, company_code) DO UPDATE SET layout_data = $3, updated_at = NOW()`, [targetScreenId, targetCompanyCode, JSON.stringify(newLayoutData)] ); } ``` --- ## 6. 테스트 계획 ### 6.1 단위 테스트 | 테스트 케이스 | 설명 | |-------------|------| | V2 레이아웃 복제 - 기본 | 단순 컴포넌트 복제 | | V2 레이아웃 복제 - flowId 매핑 | 회사 간 복제 시 flowId 변경 확인 | | V2 레이아웃 복제 - ruleId 매핑 | 회사 간 복제 시 ruleId 변경 확인 | | V2 레이아웃 복제 - 탭 screenId 매핑 | 탭 컴포넌트의 screenId 변경 확인 | | V2 레이아웃 없는 경우 | Legacy만 있는 화면 복제 시 스킵 확인 | ### 6.2 통합 테스트 | 테스트 케이스 | 설명 | |-------------|------| | 단일 화면 복제 (같은 회사) | copyScreen() - 동일 회사 내 복제 | | 단일 화면 복제 (다른 회사) | copyScreen() - 회사 간 복제 | | 메뉴 일괄 복제 | copyScreens() - 여러 화면 동시 복제 | | 모달 포함 복제 | copyScreenWithModals() - 메인 + 모달 복제 | ### 6.3 검증 항목 ``` 복제 후 확인: - [ ] screen_layouts_v2에 레코드 생성됨 - [ ] componentId가 새로 생성됨 - [ ] flowId가 정확히 매핑됨 - [ ] numberingRuleId가 정확히 매핑됨 - [ ] 탭 컴포넌트의 screenId가 정확히 매핑됨 - [ ] screen_layouts(Legacy)는 복제되지 않음 - [ ] 복제된 화면이 프론트엔드에서 정상 로드됨 - [ ] 복제된 화면 편집/저장 정상 동작 ``` --- ## 7. 영향 분석 ### 7.1 영향 받는 기능 | 기능 | 영향 | 비고 | |-----|-----|-----| | 화면 관리 - 화면 복제 | 직접 영향 | copyScreen() | | 화면 관리 - 그룹 복제 | 직접 영향 | copyScreenWithModals() | | 메뉴 복제 | 직접 영향 | menuCopyService.copyScreens() | | 화면 디자이너 | 간접 영향 | 복제된 화면 로드 시 V2 사용 | ### 7.2 롤백 계획 ``` V2 전환 롤백 (필요시): 1. Git에서 이전 버전 복원 (copyScreen, copyScreens) 2. Legacy 복제 코드 복원 3. 테스트 후 배포 주의사항: - V2로 복제된 화면들은 screen_layouts_v2에만 데이터 존재 - 롤백 시 해당 화면들은 screen_layouts에 데이터 없음 - 필요시 V2 → Legacy 역변환 스크립트 실행 ``` --- ## 8. 관련 파일 ### 8.1 수정 대상 | 파일 | 변경 내용 | |-----|----------| | `backend-node/src/services/screenManagementService.ts` | copyLayoutV2(), copyScreen() 수정 | | `backend-node/src/services/menuCopyService.ts` | copyScreens() 수정 | ### 8.2 참고 파일 | 파일 | 설명 | |-----|-----| | `docs/COMPONENT_LAYOUT_V2_ARCHITECTURE.md` | V2 아키텍처 문서 | | `frontend/lib/api/screen.ts` | getLayoutV2, saveLayoutV2 | | `frontend/lib/utils/layoutV2Converter.ts` | V2 변환 유틸리티 | --- ## 9. 체크리스트 ### 9.1 개발 전 - [ ] V2 아키텍처 문서 숙지 - [ ] 현재 복제 로직 코드 리뷰 - [ ] 테스트 데이터 준비 (V2 레이아웃이 있는 화면) ### 9.2 Phase 1 완료 조건 - [x] copyLayoutV2() 함수 구현 ✅ 2026-01-28 - [x] collectReferencesFromLayoutV2() 함수 구현 ✅ 2026-01-28 - [x] updateReferencesInLayoutV2() 함수 구현 ✅ 2026-01-28 - [x] copyScreen() - Legacy 제거, V2로 교체 ✅ 2026-01-28 - [x] copyScreens() - Legacy 제거, V2로 교체 ✅ 2026-01-28 - [x] hasLayoutChangesV2() 함수 추가 ✅ 2026-01-28 - [x] updateTabScreenReferences() V2 지원 추가 ✅ 2026-01-28 - [ ] 단위 테스트 통과 - [ ] 통합 테스트 통과 - [ ] V2 전용 복제 동작 확인 ### 9.3 Phase 2 완료 조건 - [ ] Legacy 관련 헬퍼 함수 정리 - [ ] 불필요한 코드 제거 - [ ] 코드 리뷰 완료 - [ ] 회귀 테스트 통과 --- ## 10. 시뮬레이션 검증 결과 ### 10.1 검증된 시나리오 | 시나리오 | 결과 | 비고 | |---------|------|------| | 같은 회사 내 복제 | ✅ 정상 | componentId만 새로 생성 | | 회사 간 복제 (flowId 매핑) | ✅ 정상 | flowIdMap 적용됨 | | 회사 간 복제 (ruleId 매핑) | ✅ 정상 | ruleIdMap 적용됨 | | 탭 컴포넌트 screenId 매핑 | ✅ 정상 | updateTabScreenReferences V2 지원 추가 | | V2 레이아웃 없는 화면 | ✅ 정상 | 스킵 처리 | ### 10.2 발견 및 수정된 문제 | 문제 | 해결 | |-----|------| | updateTabScreenReferences가 V2 미지원 | V2 처리 로직 추가 완료 | ### 10.3 Zod 활용 가능성 프론트엔드에 이미 훌륭한 Zod 유틸리티 존재: - `deepMerge()` - 깊은 병합 - `extractCustomConfig()` - 차이값 추출 - `loadComponentV2()` / `saveComponentV2()` - V2 로드/저장 향후 백엔드에도 Zod 추가 시: - 타입 안전성 향상 - 프론트/백엔드 스키마 공유 가능 - 범용 참조 탐색 로직으로 하드코딩 제거 가능 --- ## 11. 변경 이력 | 날짜 | 변경 내용 | 작성자 | |-----|----------|-------| | 2026-01-28 | 초안 작성 | Claude | | 2026-01-28 | V2 완전 전환 전략으로 변경 (병행 운영 → V2 전용) | Claude | | 2026-01-28 | Phase 1 구현 완료 - V2 복제 함수들 구현 및 Legacy 교체 | Claude | | 2026-01-28 | 시뮬레이션 검증 - updateTabScreenReferences V2 지원 추가 | Claude | | 2026-01-28 | V2 경로 지원 추가 - action/sections 직접 경로 (componentConfig 없이) | Claude | | 2026-01-30 | **실제 코드 구현 완료** - copyScreen(), copyScreens() V2 전환 | Claude |