/** * 노드 에디터 상태 관리 스토어 */ import { create } from "zustand"; import { Connection, Edge, EdgeChange, Node, NodeChange, addEdge, applyNodeChanges, applyEdgeChanges } from "reactflow"; import type { FlowNode, FlowEdge, NodeType, ValidationResult } from "@/types/node-editor"; import { createNodeFlow, updateNodeFlow } from "../api/nodeFlows"; // 🔥 외부 커넥션 캐시 타입 interface ExternalConnectionCache { data: any[]; timestamp: number; } interface FlowEditorState { // 노드 및 엣지 nodes: FlowNode[]; edges: FlowEdge[]; // 선택 상태 selectedNodes: string[]; selectedEdges: string[]; // 플로우 메타데이터 flowId: number | null; flowName: string; flowDescription: string; // UI 상태 isExecuting: boolean; isSaving: boolean; showValidationPanel: boolean; showPropertiesPanel: boolean; // 검증 결과 validationResult: ValidationResult | null; // 🔥 외부 커넥션 캐시 (전역 캐싱) externalConnectionsCache: ExternalConnectionCache | null; // ======================================================================== // 노드 관리 // ======================================================================== setNodes: (nodes: FlowNode[]) => void; onNodesChange: (changes: NodeChange[]) => void; addNode: (node: FlowNode) => void; updateNode: (id: string, data: Partial) => void; removeNode: (id: string) => void; removeNodes: (ids: string[]) => void; // ======================================================================== // 🔥 외부 커넥션 캐시 관리 // ======================================================================== setExternalConnectionsCache: (data: any[]) => void; clearExternalConnectionsCache: () => void; getExternalConnectionsCache: () => any[] | null; // ======================================================================== // 엣지 관리 // ======================================================================== setEdges: (edges: FlowEdge[]) => void; onEdgesChange: (changes: EdgeChange[]) => void; onConnect: (connection: Connection) => void; removeEdge: (id: string) => void; removeEdges: (ids: string[]) => void; // ======================================================================== // 선택 관리 // ======================================================================== selectNode: (id: string, multi?: boolean) => void; selectNodes: (ids: string[]) => void; selectEdge: (id: string, multi?: boolean) => void; clearSelection: () => void; // ======================================================================== // 플로우 관리 // ======================================================================== loadFlow: (id: number, name: string, description: string, nodes: FlowNode[], edges: FlowEdge[]) => void; clearFlow: () => void; setFlowName: (name: string) => void; setFlowDescription: (description: string) => void; saveFlow: () => Promise<{ success: boolean; flowId?: number; message?: string }>; exportFlow: () => string; // ======================================================================== // 검증 // ======================================================================== validateFlow: () => ValidationResult; setValidationResult: (result: ValidationResult | null) => void; // ======================================================================== // UI 상태 // ======================================================================== setIsExecuting: (value: boolean) => void; setIsSaving: (value: boolean) => void; setShowValidationPanel: (value: boolean) => void; setShowPropertiesPanel: (value: boolean) => void; // ======================================================================== // 유틸리티 // ======================================================================== getNodeById: (id: string) => FlowNode | undefined; getEdgeById: (id: string) => FlowEdge | undefined; getConnectedNodes: (nodeId: string) => { incoming: FlowNode[]; outgoing: FlowNode[] }; } export const useFlowEditorStore = create((set, get) => ({ // 초기 상태 nodes: [], edges: [], selectedNodes: [], selectedEdges: [], flowId: null, flowName: "새 제어 플로우", flowDescription: "", isExecuting: false, isSaving: false, showValidationPanel: false, showPropertiesPanel: true, validationResult: null, externalConnectionsCache: null, // 🔥 캐시 초기화 // ======================================================================== // 노드 관리 // ======================================================================== setNodes: (nodes) => set({ nodes }), onNodesChange: (changes) => { set({ nodes: applyNodeChanges(changes, get().nodes) as FlowNode[], }); }, addNode: (node) => { set((state) => ({ nodes: [...state.nodes, node], })); }, updateNode: (id, data) => { set((state) => ({ nodes: state.nodes.map((node) => node.id === id ? { ...node, data: { ...node.data, ...data }, } : node, ), })); }, removeNode: (id) => { set((state) => ({ nodes: state.nodes.filter((node) => node.id !== id), edges: state.edges.filter((edge) => edge.source !== id && edge.target !== id), })); }, removeNodes: (ids) => { set((state) => ({ nodes: state.nodes.filter((node) => !ids.includes(node.id)), edges: state.edges.filter((edge) => !ids.includes(edge.source) && !ids.includes(edge.target)), })); }, // ======================================================================== // 엣지 관리 // ======================================================================== setEdges: (edges) => set({ edges }), onEdgesChange: (changes) => { set({ edges: applyEdgeChanges(changes, get().edges) as FlowEdge[], }); }, onConnect: (connection) => { // 연결 검증 const validation = validateConnection(connection, get().nodes); if (!validation.valid) { console.warn("연결 검증 실패:", validation.error); return; } set((state) => ({ edges: addEdge( { ...connection, type: "smoothstep", animated: false, data: { validation: { valid: true }, }, }, state.edges, ) as FlowEdge[], })); }, removeEdge: (id) => { set((state) => ({ edges: state.edges.filter((edge) => edge.id !== id), })); }, removeEdges: (ids) => { set((state) => ({ edges: state.edges.filter((edge) => !ids.includes(edge.id)), })); }, // ======================================================================== // 선택 관리 // ======================================================================== selectNode: (id, multi = false) => { set((state) => ({ selectedNodes: multi ? [...state.selectedNodes, id] : [id], })); }, selectNodes: (ids) => { set({ selectedNodes: ids, showPropertiesPanel: ids.length > 0, // 노드가 선택되면 속성창 자동으로 열기 }); }, selectEdge: (id, multi = false) => { set((state) => ({ selectedEdges: multi ? [...state.selectedEdges, id] : [id], })); }, clearSelection: () => { set({ selectedNodes: [], selectedEdges: [] }); }, // ======================================================================== // 플로우 관리 // ======================================================================== loadFlow: (id, name, description, nodes, edges) => { set({ flowId: id, flowName: name, flowDescription: description, nodes, edges, selectedNodes: [], selectedEdges: [], }); }, clearFlow: () => { set({ flowId: null, flowName: "새 제어 플로우", flowDescription: "", nodes: [], edges: [], selectedNodes: [], selectedEdges: [], validationResult: null, }); }, setFlowName: (name) => set({ flowName: name }), setFlowDescription: (description) => set({ flowDescription: description }), saveFlow: async () => { const { flowId, flowName, flowDescription, nodes, edges } = get(); if (!flowName || flowName.trim() === "") { return { success: false, message: "플로우 이름을 입력해주세요." }; } // 검증 const validation = get().validateFlow(); if (!validation.valid) { return { success: false, message: `검증 실패: ${validation.errors[0]?.message || "오류가 있습니다."}` }; } set({ isSaving: true }); try { // 플로우 데이터 직렬화 const flowData = { nodes: nodes.map((node) => ({ id: node.id, type: node.type, position: node.position, data: node.data, })), edges: edges.map((edge) => ({ id: edge.id, source: edge.source, target: edge.target, sourceHandle: edge.sourceHandle, targetHandle: edge.targetHandle, })), }; const result = flowId ? await updateNodeFlow({ flowId, flowName, flowDescription, flowData: JSON.stringify(flowData), }) : await createNodeFlow({ flowName, flowDescription, flowData: JSON.stringify(flowData), }); set({ flowId: result.flowId }); return { success: true, flowId: result.flowId, message: "저장 완료!" }; } catch (error) { console.error("플로우 저장 오류:", error); return { success: false, message: error instanceof Error ? error.message : "저장 중 오류 발생" }; } finally { set({ isSaving: false }); } }, exportFlow: () => { const { flowName, flowDescription, nodes, edges } = get(); const flowData = { flowName, flowDescription, nodes, edges, version: "1.0", exportedAt: new Date().toISOString(), }; return JSON.stringify(flowData, null, 2); }, // ======================================================================== // 검증 // ======================================================================== validateFlow: () => { const { nodes, edges } = get(); const result = performFlowValidation(nodes, edges); set({ validationResult: result }); return result; }, setValidationResult: (result) => set({ validationResult: result }), // ======================================================================== // UI 상태 // ======================================================================== setIsExecuting: (value) => set({ isExecuting: value }), setIsSaving: (value) => set({ isSaving: value }), setShowValidationPanel: (value) => set({ showValidationPanel: value }), setShowPropertiesPanel: (value) => set({ showPropertiesPanel: value }), // ======================================================================== // 유틸리티 // ======================================================================== getNodeById: (id) => { return get().nodes.find((node) => node.id === id); }, getEdgeById: (id) => { return get().edges.find((edge) => edge.id === id); }, getConnectedNodes: (nodeId) => { const { nodes, edges } = get(); const incoming = edges .filter((edge) => edge.target === nodeId) .map((edge) => nodes.find((node) => node.id === edge.source)) .filter((node): node is FlowNode => node !== undefined); const outgoing = edges .filter((edge) => edge.source === nodeId) .map((edge) => nodes.find((node) => node.id === edge.target)) .filter((node): node is FlowNode => node !== undefined); return { incoming, outgoing }; }, // ======================================================================== // 🔥 외부 커넥션 캐시 관리 // ======================================================================== setExternalConnectionsCache: (data) => { set({ externalConnectionsCache: { data, timestamp: Date.now(), }, }); }, clearExternalConnectionsCache: () => { set({ externalConnectionsCache: null }); }, getExternalConnectionsCache: () => { const cache = get().externalConnectionsCache; if (!cache) return null; // 🔥 5분 후 캐시 만료 const CACHE_DURATION = 5 * 60 * 1000; // 5분 const isExpired = Date.now() - cache.timestamp > CACHE_DURATION; if (isExpired) { set({ externalConnectionsCache: null }); return null; } return cache.data; }, })); // ============================================================================ // 헬퍼 함수들 // ============================================================================ /** * 연결 검증 */ function validateConnection(connection: Connection, nodes: FlowNode[]): { valid: boolean; error?: string } { const sourceNode = nodes.find((n) => n.id === connection.source); const targetNode = nodes.find((n) => n.id === connection.target); if (!sourceNode || !targetNode) { return { valid: false, error: "노드를 찾을 수 없습니다" }; } // 소스 노드가 출력을 가져야 함 if (isSourceOnlyNode(sourceNode.type)) { // OK } else if (isActionNode(targetNode.type)) { // 액션 노드는 입력만 받을 수 있음 } else { // 기타 경우는 허용 } // 자기 자신에게 연결 방지 if (connection.source === connection.target) { return { valid: false, error: "자기 자신에게 연결할 수 없습니다" }; } return { valid: true }; } /** * 플로우 검증 */ function performFlowValidation(nodes: FlowNode[], edges: FlowEdge[]): ValidationResult { const errors: ValidationResult["errors"] = []; // 1. 노드가 하나도 없으면 경고 if (nodes.length === 0) { errors.push({ message: "노드가 없습니다. 최소 하나의 노드를 추가하세요.", severity: "warning", }); } // 2. 소스 노드 확인 const sourceNodes = nodes.filter((n) => isSourceNode(n.type)); if (sourceNodes.length === 0 && nodes.length > 0) { errors.push({ message: "데이터 소스 노드가 필요합니다.", severity: "error", }); } // 3. 액션 노드 확인 const actionNodes = nodes.filter((n) => isActionNode(n.type)); if (actionNodes.length === 0 && nodes.length > 0) { errors.push({ message: "최소 하나의 액션 노드가 필요합니다.", severity: "error", }); } // 4. 고아 노드 확인 (연결되지 않은 노드) - Comment와 Log는 제외 nodes.forEach((node) => { // Comment와 Log는 독립적으로 존재 가능 if (node.type === "comment" || node.type === "log") { return; } const hasIncoming = edges.some((e) => e.target === node.id); const hasOutgoing = edges.some((e) => e.source === node.id); if (!hasIncoming && !hasOutgoing && !isSourceNode(node.type)) { errors.push({ nodeId: node.id, message: `노드 "${node.data.displayName || node.id}"가 연결되어 있지 않습니다.`, severity: "warning", }); } }); // 5. 액션 노드가 입력을 받는지 확인 actionNodes.forEach((node) => { const hasInput = edges.some((e) => e.target === node.id); if (!hasInput) { errors.push({ nodeId: node.id, message: `액션 노드 "${node.data.displayName || node.id}"에 입력 데이터가 없습니다.`, severity: "error", }); } }); // 6. 순환 참조 검증 const cycles = detectCycles(nodes, edges); cycles.forEach((cycle) => { errors.push({ message: `순환 참조가 감지되었습니다: ${cycle.join(" → ")}`, severity: "error", }); }); // 7. 노드별 필수 속성 검증 nodes.forEach((node) => { const nodeErrors = validateNodeProperties(node); errors.push(...nodeErrors); }); return { valid: errors.filter((e) => e.severity === "error").length === 0, errors, }; } /** * 소스 노드 여부 확인 */ function isSourceNode(type: NodeType): boolean { return type === "tableSource" || type === "externalDBSource" || type === "restAPISource"; } /** * 소스 전용 노드 여부 확인 */ function isSourceOnlyNode(type: NodeType): boolean { return isSourceNode(type); } /** * 액션 노드 여부 확인 */ function isActionNode(type: NodeType): boolean { return type === "insertAction" || type === "updateAction" || type === "deleteAction" || type === "upsertAction"; } /** * 순환 참조 검증 (DFS 사용) */ function detectCycles(nodes: FlowNode[], edges: FlowEdge[]): string[][] { const cycles: string[][] = []; const visited = new Set(); const recursionStack = new Set(); // 인접 리스트 생성 const adjacencyList = new Map(); nodes.forEach((node) => adjacencyList.set(node.id, [])); edges.forEach((edge) => { const targets = adjacencyList.get(edge.source) || []; targets.push(edge.target); adjacencyList.set(edge.source, targets); }); function dfs(nodeId: string, path: string[]): boolean { visited.add(nodeId); recursionStack.add(nodeId); path.push(nodeId); const neighbors = adjacencyList.get(nodeId) || []; for (const neighbor of neighbors) { if (!visited.has(neighbor)) { if (dfs(neighbor, [...path])) { return true; } } else if (recursionStack.has(neighbor)) { // 순환 발견 const cycleStart = path.indexOf(neighbor); const cycle = path.slice(cycleStart).concat(neighbor); const nodeNames = cycle.map((id) => { const node = nodes.find((n) => n.id === id); return node?.data.displayName || id; }); cycles.push(nodeNames); return true; } } recursionStack.delete(nodeId); return false; } // 모든 노드에서 DFS 시작 nodes.forEach((node) => { if (!visited.has(node.id)) { dfs(node.id, []); } }); return cycles; } /** * 노드별 필수 속성 검증 */ function validateNodeProperties(node: FlowNode): ValidationResult["errors"] { const errors: ValidationResult["errors"] = []; switch (node.type) { case "tableSource": if (!node.data.tableName || node.data.tableName.trim() === "") { errors.push({ nodeId: node.id, message: `테이블 소스 노드 "${node.data.displayName || node.id}": 테이블명이 필요합니다.`, severity: "error", }); } break; case "externalDBSource": if (!node.data.connectionName || node.data.connectionName.trim() === "") { errors.push({ nodeId: node.id, message: `외부 DB 소스 노드 "${node.data.displayName || node.id}": 연결 이름이 필요합니다.`, severity: "error", }); } if (!node.data.tableName || node.data.tableName.trim() === "") { errors.push({ nodeId: node.id, message: `외부 DB 소스 노드 "${node.data.displayName || node.id}": 테이블명이 필요합니다.`, severity: "error", }); } break; case "restAPISource": if (!node.data.url || node.data.url.trim() === "") { errors.push({ nodeId: node.id, message: `REST API 소스 노드 "${node.data.displayName || node.id}": URL이 필요합니다.`, severity: "error", }); } if (!node.data.method) { errors.push({ nodeId: node.id, message: `REST API 소스 노드 "${node.data.displayName || node.id}": HTTP 메서드가 필요합니다.`, severity: "error", }); } break; case "insertAction": case "updateAction": case "deleteAction": if (!node.data.targetTable || node.data.targetTable.trim() === "") { errors.push({ nodeId: node.id, message: `${getActionTypeName(node.type)} 노드 "${node.data.displayName || node.id}": 타겟 테이블이 필요합니다.`, severity: "error", }); } if (node.type === "insertAction" || node.type === "updateAction") { if (!node.data.fieldMappings || node.data.fieldMappings.length === 0) { errors.push({ nodeId: node.id, message: `${getActionTypeName(node.type)} 노드 "${node.data.displayName || node.id}": 최소 하나의 필드 매핑이 필요합니다.`, severity: "error", }); } } if (node.type === "updateAction" || node.type === "deleteAction") { if (!node.data.whereConditions || node.data.whereConditions.length === 0) { errors.push({ nodeId: node.id, message: `${getActionTypeName(node.type)} 노드 "${node.data.displayName || node.id}": WHERE 조건이 필요합니다.`, severity: "error", }); } } break; case "upsertAction": if (!node.data.targetTable || node.data.targetTable.trim() === "") { errors.push({ nodeId: node.id, message: `UPSERT 액션 노드 "${node.data.displayName || node.id}": 타겟 테이블이 필요합니다.`, severity: "error", }); } if (!node.data.conflictKeys || node.data.conflictKeys.length === 0) { errors.push({ nodeId: node.id, message: `UPSERT 액션 노드 "${node.data.displayName || node.id}": 충돌 키(ON CONFLICT)가 필요합니다.`, severity: "error", }); } if (!node.data.fieldMappings || node.data.fieldMappings.length === 0) { errors.push({ nodeId: node.id, message: `UPSERT 액션 노드 "${node.data.displayName || node.id}": 최소 하나의 필드 매핑이 필요합니다.`, severity: "error", }); } break; case "condition": if (!node.data.conditions || node.data.conditions.length === 0) { errors.push({ nodeId: node.id, message: `조건 노드 "${node.data.displayName || node.id}": 최소 하나의 조건이 필요합니다.`, severity: "error", }); } break; case "fieldMapping": if (!node.data.mappings || node.data.mappings.length === 0) { errors.push({ nodeId: node.id, message: `필드 매핑 노드 "${node.data.displayName || node.id}": 최소 하나의 매핑이 필요합니다.`, severity: "warning", }); } break; case "dataTransform": if (!node.data.transformations || node.data.transformations.length === 0) { errors.push({ nodeId: node.id, message: `데이터 변환 노드 "${node.data.displayName || node.id}": 최소 하나의 변환 규칙이 필요합니다.`, severity: "warning", }); } break; case "log": if (!node.data.message || node.data.message.trim() === "") { errors.push({ nodeId: node.id, message: `로그 노드 "${node.data.displayName || node.id}": 로그 메시지가 필요합니다.`, severity: "warning", }); } break; case "comment": // Comment 노드는 내용이 없어도 괜찮음 break; } return errors; } /** * 액션 타입 이름 반환 */ function getActionTypeName(type: string): string { const names: Record = { insertAction: "INSERT", updateAction: "UPDATE", deleteAction: "DELETE", upsertAction: "UPSERT", }; return names[type] || type; }