# 메뉴 복사 기능 구현 계획서 ## 📋 목차 1. [개요](#개요) 2. [요구사항](#요구사항) 3. [데이터베이스 구조 분석](#데이터베이스-구조-분석) 4. [복사 대상 항목](#복사-대상-항목) 5. [복사 알고리즘](#복사-알고리즘) 6. [구현 단계](#구현-단계) 7. [API 명세](#api-명세) 8. [UI/UX 설계](#uiux-설계) 9. [예외 처리](#예외-처리) 10. [테스트 계획](#테스트-계획) --- ## 개요 ### 목적 메뉴관리 화면에서 **복사 버튼 하나**로 선택된 메뉴와 관련된 모든 리소스를 다른 회사로 복사하여, 복사 즉시 해당 회사에서 사용 가능하도록 합니다. ### 핵심 기능 - 메뉴 트리 구조 복사 (부모-자식 관계 유지) - 화면 + 레이아웃 복사 (모달, 조건부 컨테이너 포함) - 플로우 제어 복사 (스텝, 연결, 조건) - 코드 카테고리 + 코드 정보 복사 - 중복 화면 자동 제거 - 참조 관계 자동 재매핑 - company_code 자동 변경 --- ## 요구사항 ### 기능 요구사항 #### FR-1: 메뉴 복사 - **설명**: 선택된 메뉴와 하위 메뉴를 모두 복사 - **입력**: 원본 메뉴 objid, 대상 회사 company_code - **출력**: 복사된 메뉴 목록 - **제약**: 메뉴 계층 구조 유지 #### FR-2: 화면 복사 - **설명**: 메뉴에 할당된 모든 화면 복사 - **입력**: 메뉴 objid 목록 - **출력**: 복사된 화면 목록 - **제약**: 중복 화면은 하나만 복사 #### FR-3: 화면 내부 참조 추적 - **설명**: 화면 레이아웃에서 참조되는 화면들을 재귀적으로 추적 - **대상**: - 모달 버튼의 targetScreenId - 조건부 컨테이너의 sections[].screenId - 모달 안의 모달 (중첩 구조) - **제약**: 무한 루프 방지 (이미 방문한 화면 체크) #### FR-4: 플로우 복사 - **설명**: 화면에서 참조되는 플로우를 모두 복사 - **대상**: - flow_definition (플로우 정의) - flow_step (스텝) - flow_step_connection (스텝 간 연결) - **제약**: 스텝 ID 재매핑 #### FR-5: 코드 복사 - **설명**: 메뉴에 연결된 코드 카테고리와 코드 복사 - **대상**: - code_category (menu_objid 기준) - code_info (menu_objid 기준) - **제약**: 중복 카테고리 병합 #### FR-6: 참조 ID 재매핑 - **설명**: 복사된 리소스의 ID를 원본 ID에서 새 ID로 자동 변경 - **대상**: - screen_id (화면 ID) - flow_id (플로우 ID) - menu_objid (메뉴 ID) - step_id (스텝 ID) - **방법**: ID 매핑 테이블 사용 ### 비기능 요구사항 #### NFR-1: 성능 - 복사 시간: 메뉴 100개 기준 2분 이내 - 트랜잭션: 전체 작업을 하나의 트랜잭션으로 처리 #### NFR-2: 신뢰성 - 실패 시 롤백: 일부만 복사되는 것 방지 - 중복 실행 방지: 같은 요청 중복 처리 방지 #### NFR-3: 사용성 - 진행 상황 표시: 실시간 복사 진행률 표시 - 결과 보고서: 복사된 항목 상세 리스트 --- ## 데이터베이스 구조 분석 ### 주요 테이블 및 관계 ```sql -- 1. 메뉴 (계층 구조) menu_info ├─ objid (PK) - 메뉴 고유 ID ├─ parent_obj_id - 부모 메뉴 ID ├─ company_code (FK) - 회사 코드 └─ screen_code - 할당된 화면 코드 -- 2. 화면 정의 screen_definitions ├─ screen_id (PK) - 화면 고유 ID ├─ screen_code (UNIQUE) - 화면 코드 ├─ company_code (FK) - 회사 코드 ├─ table_name - 연결된 테이블 └─ layout_metadata (JSONB) - 레이아웃 메타데이터 -- 3. 화면 레이아웃 (컴포넌트) screen_layouts ├─ layout_id (PK) ├─ screen_id (FK) - 화면 ID ├─ component_type - 컴포넌트 타입 ├─ properties (JSONB) - 컴포넌트 속성 │ ├─ componentConfig.action.targetScreenId (모달 참조) │ ├─ sections[].screenId (조건부 컨테이너) │ └─ dataflowConfig.flowConfig.flowId (플로우 참조) └─ parent_id - 부모 컴포넌트 ID -- 4. 화면-메뉴 할당 screen_menu_assignments ├─ assignment_id (PK) ├─ screen_id (FK) - 화면 ID ├─ menu_objid (FK) - 메뉴 ID └─ company_code (FK) - 회사 코드 -- 5. 플로우 정의 flow_definition ├─ id (PK) - 플로우 ID ├─ name - 플로우 이름 ├─ table_name - 연결된 테이블 └─ company_code (FK) - 회사 코드 -- 6. 플로우 스텝 flow_step ├─ id (PK) - 스텝 ID ├─ flow_definition_id (FK) - 플로우 ID ├─ step_name - 스텝 이름 ├─ step_order - 순서 ├─ condition_json (JSONB) - 조건 └─ integration_config (JSONB) - 통합 설정 -- 7. 플로우 스텝 연결 flow_step_connection ├─ id (PK) ├─ flow_definition_id (FK) - 플로우 ID ├─ from_step_id (FK) - 출발 스텝 ID ├─ to_step_id (FK) - 도착 스텝 ID └─ label - 연결 라벨 -- 8. 코드 카테고리 code_category ├─ category_code (PK) ├─ company_code (PK, FK) ├─ menu_objid (PK, FK) - 메뉴 ID ├─ category_name - 카테고리 이름 └─ description - 설명 -- 9. 코드 정보 code_info ├─ code_category (PK, FK) ├─ company_code (PK, FK) ├─ menu_objid (PK, FK) ├─ code_value (PK) - 코드 값 ├─ code_name - 코드 이름 └─ description - 설명 ``` ### 외래키 제약조건 ```sql -- 중요: 삽입 순서 고려 필요 1. company_mng (회사 정보) - 먼저 존재해야 함 2. menu_info (메뉴) 3. screen_definitions (화면) 4. flow_definition (플로우) 5. screen_layouts (레이아웃) 6. screen_menu_assignments (화면-메뉴 할당) 7. flow_step (플로우 스텝) 8. flow_step_connection (스텝 연결) 9. code_category (코드 카테고리) 10. code_info (코드 정보) ``` --- ## 복사 대상 항목 ### 1단계: 메뉴 트리 수집 ```typescript // 재귀적으로 하위 메뉴 수집 function collectMenuTree(rootMenuObjid: number): Menu[] { const result: Menu[] = []; const stack: number[] = [rootMenuObjid]; while (stack.length > 0) { const currentObjid = stack.pop()!; const menu = getMenuByObjid(currentObjid); result.push(menu); // 자식 메뉴들을 스택에 추가 const children = getChildMenus(currentObjid); stack.push(...children.map(c => c.objid)); } return result; } ``` **수집 항목**: - 원본 메뉴 objid - 하위 메뉴 objid 목록 (재귀) - 부모-자식 관계 매핑 ### 2단계: 화면 수집 (중복 제거) ```typescript // 메뉴에 할당된 화면 + 참조 화면 수집 function collectScreens(menuObjids: number[]): Set { const screenIds = new Set(); const visited = new Set(); // 무한 루프 방지 // 1) 메뉴에 직접 할당된 화면 for (const menuObjid of menuObjids) { const assignments = getScreenMenuAssignments(menuObjid); assignments.forEach(a => screenIds.add(a.screen_id)); } // 2) 화면 내부에서 참조되는 화면 (재귀) const queue = Array.from(screenIds); while (queue.length > 0) { const screenId = queue.shift()!; if (visited.has(screenId)) continue; visited.add(screenId); const referencedScreens = extractReferencedScreens(screenId); referencedScreens.forEach(refId => { if (!screenIds.has(refId)) { screenIds.add(refId); queue.push(refId); } }); } return screenIds; } // 화면 레이아웃에서 참조 화면 추출 function extractReferencedScreens(screenId: number): number[] { const layouts = getScreenLayouts(screenId); const referenced: number[] = []; for (const layout of layouts) { const props = layout.properties; // 모달 버튼 if (props?.componentConfig?.action?.targetScreenId) { referenced.push(props.componentConfig.action.targetScreenId); } // 조건부 컨테이너 if (props?.sections) { for (const section of props.sections) { if (section.screenId) { referenced.push(section.screenId); } } } } return referenced; } ``` **수집 항목**: - 직접 할당 화면 ID 목록 - 모달 참조 화면 ID 목록 - 조건부 컨테이너 내 화면 ID 목록 - 중복 제거된 최종 화면 ID Set ### 3단계: 플로우 수집 ```typescript // 화면에서 참조되는 플로우 수집 function collectFlows(screenIds: Set): Set { const flowIds = new Set(); for (const screenId of screenIds) { const layouts = getScreenLayouts(screenId); for (const layout of layouts) { const flowId = layout.properties?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; if (flowId) { flowIds.add(flowId); } } } return flowIds; } ``` **수집 항목**: - flow_definition.id 목록 - 각 플로우의 flow_step 목록 - 각 플로우의 flow_step_connection 목록 ### 4단계: 코드 수집 ```typescript // 메뉴에 연결된 코드 수집 function collectCodes(menuObjids: number[], companyCode: string): { categories: CodeCategory[]; codes: CodeInfo[]; } { const categories: CodeCategory[] = []; const codes: CodeInfo[] = []; for (const menuObjid of menuObjids) { // 코드 카테고리 const cats = getCodeCategories(menuObjid, companyCode); categories.push(...cats); // 각 카테고리의 코드 정보 for (const cat of cats) { const infos = getCodeInfos(cat.category_code, menuObjid, companyCode); codes.push(...infos); } } return { categories, codes }; } ``` **수집 항목**: - code_category 목록 (menu_objid 기준) - code_info 목록 (menu_objid + category_code 기준) --- ## 복사 알고리즘 ### 전체 프로세스 ```typescript async function copyMenu( sourceMenuObjid: number, targetCompanyCode: string, userId: string ): Promise { // 트랜잭션 시작 const client = await pool.connect(); await client.query('BEGIN'); try { // 1단계: 수집 (Collection Phase) const menus = collectMenuTree(sourceMenuObjid); const screenIds = collectScreens(menus.map(m => m.objid)); const flowIds = collectFlows(screenIds); const codes = collectCodes(menus.map(m => m.objid), menus[0].company_code); // 2단계: 플로우 복사 (Flow Copy Phase) const flowIdMap = await copyFlows(flowIds, targetCompanyCode, userId, client); // 3단계: 화면 복사 (Screen Copy Phase) const screenIdMap = await copyScreens( screenIds, targetCompanyCode, flowIdMap, // 플로우 ID 재매핑 userId, client ); // 4단계: 메뉴 복사 (Menu Copy Phase) const menuIdMap = await copyMenus( menus, targetCompanyCode, screenIdMap, // 화면 ID 재매핑 userId, client ); // 5단계: 화면-메뉴 할당 (Assignment Phase) await createScreenMenuAssignments( menus, menuIdMap, screenIdMap, targetCompanyCode, client ); // 6단계: 코드 복사 (Code Copy Phase) await copyCodes( codes, menuIdMap, targetCompanyCode, userId, client ); // 커밋 await client.query('COMMIT'); return { success: true, copiedMenus: Object.values(menuIdMap).length, copiedScreens: Object.values(screenIdMap).length, copiedFlows: Object.values(flowIdMap).length, copiedCategories: codes.categories.length, copiedCodes: codes.codes.length, }; } catch (error) { // 롤백 await client.query('ROLLBACK'); throw error; } finally { client.release(); } } ``` ### 플로우 복사 알고리즘 ```typescript async function copyFlows( flowIds: Set, targetCompanyCode: string, userId: string, client: PoolClient ): Promise> { const flowIdMap = new Map(); // 원본 ID → 새 ID for (const originalFlowId of flowIds) { // 1) flow_definition 복사 const flowDef = await getFlowDefinition(originalFlowId, client); const newFlowId = await insertFlowDefinition({ ...flowDef, company_code: targetCompanyCode, created_by: userId, }, client); flowIdMap.set(originalFlowId, newFlowId); // 2) flow_step 복사 const steps = await getFlowSteps(originalFlowId, client); const stepIdMap = new Map(); // 스텝 ID 매핑 for (const step of steps) { const newStepId = await insertFlowStep({ ...step, flow_definition_id: newFlowId, // 새 플로우 ID }, client); stepIdMap.set(step.id, newStepId); } // 3) flow_step_connection 복사 (스텝 ID 재매핑) const connections = await getFlowStepConnections(originalFlowId, client); for (const conn of connections) { await insertFlowStepConnection({ flow_definition_id: newFlowId, from_step_id: stepIdMap.get(conn.from_step_id)!, // 재매핑 to_step_id: stepIdMap.get(conn.to_step_id)!, // 재매핑 label: conn.label, }, client); } } return flowIdMap; } ``` ### 화면 복사 알고리즘 ```typescript async function copyScreens( screenIds: Set, targetCompanyCode: string, flowIdMap: Map, // 플로우 ID 재매핑 userId: string, client: PoolClient ): Promise> { const screenIdMap = new Map(); // 원본 ID → 새 ID for (const originalScreenId of screenIds) { // 1) screen_definitions 복사 const screenDef = await getScreenDefinition(originalScreenId, client); // 새 screen_code 생성 (중복 방지) const newScreenCode = await generateUniqueScreenCode(targetCompanyCode, client); const newScreenId = await insertScreenDefinition({ ...screenDef, screen_code: newScreenCode, company_code: targetCompanyCode, created_by: userId, }, client); screenIdMap.set(originalScreenId, newScreenId); // 2) screen_layouts 복사 const layouts = await getScreenLayouts(originalScreenId, client); for (const layout of layouts) { // properties 내부 참조 업데이트 const updatedProperties = updateReferencesInProperties( layout.properties, screenIdMap, // 화면 ID 재매핑 flowIdMap // 플로우 ID 재매핑 ); await insertScreenLayout({ screen_id: newScreenId, // 새 화면 ID component_type: layout.component_type, component_id: layout.component_id, parent_id: layout.parent_id, position_x: layout.position_x, position_y: layout.position_y, width: layout.width, height: layout.height, properties: updatedProperties, // 업데이트된 속성 display_order: layout.display_order, layout_type: layout.layout_type, layout_config: layout.layout_config, zones_config: layout.zones_config, zone_id: layout.zone_id, }, client); } } return screenIdMap; } // properties 내부 참조 업데이트 function updateReferencesInProperties( properties: any, screenIdMap: Map, flowIdMap: Map ): any { if (!properties) return properties; const updated = JSON.parse(JSON.stringify(properties)); // 깊은 복사 // 1) 모달 버튼의 targetScreenId if (updated?.componentConfig?.action?.targetScreenId) { const oldId = updated.componentConfig.action.targetScreenId; const newId = screenIdMap.get(oldId); if (newId) { updated.componentConfig.action.targetScreenId = newId; } } // 2) 조건부 컨테이너의 sections[].screenId if (updated?.sections) { for (const section of updated.sections) { if (section.screenId) { const oldId = section.screenId; const newId = screenIdMap.get(oldId); if (newId) { section.screenId = newId; } } } } // 3) 플로우 제어의 flowId if (updated?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId) { const oldId = updated.webTypeConfig.dataflowConfig.flowConfig.flowId; const newId = flowIdMap.get(oldId); if (newId) { updated.webTypeConfig.dataflowConfig.flowConfig.flowId = newId; } } return updated; } ``` ### 메뉴 복사 알고리즘 ```typescript async function copyMenus( menus: Menu[], targetCompanyCode: string, screenIdMap: Map, userId: string, client: PoolClient ): Promise> { const menuIdMap = new Map(); // 원본 objid → 새 objid // 1) 메뉴를 깊이 순으로 정렬 (부모 먼저 삽입) const sortedMenus = topologicalSortMenus(menus); for (const menu of sortedMenus) { // screen_code 업데이트 (화면 ID 재매핑) const newScreenCode = menu.screen_code ? getNewScreenCode(screenIdMap, menu.screen_code) : null; // parent_obj_id 업데이트 (메뉴 ID 재매핑) const newParentObjId = menu.parent_obj_id ? menuIdMap.get(menu.parent_obj_id) || null : null; // 새 objid 생성 const newObjId = await getNextMenuObjid(client); await insertMenu({ objid: newObjId, menu_type: menu.menu_type, parent_obj_id: newParentObjId, // 재매핑 menu_name_kor: menu.menu_name_kor, menu_name_eng: menu.menu_name_eng, seq: menu.seq, menu_url: menu.menu_url, menu_desc: menu.menu_desc, writer: userId, status: menu.status, system_name: menu.system_name, company_code: targetCompanyCode, // 새 회사 코드 lang_key: menu.lang_key, lang_key_desc: menu.lang_key_desc, screen_code: newScreenCode, // 재매핑 menu_code: menu.menu_code, }, client); menuIdMap.set(menu.objid, newObjId); } return menuIdMap; } // 위상 정렬 (부모 먼저) function topologicalSortMenus(menus: Menu[]): Menu[] { const result: Menu[] = []; const visited = new Set(); function visit(menu: Menu) { if (visited.has(menu.objid)) return; // 부모 먼저 방문 if (menu.parent_obj_id) { const parent = menus.find(m => m.objid === menu.parent_obj_id); if (parent) visit(parent); } visited.add(menu.objid); result.push(menu); } for (const menu of menus) { visit(menu); } return result; } ``` ### 코드 복사 알고리즘 ```typescript async function copyCodes( codes: { categories: CodeCategory[]; codes: CodeInfo[] }, menuIdMap: Map, targetCompanyCode: string, userId: string, client: PoolClient ): Promise { // 1) 코드 카테고리 복사 (중복 체크) for (const category of codes.categories) { const newMenuObjid = menuIdMap.get(category.menu_objid); if (!newMenuObjid) continue; // 중복 체크: 같은 category_code + company_code + menu_objid const exists = await checkCodeCategoryExists( category.category_code, targetCompanyCode, newMenuObjid, client ); if (!exists) { await insertCodeCategory({ category_code: category.category_code, category_name: category.category_name, category_name_eng: category.category_name_eng, description: category.description, sort_order: category.sort_order, is_active: category.is_active, company_code: targetCompanyCode, // 새 회사 코드 menu_objid: newMenuObjid, // 재매핑 created_by: userId, }, client); } } // 2) 코드 정보 복사 (중복 체크) for (const code of codes.codes) { const newMenuObjid = menuIdMap.get(code.menu_objid); if (!newMenuObjid) continue; // 중복 체크: 같은 code_category + code_value + company_code + menu_objid const exists = await checkCodeInfoExists( code.code_category, code.code_value, targetCompanyCode, newMenuObjid, client ); if (!exists) { await insertCodeInfo({ code_category: code.code_category, code_value: code.code_value, code_name: code.code_name, code_name_eng: code.code_name_eng, description: code.description, sort_order: code.sort_order, is_active: code.is_active, company_code: targetCompanyCode, // 새 회사 코드 menu_objid: newMenuObjid, // 재매핑 created_by: userId, }, client); } } } ``` --- ## 구현 단계 ### Phase 1: 백엔드 서비스 구현 **파일**: `backend-node/src/services/menuCopyService.ts` #### 1.1 데이터 수집 함수 - `collectMenuTree()` - 메뉴 트리 수집 - `collectScreens()` - 화면 수집 (중복 제거) - `collectFlows()` - 플로우 수집 - `collectCodes()` - 코드 수집 - `extractReferencedScreens()` - 화면 참조 추출 #### 1.2 복사 함수 - `copyFlows()` - 플로우 복사 - `copyScreens()` - 화면 복사 - `copyMenus()` - 메뉴 복사 - `copyCodes()` - 코드 복사 - `createScreenMenuAssignments()` - 화면-메뉴 할당 #### 1.3 유틸리티 함수 - `updateReferencesInProperties()` - JSONB 내부 참조 업데이트 - `topologicalSortMenus()` - 메뉴 위상 정렬 - `generateUniqueScreenCode()` - 고유 화면 코드 생성 - `getNextMenuObjid()` - 다음 메뉴 objid ### Phase 2: 백엔드 컨트롤러 구현 **파일**: `backend-node/src/controllers/menuController.ts` ```typescript // POST /api/admin/menus/:menuObjid/copy export async function copyMenu( req: AuthenticatedRequest, res: Response ): Promise { try { const { menuObjid } = req.params; const { targetCompanyCode } = req.body; const userId = req.user!.userId; // 권한 체크 if (req.user!.companyCode !== "*") { // 최고 관리자만 가능 res.status(403).json({ success: false, message: "메뉴 복사는 최고 관리자만 가능합니다", }); return; } // 복사 실행 const menuCopyService = new MenuCopyService(); const result = await menuCopyService.copyMenu( parseInt(menuObjid), targetCompanyCode, userId ); res.json({ success: true, message: "메뉴 복사 완료", data: result, }); } catch (error: any) { logger.error("메뉴 복사 실패:", error); res.status(500).json({ success: false, message: "메뉴 복사 중 오류가 발생했습니다", error: error.message, }); } } ``` ### Phase 3: 백엔드 라우터 등록 **파일**: `backend-node/src/routes/admin.ts` ```typescript // 메뉴 복사 API router.post( "/menus/:menuObjid/copy", authenticate, copyMenu ); ``` ### Phase 4: 프론트엔드 API 클라이언트 **파일**: `frontend/lib/api/menu.ts` ```typescript /** * 메뉴 복사 */ export async function copyMenu( menuObjid: number, targetCompanyCode: string ): Promise> { try { const response = await apiClient.post( `/admin/menus/${menuObjid}/copy`, { targetCompanyCode } ); return response.data; } catch (error: any) { return { success: false, error: error.message, }; } } export interface MenuCopyResult { copiedMenus: number; copiedScreens: number; copiedFlows: number; copiedCategories: number; copiedCodes: number; warnings?: string[]; } ``` ### Phase 5: 프론트엔드 UI 구현 **파일**: `frontend/components/admin/MenuCopyDialog.tsx` ```typescript export function MenuCopyDialog({ menuObjid, menuName, open, onOpenChange, }: MenuCopyDialogProps) { const [targetCompanyCode, setTargetCompanyCode] = useState(""); const [companies, setCompanies] = useState([]); const [copying, setCopying] = useState(false); const [result, setResult] = useState(null); // 회사 목록 로드 useEffect(() => { if (open) { loadCompanies(); } }, [open]); const handleCopy = async () => { if (!targetCompanyCode) { toast.error("대상 회사를 선택해주세요"); return; } setCopying(true); setResult(null); const response = await copyMenu(menuObjid, targetCompanyCode); if (response.success && response.data) { setResult(response.data); toast.success("메뉴 복사 완료!"); } else { toast.error(response.error || "메뉴 복사 실패"); } setCopying(false); }; return ( 메뉴 복사 "{menuName}" 메뉴와 관련된 모든 리소스를 다른 회사로 복사합니다.
{/* 회사 선택 */}
{/* 복사 항목 안내 */}

