/** * 노드 에디터 상태 관리 스토어 */ 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"; // 🔥 Debounce 유틸리티 function debounce any>(func: T, wait: number): (...args: Parameters) => void { let timeout: NodeJS.Timeout | null = null; return function (...args: Parameters) { if (timeout) clearTimeout(timeout); timeout = setTimeout(() => func(...args), wait); }; } // 🔥 외부 커넥션 캐시 타입 interface ExternalConnectionCache { data: any[]; timestamp: number; } // 🔥 히스토리 스냅샷 타입 interface HistorySnapshot { nodes: FlowNode[]; edges: FlowEdge[]; } interface FlowEditorState { // 노드 및 엣지 nodes: FlowNode[]; edges: FlowEdge[]; // 🔥 히스토리 관리 history: HistorySnapshot[]; historyIndex: number; maxHistorySize: number; isRestoringHistory: boolean; // 🔥 히스토리 복원 중 플래그 // 선택 상태 selectedNodes: string[]; selectedEdges: string[]; // 플로우 메타데이터 flowId: number | null; flowName: string; flowDescription: string; // UI 상태 isSaving: boolean; showValidationPanel: boolean; showPropertiesPanel: boolean; // 검증 결과 validationResult: ValidationResult | null; // 🔥 외부 커넥션 캐시 (전역 캐싱) externalConnectionsCache: ExternalConnectionCache | null; // ======================================================================== // 🔥 히스토리 관리 (Undo/Redo) // ======================================================================== saveToHistory: () => void; undo: () => void; redo: () => void; canUndo: () => boolean; canRedo: () => boolean; // ======================================================================== // 노드 관리 // ======================================================================== setNodes: (nodes: FlowNode[]) => void; onNodesChange: (changes: NodeChange[]) => void; onNodeDragStart: () => 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 상태 // ======================================================================== 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[] }; } // 🔥 Debounced 히스토리 저장 함수 (스토어 외부에 생성) let debouncedSaveToHistory: (() => void) | null = null; export const useFlowEditorStore = create((set, get) => { // 🔥 Debounced 히스토리 저장 함수 초기화 if (!debouncedSaveToHistory) { debouncedSaveToHistory = debounce(() => { get().saveToHistory(); }, 500); // 500ms 지연 } return { // 초기 상태 nodes: [], edges: [], history: [{ nodes: [], edges: [] }], // 초기 빈 상태를 히스토리에 저장 historyIndex: 0, maxHistorySize: 50, isRestoringHistory: false, // 🔥 히스토리 복원 중 플래그 초기화 selectedNodes: [], selectedEdges: [], flowId: null, flowName: "새 제어 플로우", flowDescription: "", isSaving: false, showValidationPanel: false, showPropertiesPanel: true, validationResult: null, externalConnectionsCache: null, // 🔥 캐시 초기화 // ======================================================================== // 🔥 히스토리 관리 (Undo/Redo) // ======================================================================== saveToHistory: () => { const { nodes, edges, history, historyIndex, maxHistorySize } = get(); // 현재 상태를 스냅샷으로 저장 const snapshot: HistorySnapshot = { nodes: JSON.parse(JSON.stringify(nodes)), edges: JSON.parse(JSON.stringify(edges)), }; // historyIndex 이후의 히스토리 제거 (새로운 변경이 발생했으므로) const newHistory = history.slice(0, historyIndex + 1); newHistory.push(snapshot); // 최대 크기 제한 if (newHistory.length > maxHistorySize) { newHistory.shift(); } console.log("📸 히스토리 저장:", { 노드수: nodes.length, 엣지수: edges.length, 히스토리크기: newHistory.length, 현재인덱스: newHistory.length - 1, }); set({ history: newHistory, historyIndex: newHistory.length - 1, }); }, undo: () => { const { history, historyIndex } = get(); console.log("⏪ Undo 시도:", { historyIndex, historyLength: history.length }); if (historyIndex > 0) { const newIndex = historyIndex - 1; const snapshot = history[newIndex]; console.log("✅ Undo 실행:", { 이전인덱스: historyIndex, 새인덱스: newIndex, 노드수: snapshot.nodes.length, 엣지수: snapshot.edges.length, }); // 🔥 히스토리 복원 중 플래그 설정 set({ isRestoringHistory: true }); // 노드와 엣지 복원 set({ nodes: JSON.parse(JSON.stringify(snapshot.nodes)), edges: JSON.parse(JSON.stringify(snapshot.edges)), historyIndex: newIndex, }); // 🔥 다음 틱에서 플래그 해제 (ReactFlow 이벤트가 모두 처리된 후) setTimeout(() => { set({ isRestoringHistory: false }); }, 0); } else { console.log("❌ Undo 불가: 히스토리가 없음"); } }, redo: () => { const { history, historyIndex } = get(); console.log("⏩ Redo 시도:", { historyIndex, historyLength: history.length }); if (historyIndex < history.length - 1) { const newIndex = historyIndex + 1; const snapshot = history[newIndex]; console.log("✅ Redo 실행:", { 이전인덱스: historyIndex, 새인덱스: newIndex, 노드수: snapshot.nodes.length, 엣지수: snapshot.edges.length, }); // 🔥 히스토리 복원 중 플래그 설정 set({ isRestoringHistory: true }); // 노드와 엣지 복원 set({ nodes: JSON.parse(JSON.stringify(snapshot.nodes)), edges: JSON.parse(JSON.stringify(snapshot.edges)), historyIndex: newIndex, }); // 🔥 다음 틱에서 플래그 해제 (ReactFlow 이벤트가 모두 처리된 후) setTimeout(() => { set({ isRestoringHistory: false }); }, 0); } else { console.log("❌ Redo 불가: 되돌릴 히스토리가 없음"); } }, canUndo: () => { const { historyIndex } = get(); return historyIndex > 0; }, canRedo: () => { const { history, historyIndex } = get(); return historyIndex < history.length - 1; }, // ======================================================================== // 노드 관리 // ======================================================================== setNodes: (nodes) => set({ nodes }), onNodesChange: (changes) => { set({ nodes: applyNodeChanges(changes, get().nodes) as FlowNode[], }); }, onNodeDragStart: () => { // 🔥 히스토리 복원 중이면 저장하지 않음 if (get().isRestoringHistory) { console.log("⏭️ 히스토리 복원 중, 저장 스킵"); return; } // 노드 드래그 시작 시 히스토리 저장 (변경 전 상태) get().saveToHistory(); console.log("🎯 노드 이동 시작, 변경 전 상태 히스토리 저장"); }, addNode: (node) => { // 🔥 히스토리 복원 중이 아닐 때만 저장 if (!get().isRestoringHistory) { get().saveToHistory(); } set((state) => ({ nodes: [...state.nodes, node], })); }, updateNode: (id, data) => { // 🔥 Debounced 히스토리 저장 (500ms 지연 - 타이핑 중에는 저장 안됨) // 🔥 히스토리 복원 중이 아닐 때만 저장 if (!get().isRestoringHistory && debouncedSaveToHistory) { debouncedSaveToHistory(); } set((state) => ({ nodes: state.nodes.map((node) => node.id === id ? { ...node, data: { ...node.data, ...data }, } : node, ), })); }, removeNode: (id) => { // 🔥 히스토리 복원 중이 아닐 때만 저장 if (!get().isRestoringHistory) { get().saveToHistory(); } set((state) => ({ nodes: state.nodes.filter((node) => node.id !== id), edges: state.edges.filter((edge) => edge.source !== id && edge.target !== id), })); }, removeNodes: (ids) => { // 🔥 히스토리 복원 중이 아닐 때만 저장 if (!get().isRestoringHistory) { get().saveToHistory(); } 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) => { // 엣지 삭제(remove) 타입이 있으면 히스토리 저장 // 🔥 히스토리 복원 중이 아닐 때만 저장 const hasRemove = changes.some((change) => change.type === "remove"); if (hasRemove && !get().isRestoringHistory) { get().saveToHistory(); console.log("🔗 엣지 삭제, 변경 전 상태 히스토리 저장"); } set({ edges: applyEdgeChanges(changes, get().edges) as FlowEdge[], }); }, onConnect: (connection) => { // 🔥 히스토리 복원 중이 아닐 때만 저장 if (!get().isRestoringHistory) { get().saveToHistory(); } // 연결 검증 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) => { // 🔥 히스토리 복원 중이 아닐 때만 저장 if (!get().isRestoringHistory) { get().saveToHistory(); } set((state) => ({ edges: state.edges.filter((edge) => edge.id !== id), })); }, removeEdges: (ids) => { // 🔥 히스토리 복원 중이 아닐 때만 저장 if (!get().isRestoringHistory) { get().saveToHistory(); } 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) => { console.log("📂 플로우 로드:", { id, name, 노드수: nodes.length, 엣지수: edges.length }); set({ flowId: id, flowName: name, flowDescription: description, nodes, edges, selectedNodes: [], selectedEdges: [], // 로드된 상태를 히스토리의 첫 번째 스냅샷으로 저장 history: [{ nodes: JSON.parse(JSON.stringify(nodes)), edges: JSON.parse(JSON.stringify(edges)) }], historyIndex: 0, }); }, clearFlow: () => { console.log("🔄 플로우 초기화"); set({ flowId: null, flowName: "새 제어 플로우", flowDescription: "", nodes: [], edges: [], selectedNodes: [], selectedEdges: [], validationResult: null, history: [{ nodes: [], edges: [] }], // 초기 빈 상태를 히스토리에 저장 historyIndex: 0, }); }, 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) { const errorMessages = validation.errors .filter((err) => err.severity === "error") .map((err) => err.message) .join(", "); // 🔥 검증 패널 표시하여 사용자가 오류를 확인할 수 있도록 set({ validationResult: validation, showValidationPanel: true }); return { success: false, message: `플로우를 저장할 수 없습니다: ${errorMessages}`, }; } 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 상태 // ======================================================================== 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; }, }; // 🔥 return 블록 종료 }); // 🔥 create 함수 종료 // ============================================================================ // 헬퍼 함수들 // ============================================================================ /** * 연결 검증 */ 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; }