ERP-node/docs/SCREEN_COPY_V2_MIGRATION_PL...

525 lines
16 KiB
Markdown

# 화면 복제 로직 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<string, string>;
flowIdMap: Map<number, number>;
ruleIdMap: Map<string, string>;
screenIdMap: Map<number, number>;
menuIdMap?: Map<number, number>;
},
client: PoolClient
): Promise<void>
// V2 JSONB에서 참조 ID 수집
collectReferencesFromLayoutV2(layoutData: any): {
flowIds: Set<number>;
ruleIds: Set<string>;
screenIds: Set<number>;
}
// 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<any>(
`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<string, string>;
flowIdMap?: Map<number, number>;
ruleIdMap?: Map<string, string>;
screenIdMap?: Map<number, number>;
menuIdMap?: Map<number, number>;
},
client: PoolClient
): Promise<void> {
// 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
- [x] 단위 테스트 통과 ✅ 2026-01-30
- [x] 통합 테스트 통과 ✅ 2026-01-30
- [x] V2 전용 복제 동작 확인 ✅ 2026-01-30
### 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 |
| 2026-01-30 | **Phase 1 테스트 완료** - 단위/통합 테스트 통과 확인 | Claude |