복사되는 항목:

  • 메뉴 구조 (하위 메뉴 포함)
  • 화면 + 레이아웃 (모달, 조건부 컨테이너)
  • 플로우 제어 (스텝, 연결)
  • 코드 카테고리 + 코드
{/* 복사 결과 */} {result && (

복사 완료!

  • 메뉴: {result.copiedMenus}개
  • 화면: {result.copiedScreens}개
  • 플로우: {result.copiedFlows}개
  • 코드 카테고리: {result.copiedCategories}개
  • 코드: {result.copiedCodes}개
)}
{!result && ( )}
); } ``` ### Phase 6: 메뉴 관리 화면 통합 **파일**: `frontend/components/admin/MenuManagement.tsx` ```typescript // 복사 버튼 추가 // 다이얼로그 ``` --- ## API 명세 ### POST /api/admin/menus/:menuObjid/copy **설명**: 메뉴와 관련된 모든 리소스를 다른 회사로 복사 **권한**: 최고 관리자 전용 (company_code = "*") **요청**: ```typescript POST /api/admin/menus/100/copy Content-Type: application/json { "targetCompanyCode": "COMPANY_B" } ``` **응답 (성공)**: ```typescript { "success": true, "message": "메뉴 복사 완료", "data": { "copiedMenus": 5, "copiedScreens": 12, "copiedFlows": 3, "copiedCategories": 8, "copiedCodes": 45, "menuIdMap": { "100": 200, "101": 201, "102": 202 }, "screenIdMap": { "10": 30, "11": 31, "12": 32 }, "flowIdMap": { "5": 10, "6": 11 }, "warnings": [ "item_info 테이블에 데이터를 추가해야 합니다", "메뉴 권한 설정이 필요합니다" ] } } ``` **응답 (실패)**: ```typescript { "success": false, "message": "메뉴 복사 중 오류가 발생했습니다", "error": "대상 회사가 존재하지 않습니다" } ``` **에러 코드**: - `403`: 권한 없음 (최고 관리자 아님) - `404`: 메뉴를 찾을 수 없음 - `400`: 잘못된 요청 (대상 회사 코드 누락) - `500`: 서버 내부 오류 --- ## UI/UX 설계 ### 1. 메뉴 관리 화면 ``` ┌─────────────────────────────────────────────┐ │ 메뉴 관리 │ ├─────────────────────────────────────────────┤ │ ┌─ 영업관리 (objid: 100) │ │ │ ├─ [편집] [삭제] [복사] ← 복사 버튼 │ │ │ ├─ 수주관리 (objid: 101) │ │ │ │ └─ [편집] [삭제] [복사] │ │ │ └─ 견적관리 (objid: 102) │ │ │ └─ [편집] [삭제] [복사] │ │ └─ ... │ └─────────────────────────────────────────────┘ ``` ### 2. 복사 다이얼로그 #### 초기 상태 ``` ┌─────────────────────────────────────────┐ │ 메뉴 복사 [X] │ ├─────────────────────────────────────────┤ │ "영업관리" 메뉴와 관련된 모든 리소스를 │ │ 다른 회사로 복사합니다. │ │ │ │ 대상 회사 * │ │ [회사 선택 ▼] │ │ │ │ ┌──────────────────────────────────┐ │ │ │ 복사되는 항목: │ │ │ │ • 메뉴 구조 (하위 메뉴 포함) │ │ │ │ • 화면 + 레이아웃 │ │ │ │ • 플로우 제어 │ │ │ │ • 코드 카테고리 + 코드 │ │ │ └──────────────────────────────────┘ │ │ │ │ [취소] [복사 시작] │ └─────────────────────────────────────────┘ ``` #### 복사 중 ``` ┌─────────────────────────────────────────┐ │ 메뉴 복사 [X] │ ├─────────────────────────────────────────┤ │ "영업관리" 메뉴와 관련된 모든 리소스를 │ │ 다른 회사로 복사합니다. │ │ │ │ 대상 회사: 회사B (COMPANY_B) │ │ │ │ ┌──────────────────────────────────┐ │ │ │ ⚙️ 복사 진행 중... │ │ │ │ │ │ │ │ ✅ 메뉴 수집 완료 │ │ │ │ ✅ 화면 수집 완료 │ │ │ │ ⏳ 플로우 복사 중... │ │ │ │ ⬜ 화면 복사 대기 │ │ │ │ ⬜ 메뉴 복사 대기 │ │ │ │ ⬜ 코드 복사 대기 │ │ │ └──────────────────────────────────┘ │ │ │ │ [취소 불가] │ └─────────────────────────────────────────┘ ``` #### 복사 완료 ``` ┌─────────────────────────────────────────┐ │ 메뉴 복사 [X] │ ├─────────────────────────────────────────┤ │ "영업관리" 메뉴와 관련된 모든 리소스를 │ │ 다른 회사로 복사합니다. │ │ │ │ 대상 회사: 회사B (COMPANY_B) │ │ │ │ ┌──────────────────────────────────┐ │ │ │ ✅ 복사 완료! │ │ │ │ │ │ │ │ • 메뉴: 5개 │ │ │ │ • 화면: 12개 │ │ │ │ • 플로우: 3개 │ │ │ │ • 코드 카테고리: 8개 │ │ │ │ • 코드: 45개 │ │ │ │ │ │ │ │ ⚠️ 주의사항: │ │ │ │ - 실제 데이터는 복사되지 않음 │ │ │ │ - 메뉴 권한 설정 필요 │ │ │ └──────────────────────────────────┘ │ │ │ │ [닫기] │ └─────────────────────────────────────────┘ ``` ### 3. 사용자 흐름 ``` 1. 메뉴 관리 화면 접속 ↓ 2. 복사할 메뉴 옆 [복사] 버튼 클릭 ↓ 3. 복사 다이얼로그 열림 ↓ 4. 대상 회사 선택 ↓ 5. [복사 시작] 버튼 클릭 ↓ 6. 진행 상황 표시 (30초 ~ 2분) ↓ 7. 완료 메시지 확인 ↓ 8. [닫기] 버튼으로 다이얼로그 닫기 ``` --- ## 예외 처리 ### 1. 권한 검증 ```typescript if (req.user!.companyCode !== "*") { throw new Error("메뉴 복사는 최고 관리자만 가능합니다"); } ``` ### 2. 메뉴 존재 여부 ```typescript const menu = await getMenuByObjid(menuObjid, client); if (!menu) { throw new Error("메뉴를 찾을 수 없습니다"); } ``` ### 3. 대상 회사 존재 여부 ```typescript const company = await getCompanyByCode(targetCompanyCode, client); if (!company) { throw new Error("대상 회사가 존재하지 않습니다"); } ``` ### 4. 중복 메뉴 체크 ```typescript // 같은 이름의 메뉴가 이미 있는지 확인 const existingMenu = await getMenuByNameAndCompany( menu.menu_name_kor, targetCompanyCode, client ); if (existingMenu) { // 경고만 표시하고 진행 (사용자가 이름 변경 가능) warnings.push(`같은 이름의 메뉴가 이미 존재합니다: ${menu.menu_name_kor}`); } ``` ### 5. 트랜잭션 롤백 ```typescript try { await client.query('BEGIN'); // ... 복사 작업 await client.query('COMMIT'); } catch (error) { await client.query('ROLLBACK'); logger.error("메뉴 복사 실패, 롤백됨:", error); throw error; } ``` ### 6. 무한 루프 방지 ```typescript // 화면 참조 추적 시 const visited = new Set(); function collectScreensRecursive(screenId: number) { if (visited.has(screenId)) return; // 이미 방문함 visited.add(screenId); // ... 참조 화면 수집 } ``` ### 7. JSONB 파싱 오류 ```typescript try { const properties = JSON.parse(layout.properties); // ... properties 처리 } catch (error) { logger.warn(`JSONB 파싱 실패: layout_id=${layout.layout_id}`, error); // 원본 그대로 사용 } ``` ### 8. 부분 실패 처리 ```typescript // 플로우 복사 실패 시 경고만 표시하고 계속 진행 try { await copyFlows(flowIds, targetCompanyCode, userId, client); } catch (error) { logger.error("플로우 복사 실패:", error); warnings.push("일부 플로우가 복사되지 않았습니다"); // 계속 진행 (메뉴와 화면은 복사) } ``` --- ## 테스트 계획 ### 단위 테스트 (Unit Tests) #### 1. 수집 함수 테스트 ```typescript describe("MenuCopyService - Collection", () => { test("collectMenuTree: 하위 메뉴 재귀 수집", async () => { const menus = await collectMenuTree(100); expect(menus.length).toBeGreaterThan(1); expect(menus[0].objid).toBe(100); }); test("collectScreens: 중복 제거", async () => { const screenIds = await collectScreens([100, 101]); const uniqueIds = new Set(screenIds); expect(screenIds.length).toBe(uniqueIds.size); }); test("extractReferencedScreens: 모달 참조 추출", async () => { const referenced = extractReferencedScreens(10); expect(referenced).toContain(26); // 모달 화면 ID }); }); ``` #### 2. 복사 함수 테스트 ```typescript describe("MenuCopyService - Copy", () => { test("copyFlows: 플로우 + 스텝 + 연결 복사", async () => { const flowIdMap = await copyFlows( new Set([5]), "TEST_COMPANY", "test_user", client ); expect(flowIdMap.size).toBe(1); const newFlowId = flowIdMap.get(5); expect(newFlowId).toBeDefined(); const steps = await getFlowSteps(newFlowId!, client); expect(steps.length).toBeGreaterThan(0); }); test("copyScreens: properties 내부 참조 업데이트", async () => { const screenIdMap = await copyScreens( new Set([10]), "TEST_COMPANY", new Map(), // flowIdMap "test_user", client ); const newScreenId = screenIdMap.get(10); const layouts = await getScreenLayouts(newScreenId!, client); // targetScreenId가 재매핑되었는지 확인 const modalLayout = layouts.find( l => l.properties?.componentConfig?.action?.type === "modal" ); expect(modalLayout?.properties.componentConfig.action.targetScreenId).not.toBe(26); }); }); ``` #### 3. ID 재매핑 테스트 ```typescript describe("MenuCopyService - Remapping", () => { test("updateReferencesInProperties: 모달 참조 업데이트", () => { const properties = { componentConfig: { action: { type: "modal", targetScreenId: 26 } } }; const screenIdMap = new Map([[26, 50]]); const updated = updateReferencesInProperties(properties, screenIdMap, new Map()); expect(updated.componentConfig.action.targetScreenId).toBe(50); }); test("updateReferencesInProperties: 조건부 컨테이너 참조 업데이트", () => { const properties = { sections: [ { id: "1", condition: "A", screenId: 10 }, { id: "2", condition: "B", screenId: 11 } ] }; const screenIdMap = new Map([[10, 30], [11, 31]]); const updated = updateReferencesInProperties(properties, screenIdMap, new Map()); expect(updated.sections[0].screenId).toBe(30); expect(updated.sections[1].screenId).toBe(31); }); }); ``` ### 통합 테스트 (Integration Tests) #### 1. 전체 복사 플로우 ```typescript describe("Menu Copy - Full Flow", () => { let testMenuObjid: number; let targetCompanyCode: string; beforeAll(async () => { // 테스트 데이터 준비 testMenuObjid = await createTestMenu(); targetCompanyCode = "TEST_COMPANY_" + Date.now(); await createTestCompany(targetCompanyCode); }); afterAll(async () => { // 테스트 데이터 정리 await deleteTestData(targetCompanyCode); }); test("메뉴 복사: 성공", async () => { const menuCopyService = new MenuCopyService(); const result = await menuCopyService.copyMenu( testMenuObjid, targetCompanyCode, "test_user" ); expect(result.success).toBe(true); expect(result.copiedMenus).toBeGreaterThan(0); expect(result.copiedScreens).toBeGreaterThan(0); // 복사된 메뉴 검증 const copiedMenus = await getMenusByCompany(targetCompanyCode); expect(copiedMenus.length).toBe(result.copiedMenus); // 복사된 화면 검증 const copiedScreens = await getScreensByCompany(targetCompanyCode); expect(copiedScreens.length).toBe(result.copiedScreens); }); test("복사된 화면이 정상 작동", async () => { // 복사된 화면에서 데이터 조회 가능한지 확인 const screens = await getScreensByCompany(targetCompanyCode); const firstScreen = screens[0]; const layouts = await getScreenLayouts(firstScreen.screen_id); expect(layouts.length).toBeGreaterThan(0); }); }); ``` #### 2. 트랜잭션 롤백 테스트 ```typescript describe("Menu Copy - Rollback", () => { test("실패 시 롤백", async () => { const invalidCompanyCode = "INVALID_COMPANY"; const menuCopyService = new MenuCopyService(); await expect( menuCopyService.copyMenu(100, invalidCompanyCode, "test_user") ).rejects.toThrow(); // 롤백 확인: 데이터가 생성되지 않았는지 const menus = await getMenusByCompany(invalidCompanyCode); expect(menus.length).toBe(0); }); }); ``` ### E2E 테스트 (End-to-End Tests) #### 1. UI 테스트 ```typescript describe("Menu Copy - E2E", () => { test("메뉴 관리 화면에서 복사 버튼 클릭", async () => { // 1. 로그인 await page.goto("http://localhost:9771/login"); await page.fill('input[name="userId"]', "wace"); await page.fill('input[name="password"]', "qlalfqjsgh11"); await page.click('button[type="submit"]'); // 2. 메뉴 관리 화면 이동 await page.goto("http://localhost:9771/admin/menus"); await page.waitForSelector(".menu-list"); // 3. 복사 버튼 클릭 await page.click('button[aria-label="메뉴 복사"]'); // 4. 대상 회사 선택 await page.selectOption('select[name="targetCompany"]', "COMPANY_B"); // 5. 복사 시작 await page.click('button:has-text("복사 시작")'); // 6. 완료 메시지 확인 await page.waitForSelector('text=복사 완료', { timeout: 120000 }); // 7. 복사된 메뉴 확인 await page.selectOption('select[name="company"]', "COMPANY_B"); await page.waitForSelector('.menu-list'); const menuCount = await page.locator('.menu-item').count(); expect(menuCount).toBeGreaterThan(0); }); }); ``` ### 성능 테스트 #### 1. 대량 메뉴 복사 ```typescript test("100개 메뉴 복사 성능", async () => { const startTime = Date.now(); const result = await menuCopyService.copyMenu( largeMenuObjid, // 하위 메뉴 100개 "TEST_COMPANY", "test_user" ); const endTime = Date.now(); const duration = endTime - startTime; expect(duration).toBeLessThan(120000); // 2분 이내 expect(result.copiedMenus).toBe(100); }); ``` #### 2. 동시 복사 요청 ```typescript test("동시 복사 요청 처리", async () => { const promises = Array.from({ length: 5 }, (_, i) => menuCopyService.copyMenu( testMenuObjid, `TEST_COMPANY_${i}`, "test_user" ) ); const results = await Promise.all(promises); expect(results.every(r => r.success)).toBe(true); }); ``` --- ## 구현 체크리스트 ### 백엔드 - [ ] `menuCopyService.ts` 생성 - [ ] `collectMenuTree()` - [ ] `collectScreens()` - [ ] `collectFlows()` - [ ] `collectCodes()` - [ ] `extractReferencedScreens()` - [ ] `copyFlows()` - [ ] `copyScreens()` - [ ] `copyMenus()` - [ ] `copyCodes()` - [ ] `createScreenMenuAssignments()` - [ ] `updateReferencesInProperties()` - [ ] `topologicalSortMenus()` - [ ] `generateUniqueScreenCode()` - [ ] `menuController.ts` 업데이트 - [ ] `copyMenu()` 컨트롤러 추가 - [ ] `admin.ts` 라우터 업데이트 - [ ] `/menus/:menuObjid/copy` 라우트 추가 - [ ] 단위 테스트 작성 - [ ] 통합 테스트 작성 ### 프론트엔드 - [ ] `menu.ts` API 클라이언트 업데이트 - [ ] `copyMenu()` 함수 추가 - [ ] `MenuCopyResult` 인터페이스 추가 - [ ] `MenuCopyDialog.tsx` 생성 - [ ] 회사 선택 드롭다운 - [ ] 복사 진행 상태 표시 - [ ] 복사 결과 표시 - [ ] `MenuManagement.tsx` 업데이트 - [ ] 복사 버튼 추가 - [ ] 다이얼로그 통합 - [ ] E2E 테스트 작성 ### 문서 - [ ] API 문서 업데이트 - [ ] 사용자 매뉴얼 작성 - [ ] 개발자 가이드 작성 --- ## 예상 소요 시간 | 단계 | 작업 | 예상 시간 | |------|------|-----------| | 1 | 백엔드 서비스 구현 | 6시간 | | 2 | 백엔드 컨트롤러/라우터 | 1시간 | | 3 | 백엔드 테스트 | 3시간 | | 4 | 프론트엔드 API 클라이언트 | 0.5시간 | | 5 | 프론트엔드 UI 구현 | 3시간 | | 6 | 프론트엔드 통합 | 1시간 | | 7 | E2E 테스트 | 2시간 | | 8 | 문서 작성 | 1.5시간 | | 9 | 버그 수정 및 최적화 | 2시간 | | **총계** | | **20시간** | --- ## 참고 사항 ### 멀티테넌시 주의사항 - 모든 쿼리에 `company_code` 필터링 적용 - 최고 관리자(company_code = "*")만 메뉴 복사 가능 - 복사 시 `company_code`를 대상 회사 코드로 변경 ### 데이터 무결성 - 외래키 제약조건 순서 준수 - 트랜잭션으로 원자성 보장 - 중복 데이터 체크 및 병합 ### 성능 최적화 - 배치 삽입 사용 (bulk insert) - 불필요한 쿼리 최소화 - ID 매핑 테이블로 참조 업데이트 ### 보안 - 권한 검증 (최고 관리자만) - SQL 인젝션 방지 - 입력값 검증 --- ## 변경 이력 | 날짜 | 버전 | 작성자 | 변경 내용 | |------|------|--------|----------| | 2025-01-24 | 1.0 | AI | 초안 작성 |