화면 복제 로직 V2 마이그레이션 계획서
작성일: 2026-01-28
1. 현황 분석
1.1 현재 복제 방식 (Legacy)
테이블: screen_layouts (다중 레코드)
방식: 화면당 N개 레코드 (컴포넌트 수만큼)
저장: properties에 전체 설정 "박제"
데이터 구조:
-- 화면당 여러 레코드
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 (차이값만)
데이터 구조:
-- 화면당 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 처리: ❌ 없음
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 업데이트 |
| 함수 |
변경 내용 |
copyScreens() |
screen_layouts_v2 복제 로직 추가 |
hasLayoutChanges() |
V2 JSONB 비교 로직 |
updateReferencesInProperties() |
V2 overrides 내 참조 업데이트 |
3.3 새로 추가할 함수들
// 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):
// 4. 원본 화면의 레이아웃 정보 조회
const sourceLayoutsResult = await client.query<any>(
`SELECT * FROM screen_layouts WHERE screen_id = $1`,
[sourceScreenId]
);
// ... N개 레코드 순회하며 INSERT
After (V2):
// 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):
// 레이아웃 배치 INSERT
await client.query(
`INSERT INTO screen_layouts (...) VALUES ${layoutValues.join(", ")}`,
layoutParams
);
After (V2):
// V2 레이아웃 UPSERT
await this.copyLayoutV2(
originalScreenId, targetScreenId, sourceCompanyCode, targetCompanyCode,
{ componentIdMap, flowIdMap, ruleIdMap, screenIdMap, menuIdMap },
client
);
5.2 copyLayoutV2() 구현 방안
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 개발 전
9.2 Phase 1 완료 조건
9.3 Phase 2 완료 조건
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 |