diff --git a/backend-node/src/routes/dataflow/node-flows.ts b/backend-node/src/routes/dataflow/node-flows.ts new file mode 100644 index 00000000..0e9a2d3e --- /dev/null +++ b/backend-node/src/routes/dataflow/node-flows.ts @@ -0,0 +1,237 @@ +/** + * 노드 기반 데이터 플로우 API + */ + +import { Router, Request, Response } from "express"; +import { query, queryOne } from "../../database/db"; +import { logger } from "../../utils/logger"; +import { NodeFlowExecutionService } from "../../services/nodeFlowExecutionService"; + +const router = Router(); + +/** + * 플로우 목록 조회 + */ +router.get("/", async (req: Request, res: Response) => { + try { + const flows = await query( + ` + SELECT + flow_id as "flowId", + flow_name as "flowName", + flow_description as "flowDescription", + created_at as "createdAt", + updated_at as "updatedAt" + FROM node_flows + ORDER BY updated_at DESC + `, + [] + ); + + return res.json({ + success: true, + data: flows, + }); + } catch (error) { + logger.error("플로우 목록 조회 실패:", error); + return res.status(500).json({ + success: false, + message: "플로우 목록을 조회하지 못했습니다.", + }); + } +}); + +/** + * 플로우 상세 조회 + */ +router.get("/:flowId", async (req: Request, res: Response) => { + try { + const { flowId } = req.params; + + const flow = await queryOne( + ` + SELECT + flow_id as "flowId", + flow_name as "flowName", + flow_description as "flowDescription", + flow_data as "flowData", + created_at as "createdAt", + updated_at as "updatedAt" + FROM node_flows + WHERE flow_id = $1 + `, + [flowId] + ); + + if (!flow) { + return res.status(404).json({ + success: false, + message: "플로우를 찾을 수 없습니다.", + }); + } + + return res.json({ + success: true, + data: flow, + }); + } catch (error) { + logger.error("플로우 조회 실패:", error); + return res.status(500).json({ + success: false, + message: "플로우를 조회하지 못했습니다.", + }); + } +}); + +/** + * 플로우 저장 (신규) + */ +router.post("/", async (req: Request, res: Response) => { + try { + const { flowName, flowDescription, flowData } = req.body; + + if (!flowName || !flowData) { + return res.status(400).json({ + success: false, + message: "플로우 이름과 데이터는 필수입니다.", + }); + } + + const result = await queryOne( + ` + INSERT INTO node_flows (flow_name, flow_description, flow_data) + VALUES ($1, $2, $3) + RETURNING flow_id as "flowId" + `, + [flowName, flowDescription || "", flowData] + ); + + logger.info(`플로우 저장 성공: ${result.flowId}`); + + return res.json({ + success: true, + message: "플로우가 저장되었습니다.", + data: { + flowId: result.flowId, + }, + }); + } catch (error) { + logger.error("플로우 저장 실패:", error); + return res.status(500).json({ + success: false, + message: "플로우를 저장하지 못했습니다.", + }); + } +}); + +/** + * 플로우 수정 + */ +router.put("/", async (req: Request, res: Response) => { + try { + const { flowId, flowName, flowDescription, flowData } = req.body; + + if (!flowId || !flowName || !flowData) { + return res.status(400).json({ + success: false, + message: "플로우 ID, 이름, 데이터는 필수입니다.", + }); + } + + await query( + ` + UPDATE node_flows + SET flow_name = $1, + flow_description = $2, + flow_data = $3, + updated_at = NOW() + WHERE flow_id = $4 + `, + [flowName, flowDescription || "", flowData, flowId] + ); + + logger.info(`플로우 수정 성공: ${flowId}`); + + return res.json({ + success: true, + message: "플로우가 수정되었습니다.", + data: { + flowId, + }, + }); + } catch (error) { + logger.error("플로우 수정 실패:", error); + return res.status(500).json({ + success: false, + message: "플로우를 수정하지 못했습니다.", + }); + } +}); + +/** + * 플로우 삭제 + */ +router.delete("/:flowId", async (req: Request, res: Response) => { + try { + const { flowId } = req.params; + + await query( + ` + DELETE FROM node_flows + WHERE flow_id = $1 + `, + [flowId] + ); + + logger.info(`플로우 삭제 성공: ${flowId}`); + + return res.json({ + success: true, + message: "플로우가 삭제되었습니다.", + }); + } catch (error) { + logger.error("플로우 삭제 실패:", error); + return res.status(500).json({ + success: false, + message: "플로우를 삭제하지 못했습니다.", + }); + } +}); + +/** + * 플로우 실행 + * POST /api/dataflow/node-flows/:flowId/execute + */ +router.post("/:flowId/execute", async (req: Request, res: Response) => { + try { + const { flowId } = req.params; + const contextData = req.body; + + logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, { + contextDataKeys: Object.keys(contextData), + }); + + // 플로우 실행 + const result = await NodeFlowExecutionService.executeFlow( + parseInt(flowId, 10), + contextData + ); + + return res.json({ + success: result.success, + message: result.message, + data: result, + }); + } catch (error) { + logger.error("플로우 실행 실패:", error); + return res.status(500).json({ + success: false, + message: + error instanceof Error + ? error.message + : "플로우 실행 중 오류가 발생했습니다.", + }); + } +}); + +export default router; diff --git a/backend-node/src/routes/dataflowRoutes.ts b/backend-node/src/routes/dataflowRoutes.ts index fc6c235d..90e07075 100644 --- a/backend-node/src/routes/dataflowRoutes.ts +++ b/backend-node/src/routes/dataflowRoutes.ts @@ -21,6 +21,7 @@ import { testConditionalConnection, executeConditionalActions, } from "../controllers/conditionalConnectionController"; +import nodeFlowsRouter from "./dataflow/node-flows"; const router = express.Router(); @@ -146,4 +147,10 @@ router.post("/diagrams/:diagramId/test-conditions", testConditionalConnection); */ router.post("/diagrams/:diagramId/execute-actions", executeConditionalActions); +/** + * 노드 기반 플로우 관리 + * /api/dataflow/node-flows/* + */ +router.use("/node-flows", nodeFlowsRouter); + export default router; diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts new file mode 100644 index 00000000..f0510623 --- /dev/null +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -0,0 +1,1073 @@ +/** + * 노드 플로우 실행 엔진 + * + * 기능: + * - 위상 정렬 (Topological Sort) + * - 레벨별 병렬 실행 (Promise.allSettled) + * - 독립 트랜잭션 처리 + * - 연쇄 중단 (부모 실패 시 자식 스킵) + */ + +import { query, queryOne, transaction } from "../database/db"; +import { logger } from "../utils/logger"; + +// ===== 타입 정의 ===== + +export interface FlowNode { + id: string; + type: NodeType; + position?: { x: number; y: number }; + data: NodeData; +} + +export type NodeType = + | "tableSource" + | "externalDBSource" + | "restAPISource" + | "condition" + | "fieldMapping" + | "dataTransform" + | "insertAction" + | "updateAction" + | "deleteAction" + | "upsertAction" + | "comment" + | "log"; + +export interface NodeData { + displayName?: string; + [key: string]: any; +} + +export interface FlowEdge { + id: string; + source: string; + target: string; + sourceHandle?: string; + targetHandle?: string; +} + +export interface ExecutionContext { + sourceData?: any[]; // 외부에서 주입된 데이터 (선택된 행 또는 폼 데이터) + dataSourceType?: string; // "table-selection" | "form" | "none" + nodeResults: Map; + executionOrder: string[]; + buttonContext?: ButtonContext; +} + +export interface ButtonContext { + buttonId: string; + screenId?: number; + companyCode?: string; + userId?: string; + formData?: Record; + selectedRowsData?: Record[]; +} + +export interface NodeResult { + nodeId: string; + status: "pending" | "success" | "failed" | "skipped"; + data?: any; + error?: Error; + startTime: number; + endTime?: number; +} + +export interface ExecutionResult { + success: boolean; + message: string; + executionTime: number; + nodes: NodeExecutionSummary[]; + summary: { + total: number; + success: number; + failed: number; + skipped: number; + }; +} + +export interface NodeExecutionSummary { + nodeId: string; + nodeName: string; + nodeType: NodeType; + status: "success" | "failed" | "skipped" | "pending"; + duration?: number; + error?: string; +} + +// ===== 메인 실행 서비스 ===== + +export class NodeFlowExecutionService { + /** + * 플로우 실행 메인 함수 + */ + static async executeFlow( + flowId: number, + contextData: Record + ): Promise { + const startTime = Date.now(); + + try { + logger.info(`🚀 플로우 실행 시작: flowId=${flowId}`); + + // 1. 플로우 데이터 조회 + const flow = await queryOne<{ + flow_id: number; + flow_name: string; + flow_data: any; + }>( + `SELECT flow_id, flow_name, flow_data FROM node_flows WHERE flow_id = $1`, + [flowId] + ); + + if (!flow) { + throw new Error(`플로우를 찾을 수 없습니다: flowId=${flowId}`); + } + + const flowData = + typeof flow.flow_data === "string" + ? JSON.parse(flow.flow_data) + : flow.flow_data; + + const { nodes, edges } = flowData; + + logger.info(`📊 플로우 정보:`, { + flowName: flow.flow_name, + nodeCount: nodes.length, + edgeCount: edges.length, + }); + + // 2. 실행 컨텍스트 준비 + const context: ExecutionContext = { + sourceData: contextData.sourceData || [], + dataSourceType: contextData.dataSourceType || "none", + nodeResults: new Map(), + executionOrder: [], + buttonContext: { + buttonId: + contextData.buttonId || contextData.context?.buttonId || "unknown", + screenId: contextData.screenId || contextData.context?.screenId, + companyCode: + contextData.companyCode || contextData.context?.companyCode, + userId: contextData.userId || contextData.context?.userId, + formData: contextData.formData || contextData.context?.formData, + selectedRowsData: + contextData.selectedRowsData || + contextData.context?.selectedRowsData, + }, + }; + + logger.info(`📦 실행 컨텍스트:`, { + dataSourceType: context.dataSourceType, + sourceDataCount: context.sourceData?.length || 0, + buttonContext: context.buttonContext, + }); + + // 3. 위상 정렬 + const levels = this.topologicalSort(nodes, edges); + logger.info(`📋 실행 순서 (레벨별):`, levels); + + // 4. 레벨별 실행 + for (const level of levels) { + await this.executeLevel(level, nodes, edges, context); + } + + // 5. 결과 생성 + const executionTime = Date.now() - startTime; + const result = this.generateExecutionResult( + nodes, + context, + executionTime + ); + + logger.info(`✅ 플로우 실행 완료:`, result.summary); + + return result; + } catch (error) { + logger.error(`❌ 플로우 실행 실패:`, error); + throw error; + } + } + + /** + * 소스 데이터 준비 + */ + private static prepareSourceData(contextData: Record): any[] { + const { controlDataSource, formData, selectedRowsData } = contextData; + + switch (controlDataSource) { + case "form": + return formData ? [formData] : []; + + case "table-selection": + return selectedRowsData || []; + + case "both": + return [ + { source: "form", data: formData }, + { source: "table", data: selectedRowsData }, + ]; + + default: + return formData ? [formData] : []; + } + } + + /** + * 위상 정렬 (Topological Sort) + * DAG(Directed Acyclic Graph)를 레벨별로 그룹화 + */ + private static topologicalSort( + nodes: FlowNode[], + edges: FlowEdge[] + ): string[][] { + const levels: string[][] = []; + const inDegree = new Map(); + const adjacency = new Map(); + + // 초기화 + nodes.forEach((node) => { + inDegree.set(node.id, 0); + adjacency.set(node.id, []); + }); + + // 진입 차수 계산 + edges.forEach((edge) => { + inDegree.set(edge.target, (inDegree.get(edge.target) || 0) + 1); + adjacency.get(edge.source)?.push(edge.target); + }); + + // 레벨별 분류 + let currentLevel = nodes + .filter((node) => inDegree.get(node.id) === 0) + .map((node) => node.id); + + while (currentLevel.length > 0) { + levels.push([...currentLevel]); + + const nextLevel: string[] = []; + currentLevel.forEach((nodeId) => { + const neighbors = adjacency.get(nodeId) || []; + neighbors.forEach((neighbor) => { + const newDegree = (inDegree.get(neighbor) || 1) - 1; + inDegree.set(neighbor, newDegree); + if (newDegree === 0) { + nextLevel.push(neighbor); + } + }); + }); + + currentLevel = nextLevel; + } + + return levels; + } + + /** + * 레벨 내 노드 병렬 실행 + */ + private static async executeLevel( + nodeIds: string[], + nodes: FlowNode[], + edges: FlowEdge[], + context: ExecutionContext + ): Promise { + logger.info(`⏳ 레벨 실행 시작: ${nodeIds.length}개 노드`); + + // Promise.allSettled로 병렬 실행 + const results = await Promise.allSettled( + nodeIds.map((nodeId) => this.executeNode(nodeId, nodes, edges, context)) + ); + + // 결과 저장 + results.forEach((result, index) => { + const nodeId = nodeIds[index]; + if (result.status === "fulfilled") { + context.nodeResults.set(nodeId, result.value); + context.executionOrder.push(nodeId); + } else { + context.nodeResults.set(nodeId, { + nodeId, + status: "failed", + error: result.reason, + startTime: Date.now(), + endTime: Date.now(), + }); + } + }); + + logger.info(`✅ 레벨 실행 완료`); + } + + /** + * 개별 노드 실행 + */ + private static async executeNode( + nodeId: string, + nodes: FlowNode[], + edges: FlowEdge[], + context: ExecutionContext + ): Promise { + const startTime = Date.now(); + const node = nodes.find((n) => n.id === nodeId); + + if (!node) { + throw new Error(`노드를 찾을 수 없습니다: ${nodeId}`); + } + + logger.info(`🔄 노드 실행 시작: ${nodeId} (${node.type})`); + + // 1. 부모 노드 상태 확인 (연쇄 중단) + const parents = this.getParentNodes(nodeId, edges); + const parentFailed = parents.some((parentId) => { + const parentResult = context.nodeResults.get(parentId); + return parentResult?.status === "failed"; + }); + + if (parentFailed) { + logger.warn(`⏭️ 노드 스킵 (부모 실패): ${nodeId}`); + return { + nodeId, + status: "skipped", + error: new Error("Parent node failed"), + startTime, + endTime: Date.now(), + }; + } + + // 2. 입력 데이터 준비 + const inputData = this.prepareInputData(nodeId, parents, edges, context); + + // 3. 노드 타입별 실행 + try { + const result = await this.executeNodeByType(node, inputData, context); + + logger.info(`✅ 노드 실행 성공: ${nodeId}`); + + return { + nodeId, + status: "success", + data: result, + startTime, + endTime: Date.now(), + }; + } catch (error) { + logger.error(`❌ 노드 실행 실패: ${nodeId}`, error); + + return { + nodeId, + status: "failed", + error: error as Error, + startTime, + endTime: Date.now(), + }; + } + } + + /** + * 부모 노드 목록 조회 + */ + private static getParentNodes(nodeId: string, edges: FlowEdge[]): string[] { + return edges + .filter((edge) => edge.target === nodeId) + .map((edge) => edge.source); + } + + /** + * 입력 데이터 준비 + */ + private static prepareInputData( + nodeId: string, + parents: string[], + edges: FlowEdge[], + context: ExecutionContext + ): any { + if (parents.length === 0) { + // 소스 노드: 원본 데이터 사용 + return context.sourceData; + } else if (parents.length === 1) { + // 단일 부모: 부모의 결과 데이터 전달 + const parentResult = context.nodeResults.get(parents[0]); + return parentResult?.data || context.sourceData; + } else { + // 다중 부모: 모든 부모의 데이터 병합 + return parents.map((parentId) => { + const result = context.nodeResults.get(parentId); + return result?.data || context.sourceData; + }); + } + } + + /** + * 노드 타입별 실행 로직 + */ + private static async executeNodeByType( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + switch (node.type) { + case "tableSource": + return this.executeTableSource(node, context); + + case "dataTransform": + return this.executeDataTransform(node, inputData, context); + + case "insertAction": + return this.executeInsertAction(node, inputData, context); + + case "updateAction": + return this.executeUpdateAction(node, inputData, context); + + case "deleteAction": + return this.executeDeleteAction(node, inputData, context); + + case "upsertAction": + return this.executeUpsertAction(node, inputData, context); + + case "condition": + return this.executeCondition(node, inputData, context); + + case "comment": + case "log": + // 로그/코멘트는 실행 없이 통과 + logger.info(`📝 ${node.type}: ${node.data.displayName || node.id}`); + return { message: "Logged" }; + + default: + logger.warn(`⚠️ 지원하지 않는 노드 타입: ${node.type}`); + return { message: "Unsupported node type" }; + } + } + + /** + * 테이블 소스 노드 실행 + */ + private static async executeTableSource( + node: FlowNode, + context: ExecutionContext + ): Promise { + // 🔥 외부에서 주입된 데이터가 있으면 우선 사용 + if ( + context.sourceData && + Array.isArray(context.sourceData) && + context.sourceData.length > 0 + ) { + logger.info( + `📊 외부 주입 데이터 사용: ${context.dataSourceType}, ${context.sourceData.length}건` + ); + return context.sourceData; + } + + // 외부 데이터가 없으면 DB 쿼리 실행 + const { tableName, schema, whereConditions } = node.data; + + if (!tableName) { + logger.warn( + "⚠️ 테이블 소스 노드에 테이블명이 없고, 외부 데이터도 없습니다." + ); + return []; + } + + const schemaPrefix = schema ? `${schema}.` : ""; + const whereClause = whereConditions + ? `WHERE ${this.buildWhereClause(whereConditions)}` + : ""; + + const sql = `SELECT * FROM ${schemaPrefix}${tableName} ${whereClause}`; + + const result = await query(sql, []); + + logger.info(`📊 테이블 소스 조회: ${tableName}, ${result.length}건`); + + return result; + } + + /** + * INSERT 액션 노드 실행 + */ + private static async executeInsertAction( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { targetTable, fieldMappings } = node.data; + + return transaction(async (client) => { + const dataArray = Array.isArray(inputData) ? inputData : [inputData]; + let insertedCount = 0; + + for (const data of dataArray) { + const fields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + fieldMappings.forEach((mapping: any) => { + fields.push(mapping.targetField); + const value = + mapping.staticValue !== undefined + ? mapping.staticValue + : data[mapping.sourceField]; + values.push(value); + }); + + const sql = ` + INSERT INTO ${targetTable} (${fields.join(", ")}) + VALUES (${fields.map((_, i) => `$${i + 1}`).join(", ")}) + `; + + await client.query(sql, values); + insertedCount++; + } + + logger.info(`✅ INSERT 완료: ${targetTable}, ${insertedCount}건`); + + return { insertedCount }; + }); + } + + /** + * UPDATE 액션 노드 실행 + */ + private static async executeUpdateAction( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { targetTable, fieldMappings, whereConditions } = node.data; + + return transaction(async (client) => { + const dataArray = Array.isArray(inputData) ? inputData : [inputData]; + let updatedCount = 0; + + for (const data of dataArray) { + const setClauses: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + fieldMappings.forEach((mapping: any) => { + const value = + mapping.staticValue !== undefined + ? mapping.staticValue + : data[mapping.sourceField]; + setClauses.push(`${mapping.targetField} = $${paramIndex}`); + values.push(value); + paramIndex++; + }); + + const whereClause = this.buildWhereClause( + whereConditions, + data, + paramIndex + ); + + const sql = ` + UPDATE ${targetTable} + SET ${setClauses.join(", ")} + ${whereClause} + `; + + const result = await client.query(sql, values); + updatedCount += result.rowCount || 0; + } + + logger.info(`✅ UPDATE 완료: ${targetTable}, ${updatedCount}건`); + + return { updatedCount }; + }); + } + + /** + * DELETE 액션 노드 실행 + */ + private static async executeDeleteAction( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { targetTable, whereConditions } = node.data; + + return transaction(async (client) => { + const dataArray = Array.isArray(inputData) ? inputData : [inputData]; + let deletedCount = 0; + + for (const data of dataArray) { + const whereClause = this.buildWhereClause(whereConditions, data, 1); + + const sql = `DELETE FROM ${targetTable} ${whereClause}`; + + const result = await client.query(sql, []); + deletedCount += result.rowCount || 0; + } + + logger.info(`✅ DELETE 완료: ${targetTable}, ${deletedCount}건`); + + return { deletedCount }; + }); + } + + /** + * UPSERT 액션 노드 실행 (로직 기반) + * DB 제약 조건 없이 SELECT → UPDATE or INSERT 방식으로 구현 + */ + private static async executeUpsertAction( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { targetTable, fieldMappings, conflictKeys } = node.data; + + if (!targetTable || !fieldMappings || fieldMappings.length === 0) { + throw new Error("UPSERT 액션에 필수 설정이 누락되었습니다."); + } + + if (!conflictKeys || conflictKeys.length === 0) { + throw new Error("UPSERT 액션에 충돌 키(Conflict Keys)가 필요합니다."); + } + + return transaction(async (client) => { + const dataArray = Array.isArray(inputData) ? inputData : [inputData]; + let insertedCount = 0; + let updatedCount = 0; + + for (const data of dataArray) { + // 1. 충돌 키 값 추출 + const conflictKeyValues: Record = {}; + conflictKeys.forEach((key: string) => { + const mapping = fieldMappings.find((m: any) => m.targetField === key); + if (mapping) { + conflictKeyValues[key] = + mapping.staticValue !== undefined + ? mapping.staticValue + : data[mapping.sourceField]; + } + }); + + // 2. 존재 여부 확인 (SELECT) + const whereConditions = conflictKeys + .map((key: string, index: number) => `${key} = $${index + 1}`) + .join(" AND "); + const whereValues = conflictKeys.map( + (key: string) => conflictKeyValues[key] + ); + + const checkSql = `SELECT id FROM ${targetTable} WHERE ${whereConditions} LIMIT 1`; + const existingRow = await client.query(checkSql, whereValues); + + if (existingRow.rows.length > 0) { + // 3-A. 존재하면 UPDATE + const setClauses: string[] = []; + const updateValues: any[] = []; + let paramIndex = 1; + + fieldMappings.forEach((mapping: any) => { + // 충돌 키가 아닌 필드만 UPDATE + if (!conflictKeys.includes(mapping.targetField)) { + const value = + mapping.staticValue !== undefined + ? mapping.staticValue + : data[mapping.sourceField]; + setClauses.push(`${mapping.targetField} = $${paramIndex}`); + updateValues.push(value); + paramIndex++; + } + }); + + // WHERE 조건 생성 (파라미터 인덱스 이어서) + const updateWhereConditions = conflictKeys + .map( + (key: string, index: number) => `${key} = $${paramIndex + index}` + ) + .join(" AND "); + + // WHERE 조건 값 추가 + whereValues.forEach((val: any) => { + updateValues.push(val); + }); + + const updateSql = ` + UPDATE ${targetTable} + SET ${setClauses.join(", ")} + WHERE ${updateWhereConditions} + `; + + logger.info(`🔄 UPDATE 실행:`, { + table: targetTable, + conflictKeys, + conflictKeyValues, + sql: updateSql, + values: updateValues, + }); + + await client.query(updateSql, updateValues); + updatedCount++; + } else { + // 3-B. 없으면 INSERT + const columns: string[] = []; + const values: any[] = []; + + fieldMappings.forEach((mapping: any) => { + const value = + mapping.staticValue !== undefined + ? mapping.staticValue + : data[mapping.sourceField]; + columns.push(mapping.targetField); + values.push(value); + }); + + const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); + const insertSql = ` + INSERT INTO ${targetTable} (${columns.join(", ")}) + VALUES (${placeholders}) + `; + + logger.info(`➕ INSERT 실행:`, { + table: targetTable, + conflictKeys, + conflictKeyValues, + }); + + await client.query(insertSql, values); + insertedCount++; + } + } + + logger.info( + `✅ UPSERT 완료: ${targetTable}, INSERT ${insertedCount}건, UPDATE ${updatedCount}건` + ); + + return { + insertedCount, + updatedCount, + totalCount: insertedCount + updatedCount, + }; + }); + } + + /** + * 조건 노드 실행 + */ + private static async executeCondition( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { conditions, logic } = node.data; + + const results = conditions.map((condition: any) => { + const fieldValue = inputData[condition.field]; + return this.evaluateCondition( + fieldValue, + condition.operator, + condition.value + ); + }); + + const result = + logic === "OR" + ? results.some((r: boolean) => r) + : results.every((r: boolean) => r); + + logger.info(`🔍 조건 평가 결과: ${result}`); + + return result; + } + + /** + * WHERE 절 생성 + */ + private static buildWhereClause( + conditions: any[], + data?: any, + startIndex: number = 1 + ): string { + if (!conditions || conditions.length === 0) { + return ""; + } + + const clauses = conditions.map((condition, index) => { + const value = data ? data[condition.field] : condition.value; + return `${condition.field} ${condition.operator} $${startIndex + index}`; + }); + + return `WHERE ${clauses.join(" AND ")}`; + } + + /** + * 조건 평가 + */ + private static evaluateCondition( + fieldValue: any, + operator: string, + expectedValue: any + ): boolean { + switch (operator) { + case "equals": + case "=": + return fieldValue === expectedValue; + case "notEquals": + case "!=": + return fieldValue !== expectedValue; + case "greaterThan": + case ">": + return fieldValue > expectedValue; + case "lessThan": + case "<": + return fieldValue < expectedValue; + case "contains": + return String(fieldValue).includes(String(expectedValue)); + default: + return false; + } + } + + /** + * 실행 결과 생성 + */ + private static generateExecutionResult( + nodes: FlowNode[], + context: ExecutionContext, + executionTime: number + ): ExecutionResult { + const nodeSummaries: NodeExecutionSummary[] = nodes.map((node) => { + const result = context.nodeResults.get(node.id); + return { + nodeId: node.id, + nodeName: node.data.displayName || node.id, + nodeType: node.type, + status: result?.status || "pending", + duration: result?.endTime + ? result.endTime - result.startTime + : undefined, + error: result?.error?.message, + }; + }); + + const summary = { + total: nodes.length, + success: nodeSummaries.filter((n) => n.status === "success").length, + failed: nodeSummaries.filter((n) => n.status === "failed").length, + skipped: nodeSummaries.filter((n) => n.status === "skipped").length, + }; + + const success = summary.failed === 0; + + return { + success, + message: success + ? `플로우 실행 성공 (${summary.success}/${summary.total})` + : `플로우 실행 부분 실패 (성공: ${summary.success}, 실패: ${summary.failed})`, + executionTime, + nodes: nodeSummaries, + summary, + }; + } + + /** + * 데이터 변환 노드 실행 + */ + private static async executeDataTransform( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { transformations } = node.data; + + if ( + !transformations || + !Array.isArray(transformations) || + transformations.length === 0 + ) { + logger.warn(`⚠️ 데이터 변환 노드에 변환 규칙이 없습니다: ${node.id}`); + return Array.isArray(inputData) ? inputData : [inputData]; + } + + // inputData를 배열로 정규화 + const rows = Array.isArray(inputData) ? inputData : [inputData]; + logger.info( + `🔄 데이터 변환 시작: ${rows.length}개 행, ${transformations.length}개 변환` + ); + + // 각 변환 규칙을 순차적으로 적용 + let transformedRows = rows; + + for (const transform of transformations) { + const transformType = transform.type; + logger.info(` 🔹 변환 적용: ${transformType}`); + + transformedRows = this.applyTransformation(transformedRows, transform); + } + + logger.info(`✅ 데이터 변환 완료: ${transformedRows.length}개 행`); + return transformedRows; + } + + /** + * 단일 변환 규칙 적용 + */ + private static applyTransformation(rows: any[], transform: any): any[] { + const { + type, + sourceField, + targetField, + delimiter, + separator, + searchValue, + replaceValue, + splitIndex, + castType, + expression, + } = transform; + + // 타겟 필드 결정 (비어있으면 소스 필드 사용 = in-place) + const actualTargetField = targetField || sourceField; + + switch (type) { + case "UPPERCASE": + return rows.map((row) => ({ + ...row, + [actualTargetField]: + row[sourceField]?.toString().toUpperCase() || row[sourceField], + })); + + case "LOWERCASE": + return rows.map((row) => ({ + ...row, + [actualTargetField]: + row[sourceField]?.toString().toLowerCase() || row[sourceField], + })); + + case "TRIM": + return rows.map((row) => ({ + ...row, + [actualTargetField]: + row[sourceField]?.toString().trim() || row[sourceField], + })); + + case "EXPLODE": + return this.applyExplode( + rows, + sourceField, + actualTargetField, + delimiter || "," + ); + + case "CONCAT": + return rows.map((row) => { + const value1 = row[sourceField] || ""; + // CONCAT은 여러 필드를 합칠 수 있지만, 단순화하여 expression 사용 + const value2 = expression || ""; + return { + ...row, + [actualTargetField]: `${value1}${separator || ""}${value2}`, + }; + }); + + case "SPLIT": + return rows.map((row) => { + const value = row[sourceField]?.toString() || ""; + const parts = value.split(delimiter || ","); + const index = splitIndex !== undefined ? splitIndex : 0; + return { + ...row, + [actualTargetField]: parts[index] || "", + }; + }); + + case "REPLACE": + return rows.map((row) => { + const value = row[sourceField]?.toString() || ""; + const replaced = value.replace( + new RegExp(searchValue || "", "g"), + replaceValue || "" + ); + return { + ...row, + [actualTargetField]: replaced, + }; + }); + + case "CAST": + return rows.map((row) => { + const value = row[sourceField]; + let castedValue = value; + + switch (castType) { + case "string": + castedValue = value?.toString() || ""; + break; + case "number": + castedValue = parseFloat(value) || 0; + break; + case "boolean": + castedValue = Boolean(value); + break; + case "date": + castedValue = new Date(value); + break; + } + + return { + ...row, + [actualTargetField]: castedValue, + }; + }); + + case "FORMAT": + case "CALCULATE": + case "JSON_EXTRACT": + case "CUSTOM": + // 표현식 기반 변환 (현재는 단순 구현) + logger.warn(`⚠️ ${type} 변환은 아직 완전히 구현되지 않았습니다.`); + return rows.map((row) => ({ + ...row, + [actualTargetField]: row[sourceField] || "", + })); + + default: + logger.warn(`⚠️ 지원하지 않는 변환 타입: ${type}`); + return rows; + } + } + + /** + * EXPLODE 변환: 1개 행을 N개 행으로 확장 + */ + private static applyExplode( + rows: any[], + sourceField: string, + targetField: string, + delimiter: string + ): any[] { + const expandedRows: any[] = []; + + for (const row of rows) { + const value = row[sourceField]; + + if (!value) { + // 값이 없으면 원본 행 유지 + expandedRows.push(row); + continue; + } + + // 문자열을 구분자로 분리 + const values = value + .toString() + .split(delimiter) + .map((v: string) => v.trim()); + + // 각 값마다 새 행 생성 + for (const val of values) { + expandedRows.push({ + ...row, // 다른 필드들은 복제 + [targetField]: val, // 타겟 필드에 분리된 값 저장 + }); + } + } + + logger.info( + ` 💥 EXPLODE: ${rows.length}개 행 → ${expandedRows.length}개 행` + ); + return expandedRows; + } +} diff --git a/docs/노드_구조_개선안.md b/docs/노드_구조_개선안.md new file mode 100644 index 00000000..7c2cc260 --- /dev/null +++ b/docs/노드_구조_개선안.md @@ -0,0 +1,481 @@ +# 노드 구조 개선안 - FROM/TO 테이블 명확화 + +**작성일**: 2025-01-02 +**버전**: 1.0 +**상태**: 🤔 검토 중 + +--- + +## 📋 문제 인식 + +### 현재 설계의 한계 + +``` +현재 플로우: +TableSource(user_info) → FieldMapping → InsertAction(targetTable: "orders") +``` + +**문제점**: + +1. 타겟 테이블(orders)이 노드로 표현되지 않음 +2. InsertAction의 속성으로만 존재 → 시각적으로 불명확 +3. FROM(user_info)과 TO(orders)의 관계가 직관적이지 않음 +4. 타겟 테이블의 스키마 정보를 참조하기 어려움 + +--- + +## 💡 개선 방안 + +### 옵션 1: TableTarget 노드 추가 (권장 ⭐) + +**새로운 플로우**: + +``` +TableSource(user_info) → FieldMapping → TableTarget(orders) → InsertAction +``` + +**노드 추가**: + +- `TableTarget` - 타겟 테이블을 명시적으로 표현 + +**장점**: + +- ✅ FROM/TO가 시각적으로 명확 +- ✅ 타겟 테이블 스키마를 미리 로드 가능 +- ✅ FieldMapping에서 타겟 필드 자동 완성 가능 +- ✅ 데이터 흐름이 직관적 + +**단점**: + +- ⚠️ 노드 개수 증가 (복잡도 증가) +- ⚠️ 기존 설계와 호환성 문제 + +--- + +### 옵션 2: Action 노드에 Target 속성 유지 (현재 방식) + +**현재 플로우 유지**: + +``` +TableSource(user_info) → FieldMapping → InsertAction(targetTable: "orders") +``` + +**개선 방법**: + +- Action 노드에서 타겟 테이블을 더 명확히 표시 +- 노드 UI에 타겟 테이블명을 크게 표시 +- Properties Panel에서 타겟 테이블 선택 시 스키마 정보 제공 + +**장점**: + +- ✅ 기존 설계 유지 (구현 완료된 상태) +- ✅ 노드 개수가 적음 (간결함) +- ✅ 빠른 플로우 구성 가능 + +**단점**: + +- ❌ 시각적으로 FROM/TO 관계가 불명확 +- ❌ FieldMapping 단계에서 타겟 필드 정보 접근이 어려움 + +--- + +### 옵션 3: 가상 노드 자동 표시 (신규 제안 ⭐⭐) + +**개념**: +Action 노드에서 targetTable 속성을 설정하면, **시각적으로만** 타겟 테이블 노드를 자동 생성 + +**실제 플로우 (저장되는 구조)**: + +``` +TableSource(user_info) → FieldMapping → InsertAction(targetTable: "orders") +``` + +**시각적 표시 (화면에 보이는 모습)**: + +``` +TableSource(user_info) + → FieldMapping + → InsertAction(targetTable: "orders") + → 👻 orders (가상 노드, 자동 생성) +``` + +**특징**: + +- 가상 노드는 선택/이동/삭제 불가능 +- 반투명하게 표시하여 가상임을 명확히 표시 +- Action 노드의 targetTable 속성 변경 시 자동 업데이트 +- 저장 시에는 가상 노드 제외 + +**장점**: + +- ✅ 사용자는 기존대로 사용 (노드 추가 불필요) +- ✅ 시각적으로 FROM/TO 관계 명확 +- ✅ 기존 설계 100% 유지 +- ✅ 구현 복잡도 낮음 +- ✅ 기존 플로우와 완벽 호환 + +**단점**: + +- ⚠️ 가상 노드의 상호작용 제한 필요 +- ⚠️ "왜 클릭이 안 되지?" 혼란 가능성 +- ⚠️ 가상 노드 렌더링 로직 추가 + +--- + +### 옵션 4: 하이브리드 방식 + +**조건부 사용**: + +``` +// 단순 케이스: TableTarget 생략 +TableSource → FieldMapping → InsertAction(targetTable 지정) + +// 복잡한 케이스: TableTarget 사용 +TableSource → FieldMapping → TableTarget → InsertAction +``` + +**장점**: + +- ✅ 유연성 제공 +- ✅ 단순/복잡한 케이스 모두 대응 + +**단점**: + +- ❌ 사용자 혼란 가능성 +- ❌ 검증 로직 복잡 + +--- + +## 🎯 권장 방안 비교 + +### 옵션 재평가 + +| 항목 | 옵션 1
(TableTarget) | 옵션 2
(현재 방식) | 옵션 3
(가상 노드) ⭐ | +| ----------------- | ------------------------ | ---------------------- | ------------------------- | +| **시각적 명확성** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | +| **구현 복잡도** | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ | +| **사용자 편의성** | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | +| **기존 호환성** | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | +| **자동 완성** | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ | +| **유지보수성** | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | +| **학습 곡선** | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | + +### 최종 권장: **옵션 3 (가상 노드 자동 표시)** ⭐⭐ + +**선택 이유**: + +1. ✅ **최고의 시각적 명확성** - FROM/TO 관계가 한눈에 보임 +2. ✅ **사용자 편의성** - 기존 방식 그대로, 노드 추가 불필요 +3. ✅ **완벽한 호환성** - 기존 플로우 수정 불필요 +4. ✅ **낮은 학습 곡선** - 새로운 노드 타입 학습 불필요 +5. ✅ **적절한 구현 복잡도** - React Flow의 커스텀 렌더링 활용 + +**구현 방식**: + +```typescript +// Action 노드가 있으면 자동으로 가상 타겟 노드 생성 +function generateVirtualTargetNodes(nodes: FlowNode[]): VirtualNode[] { + return nodes + .filter((node) => isActionNode(node.type) && node.data.targetTable) + .map((actionNode) => ({ + id: `virtual-target-${actionNode.id}`, + type: "virtualTarget", + position: { + x: actionNode.position.x, + y: actionNode.position.y + 150, + }, + data: { + tableName: actionNode.data.targetTable, + sourceActionId: actionNode.id, + isVirtual: true, + }, + })); +} +``` + +--- + +## 🎯 대안: 옵션 1 (TableTarget 추가) + +### 새로운 노드 타입 추가 + +#### TableTarget 노드 + +**타입**: `tableTarget` + +**데이터 구조**: + +```typescript +interface TableTargetNodeData { + tableName: string; // 타겟 테이블명 + schema?: string; // 스키마 (선택) + columns?: Array<{ + // 타겟 컬럼 정보 + name: string; + type: string; + nullable: boolean; + primaryKey: boolean; + }>; + displayName?: string; +} +``` + +**특징**: + +- 입력: FieldMapping, DataTransform 등에서 받음 +- 출력: Action 노드로 전달 +- 타겟 테이블 스키마를 미리 로드하여 검증 가능 + +**시각적 표현**: + +``` +┌────────────────────┐ +│ 📊 Table Target │ +├────────────────────┤ +│ orders │ +│ schema: public │ +├────────────────────┤ +│ 컬럼: │ +│ • order_id (PK) │ +│ • customer_id │ +│ • order_date │ +│ • total_amount │ +└────────────────────┘ +``` + +--- + +### 개선된 연결 규칙 + +#### TableTarget 추가 시 연결 규칙 + +**허용되는 연결**: + +``` +✅ FieldMapping → TableTarget +✅ DataTransform → TableTarget +✅ Condition → TableTarget +✅ TableTarget → InsertAction +✅ TableTarget → UpdateAction +✅ TableTarget → UpsertAction +``` + +**금지되는 연결**: + +``` +❌ TableSource → TableTarget (직접 연결 불가) +❌ TableTarget → DeleteAction (DELETE는 타겟 불필요) +❌ TableTarget → TableTarget +``` + +**새로운 검증 규칙**: + +1. Action 노드는 TableTarget 또는 targetTable 속성 중 하나 필수 +2. TableTarget이 있으면 Action의 targetTable 속성 무시 +3. FieldMapping 이후에 TableTarget이 오면 자동 필드 매칭 제안 + +--- + +### 실제 사용 예시 + +#### 예시 1: 단순 데이터 복사 + +**기존 방식**: + +``` +TableSource(user_info) + → FieldMapping(user_id → customer_id, user_name → name) + → InsertAction(targetTable: "customers") +``` + +**개선 방식**: + +``` +TableSource(user_info) + → FieldMapping(user_id → customer_id) + → TableTarget(customers) + → InsertAction +``` + +**장점**: + +- customers 테이블 스키마를 FieldMapping에서 참조 가능 +- 필드 자동 완성 제공 + +--- + +#### 예시 2: 조건부 데이터 처리 + +**개선 방식**: + +``` +TableSource(user_info) + → Condition(age >= 18) + ├─ TRUE → TableTarget(adult_users) → InsertAction + └─ FALSE → TableTarget(minor_users) → InsertAction +``` + +**장점**: + +- TRUE/FALSE 분기마다 다른 타겟 테이블 명확히 표시 + +--- + +#### 예시 3: 멀티 소스 + 단일 타겟 + +**개선 방식**: + +``` +┌─ TableSource(users) ────┐ +│ ↓ +└─ ExternalDB(orders) ─→ FieldMapping → TableTarget(user_orders) → InsertAction +``` + +**장점**: + +- 여러 소스에서 데이터를 받아 하나의 타겟으로 통합 +- 타겟 테이블이 시각적으로 명확 + +--- + +## 🔧 구현 계획 + +### Phase 1: TableTarget 노드 구현 + +**작업 항목**: + +1. ✅ `TableTargetNodeData` 인터페이스 정의 +2. ✅ `TableTargetNode.tsx` 컴포넌트 생성 +3. ✅ `TableTargetProperties.tsx` 속성 패널 생성 +4. ✅ Node Palette에 추가 +5. ✅ FlowEditor에 등록 + +**예상 시간**: 2시간 + +--- + +### Phase 2: 연결 규칙 업데이트 + +**작업 항목**: + +1. ✅ `validateConnection`에 TableTarget 규칙 추가 +2. ✅ Action 노드가 TableTarget 입력을 받도록 수정 +3. ✅ 검증 로직 업데이트 + +**예상 시간**: 1시간 + +--- + +### Phase 3: 자동 필드 매핑 개선 + +**작업 항목**: + +1. ✅ TableTarget이 연결되면 타겟 스키마 자동 로드 +2. ✅ FieldMapping에서 타겟 필드 자동 완성 제공 +3. ✅ 필드 타입 호환성 검증 + +**예상 시간**: 2시간 + +--- + +### Phase 4: 기존 플로우 마이그레이션 + +**작업 항목**: + +1. ✅ 기존 InsertAction의 targetTable을 TableTarget으로 변환 +2. ✅ 자동 마이그레이션 스크립트 작성 +3. ✅ 호환성 유지 모드 제공 + +**예상 시간**: 2시간 + +--- + +## 🤔 고려사항 + +### 1. 기존 플로우와의 호환성 + +**문제**: 이미 저장된 플로우는 TableTarget 없이 구성됨 + +**해결 방안**: + +- **옵션 A**: 자동 마이그레이션 + + - 플로우 로드 시 InsertAction의 targetTable을 TableTarget 노드로 변환 + - 기존 데이터는 보존 + +- **옵션 B**: 호환성 모드 + - TableTarget 없이도 동작하도록 유지 + - 새 플로우만 TableTarget 사용 권장 + +**권장**: 옵션 B (호환성 모드) + +--- + +### 2. 사용자 경험 + +**우려**: 노드가 하나 더 추가되어 복잡해짐 + +**완화 방안**: + +- 템플릿 제공: "TableSource → FieldMapping → TableTarget → InsertAction" 세트를 템플릿으로 제공 +- 자동 생성: InsertAction 생성 시 TableTarget 자동 생성 옵션 +- 가이드: 처음 사용자를 위한 튜토리얼 + +--- + +### 3. 성능 + +**우려**: TableTarget이 스키마를 로드하면 성능 저하 가능성 + +**완화 방안**: + +- 캐싱: 한 번 로드한 스키마는 캐싱 +- 지연 로딩: 필요할 때만 스키마 로드 +- 백그라운드 로딩: 비동기로 스키마 로드 + +--- + +## 📊 비교 분석 + +| 항목 | 옵션 1 (TableTarget) | 옵션 2 (현재 방식) | +| ------------------- | -------------------- | ------------------ | +| **시각적 명확성** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | +| **구현 복잡도** | ⭐⭐⭐⭐ | ⭐⭐ | +| **사용자 학습곡선** | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | +| **자동 완성 지원** | ⭐⭐⭐⭐⭐ | ⭐⭐ | +| **유지보수성** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | +| **기존 호환성** | ⭐⭐ | ⭐⭐⭐⭐⭐ | + +--- + +## 🎯 결론 + +### 권장 사항: **옵션 1 (TableTarget 추가)** + +**이유**: + +1. ✅ 데이터 흐름이 시각적으로 명확 +2. ✅ 스키마 기반 자동 완성 가능 +3. ✅ 향후 확장성 우수 +4. ✅ 복잡한 데이터 흐름에서 특히 유용 + +**단계적 도입**: + +- Phase 1: TableTarget 노드 추가 (선택 사항) +- Phase 2: 기존 방식과 공존 +- Phase 3: 사용자 피드백 수집 +- Phase 4: 장기적으로 TableTarget 방식 권장 + +--- + +## 📝 다음 단계 + +1. **의사 결정**: 옵션 1 vs 옵션 2 선택 +2. **프로토타입**: TableTarget 노드 간단히 구현 +3. **테스트**: 실제 사용 시나리오로 검증 +4. **문서화**: 사용 가이드 작성 +5. **배포**: 단계적 릴리스 + +--- + +**피드백 환영**: 이 설계에 대한 의견을 주시면 개선하겠습니다! 💬 diff --git a/docs/노드_기반_제어_시스템_개선_계획.md b/docs/노드_기반_제어_시스템_개선_계획.md new file mode 100644 index 00000000..5615ac8e --- /dev/null +++ b/docs/노드_기반_제어_시스템_개선_계획.md @@ -0,0 +1,1920 @@ +# 🎯 노드 기반 데이터 제어 시스템 개선 계획 + +## 📋 문서 정보 + +- **작성일**: 2025-10-02 +- **최종 수정일**: 2025-10-02 +- **버전**: 1.5 +- **상태**: 🎉 Phase 1 완료! (100%) +- **담당**: 개발팀 + +## 📈 구현 진행 상황 + +### ✅ 완료된 작업 (Phase 1) + +#### Week 1 - 완료 ✓ + +- ✅ React Flow 11.10.4 통합 및 기본 설정 + - ✅ 라이브러리 설치 및 설정 + - ✅ 기본 캔버스 구현 + - ✅ 그리드 배경 및 줌/팬 기능 +- ✅ 기본 노드 타입 구현 (3개/3개) + - ✅ 테이블 소스 노드 (TableSourceNode) + - ✅ INSERT 액션 노드 (InsertActionNode) + - ✅ 필드 매핑 노드 (FieldMappingNode) +- ✅ 핵심 인프라 + - ✅ TypeScript 타입 정의 완료 (types/node-editor.ts) + - ✅ Zustand 상태 관리 스토어 (flowEditorStore.ts) + - ✅ 노드 팔레트 설정 (nodePaletteConfig.ts) + +#### Week 2 - 완료 ✓ + +- ✅ 드래그 앤 드롭 기능 + - ✅ 도구 패널에서 캔버스로 드래그 + - ✅ 노드 이동 및 재배치 + - ✅ 다중 선택 및 그룹 이동 +- ✅ 연결선 그리기 + - ✅ 기본 연결 생성 + - ✅ 연결 검증 (타입 호환성, 순환 참조, 중복 연결) + - ✅ 연결 스타일링 (smoothstep) +- ✅ 추가 노드 타입 구현 (4개 완료) + - ✅ 조건 분기 노드 (ConditionNode) - TRUE/FALSE 2개 출력 + - ✅ 외부 DB 소스 노드 (ExternalDBSourceNode) - DB 타입별 색상 + - ✅ UPDATE 액션 노드 (UpdateActionNode) - WHERE 조건 포함 + - ✅ DELETE 액션 노드 (DeleteActionNode) - 경고 메시지 + +**마일스톤 1 완료**: 7개 노드 타입 구현 완료 (58%) + +#### Week 3 - 완료 ✓ + +- ✅ 속성 편집 기능 (완료) + - ✅ 노드 선택 이벤트 핸들러 (onSelectionChange) + - ✅ 속성 패널 프레임워크 (PropertiesPanel) + - ✅ 노드 타입별 속성 라우팅 + - ✅ 7개 노드 속성 편집 UI 완성 + - ✅ TableSource: 테이블명, 스키마, 출력 필드 + - ✅ InsertAction: 필드 매핑, 배치 크기, 옵션 + - ✅ FieldMapping: 소스→타겟 매핑, 변환 함수 + - ✅ Condition: 12가지 연산자, AND/OR 로직 + - ✅ UpdateAction: WHERE 조건, 업데이트 필드 + - ✅ DeleteAction: WHERE 조건, 위험 경고 UI + - ✅ ExternalDBSource: DB 타입별 색상, 연결 정보 + +**마일스톤 2 완료**: 속성 편집 기능 100% 완료 (7/7 노드) + +#### Week 4 - 완료 ✓ + +- ✅ 저장/불러오기 기능 (완료) + - ✅ 플로우 저장 API (신규/수정) + - ✅ 플로우 목록 조회 API + - ✅ 플로우 상세 조회 API + - ✅ 플로우 삭제 API + - ✅ JSON 직렬화/역직렬화 + - ✅ 불러오기 다이얼로그 UI + - ✅ JSON 파일 내보내기 + - ✅ node_flows 테이블 생성 + - ✅ API 클라이언트 통합 + - ✅ JSONB 타입 처리 (문자열/객체 자동 변환) + - ✅ **실제 저장/불러오기 동작 검증 완료** + +**마일스톤 3 완료**: 저장/불러오기 100% 완료 및 검증 + +## 🎉 Phase 1 & Phase 2 완료! + +모든 핵심 기능이 구현 및 테스트 완료되었습니다: + +**Phase 1 (완료)** + +- ✅ 7개 노드 타입 구현 +- ✅ 7개 속성 편집 UI +- ✅ 저장/불러오기 시스템 (DB + JSON) +- ✅ 검증 시스템 +- ✅ 드래그앤드롭 에디터 +- ✅ **실제 저장/불러오기 테스트 완료** + +**Phase 2 (완료)** + +- ✅ 12개 노드 타입 구현 (100%) +- ✅ 12개 속성 편집 UI (100%) +- ✅ REST API, UPSERT, 데이터 변환, 주석, 로그 노드 추가 + +### 🎯 다음 작업 (Phase 3) + +#### Week 5 - 완료 ✓ + +- ✅ 남은 노드 타입 구현 (5개) + + - ✅ UpsertAction: INSERT + UPDATE 결합 (ON CONFLICT) + - ✅ DataTransform: 데이터 변환 (UPPERCASE, LOWERCASE, TRIM 등) + - ✅ RestAPISource: REST API 호출 + - ✅ Comment: 주석 노드 + - ✅ Log: 로그 출력 노드 + +- ✅ 남은 속성 편집 패널 구현 (5개) + - ✅ UpsertActionProperties: 충돌 키, 필드 매핑, 옵션 + - ✅ DataTransformProperties: 변환 규칙, 표현식 + - ✅ RestAPISourceProperties: URL, 메서드, 헤더, 인증 + - ✅ CommentProperties: 메모 내용 + - ✅ LogProperties: 로그 레벨, 메시지, 데이터 포함 + +**마일스톤 4 완료**: 노드 타입 100% 완료 (12/12) +**마일스톤 5 완료**: 속성 패널 100% 완료 (12/12) + +## 🎉 Phase 2 완료! + +모든 노드 타입과 속성 패널이 구현되었습니다! + +### 🎯 Phase 3 계획: 검증 및 실행 시스템 + +#### Week 6 - 완료 ✓ + +- ✅ 검증 시스템 강화 + - ✅ 순환 참조 검증 (DFS 알고리즘) + - ✅ 노드별 필수 속성 검증 (12개 노드 타입) + - ✅ Comment/Log 노드 독립 허용 + - ✅ 상세한 오류 메시지 및 노드 ID 포함 + +**마일스톤 6 완료**: 검증 시스템 100% 완료 + +#### 우선순위 1: 검증 시스템 강화 ✅ 완료 + +#### 우선순위 2: 플로우 실행 엔진 (추후 구현) + +- ⏳ 노드 실행 로직 +- ⏳ 데이터 흐름 처리 +- ⏳ 에러 핸들링 +- ⏳ 트랜잭션 관리 +- ⏳ 플로우 목록 조회 + +### 📦 구현된 컴포넌트 + +``` +frontend/ +├── types/ +│ └── node-editor.ts ✅ (완료) +├── lib/ +│ └── stores/ +│ └── flowEditorStore.ts ✅ (완료) +├── components/dataflow/node-editor/ +│ ├── FlowEditor.tsx ✅ (완료) +│ ├── FlowToolbar.tsx ✅ (완료) +│ ├── nodes/ +│ │ ├── TableSourceNode.tsx ✅ (완료) +│ │ ├── ExternalDBSourceNode.tsx ✅ (완료) +│ │ ├── ConditionNode.tsx ✅ (완료) +│ │ ├── FieldMappingNode.tsx ✅ (완료) +│ │ ├── InsertActionNode.tsx ✅ (완료) +│ │ ├── UpdateActionNode.tsx ✅ (완료) +│ │ ├── DeleteActionNode.tsx ✅ (완료) +│ │ ├── RestAPISourceNode.tsx ✅ (완료) +│ │ ├── DataTransformNode.tsx ✅ (완료) +│ │ ├── UpsertActionNode.tsx ✅ (완료) +│ │ ├── CommentNode.tsx ✅ (완료) +│ │ └── LogNode.tsx ✅ (완료) +│ ├── sidebar/ +│ │ ├── NodePalette.tsx ✅ (완료) +│ │ └── nodePaletteConfig.ts ✅ (완료) +│ └── panels/ +│ ├── PropertiesPanel.tsx ✅ (완료) +│ └── properties/ +│ ├── TableSourceProperties.tsx ✅ (완료) +│ ├── ExternalDBSourceProperties.tsx ✅ (완료) +│ ├── ConditionProperties.tsx ✅ (완료) +│ ├── FieldMappingProperties.tsx ✅ (완료) +│ ├── InsertActionProperties.tsx ✅ (완료) +│ ├── UpdateActionProperties.tsx ✅ (완료) +│ ├── DeleteActionProperties.tsx ✅ (완료) +│ ├── UpsertActionProperties.tsx ✅ (완료) +│ ├── DataTransformProperties.tsx ✅ (완료) +│ ├── RestAPISourceProperties.tsx ✅ (완료) +│ ├── CommentProperties.tsx ✅ (완료) +│ └── LogProperties.tsx ✅ (완료) +└── app/(main)/admin/dataflow/ + └── node-editor/ + └── page.tsx ✅ (완료) +``` + +### 🎮 현재 사용 가능한 기능 + +- ✅ 노드 드래그 앤 드롭으로 캔버스에 추가 +- ✅ 노드 간 연결선 그리기 +- ✅ 노드 선택 및 속성 편집 (100%) +- ✅ 줌/팬 컨트롤 (확대/축소/전체보기) +- ✅ 미니맵 표시 +- ✅ 플로우 검증 (소스/액션 체크, 고아 노드 감지) +- ✅ 반응형 레이아웃 (250px 사이드바 + 캔버스 + 350px 속성) +- ✅ 7개 노드 타입 구현 (58%) + - 데이터 소스: 테이블, 외부 DB + - 변환/조건: 필드 매핑, 조건 분기 (TRUE/FALSE 출력) + - 액션: INSERT, UPDATE, DELETE +- ✅ 7개 노드 속성 편집 완성 (100%) + - TableSource: 테이블/스키마 설정 + - InsertAction: 필드 매핑, 배치 옵션 + - FieldMapping: 소스→타겟, 변환 함수 + - Condition: 12가지 연산자, AND/OR + - UpdateAction: WHERE 조건, 업데이트 필드 + - DeleteAction: WHERE 조건, 위험 경고 + - ExternalDBSource: DB 타입별 UI +- ✅ 저장/불러오기 시스템 (100%) + - DB 저장 (신규/수정) + - 플로우 목록 조회 + - 플로우 불러오기 + - 플로우 삭제 + - JSON 파일 내보내기 + +### 📍 테스트 URL + +``` +http://localhost:3000/admin/dataflow/node-editor +``` + +--- + +## 📊 현재 시스템 분석 + +### 현재 구조 + +현재 데이터 제어 시스템은 4단계 마법사 방식을 사용합니다: + +``` +Step 1: 연결 설정 (FROM/TO 테이블 선택) + ↓ +Step 2: 데이터 선택 (컬럼 매핑) + ↓ +Step 3: 조건 설정 (제어 조건) + ↓ +Step 4: 액션 설정 (INSERT/UPDATE/DELETE) +``` + +### 문제점 + +#### 1. 사용성 문제 + +- ❌ **전체 흐름 파악 어려움**: 4단계를 모두 거쳐야 전체 구조 이해 가능 +- ❌ **수정 불편**: 이전 단계로 돌아가서 수정하기 번거로움 +- ❌ **복잡한 로직 표현 제한**: 다중 분기, 조건부 실행 등 표현 어려움 +- ❌ **시각화 제한**: "흐름 미리보기" 탭에서만 전체 구조 확인 가능 + +#### 2. 기능적 제한 + +- 단선형 흐름만 지원 (A → B → C) +- 복잡한 데이터 변환 로직 구현 어려움 +- 디버깅 시 어느 단계에서 문제가 발생했는지 파악 어려움 +- 재사용 가능한 로직 패턴 저장 불가 + +#### 3. 학습 곡선 + +- 새로운 사용자가 4단계 프로세스를 이해하는데 시간 소요 +- 각 단계의 설정이 최종 결과에 어떤 영향을 주는지 직관적이지 않음 + +--- + +## 🚀 제안: 노드 기반 비주얼 에디터 + +### 핵심 개념 + +**블루프린트 스타일 노드 프로그래밍** + +- 언리얼 엔진, N8N, Node-RED와 같은 비주얼 프로그래밍 방식 채택 +- 드래그 앤 드롭으로 노드 배치 +- 노드 간 연결선으로 데이터 흐름 표현 +- 실시간 시각적 피드백 + +### 주요 장점 + +#### 1. 직관성 + +- ✅ 전체 데이터 흐름을 한 화면에서 파악 +- ✅ 시각적으로 로직 구조 이해 +- ✅ 노드 간 관계가 명확하게 표현됨 + +#### 2. 유연성 + +- ✅ 자유로운 노드 배치 및 재배치 +- ✅ 복잡한 분기 로직 쉽게 구현 +- ✅ 동적으로 노드 추가/제거 + +#### 3. 생산성 + +- ✅ 드래그 앤 드롭으로 빠른 구성 +- ✅ 템플릿 시스템으로 재사용 +- ✅ 실시간 검증으로 오류 조기 발견 + +#### 4. 디버깅 + +- ✅ 각 노드별 실행 상태 시각화 +- ✅ 데이터 흐름 추적 가능 +- ✅ 병목 지점 쉽게 파악 + +--- + +## 🎨 UI/UX 디자인 + +### 전체 레이아웃 + +``` +┌───────────────────────────────────────────────────────────────────────────┐ +│ 📋 제어 관리: 오라클 테스트2 [저장] [테스트 실행] [닫기] │ +├────────────────┬──────────────────────────────────────────────────────────┤ +│ 🔧 도구 패널 │ 🎨 캔버스 (노드 배치 영역) │ +│ (250px) │ (나머지 영역) │ +│ │ │ +│ ┌─ 데이터 소스 │ ┌────────────────────────────────────────────────────┐ │ +│ │ 📊 테이블 │ │ │ │ +│ │ 🔌 외부 DB │ │ 노드를 여기에 드래그 앤 드롭하세요 │ │ +│ │ 📁 REST API │ │ │ │ +│ │ 🌐 GraphQL │ │ [빈 캔버스] │ │ +│ └──────────────│ │ │ │ +│ │ └────────────────────────────────────────────────────┘ │ +│ ┌─ 변환/조건 │ │ +│ │ 🔀 필드 매핑 │ ┌─────────────────┐ │ +│ │ ⚡ 조건 분기 │ │ 미니맵 │ │ +│ │ 🔧 데이터 변환│ │ ┌───────────┐ │ │ +│ │ 🔄 루프 │ │ │ ▪ ▪ ▪ │ │ 150x100px │ +│ └──────────────│ │ │ ▪ ▪ │ │ │ +│ │ │ └───────────┘ │ │ +│ ┌─ 액션 │ └─────────────────┘ │ +│ │ ➕ INSERT │ │ +│ │ ✏️ UPDATE │ 컨트롤 바: [확대/축소] [전체보기] [그리드 ON/OFF] │ +│ │ ❌ DELETE │ │ +│ │ 🔄 UPSERT │ │ +│ └──────────────│ │ +│ │ │ +│ ┌─ 유틸리티 │ │ +│ │ 💬 주석 │ │ +│ │ 📦 그룹 │ │ +│ │ 🔍 로그 │ │ +│ └──────────────│ │ +└────────────────┴──────────────────────────────────────────────────────────┘ +``` + +### 도구 패널 상세 + +#### 데이터 소스 섹션 + +``` +┌─ 데이터 소스 ─────────────────┐ +│ │ +│ 📊 내부 테이블 │ +│ ┌─────────────────────────┐ │ +│ │ [끌어서 캔버스에 배치] │ │ +│ └─────────────────────────┘ │ +│ │ +│ 🔌 외부 DB 연결 │ +│ ┌─────────────────────────┐ │ +│ │ [외부 데이터베이스] │ │ +│ └─────────────────────────┘ │ +│ │ +│ 📁 REST API │ +│ ┌─────────────────────────┐ │ +│ │ [HTTP 요청 노드] │ │ +│ └─────────────────────────┘ │ +│ │ +│ 🌐 GraphQL │ +│ ┌─────────────────────────┐ │ +│ │ [GraphQL 쿼리 노드] │ │ +│ └─────────────────────────┘ │ +│ │ +└───────────────────────────────┘ +``` + +--- + +## 🎯 노드 타입 정의 + +### 1. 데이터 소스 노드 + +#### 1.1 테이블 소스 노드 + +``` +┌─────────────────────────────────────┐ +│ 📊 사용자정보 │ ← 파란색 헤더 (#3B82F6) +│ user_info │ +├─────────────────────────────────────┤ +│ 📍 연결: 메인 DB │ +│ 📋 스키마: public │ +│ │ +│ 출력 필드: [모두 보기 ▼] │ +│ ┌─────────────────────────────────┐ │ +│ │ ○ user_id (integer) → │ │ ← 필드별 연결 포인트 +│ │ ○ user_name (varchar) → │ │ +│ │ ○ email (varchar) → │ │ +│ │ ○ created_at (timestamp) → │ │ +│ │ ○ updated_at (timestamp) → │ │ +│ └─────────────────────────────────┘ │ +│ │ +│ [설정 ⚙️] [프리뷰 👁️] │ +└─────────────────────────────────────┘ + +노드 데이터 구조: +{ + type: 'tableSource', + data: { + connectionId: 0, + tableName: 'user_info', + schema: 'public', + fields: [ + { name: 'user_id', type: 'integer', nullable: false }, + { name: 'user_name', type: 'varchar', nullable: false }, + // ... + ] + } +} +``` + +#### 1.2 외부 DB 소스 노드 + +``` +┌─────────────────────────────────────┐ +│ 🔌 Oracle DB │ ← 주황색 헤더 (#F59E0B) +│ USERS 테이블 │ +├─────────────────────────────────────┤ +│ 📍 연결: Oracle 운영 서버 │ +│ 🔐 상태: 연결됨 ✅ │ +│ │ +│ 출력 필드: │ +│ ○ SALT (VARCHAR2) → │ +│ ○ USERNAME (VARCHAR2) → │ +│ ○ EMAIL (VARCHAR2) → │ +│ │ +│ [연결 테스트] [새로고침] │ +└─────────────────────────────────────┘ +``` + +#### 1.3 REST API 노드 + +``` +┌─────────────────────────────────────┐ +│ 📁 HTTP 요청 │ ← 초록색 헤더 (#10B981) +├─────────────────────────────────────┤ +│ 메서드: [GET ▼] │ +│ URL: https://api.example.com/users │ +│ │ +│ 헤더: │ +│ Authorization: Bearer {token} │ +│ │ +│ 응답 필드: │ +│ ○ data.users → │ +│ ○ data.total → │ +│ │ +│ [요청 테스트] [저장] │ +└─────────────────────────────────────┘ +``` + +### 2. 변환/조건 노드 + +#### 2.1 조건 분기 노드 + +``` +┌─────────────────────────────────────┐ +│ ⚡ 조건 검사 │ ← 노란색 헤더 (#EAB308) +├─────────────────────────────────────┤ +│ ← 입력 데이터 │ +│ │ +│ 조건식: │ +│ ┌─────────────────────────────────┐ │ +│ │ [user_id] [IS NOT NULL] [✓] │ │ +│ │ [AND] │ │ +│ │ [email] [LIKE] [%@%] [✓] │ │ +│ └─────────────────────────────────┘ │ +│ │ +│ [+ 조건 추가] │ +│ │ +│ 분기: │ +│ ✅ TRUE → │ ← 조건 충족 시 +│ ❌ FALSE → │ ← 조건 불충족 시 +└─────────────────────────────────────┘ + +노드 데이터 구조: +{ + type: 'condition', + data: { + conditions: [ + { field: 'user_id', operator: 'IS_NOT_NULL', value: null }, + { field: 'email', operator: 'LIKE', value: '%@%' } + ], + logic: 'AND' + } +} +``` + +#### 2.2 필드 매핑 노드 + +``` +┌─────────────────────────────────────┐ +│ 🔀 필드 매핑 │ ← 보라색 헤더 (#8B5CF6) +├─────────────────────────────────────┤ +│ 입력: │ +│ ← user_id │ +│ ← user_name │ +│ ← email │ +│ ← created_at │ +│ │ +│ 매핑 규칙: │ +│ ┌─────────────────────────────────┐ │ +│ │ user_id → SALT │ │ +│ │ user_name → USERNAME │ │ +│ │ email → EMAIL │ │ +│ │ NOW() → CREATED_AT │ │ ← 함수/상수 +│ └─────────────────────────────────┘ │ +│ │ +│ [+ 매핑 추가] [자동 매핑] │ +│ │ +│ 출력 → │ +└─────────────────────────────────────┘ + +노드 데이터 구조: +{ + type: 'fieldMapping', + data: { + mappings: [ + { source: 'user_id', target: 'SALT', transform: null }, + { source: 'user_name', target: 'USERNAME', transform: null }, + { source: 'email', target: 'EMAIL', transform: null }, + { source: null, target: 'CREATED_AT', transform: 'NOW()' } + ] + } +} +``` + +#### 2.3 데이터 변환 노드 + +``` +┌─────────────────────────────────────┐ +│ 🔧 데이터 변환 │ ← 청록색 헤더 (#06B6D4) +├─────────────────────────────────────┤ +│ ← 입력 데이터 │ +│ │ +│ 변환 규칙: │ +│ ┌─────────────────────────────────┐ │ +│ │ UPPER(user_name) │ │ +│ │ TRIM(email) │ │ +│ │ CONCAT(first_name, last_name) │ │ +│ └─────────────────────────────────┘ │ +│ │ +│ [+ 변환 추가] [함수 도움말] │ +│ │ +│ 출력 → │ +└─────────────────────────────────────┘ +``` + +### 3. 액션 노드 + +#### 3.1 INSERT 노드 + +``` +┌─────────────────────────────────────┐ +│ ➕ INSERT │ ← 초록색 헤더 (#22C55E) +├─────────────────────────────────────┤ +│ 타겟: USERS │ +│ 연결: Oracle 운영 서버 │ +│ │ +│ ← 매핑 데이터 입력 │ +│ │ +│ 삽입 필드: │ +│ ┌─────────────────────────────────┐ │ +│ │ • SALT ← user_id │ │ +│ │ • USERNAME ← user_name │ │ +│ │ • EMAIL ← email │ │ +│ │ • PASSWORD ← [미매핑] ⚠️ │ │ +│ └─────────────────────────────────┘ │ +│ │ +│ 옵션: │ +│ ☑ 중복 시 무시 │ +│ ☐ 배치 처리 (1000건) │ +│ │ +│ ✅ 완료 → │ +└─────────────────────────────────────┘ +``` + +#### 3.2 UPDATE 노드 + +``` +┌─────────────────────────────────────┐ +│ ✏️ UPDATE │ ← 파란색 헤더 (#3B82F6) +├─────────────────────────────────────┤ +│ 타겟: user_info │ +│ 연결: 메인 DB │ +│ │ +│ ← 데이터 입력 │ +│ │ +│ 조건 (WHERE): │ +│ user_id = {input.user_id} │ +│ │ +│ 업데이트 필드: │ +│ ┌─────────────────────────────────┐ │ +│ │ • user_name ← new_name │ │ +│ │ • updated_at ← NOW() │ │ +│ └─────────────────────────────────┘ │ +│ │ +│ ✅ 완료 → │ +└─────────────────────────────────────┘ +``` + +#### 3.3 DELETE 노드 + +``` +┌─────────────────────────────────────┐ +│ ❌ DELETE │ ← 빨간색 헤더 (#EF4444) +├─────────────────────────────────────┤ +│ 타겟: temp_data │ +│ 연결: 메인 DB │ +│ │ +│ ← 데이터 입력 │ +│ │ +│ 조건 (WHERE): │ +│ created_at < DATE_SUB(NOW(), 7) │ +│ │ +│ ⚠️ 경고: │ +│ 삭제 작업은 되돌릴 수 없습니다 │ +│ │ +│ ☑ 삭제 전 확인 │ +│ │ +│ ✅ 완료 → │ +└─────────────────────────────────────┘ +``` + +### 4. 유틸리티 노드 + +#### 4.1 주석 노드 + +``` +┌─────────────────────────────────────┐ +│ 💬 주석 │ ← 회색 헤더 (#6B7280) +├─────────────────────────────────────┤ +│ │ +│ 사용자 데이터를 Oracle DB로 동기화 │ +│ │ +│ 작성자: 김주석 │ +│ 날짜: 2025-10-02 │ +│ │ +└─────────────────────────────────────┘ +``` + +#### 4.2 로그 노드 + +``` +┌─────────────────────────────────────┐ +│ 🔍 로그 출력 │ ← 회색 헤더 +├─────────────────────────────────────┤ +│ ← 입력 데이터 │ +│ │ +│ 로그 레벨: [INFO ▼] │ +│ 메시지: "데이터 처리 완료: {count}" │ +│ │ +│ 출력 → │ +└─────────────────────────────────────┘ +``` + +--- + +## 📐 실제 사용 예시 + +### 예시 1: 간단한 데이터 복사 + +#### 현재 시스템 + +``` +Step 1: user_info 선택 +Step 2: USERS (Oracle) 선택 +Step 3: 조건 없음 +Step 4: INSERT + 필드 매핑 4개 +``` + +#### 노드 기반 시스템 + +``` +캔버스 뷰: + +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ 📊 user_info │ │ 🔀 필드 매핑 │ │ ➕ INSERT │ +│ │═══════>│ │═══════>│ USERS │ +│ user_id ○─┼───────>│ 4개 매핑 │ │ (Oracle) │ +│ user_name ○─┼───────>│ │ │ │ +│ email ○─┼───────>│ │ │ │ +└──────────────┘ └──────────────┘ └──────────────┘ +``` + +### 예시 2: 조건부 데이터 처리 + +#### 시나리오 + +- user_info에서 데이터 읽기 +- user_id가 NULL이 아니고 email이 유효한 경우만 처리 +- 조건 통과 시 Oracle DB에 INSERT + +#### 노드 배치 + +``` +┌──────────────┐ ┌──────────────┐ TRUE ┌──────────────┐ ┌──────────────┐ +│ 📊 user_info │ │ ⚡ 조건 검사 │═══════>│ 🔀 필드 매핑 │ │ ➕ INSERT │ +│ │═══════>│ │ │ │═══════>│ USERS │ +│ user_id ○─┼───────>│ user_id │ │ 4개 매핑 │ │ │ +│ user_name ○─┼─┐ │ IS NOT NULL │ │ │ │ │ +│ email ○─┼─┼─────>│ AND │ │ │ │ │ +└──────────────┘ │ │ email LIKE │ FALSE │ │ │ │ + │ │ '%@%' │═══════>│ [중단] │ │ │ + │ └──────────────┘ └──────────────┘ └──────────────┘ + │ ↑ + └─────────────────────────────────────┘ +``` + +### 예시 3: 복잡한 다중 분기 + +#### 시나리오 + +- 사용자 타입에 따라 다른 테이블에 저장 +- 관리자 → admin_users +- 일반 사용자 → regular_users +- 게스트 → guest_logs + +#### 노드 배치 + +``` + ┌──────────────┐ ┌──────────────┐ + │ 🔀 매핑1 │ │ ➕ INSERT │ + admin ═══>│ │═══════>│ admin_users │ + ╱ └──────────────┘ └──────────────┘ +┌──────────────┐ ┌──╱───────────┐ +│ 📊 user_info │ │ ⚡ 타입 분기 │ ┌──────────────┐ ┌──────────────┐ +│ │═══════>│ │ user ═══>│ 🔀 매핑2 │ │ ➕ INSERT │ +│ user_type ○─┼───────>│ user_type │ │ │═══════>│ regular_users│ +└──────────────┘ │ │ └──────────────┘ └──────────────┘ + └──╲───────────┘ + ╲ ┌──────────────┐ ┌──────────────┐ + guest ════>│ 🔀 매핑3 │ │ ➕ INSERT │ + │ │═══════>│ guest_logs │ + └──────────────┘ └──────────────┘ +``` + +--- + +## 🎮 주요 기능 명세 + +### 1. 드래그 앤 드롭 + +#### 1.1 노드 추가 + +```typescript +// 사용자 액션 +1. 좌측 도구 패널에서 노드 아이템 클릭 +2. 캔버스로 드래그 +3. 원하는 위치에 드롭 + +// 시스템 동작 +- 마우스 커서를 따라 노드 프리뷰 표시 +- 드롭 가능 영역 하이라이트 +- 드롭 시 새 노드 생성 및 배치 +``` + +#### 1.2 노드 이동 + +```typescript +// 사용자 액션 +1. 캔버스의 노드 헤더 클릭 +2. 원하는 위치로 드래그 +3. 드롭하여 재배치 + +// 시스템 동작 +- 연결된 선들이 함께 움직임 +- 그리드 스냅 옵션 (10px 단위) +- 다중 선택 시 여러 노드 동시 이동 +``` + +#### 1.3 다중 선택 + +```typescript +// 방법 1: Shift + 클릭 +노드를 하나씩 Shift + 클릭하여 선택 + +// 방법 2: 드래그 영역 선택 +빈 공간을 드래그하여 사각형 영역 내 노드 선택 + +// 방법 3: Ctrl + A +전체 노드 선택 +``` + +### 2. 연결선 그리기 + +#### 2.1 연결 생성 + +```typescript +// 단계별 프로세스 +1. 출력 포인트(○) 클릭 +2. 마우스를 드래그하여 선 그리기 +3. 입력 포인트에 드롭하여 연결 + +// 시각적 피드백 +- 드래그 중 임시 선 표시 +- 호환되는 입력 포인트 하이라이트 +- 호환되지 않는 포인트 비활성화 +``` + +#### 2.2 연결 검증 + +```typescript +// 타입 검증 +const validateConnection = (source, target) => { + // 데이터 타입 호환성 체크 + if (!isCompatibleType(source.dataType, target.dataType)) { + return { valid: false, error: "데이터 타입 불일치" }; + } + + // 순환 참조 체크 + if (hasCircularReference(source, target)) { + return { valid: false, error: "순환 참조 감지" }; + } + + // 중복 연결 체크 + if (isDuplicateConnection(source, target)) { + return { valid: false, error: "이미 연결되어 있음" }; + } + + return { valid: true }; +}; +``` + +#### 2.3 연결 스타일 + +```typescript +// 연결선 종류 +1. 데이터 흐름: 굵은 곡선 (베지어 커브) +2. 필드 연결: 가는 직선 +3. 조건 분기: 점선 (TRUE/FALSE) + +// 색상 코딩 +- 정상: #3B82F6 (파란색) +- 경고: #F59E0B (주황색) +- 오류: #EF4444 (빨간색) +- 비활성: #9CA3AF (회색) +``` + +### 3. 실시간 검증 + +#### 3.1 구문 검증 + +```typescript +// 필드명 검증 +validateFieldName(fieldName) { + // 존재하지 않는 필드 + if (!sourceFields.includes(fieldName)) { + return error('필드를 찾을 수 없습니다'); + } +} + +// SQL 표현식 검증 +validateSQLExpression(expression) { + try { + // 기본 SQL 파싱 + parseSQLExpression(expression); + return success(); + } catch (e) { + return error('잘못된 SQL 표현식'); + } +} +``` + +#### 3.2 논리 검증 + +```typescript +// 필수 연결 체크 +validateRequiredConnections(node) { + if (node.type === 'action' && !node.hasInput) { + return error('입력 데이터가 필요합니다'); + } +} + +// 고아 노드 감지 +findOrphanNodes() { + return nodes.filter(node => + !node.hasInput && + !node.hasOutput && + node.type !== 'source' + ); +} +``` + +#### 3.3 시각적 피드백 + +``` +노드 상태 표시: +✅ 정상: 초록색 체크마크 +⚠️ 경고: 노란색 경고 아이콘 +❌ 오류: 빨간색 X 아이콘 +⏳ 미완성: 회색 점선 테두리 +``` + +### 4. 그룹화 및 주석 + +#### 4.1 그룹 노드 + +``` +┌─ 사용자 데이터 처리 그룹 ─────────────────────────────────┐ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ 소스 │═══════>│ 변환 │═══════>│ 액션 │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +│ │ +│ 💬 주석: Oracle DB 동기화 로직 │ +└────────────────────────────────────────────────────────────┘ + +기능: +- 그룹 단위 이동 +- 그룹 접기/펼치기 +- 그룹 색상 지정 +- 그룹 내보내기/가져오기 +``` + +#### 4.2 주석 시스템 + +``` +주석 타입: +1. 노드 주석: 특정 노드에 첨부 +2. 플로팅 주석: 캔버스에 독립적으로 배치 +3. 연결선 주석: 연결선에 레이블 추가 + +표시 옵션: +- 항상 표시 +- 마우스 오버 시 표시 +- 클릭 시 표시 +``` + +--- + +## 🔧 기술 스택 + +### 1. 노드 에디터 라이브러리 비교 + +| 라이브러리 | 장점 | 단점 | 추천도 | +| ----------------- | ---------------------------------------------------------------------------- | ----------------------------------------- | ---------- | +| **React Flow** | • 가장 인기있음
• 풍부한 예제
• TypeScript 지원
• 커스터마이징 용이 | • 복잡한 로직은 직접 구현 | ⭐⭐⭐⭐⭐ | +| **Rete.js** | • 강력한 플러그인 시스템
• 자동 레이아웃 | • 학습 곡선 높음
• React 통합 까다로움 | ⭐⭐⭐⭐ | +| **react-diagram** | • 간단한 구조 | • 기능 제한적
• 업데이트 느림 | ⭐⭐⭐ | + +### 2. React Flow 선택 이유 + +```typescript +// 1. 쉬운 설치 및 설정 +npm install reactflow + +// 2. 간단한 초기 설정 +import ReactFlow, { MiniMap, Controls, Background } from 'reactflow'; +import 'reactflow/dist/style.css'; + +function FlowEditor() { + return ( + + + + + + ); +} + +// 3. 커스텀 노드 쉽게 구현 +const nodeTypes = { + tableSource: TableSourceNode, + condition: ConditionNode, + action: ActionNode, +}; + + +``` + +### 3. 핵심 컴포넌트 구조 + +``` +frontend/components/dataflow/node-editor/ +├── FlowEditor.tsx # 메인 에디터 컴포넌트 +├── nodes/ # 노드 컴포넌트들 +│ ├── TableSourceNode.tsx +│ ├── ExternalDBNode.tsx +│ ├── ConditionNode.tsx +│ ├── FieldMappingNode.tsx +│ ├── InsertActionNode.tsx +│ ├── UpdateActionNode.tsx +│ ├── DeleteActionNode.tsx +│ └── index.ts +├── edges/ # 커스텀 엣지 +│ ├── DataFlowEdge.tsx +│ ├── ConditionEdge.tsx +│ └── index.ts +├── sidebar/ # 도구 패널 +│ ├── NodePalette.tsx +│ ├── NodeLibrary.tsx +│ └── index.ts +├── panels/ # 설정 패널 +│ ├── NodePropertiesPanel.tsx +│ ├── ValidationPanel.tsx +│ └── index.ts +├── hooks/ # 커스텀 훅 +│ ├── useNodeValidation.ts +│ ├── useAutoLayout.ts +│ └── useFlowExecution.ts +└── utils/ # 유틸리티 + ├── nodeFactory.ts + ├── flowValidator.ts + └── flowSerializer.ts +``` + +### 4. 데이터 구조 + +#### 4.1 노드 데이터 모델 + +```typescript +// 기본 노드 인터페이스 +interface BaseNode { + id: string; + type: string; + position: { x: number; y: number }; + data: any; + style?: CSSProperties; +} + +// 테이블 소스 노드 +interface TableSourceNode extends BaseNode { + type: "tableSource"; + data: { + connectionId: number; + tableName: string; + schema: string; + fields: Array<{ + name: string; + type: string; + nullable: boolean; + primaryKey: boolean; + }>; + filters?: Array<{ + field: string; + operator: string; + value: any; + }>; + }; +} + +// 조건 노드 +interface ConditionNode extends BaseNode { + type: "condition"; + data: { + conditions: Array<{ + field: string; + operator: + | "EQUALS" + | "NOT_EQUALS" + | "GREATER_THAN" + | "LESS_THAN" + | "LIKE" + | "IN" + | "IS_NULL" + | "IS_NOT_NULL"; + value: any; + }>; + logic: "AND" | "OR"; + }; +} + +// 액션 노드 +interface ActionNode extends BaseNode { + type: "insertAction" | "updateAction" | "deleteAction" | "upsertAction"; + data: { + targetConnection: number; + targetTable: string; + fieldMappings: Array<{ + sourceField: string; + targetField: string; + transform?: string; + }>; + options?: { + batchSize?: number; + ignoreErrors?: boolean; + upsertKey?: string[]; + }; + }; +} +``` + +#### 4.2 연결선 데이터 모델 + +```typescript +interface Edge { + id: string; + source: string; // 출발 노드 ID + target: string; // 도착 노드 ID + sourceHandle?: string; // 출발 핸들 ID (필드별 연결 시) + targetHandle?: string; // 도착 핸들 ID + type?: "default" | "smoothstep" | "step" | "straight"; + animated?: boolean; + label?: string; + style?: CSSProperties; + data?: { + dataType?: string; + validation?: { + valid: boolean; + errors?: string[]; + }; + }; +} + +// 조건 분기 엣지 +interface ConditionalEdge extends Edge { + data: { + condition: "TRUE" | "FALSE"; + label: string; + }; +} +``` + +#### 4.3 전체 플로우 데이터 + +```typescript +interface DataFlow { + id: number; + name: string; + description: string; + companyCode: string; + nodes: BaseNode[]; + edges: Edge[]; + viewport: { + x: number; + y: number; + zoom: number; + }; + metadata: { + createdAt: string; + updatedAt: string; + createdBy: string; + version: number; + tags?: string[]; + }; +} +``` + +### 5. 상태 관리 + +```typescript +// Zustand를 사용한 플로우 상태 관리 +import create from "zustand"; + +interface FlowState { + nodes: Node[]; + edges: Edge[]; + selectedNodes: string[]; + selectedEdges: string[]; + + // 노드 관리 + addNode: (node: Node) => void; + updateNode: (id: string, data: Partial) => void; + removeNode: (id: string) => void; + + // 엣지 관리 + addEdge: (edge: Edge) => void; + updateEdge: (id: string, data: Partial) => void; + removeEdge: (id: string) => void; + + // 선택 관리 + selectNode: (id: string, multi?: boolean) => void; + clearSelection: () => void; + + // 검증 + validateFlow: () => ValidationResult; + + // 실행 + executeFlow: () => Promise; +} + +const useFlowStore = create((set, get) => ({ + // ... 구현 +})); +``` + +--- + +## 📊 비교 분석 + +### 현재 시스템 vs 노드 기반 시스템 + +| 측면 | 현재 시스템 | 노드 기반 시스템 | 개선도 | +| --------------- | --------------------- | -------------------- | ------ | +| **학습 곡선** | 중간 (4단계 프로세스) | 쉬움 (시각적 직관성) | +40% | +| **전체 파악** | 어려움 (단계별 분리) | 쉬움 (한눈에 파악) | +60% | +| **수정 편의성** | 불편 (단계 이동 필요) | 편리 (직접 수정) | +50% | +| **복잡한 로직** | 제한적 (선형 흐름) | 우수 (다중 분기) | +80% | +| **재사용성** | 낮음 (수동 복사) | 높음 (템플릿 시스템) | +70% | +| **디버깅** | 어려움 (로그 확인) | 쉬움 (시각적 추적) | +65% | +| **협업** | 보통 (설명 필요) | 우수 (자체 문서화) | +55% | +| **성능** | 양호 | 양호 (동일) | 0% | + +### 사용자 시나리오별 비교 + +#### 시나리오 1: 간단한 테이블 복사 + +``` +현재: 4단계 × 평균 2분 = 8분 +노드: 드래그 3개 + 연결 2개 = 3분 +개선: 62.5% 단축 +``` + +#### 시나리오 2: 조건부 다중 액션 + +``` +현재: 4단계 × 5분 + 조건 설정 5분 = 25분 +노드: 드래그 7개 + 연결 8개 + 설정 5분 = 12분 +개선: 52% 단축 +``` + +#### 시나리오 3: 복잡한 데이터 변환 + +``` +현재: 여러 제어를 순차적으로 생성 = 45분 +노드: 하나의 플로우에서 모두 처리 = 20분 +개선: 55.5% 단축 +``` + +--- + +## 🚀 구현 로드맵 + +### Phase 1: 기본 노드 에디터 (2주) + +#### Week 1 + +- [ ] React Flow 통합 및 기본 설정 + - [ ] 라이브러리 설치 및 설정 + - [ ] 기본 캔버스 구현 + - [ ] 그리드 배경 및 줌/팬 기능 +- [ ] 기본 노드 타입 구현 + - [ ] 테이블 소스 노드 + - [ ] INSERT 액션 노드 + - [ ] 필드 매핑 노드 + +#### Week 2 + +- [ ] 드래그 앤 드롭 기능 + - [ ] 도구 패널에서 캔버스로 드래그 + - [ ] 노드 이동 및 재배치 + - [ ] 다중 선택 및 그룹 이동 +- [ ] 연결선 그리기 + - [ ] 기본 연결 생성 + - [ ] 연결 검증 + - [ ] 연결 스타일링 +- [ ] 데이터 저장/불러오기 + - [ ] JSON 직렬화 + - [ ] 백엔드 API 연동 + +**마일스톤 1**: 기본적인 테이블 → 필드 매핑 → INSERT 플로우 구현 + +### Phase 2: 고급 기능 (2주) + +#### Week 3 + +- [ ] 추가 노드 타입 + - [ ] 외부 DB 소스 노드 + - [ ] 조건 분기 노드 + - [ ] UPDATE/DELETE 액션 노드 +- [ ] 데이터 변환 노드 + - [ ] SQL 함수 지원 + - [ ] 커스텀 표현식 +- [ ] 노드 설정 패널 + - [ ] 우측 속성 패널 + - [ ] 인라인 편집 + - [ ] 필드 자동 완성 + +#### Week 4 + +- [ ] 실시간 검증 시스템 + - [ ] 구문 검증 + - [ ] 논리 검증 + - [ ] 타입 검증 +- [ ] 조건부 실행 + - [ ] TRUE/FALSE 분기 + - [ ] 다중 조건 처리 +- [ ] 오류 표시 및 해결 + - [ ] 오류 하이라이트 + - [ ] 오류 메시지 툴팁 + - [ ] 자동 수정 제안 + +**마일스톤 2**: 조건 분기 및 다중 액션을 포함한 복잡한 플로우 구현 + +### Phase 3: UX 개선 (1주) + +#### Week 5 + +- [ ] 미니맵 + - [ ] 전체 플로우 미리보기 + - [ ] 현재 뷰포트 표시 + - [ ] 미니맵 클릭 네비게이션 +- [ ] 줌/팬 컨트롤 + - [ ] 줌 인/아웃 버튼 + - [ ] 전체 보기 버튼 + - [ ] 선택된 노드로 포커스 +- [ ] 노드 템플릿 시스템 + - [ ] 자주 사용하는 패턴 저장 + - [ ] 템플릿 갤러리 + - [ ] 드래그로 템플릿 적용 +- [ ] 키보드 단축키 + - [ ] Ctrl+C/V: 복사/붙여넣기 + - [ ] Delete: 삭제 + - [ ] Ctrl+Z/Y: 실행 취소/다시 실행 + - [ ] Ctrl+A: 전체 선택 + - [ ] Space + Drag: 팬 +- [ ] 튜토리얼 및 도움말 + - [ ] 첫 방문자 가이드 + - [ ] 인터랙티브 튜토리얼 + - [ ] 컨텍스트 도움말 + +**마일스톤 3**: 사용자 친화적인 인터페이스 완성 + +### Phase 4: 고급 기능 (1주) + +#### Week 6 + +- [ ] 그룹화 기능 + - [ ] 노드 그룹 생성 + - [ ] 그룹 접기/펼치기 + - [ ] 그룹 색상 및 라벨 +- [ ] 주석 시스템 + - [ ] 노드 주석 + - [ ] 플로팅 주석 + - [ ] 연결선 라벨 +- [ ] 실행 추적 (디버그 모드) + - [ ] 노드별 실행 상태 표시 + - [ ] 데이터 흐름 애니메이션 + - [ ] 실행 로그 패널 + - [ ] 중단점 설정 +- [ ] 버전 관리 + - [ ] 플로우 버전 히스토리 + - [ ] 버전 비교 + - [ ] 이전 버전 복원 +- [ ] 내보내기/가져오기 + - [ ] JSON 파일 내보내기 + - [ ] JSON 파일 가져오기 + - [ ] 이미지 내보내기 (PNG/SVG) + - [ ] 템플릿 공유 + +**마일스톤 4**: 프로덕션 레디 기능 완성 + +### Phase 5: 최적화 및 테스트 (1주) + +#### Week 7 + +- [ ] 성능 최적화 + - [ ] 대규모 플로우 렌더링 최적화 + - [ ] 가상 스크롤링 + - [ ] 레이지 로딩 +- [ ] 단위 테스트 + - [ ] 노드 컴포넌트 테스트 + - [ ] 검증 로직 테스트 + - [ ] 플로우 실행 테스트 +- [ ] 통합 테스트 + - [ ] E2E 시나리오 테스트 + - [ ] 크로스 브라우저 테스트 +- [ ] 사용자 테스트 + - [ ] 베타 테스터 모집 + - [ ] 피드백 수집 및 반영 +- [ ] 문서화 + - [ ] 사용자 가이드 + - [ ] API 문서 + - [ ] 개발자 문서 + +**최종 마일스톤**: 프로덕션 배포 준비 완료 + +--- + +## 💡 추가 기능 아이디어 + +### 1. 템플릿 갤러리 + +#### 1.1 내장 템플릿 + +``` +📚 템플릿 카테고리: + +1️⃣ 데이터 동기화 +- 테이블 → 테이블 복사 +- 테이블 → 외부 DB 동기화 +- 양방향 동기화 +- 증분 동기화 (변경분만) + +2️⃣ 데이터 정제 +- 중복 제거 +- NULL 값 처리 +- 데이터 형식 변환 +- 데이터 검증 + +3️⃣ 데이터 집계 +- 그룹별 집계 +- 시계열 집계 +- 피벗 테이블 생성 + +4️⃣ 외부 연동 +- REST API → DB +- DB → REST API +- 파일 → DB +- DB → 파일 + +5️⃣ 배치 처리 +- 일일 배치 +- 대용량 데이터 처리 +- 에러 핸들링 +``` + +#### 1.2 사용자 정의 템플릿 + +```typescript +// 템플릿 저장 +const saveAsTemplate = () => { + const template = { + name: "사용자 데이터 동기화", + description: "user_info를 Oracle USERS로 동기화", + category: "custom", + nodes: currentNodes, + edges: currentEdges, + thumbnail: generateThumbnail(), + parameters: extractParameters(), // 매개변수화 + }; + + saveTemplate(template); +}; + +// 템플릿 적용 +const applyTemplate = (template) => { + // 매개변수 입력 받기 + const params = promptParameters(template.parameters); + + // 노드 생성 + const nodes = template.nodes.map((node) => replaceParameters(node, params)); + + addNodes(nodes); + addEdges(template.edges); +}; +``` + +### 2. AI 어시스턴트 + +#### 2.1 자연어 플로우 생성 + +``` +사용자: "사용자 테이블을 Oracle DB로 복사하고 싶어요" + +AI 분석: +1. 소스: user_info 테이블 +2. 타겟: Oracle USERS 테이블 +3. 액션: INSERT + +AI 제안: +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ 📊 user_info │═══════>│ 🔀 필드 매핑 │═══════>│ ➕ INSERT │ +│ │ │ 자동 매핑 │ │ USERS │ +└──────────────┘ └──────────────┘ └──────────────┘ + +"이 플로우를 생성할까요? [생성] [수정]" +``` + +#### 2.2 오류 자동 수정 + +``` +오류 감지: +❌ user_name 필드가 매핑되지 않았습니다 + +AI 제안: +"user_name을 USERNAME으로 자동 매핑할까요?" +[자동 수정] [무시] [수동 수정] +``` + +#### 2.3 최적화 제안 + +``` +AI 분석: +⚠️ 현재 플로우는 10,000건 이상의 데이터를 처리합니다. + +AI 제안: +1. 배치 처리 활성화 (1000건씩) +2. 인덱스가 없는 필드 조건 제거 +3. 불필요한 필드 변환 최적화 + +예상 성능 개선: 3배 +[적용하기] [상세 보기] +``` + +### 3. 실행 추적 (디버그 모드) + +#### 3.1 실시간 실행 상태 + +``` +실행 중 노드 상태: + +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ 📊 user_info │ │ 🔀 필드 매핑 │ │ ➕ INSERT │ +│ ✅ 완료 │═══════>│ ⚡ 실행 중 │═══════>│ ⏳ 대기 중 │ +│ 100건 읽음 │ │ 47/100 │ │ │ +└──────────────┘ └──────────────┘ └──────────────┘ + +진행률: ████████░░ 80% +소요 시간: 00:00:23 +예상 완료: 00:00:29 +``` + +#### 3.2 데이터 미리보기 + +``` +노드 클릭 시 데이터 샘플: + +┌─ user_info 출력 데이터 ─────────────┐ +│ Row 1: │ +│ user_id: 1001 │ +│ user_name: "홍길동" │ +│ email: "hong@example.com" │ +│ │ +│ Row 2: │ +│ user_id: 1002 │ +│ user_name: "김철수" │ +│ ... │ +│ │ +│ [전체 보기] [CSV 다운로드] │ +└─────────────────────────────────────┘ +``` + +#### 3.3 중단점 설정 + +``` +디버그 기능: + +1. 중단점 설정 + - 노드에서 우클릭 → "중단점 설정" + - 해당 노드에서 실행 일시 정지 + +2. 단계별 실행 + [▶️ 다음 노드] [⏭️ 완료까지] [⏹️ 중지] + +3. 변수 감시 + - 특정 필드 값 추적 + - 조건 변화 모니터링 +``` + +### 4. 협업 기능 + +#### 4.1 실시간 공동 편집 + +``` +사용자 표시: +- 👤 김주석 (나) +- 👤 이철수 (편집 중) ← 빨간색 커서 +- 👤 박영희 (보기만) ← 초록색 커서 + +동시 편집: +- 각 사용자의 커서 위치 표시 +- 노드 잠금 (편집 중인 노드) +- 충돌 방지 메커니즘 +``` + +#### 4.2 변경 이력 및 댓글 + +``` +┌──────────────┐ +│ 필드 매핑 │ +│ │ 💬 3 +│ │ +└──────────────┘ + +댓글: +김주석: "user_id를 SALT로 매핑하는게 맞나요?" +이철수: "네, 맞습니다. 보안을 위한 필드입니다." +박영희: "문서에 추가했습니다." +``` + +#### 4.3 승인 워크플로우 + +``` +플로우 변경 승인 프로세스: + +1. 개발자: 플로우 수정 + ↓ +2. 검토 요청 + ↓ +3. 관리자: 변경 사항 검토 + - 변경 전/후 비교 + - 영향도 분석 + ↓ +4. 승인/반려 + ↓ +5. 배포 또는 재작업 +``` + +--- + +## 🎓 학습 리소스 및 온보딩 + +### 1. 인터랙티브 튜토리얼 + +#### 단계별 가이드 + +``` +튜토리얼 1: 첫 번째 플로우 만들기 (5분) +┌─────────────────────────────────────┐ +│ 1단계: 데이터 소스 추가 │ +│ "좌측에서 '테이블' 노드를 드래그" │ +│ │ +│ [다음] [건너뛰기] │ +└─────────────────────────────────────┘ + +튜토리얼 2: 조건 분기 사용하기 (7분) +튜토리얼 3: 복잡한 변환 구현하기 (10분) +튜토리얼 4: 디버깅 및 최적화 (8분) +``` + +### 2. 도움말 시스템 + +#### 컨텍스트 도움말 + +``` +노드 위에 마우스 오버: +┌─────────────────────────────────────┐ +│ 💡 필드 매핑 노드 │ +│ │ +│ 소스 필드를 타겟 필드로 매핑합니다.│ +│ │ +│ 사용 방법: │ +│ 1. 입력 데이터 연결 │ +│ 2. 매핑 규칙 정의 │ +│ 3. 출력을 다음 노드로 연결 │ +│ │ +│ [자세히 보기] [튜토리얼 보기] │ +└─────────────────────────────────────┘ +``` + +### 3. 예제 갤러리 + +``` +📚 예제 플로우 갤러리 + +초급: +- 단순 테이블 복사 +- 필드명 변경하여 복사 +- 특정 레코드만 복사 + +중급: +- 조건부 데이터 처리 +- 다중 테이블 병합 +- 데이터 형식 변환 + +고급: +- 복잡한 데이터 정제 +- 다중 분기 처리 +- 배치 최적화 + +[예제 열기] [템플릿으로 저장] +``` + +--- + +## 📈 성공 지표 (KPI) + +### 사용자 경험 지표 + +``` +1. 학습 시간 + - 목표: 기존 대비 40% 단축 + - 측정: 첫 플로우 완성까지 소요 시간 + - 현재: 평균 15분 + - 목표: 평균 9분 + +2. 작업 효율성 + - 목표: 플로우 생성 시간 50% 단축 + - 측정: 동일 기능 구현 소요 시간 + - 현재: 평균 20분 + - 목표: 평균 10분 + +3. 오류 감소 + - 목표: 설정 오류 60% 감소 + - 측정: 실행 실패율 + - 현재: 12% + - 목표: 4.8% + +4. 사용자 만족도 + - 목표: NPS 40점 이상 + - 측정: 설문 조사 + - 주기: 분기별 +``` + +### 기술 성능 지표 + +``` +1. 렌더링 성능 + - 목표: 100개 노드 렌더링 < 100ms + - 측정: React Profiler + +2. 메모리 사용 + - 목표: 대규모 플로우 < 100MB + - 측정: Chrome DevTools + +3. 저장/로드 속도 + - 목표: 플로우 저장 < 500ms + - 측정: Network 탭 + +4. 검증 속도 + - 목표: 실시간 검증 < 50ms + - 측정: Performance API +``` + +### 비즈니스 지표 + +``` +1. 도입률 + - 목표: 신규 사용자 60% 이상이 노드 에디터 선택 + - 측정: 사용 통계 + +2. 재사용률 + - 목표: 템플릿 재사용 40% 이상 + - 측정: 템플릿 사용 횟수 + +3. 생산성 + - 목표: 플로우 생성 수 2배 증가 + - 측정: 월별 생성된 플로우 수 + +4. 유지보수 + - 목표: 플로우 수정 시간 30% 단축 + - 측정: 평균 수정 소요 시간 +``` + +--- + +## 🔒 보안 및 권한 + +### 1. 접근 제어 + +```typescript +// 플로우 권한 레벨 +enum FlowPermission { + VIEW = "view", // 보기만 + EDIT = "edit", // 편집 가능 + EXECUTE = "execute", // 실행 가능 + ADMIN = "admin", // 모든 권한 +} + +// 권한 체크 +const checkPermission = ( + userId: string, + flowId: number, + permission: FlowPermission +) => { + const userPermissions = getUserFlowPermissions(userId, flowId); + return userPermissions.includes(permission); +}; +``` + +### 2. 민감 정보 보호 + +```typescript +// 비밀번호 등 민감 정보 마스킹 +const renderSensitiveField = (value: string, fieldType: string) => { + if (fieldType === "password" || fieldType === "secret") { + return "••••••••"; + } + return value; +}; + +// 실행 로그에서 민감 정보 제외 +const sanitizeLog = (log: string) => { + return log + .replace(/password=\w+/gi, "password=***") + .replace(/token=\w+/gi, "token=***"); +}; +``` + +### 3. 감사 로그 + +```typescript +// 모든 플로우 변경 기록 +interface AuditLog { + timestamp: Date; + userId: string; + flowId: number; + action: "create" | "update" | "delete" | "execute"; + changes: { + before: any; + after: any; + }; + ipAddress: string; +} + +// 로그 기록 +const logFlowChange = (log: AuditLog) => { + saveAuditLog(log); + + if (isCriticalChange(log)) { + notifyAdmins(log); + } +}; +``` + +--- + +## 🌐 국제화 (i18n) + +### 지원 언어 + +``` +- 한국어 (ko) - 기본 +- 영어 (en) +- 일본어 (ja) +- 중국어 간체 (zh-CN) +``` + +### 번역 대상 + +``` +1. UI 라벨 및 버튼 +2. 노드 타입명 및 설명 +3. 오류 메시지 +4. 도움말 및 튜토리얼 +5. 템플릿명 및 설명 +``` + +--- + +## 📱 반응형 디자인 + +### 화면 크기별 대응 + +``` +1. 데스크톱 (1920px+) + - 전체 기능 사용 가능 + - 3열 레이아웃 (도구-캔버스-속성) + +2. 노트북 (1366px ~ 1920px) + - 속성 패널 접기 가능 + - 2열 레이아웃 + +3. 태블릿 (768px ~ 1366px) + - 도구 패널 오버레이 + - 1열 레이아웃 + 플로팅 패널 + +4. 모바일 (~ 768px) + - 뷰어 모드만 지원 (편집 불가) + - 플로우 실행 및 모니터링 가능 +``` + +--- + +## 🎉 결론 + +노드 기반 데이터 제어 시스템은 현재의 단계별 마법사 방식에 비해 다음과 같은 혁신적인 개선을 제공합니다: + +### 핵심 가치 + +1. **직관성**: 비개발자도 쉽게 이해하고 사용 +2. **효율성**: 작업 시간 50% 이상 단축 +3. **유연성**: 복잡한 로직도 쉽게 표현 +4. **협업**: 팀 간 소통 및 협업 향상 +5. **품질**: 실시간 검증으로 오류 감소 + +### 기대 효과 + +- 사용자 만족도 향상 +- 개발 생산성 증가 +- 유지보수 비용 감소 +- 시스템 안정성 향상 + +### 다음 단계 + +1. 이해관계자 리뷰 및 피드백 +2. 프로토타입 개발 (1주) +3. 사용자 테스트 (1주) +4. 본격 개발 시작 + +--- + +## 📚 참고 자료 + +### 유사 시스템 + +1. **n8n**: 워크플로우 자동화 플랫폼 + - https://n8n.io/ +2. **Node-RED**: IoT 플로우 기반 프로그래밍 + - https://nodered.org/ +3. **Unreal Blueprint**: 게임 개발 비주얼 스크립팅 + - https://docs.unrealengine.com/en-US/ProgrammingAndScripting/Blueprints/ + +### 기술 문서 + +1. **React Flow 공식 문서** + - https://reactflow.dev/ +2. **React Flow 예제** + - https://reactflow.dev/examples +3. **TypeScript 베스트 프랙티스** + - https://www.typescriptlang.org/docs/ + +### 디자인 가이드 + +1. **Material Design - Data Visualization** + - https://material.io/design/communication/data-visualization.html +2. **Blueprint UI Framework** + - https://blueprintjs.com/ + +--- + +**문서 버전**: 1.0 +**최종 수정**: 2025-10-02 +**작성자**: 개발팀 +**검토자**: 제품팀, 디자인팀 +**승인자**: CTO diff --git a/docs/노드_시스템_버튼_통합_분석.md b/docs/노드_시스템_버튼_통합_분석.md new file mode 100644 index 00000000..c1a97517 --- /dev/null +++ b/docs/노드_시스템_버튼_통합_분석.md @@ -0,0 +1,939 @@ +# 노드 시스템 - 버튼 통합 호환성 분석 + +**작성일**: 2025-01-02 +**버전**: 1.0 +**상태**: 🔍 분석 완료 + +--- + +## 📋 목차 + +1. [개요](#개요) +2. [현재 시스템 분석](#현재-시스템-분석) +3. [호환성 분석](#호환성-분석) +4. [통합 전략](#통합-전략) +5. [마이그레이션 계획](#마이그레이션-계획) + +--- + +## 개요 + +### 목적 + +화면관리의 버튼 컴포넌트에 할당된 기존 제어 시스템을 새로운 노드 기반 제어 시스템으로 전환하기 위한 호환성 분석 + +### 비교 대상 + +- **현재**: `relationshipId` 기반 제어 시스템 +- **신규**: `flowId` 기반 노드 제어 시스템 + +--- + +## 현재 시스템 분석 + +### 1. 데이터 구조 + +#### ButtonDataflowConfig + +```typescript +interface ButtonDataflowConfig { + controlMode: "relationship" | "none"; + + relationshipConfig?: { + relationshipId: string; // 🔑 핵심: 관계 ID + relationshipName: string; + executionTiming: "before" | "after" | "replace"; + contextData?: Record; + }; + + controlDataSource?: "form" | "table-selection" | "both"; + executionOptions?: ExecutionOptions; +} +``` + +#### 관계 데이터 구조 + +```typescript +{ + relationshipId: "rel-123", + conditions: [ + { + field: "status", + operator: "equals", + value: "active" + } + ], + actionGroups: [ + { + name: "메인 액션", + actions: [ + { + type: "database", + operation: "INSERT", + tableName: "users", + fields: [...] + } + ] + } + ] +} +``` + +--- + +### 2. 실행 흐름 + +``` +┌─────────────────────────────────────┐ +│ 1. 버튼 클릭 │ +│ OptimizedButtonComponent.tsx │ +└─────────────┬───────────────────────┘ + ↓ +┌─────────────────────────────────────┐ +│ 2. executeButtonAction() │ +│ ImprovedButtonActionExecutor.ts │ +│ - executionPlan 생성 │ +│ - before/after/replace 구분 │ +└─────────────┬───────────────────────┘ + ↓ +┌─────────────────────────────────────┐ +│ 3. executeControls() │ +│ - relationshipId로 관계 조회 │ +│ - 조건 검증 │ +└─────────────┬───────────────────────┘ + ↓ +┌─────────────────────────────────────┐ +│ 4. evaluateConditions() │ +│ - formData 검증 │ +│ - selectedRowsData 검증 │ +└─────────────┬───────────────────────┘ + ↓ +┌─────────────────────────────────────┐ +│ 5. executeDataAction() │ +│ - INSERT/UPDATE/DELETE 실행 │ +│ - 순차적 액션 실행 │ +└─────────────────────────────────────┘ +``` + +--- + +### 3. 데이터 전달 방식 + +#### 입력 데이터 + +```typescript +{ + formData: { + name: "김철수", + email: "test@example.com", + status: "active" + }, + selectedRowsData: [ + { id: 1, name: "이영희" }, + { id: 2, name: "박민수" } + ], + context: { + buttonId: "btn-1", + screenId: 123, + companyCode: "COMPANY_A", + userId: "user-1" + } +} +``` + +#### 액션 실행 시 + +```typescript +// 각 액션에 전체 데이터 전달 +executeDataAction(action, { + formData, + selectedRowsData, + context, +}); +``` + +--- + +## 새로운 노드 시스템 분석 + +### 1. 데이터 구조 + +#### FlowData + +```typescript +interface FlowData { + flowId: number; + flowName: string; + flowDescription: string; + nodes: FlowNode[]; // 🔑 핵심: 노드 배열 + edges: FlowEdge[]; // 🔑 핵심: 연결 정보 +} +``` + +#### 노드 예시 + +```typescript +// 소스 노드 +{ + id: "source-1", + type: "tableSource", + data: { + tableName: "users", + schema: "public", + outputFields: [...] + } +} + +// 조건 노드 +{ + id: "condition-1", + type: "condition", + data: { + conditions: [{ + field: "status", + operator: "equals", + value: "active" + }], + logic: "AND" + } +} + +// 액션 노드 +{ + id: "insert-1", + type: "insertAction", + data: { + targetTable: "users", + fieldMappings: [...] + } +} +``` + +#### 연결 예시 + +```typescript +// 엣지 (노드 간 연결) +{ + id: "edge-1", + source: "source-1", + target: "condition-1" +}, +{ + id: "edge-2", + source: "condition-1", + target: "insert-1", + sourceHandle: "true" // TRUE 분기 +} +``` + +--- + +### 2. 실행 흐름 + +``` +┌─────────────────────────────────────┐ +│ 1. 버튼 클릭 │ +│ FlowEditor 또는 Button Component │ +└─────────────┬───────────────────────┘ + ↓ +┌─────────────────────────────────────┐ +│ 2. executeFlow() │ +│ - flowId로 플로우 조회 │ +│ - nodes + edges 로드 │ +└─────────────┬───────────────────────┘ + ↓ +┌─────────────────────────────────────┐ +│ 3. topologicalSort() │ +│ - 노드 의존성 분석 │ +│ - 실행 순서 결정 │ +│ Result: [["source"], ["insert", "update"]] │ +└─────────────┬───────────────────────┘ + ↓ +┌─────────────────────────────────────┐ +│ 4. executeLevel() │ +│ - 같은 레벨 노드 병렬 실행 │ +│ - Promise.allSettled 사용 │ +└─────────────┬───────────────────────┘ + ↓ +┌─────────────────────────────────────┐ +│ 5. executeNode() │ +│ - 부모 노드 상태 확인 │ +│ - 실패 시 스킵 │ +└─────────────┬───────────────────────┘ + ↓ +┌─────────────────────────────────────┐ +│ 6. executeActionWithTransaction() │ +│ - 독립 트랜잭션 시작 │ +│ - 액션 실행 │ +│ - 성공 시 커밋, 실패 시 롤백 │ +└─────────────────────────────────────┘ +``` + +--- + +### 3. 데이터 전달 방식 + +#### ExecutionContext + +```typescript +{ + sourceData: [ + { id: 1, name: "김철수", status: "active" }, + { id: 2, name: "이영희", status: "inactive" } + ], + nodeResults: Map { + "source-1" => { status: "success", data: [...] }, + "condition-1" => { status: "success", data: true }, + "insert-1" => { status: "success", data: { insertedCount: 1 } } + }, + executionOrder: ["source-1", "condition-1", "insert-1"] +} +``` + +#### 노드 실행 시 + +```typescript +// 부모 노드 결과 전달 +const inputData = prepareInputData(node, parents, context); + +// 부모가 하나면 부모의 결과 데이터 +// 부모가 여러 개면 모든 부모의 데이터 병합 +``` + +--- + +## 호환성 분석 + +### ✅ 호환 가능한 부분 + +#### 1. 조건 검증 + +**현재**: + +```typescript +{ + field: "status", + operator: "equals", + value: "active" +} +``` + +**신규**: + +```typescript +{ + type: "condition", + data: { + conditions: [ + { + field: "status", + operator: "equals", + value: "active" + } + ] + } +} +``` + +**결론**: ✅ **조건 구조가 거의 동일** → 마이그레이션 쉬움 + +--- + +#### 2. 액션 실행 + +**현재**: + +```typescript +{ + type: "database", + operation: "INSERT", + tableName: "users", + fields: [ + { name: "name", value: "김철수" } + ] +} +``` + +**신규**: + +```typescript +{ + type: "insertAction", + data: { + targetTable: "users", + fieldMappings: [ + { sourceField: "name", targetField: "name" } + ] + } +} +``` + +**결론**: ✅ **액션 개념이 동일** → 필드명만 변환하면 됨 + +--- + +#### 3. 데이터 소스 + +**현재**: + +```typescript +controlDataSource: "form" | "table-selection" | "both"; +``` + +**신규**: + +```typescript +{ + type: "tableSource", // 테이블 선택 데이터 + // 또는 + type: "manualInput", // 폼 데이터 +} +``` + +**결론**: ✅ **소스 타입 매핑 가능** + +--- + +### ⚠️ 차이점 및 주의사항 + +#### 1. 실행 타이밍 + +**현재**: + +```typescript +executionTiming: "before" | "after" | "replace"; +``` + +**신규**: + +``` +노드 그래프 자체가 실행 순서를 정의 +타이밍은 노드 연결로 표현됨 +``` + +**문제점**: + +- `before/after` 개념이 노드에 없음 +- 버튼의 기본 액션과 제어를 어떻게 조합할지? + +**해결 방안**: + +``` +Option A: 버튼 액션을 노드로 표현 + Button → [Before Nodes] → [Button Action Node] → [After Nodes] + +Option B: 실행 시점 지정 + flowConfig: { + flowId: 123, + timing: "before" | "after" | "replace" + } +``` + +--- + +#### 2. ActionGroups vs 병렬 실행 + +**현재**: + +```typescript +actionGroups: [ + { + name: "그룹1", + actions: [action1, action2], // 순차 실행 + }, +]; +``` + +**신규**: + +``` +소스 + ↓ + ├─→ INSERT (병렬) + ├─→ UPDATE (병렬) + └─→ DELETE (병렬) +``` + +**문제점**: + +- 현재는 "그룹 내 순차, 그룹 간 조건부" +- 신규는 "레벨별 병렬, 연쇄 중단" + +**해결 방안**: + +``` +노드 연결로 순차/병렬 표현: + +순차: INSERT → UPDATE → DELETE +병렬: Source → INSERT + → UPDATE + → DELETE +``` + +--- + +#### 3. 데이터 전달 방식 + +**현재**: + +```typescript +// 모든 액션에 동일한 데이터 전달 +executeDataAction(action, { + formData, + selectedRowsData, + context, +}); +``` + +**신규**: + +```typescript +// 부모 노드 결과를 자식에게 전달 +const inputData = parentResult.data || sourceData; +``` + +**문제점**: + +- 현재는 "원본 데이터 공유" +- 신규는 "결과 데이터 체이닝" + +**해결 방안**: + +```typescript +// 버튼 실행 시 초기 데이터 설정 +context.sourceData = { + formData, + selectedRowsData, +}; + +// 각 노드는 필요에 따라 선택 +- formData 사용 +- 부모 결과 사용 +- 둘 다 사용 +``` + +--- + +#### 4. 컨텍스트 정보 + +**현재**: + +```typescript +{ + buttonId: "btn-1", + screenId: 123, + companyCode: "COMPANY_A", + userId: "user-1" +} +``` + +**신규**: + +```typescript +// ExecutionContext에 추가 필요 +{ + sourceData: [...], + nodeResults: Map(), + // 🆕 추가 필요 + buttonContext?: { + buttonId: string, + screenId: number, + companyCode: string, + userId: string + } +} +``` + +**결론**: ✅ **컨텍스트 확장 가능** + +--- + +## 통합 전략 + +### 전략 1: 하이브리드 방식 (권장 ⭐⭐⭐) + +#### 개념 + +버튼 설정에서 `relationshipId` 대신 `flowId`를 저장하고, 기존 타이밍 개념 유지 + +#### 버튼 설정 + +```typescript +interface ButtonDataflowConfig { + controlMode: "flow"; // 🆕 신규 모드 + + flowConfig?: { + flowId: number; // 🔑 노드 플로우 ID + flowName: string; + executionTiming: "before" | "after" | "replace"; // 기존 유지 + contextData?: Record; + }; + + controlDataSource?: "form" | "table-selection" | "both"; +} +``` + +#### 실행 로직 + +```typescript +async function executeButtonWithFlow( + buttonConfig: ButtonDataflowConfig, + formData: Record, + context: ButtonExecutionContext +) { + const { flowConfig } = buttonConfig; + + // 1. 플로우 조회 + const flow = await getNodeFlow(flowConfig.flowId); + + // 2. 초기 데이터 준비 + const executionContext: ExecutionContext = { + sourceData: prepareSourceData(formData, context), + nodeResults: new Map(), + executionOrder: [], + buttonContext: { + // 🆕 버튼 컨텍스트 추가 + buttonId: context.buttonId, + screenId: context.screenId, + companyCode: context.companyCode, + userId: context.userId, + }, + }; + + // 3. 타이밍에 따라 실행 + switch (flowConfig.executionTiming) { + case "before": + await executeFlow(flow, executionContext); + await executeOriginalButtonAction(buttonConfig, context); + break; + + case "after": + await executeOriginalButtonAction(buttonConfig, context); + await executeFlow(flow, executionContext); + break; + + case "replace": + await executeFlow(flow, executionContext); + break; + } +} +``` + +#### 소스 데이터 준비 + +```typescript +function prepareSourceData( + formData: Record, + context: ButtonExecutionContext +): any[] { + const { controlDataSource, selectedRowsData } = context; + + switch (controlDataSource) { + case "form": + return [formData]; // 폼 데이터를 배열로 + + case "table-selection": + return selectedRowsData || []; // 테이블 선택 데이터 + + case "both": + return [ + { source: "form", data: formData }, + { source: "table", data: selectedRowsData }, + ]; + + default: + return [formData]; + } +} +``` + +--- + +### 전략 2: 완전 전환 방식 + +#### 개념 + +버튼 액션 자체를 노드로 표현 (버튼 = 플로우 트리거) + +#### 플로우 구조 + +``` +ManualInput (formData) + ↓ +Condition (status == "active") + ↓ + ┌─┴─┐ +TRUE FALSE + ↓ ↓ +INSERT CANCEL + ↓ +ButtonAction (원래 버튼 액션) +``` + +#### 장점 + +- ✅ 시스템 단순화 (노드만 존재) +- ✅ 시각적으로 명확 +- ✅ 유연한 워크플로우 + +#### 단점 + +- ⚠️ 기존 버튼 개념 변경 +- ⚠️ 마이그레이션 복잡 +- ⚠️ UI 학습 곡선 + +--- + +## 마이그레이션 계획 + +### Phase 1: 하이브리드 지원 + +#### 목표 + +기존 `relationshipId` 방식과 새로운 `flowId` 방식 모두 지원 + +#### 작업 + +1. **ButtonDataflowConfig 확장** + +```typescript +interface ButtonDataflowConfig { + controlMode: "relationship" | "flow" | "none"; + + // 기존 (하위 호환) + relationshipConfig?: { + relationshipId: string; + executionTiming: "before" | "after" | "replace"; + }; + + // 🆕 신규 + flowConfig?: { + flowId: number; + executionTiming: "before" | "after" | "replace"; + }; +} +``` + +2. **실행 로직 분기** + +```typescript +if (buttonConfig.controlMode === "flow") { + await executeButtonWithFlow(buttonConfig, formData, context); +} else if (buttonConfig.controlMode === "relationship") { + await executeButtonWithRelationship(buttonConfig, formData, context); +} +``` + +3. **UI 업데이트** + +- 버튼 설정에 "제어 방식 선택" 추가 +- "기존 관계" vs "노드 플로우" 선택 가능 + +--- + +### Phase 2: 마이그레이션 도구 + +#### 관계 → 플로우 변환기 + +```typescript +async function migrateRelationshipToFlow( + relationshipId: string +): Promise { + // 1. 기존 관계 조회 + const relationship = await getRelationship(relationshipId); + + // 2. 노드 생성 + const nodes: FlowNode[] = []; + const edges: FlowEdge[] = []; + + // 소스 노드 (formData 또는 table) + const sourceNode = { + id: "source-1", + type: "manualInput", + data: { fields: extractFields(relationship) }, + }; + nodes.push(sourceNode); + + // 조건 노드 + if (relationship.conditions.length > 0) { + const conditionNode = { + id: "condition-1", + type: "condition", + data: { + conditions: relationship.conditions, + logic: relationship.logic || "AND", + }, + }; + nodes.push(conditionNode); + edges.push({ id: "e1", source: "source-1", target: "condition-1" }); + } + + // 액션 노드들 + let lastNodeId = + relationship.conditions.length > 0 ? "condition-1" : "source-1"; + + relationship.actionGroups.forEach((group, groupIdx) => { + group.actions.forEach((action, actionIdx) => { + const actionNodeId = `action-${groupIdx}-${actionIdx}`; + const actionNode = convertActionToNode(action, actionNodeId); + nodes.push(actionNode); + + edges.push({ + id: `e-${actionNodeId}`, + source: lastNodeId, + target: actionNodeId, + }); + + // 순차 실행인 경우 + if (group.sequential) { + lastNodeId = actionNodeId; + } + }); + }); + + // 3. 플로우 저장 + const flowData = { + flowName: `Migrated: ${relationship.name}`, + flowDescription: `Migrated from relationship ${relationshipId}`, + flowData: JSON.stringify({ nodes, edges }), + }; + + const { flowId } = await createNodeFlow(flowData); + + // 4. 버튼 설정 업데이트 + await updateButtonConfig(relationshipId, { + controlMode: "flow", + flowConfig: { + flowId, + executionTiming: relationship.timing || "before", + }, + }); + + return flowId; +} +``` + +#### 액션 변환 로직 + +```typescript +function convertActionToNode(action: DataflowAction, nodeId: string): FlowNode { + switch (action.operation) { + case "INSERT": + return { + id: nodeId, + type: "insertAction", + data: { + targetTable: action.tableName, + fieldMappings: action.fields.map((f) => ({ + sourceField: f.name, + targetField: f.name, + staticValue: f.type === "static" ? f.value : undefined, + })), + }, + }; + + case "UPDATE": + return { + id: nodeId, + type: "updateAction", + data: { + targetTable: action.tableName, + whereConditions: action.conditions, + fieldMappings: action.fields.map((f) => ({ + sourceField: f.name, + targetField: f.name, + })), + }, + }; + + case "DELETE": + return { + id: nodeId, + type: "deleteAction", + data: { + targetTable: action.tableName, + whereConditions: action.conditions, + }, + }; + + default: + throw new Error(`Unsupported operation: ${action.operation}`); + } +} +``` + +--- + +### Phase 3: 완전 전환 + +#### 목표 + +모든 버튼이 노드 플로우 방식 사용 + +#### 작업 + +1. **마이그레이션 스크립트 실행** + +```sql +-- 모든 관계를 플로우로 변환 +SELECT migrate_all_relationships_to_flows(); +``` + +2. **UI에서 관계 모드 제거** + +```typescript +// controlMode에서 "relationship" 제거 +type ControlMode = "flow" | "none"; +``` + +3. **레거시 코드 정리** + +- `executeButtonWithRelationship()` 제거 +- `RelationshipService` 제거 (또는 읽기 전용) + +--- + +## 결론 + +### ✅ 호환 가능 + +노드 시스템과 버튼 제어 시스템은 **충분히 호환 가능**합니다! + +### 🎯 권장 방안 + +**하이브리드 방식 (전략 1)**으로 점진적 마이그레이션 + +#### 이유 + +1. ✅ **기존 시스템 유지** - 서비스 중단 없음 +2. ✅ **점진적 전환** - 리스크 최소화 +3. ✅ **유연성** - 두 방식 모두 활용 가능 +4. ✅ **학습 곡선** - 사용자가 천천히 적응 + +### 📋 다음 단계 + +1. **Phase 1 구현** (예상: 2일) + + - `ButtonDataflowConfig` 확장 + - `executeButtonWithFlow()` 구현 + - UI 선택 옵션 추가 + +2. **Phase 2 도구 개발** (예상: 1일) + + - 마이그레이션 스크립트 + - 자동 변환 로직 + +3. **Phase 3 전환** (예상: 1일) + - 데이터 마이그레이션 + - 레거시 제거 + +### 총 소요 시간 + +**약 4일** + +--- + +**참고 문서**: + +- [노드\_실행\_엔진\_설계.md](./노드_실행_엔진_설계.md) +- [노드\_기반\_제어\_시스템\_개선\_계획.md](./노드_기반_제어_시스템_개선_계획.md) diff --git a/docs/노드_실행_엔진_설계.md b/docs/노드_실행_엔진_설계.md new file mode 100644 index 00000000..d5444a39 --- /dev/null +++ b/docs/노드_실행_엔진_설계.md @@ -0,0 +1,617 @@ +# 노드 실행 엔진 설계 + +**작성일**: 2025-01-02 +**버전**: 1.0 +**상태**: ✅ 확정 + +--- + +## 📋 목차 + +1. [개요](#개요) +2. [실행 방식](#실행-방식) +3. [데이터 흐름](#데이터-흐름) +4. [오류 처리](#오류-처리) +5. [구현 계획](#구현-계획) + +--- + +## 개요 + +### 목적 + +노드 기반 데이터 플로우의 실행 엔진을 설계하여: + +- 효율적인 병렬 처리 +- 안정적인 오류 처리 +- 명확한 데이터 흐름 + +### 핵심 원칙 + +1. **독립적 트랜잭션**: 각 액션 노드는 독립적인 트랜잭션 +2. **부분 실패 허용**: 일부 실패해도 성공한 노드는 커밋 +3. **연쇄 중단**: 부모 노드 실패 시 자식 노드 스킵 +4. **병렬 실행**: 의존성 없는 노드는 병렬 실행 + +--- + +## 실행 방식 + +### 1. 기본 구조 + +```typescript +interface ExecutionContext { + sourceData: any[]; // 원본 데이터 + nodeResults: Map; // 각 노드 실행 결과 + executionOrder: string[]; // 실행 순서 +} + +interface NodeResult { + nodeId: string; + status: "pending" | "success" | "failed" | "skipped"; + data?: any; + error?: Error; + startTime: number; + endTime?: number; +} +``` + +--- + +### 2. 실행 단계 + +#### Step 1: 위상 정렬 (Topological Sort) + +노드 간 의존성을 파악하여 실행 순서 결정 + +```typescript +function topologicalSort(nodes: FlowNode[], edges: FlowEdge[]): string[][] { + // DAG(Directed Acyclic Graph) 순회 + // 같은 레벨의 노드들은 배열로 그룹화 + + return [ + ["tableSource-1"], // Level 0: 소스 + ["insert-1", "update-1", "delete-1"], // Level 1: 병렬 실행 가능 + ["update-2"], // Level 2: insert-1에 의존 + ]; +} +``` + +#### Step 2: 레벨별 실행 + +```typescript +async function executeFlow( + nodes: FlowNode[], + edges: FlowEdge[] +): Promise { + const levels = topologicalSort(nodes, edges); + const context: ExecutionContext = { + sourceData: [], + nodeResults: new Map(), + executionOrder: [], + }; + + for (const level of levels) { + // 같은 레벨의 노드들은 병렬 실행 + await executeLevel(level, nodes, context); + } + + return generateExecutionReport(context); +} +``` + +#### Step 3: 레벨 내 병렬 실행 + +```typescript +async function executeLevel( + nodeIds: string[], + nodes: FlowNode[], + context: ExecutionContext +): Promise { + // Promise.allSettled로 병렬 실행 + const results = await Promise.allSettled( + nodeIds.map((nodeId) => executeNode(nodeId, nodes, context)) + ); + + // 결과 저장 + results.forEach((result, index) => { + const nodeId = nodeIds[index]; + if (result.status === "fulfilled") { + context.nodeResults.set(nodeId, result.value); + } else { + context.nodeResults.set(nodeId, { + nodeId, + status: "failed", + error: result.reason, + startTime: Date.now(), + endTime: Date.now(), + }); + } + }); +} +``` + +--- + +## 데이터 흐름 + +### 1. 소스 노드 실행 + +```typescript +async function executeSourceNode(node: TableSourceNode): Promise { + const { tableName, schema, whereConditions } = node.data; + + // 데이터베이스 쿼리 실행 + const query = buildSelectQuery(tableName, schema, whereConditions); + const data = await executeQuery(query); + + return data; +} +``` + +**결과 예시**: + +```json +[ + { "id": 1, "name": "김철수", "age": 30 }, + { "id": 2, "name": "이영희", "age": 25 }, + { "id": 3, "name": "박민수", "age": 35 } +] +``` + +--- + +### 2. 액션 노드 실행 + +#### 데이터 전달 방식 + +```typescript +async function executeNode( + nodeId: string, + nodes: FlowNode[], + context: ExecutionContext +): Promise { + const node = nodes.find((n) => n.id === nodeId); + const parents = getParentNodes(nodeId, edges); + + // 1️⃣ 부모 노드 상태 확인 + const parentFailed = parents.some((p) => { + const parentResult = context.nodeResults.get(p.id); + return parentResult?.status === "failed"; + }); + + if (parentFailed) { + return { + nodeId, + status: "skipped", + error: new Error("Parent node failed"), + startTime: Date.now(), + endTime: Date.now(), + }; + } + + // 2️⃣ 입력 데이터 준비 + const inputData = prepareInputData(node, parents, context); + + // 3️⃣ 액션 실행 (독립 트랜잭션) + return await executeActionWithTransaction(node, inputData); +} +``` + +#### 입력 데이터 준비 + +```typescript +function prepareInputData( + node: FlowNode, + parents: FlowNode[], + context: ExecutionContext +): any { + if (parents.length === 0) { + // 소스 노드 + return null; + } else if (parents.length === 1) { + // 단일 부모: 부모의 결과 데이터 전달 + const parentResult = context.nodeResults.get(parents[0].id); + return parentResult?.data || context.sourceData; + } else { + // 다중 부모: 모든 부모의 데이터 병합 + return parents.map((p) => { + const result = context.nodeResults.get(p.id); + return result?.data || context.sourceData; + }); + } +} +``` + +--- + +### 3. 병렬 실행 예시 + +``` + TableSource + (100개 레코드) + ↓ + ┌──────┼──────┐ + ↓ ↓ ↓ + INSERT UPDATE DELETE + (독립) (독립) (독립) +``` + +**실행 과정**: + +```typescript +// 1. TableSource 실행 +const sourceData = await executeTableSource(); +// → [100개 레코드] + +// 2. 병렬 실행 (Promise.allSettled) +const results = await Promise.allSettled([ + executeInsertAction(insertNode, sourceData), + executeUpdateAction(updateNode, sourceData), + executeDeleteAction(deleteNode, sourceData), +]); + +// 3. 각 액션은 독립 트랜잭션 +// - INSERT 실패 → INSERT만 롤백 +// - UPDATE 성공 → UPDATE 커밋 +// - DELETE 성공 → DELETE 커밋 +``` + +--- + +### 4. 연쇄 실행 예시 + +``` + TableSource + ↓ + INSERT + ❌ (실패) + ↓ + UPDATE-2 + ⏭️ (스킵) +``` + +**실행 과정**: + +```typescript +// 1. TableSource 실행 +const sourceData = await executeTableSource(); +// → 성공 ✅ + +// 2. INSERT 실행 +const insertResult = await executeInsertAction(insertNode, sourceData); +// → 실패 ❌ (롤백됨) + +// 3. UPDATE-2 실행 시도 +const parentFailed = insertResult.status === "failed"; +if (parentFailed) { + return { + status: "skipped", + reason: "Parent INSERT failed", + }; + // → 스킬 ⏭️ +} +``` + +--- + +## 오류 처리 + +### 1. 독립 트랜잭션 + +각 액션 노드는 자체 트랜잭션을 가짐 + +```typescript +async function executeActionWithTransaction( + node: FlowNode, + inputData: any +): Promise { + // 트랜잭션 시작 + const transaction = await db.beginTransaction(); + + try { + const result = await performAction(node, inputData, transaction); + + // 성공 시 커밋 + await transaction.commit(); + + return { + nodeId: node.id, + status: "success", + data: result, + startTime: Date.now(), + endTime: Date.now(), + }; + } catch (error) { + // 실패 시 롤백 + await transaction.rollback(); + + return { + nodeId: node.id, + status: "failed", + error: error, + startTime: Date.now(), + endTime: Date.now(), + }; + } +} +``` + +--- + +### 2. 부분 실패 허용 + +```typescript +// Promise.allSettled 사용 +const results = await Promise.allSettled([action1(), action2(), action3()]); + +// 결과 수집 +const summary = { + total: results.length, + success: results.filter((r) => r.status === "fulfilled").length, + failed: results.filter((r) => r.status === "rejected").length, + details: results, +}; +``` + +**예시 결과**: + +```json +{ + "total": 3, + "success": 2, + "failed": 1, + "details": [ + { "status": "rejected", "reason": "Duplicate key error" }, + { "status": "fulfilled", "value": { "updatedCount": 100 } }, + { "status": "fulfilled", "value": { "deletedCount": 50 } } + ] +} +``` + +--- + +### 3. 연쇄 중단 + +부모 노드 실패 시 자식 노드 자동 스킵 + +```typescript +function shouldSkipNode(node: FlowNode, context: ExecutionContext): boolean { + const parents = getParentNodes(node.id); + + return parents.some((parent) => { + const parentResult = context.nodeResults.get(parent.id); + return parentResult?.status === "failed"; + }); +} +``` + +--- + +### 4. 오류 메시지 + +```typescript +interface ExecutionError { + nodeId: string; + nodeName: string; + errorType: "validation" | "execution" | "connection" | "timeout"; + message: string; + details?: any; + timestamp: number; +} +``` + +**오류 메시지 예시**: + +```json +{ + "nodeId": "insert-1", + "nodeName": "INSERT 액션", + "errorType": "execution", + "message": "Duplicate key error: 'email' already exists", + "details": { + "table": "users", + "constraint": "users_email_unique", + "value": "test@example.com" + }, + "timestamp": 1704182400000 +} +``` + +--- + +## 구현 계획 + +### Phase 1: 기본 실행 엔진 (우선순위: 높음) + +**작업 항목**: + +1. ✅ 위상 정렬 알고리즘 구현 +2. ✅ 레벨별 실행 로직 +3. ✅ Promise.allSettled 기반 병렬 실행 +4. ✅ 독립 트랜잭션 처리 +5. ✅ 연쇄 중단 로직 + +**예상 시간**: 1일 + +--- + +### Phase 2: 소스 노드 실행 (우선순위: 높음) + +**작업 항목**: + +1. ✅ TableSource 실행기 +2. ✅ ExternalDBSource 실행기 +3. ✅ RestAPISource 실행기 +4. ✅ 데이터 캐싱 + +**예상 시간**: 1일 + +--- + +### Phase 3: 액션 노드 실행 (우선순위: 높음) + +**작업 항목**: + +1. ✅ INSERT 액션 실행기 +2. ✅ UPDATE 액션 실행기 +3. ✅ DELETE 액션 실행기 +4. ✅ UPSERT 액션 실행기 +5. ✅ 필드 매핑 적용 + +**예상 시간**: 2일 + +--- + +### Phase 4: 변환 노드 실행 (우선순위: 중간) + +**작업 항목**: + +1. ✅ FieldMapping 실행기 +2. ✅ DataTransform 실행기 +3. ✅ Condition 분기 처리 + +**예상 시간**: 1일 + +--- + +### Phase 5: 오류 처리 및 모니터링 (우선순위: 중간) + +**작업 항목**: + +1. ✅ 상세 오류 메시지 +2. ✅ 실행 결과 리포트 +3. ✅ 실행 로그 저장 +4. ✅ 실시간 진행 상태 표시 + +**예상 시간**: 1일 + +--- + +### Phase 6: 최적화 (우선순위: 낮음) + +**작업 항목**: + +1. ⏳ 데이터 스트리밍 (대용량 데이터) +2. ⏳ 배치 처리 최적화 +3. ⏳ 병렬 처리 튜닝 +4. ⏳ 캐싱 전략 + +**예상 시간**: 2일 + +--- + +## 실행 결과 예시 + +### 성공 케이스 + +```json +{ + "flowId": "flow-123", + "flowName": "사용자 데이터 동기화", + "status": "completed", + "startTime": "2025-01-02T10:00:00Z", + "endTime": "2025-01-02T10:00:05Z", + "duration": 5000, + "nodes": [ + { + "nodeId": "source-1", + "nodeName": "TableSource", + "status": "success", + "recordCount": 100, + "duration": 500 + }, + { + "nodeId": "insert-1", + "nodeName": "INSERT", + "status": "success", + "insertedCount": 100, + "duration": 2000 + }, + { + "nodeId": "update-1", + "nodeName": "UPDATE", + "status": "success", + "updatedCount": 80, + "duration": 1500 + } + ], + "summary": { + "total": 3, + "success": 3, + "failed": 0, + "skipped": 0 + } +} +``` + +--- + +### 부분 실패 케이스 + +```json +{ + "flowId": "flow-124", + "flowName": "데이터 처리", + "status": "partial_success", + "startTime": "2025-01-02T11:00:00Z", + "endTime": "2025-01-02T11:00:08Z", + "duration": 8000, + "nodes": [ + { + "nodeId": "source-1", + "nodeName": "TableSource", + "status": "success", + "recordCount": 100 + }, + { + "nodeId": "insert-1", + "nodeName": "INSERT", + "status": "failed", + "error": "Duplicate key error", + "details": "email 'test@example.com' already exists" + }, + { + "nodeId": "update-2", + "nodeName": "UPDATE-2", + "status": "skipped", + "reason": "Parent INSERT failed" + }, + { + "nodeId": "update-1", + "nodeName": "UPDATE", + "status": "success", + "updatedCount": 50 + }, + { + "nodeId": "delete-1", + "nodeName": "DELETE", + "status": "success", + "deletedCount": 20 + } + ], + "summary": { + "total": 5, + "success": 3, + "failed": 1, + "skipped": 1 + } +} +``` + +--- + +## 다음 단계 + +1. ✅ 데이터 처리 방식 확정 (완료) +2. ⏳ 실행 엔진 구현 시작 +3. ⏳ 테스트 케이스 작성 +4. ⏳ UI에서 실행 결과 표시 + +--- + +**참고 문서**: + +- [노드*기반*제어*시스템*개선\_계획.md](./노드_기반_제어_시스템_개선_계획.md) +- [노드*연결*규칙\_설계.md](./노드_연결_규칙_설계.md) +- [노드*구조*개선안.md](./노드_구조_개선안.md) diff --git a/docs/노드_연결_규칙_설계.md b/docs/노드_연결_규칙_설계.md new file mode 100644 index 00000000..2fd16bcd --- /dev/null +++ b/docs/노드_연결_규칙_설계.md @@ -0,0 +1,431 @@ +# 노드 연결 규칙 설계 + +**작성일**: 2025-01-02 +**버전**: 1.0 +**상태**: 🔄 설계 중 + +--- + +## 📋 목차 + +1. [개요](#개요) +2. [노드 분류](#노드-분류) +3. [연결 규칙 매트릭스](#연결-규칙-매트릭스) +4. [상세 연결 규칙](#상세-연결-규칙) +5. [구현 계획](#구현-계획) + +--- + +## 개요 + +### 목적 + +노드 간 연결 가능 여부를 명확히 정의하여: + +- 사용자의 실수 방지 +- 논리적으로 올바른 플로우만 생성 가능 +- 명확한 오류 메시지 제공 + +### 기본 원칙 + +1. **데이터 흐름 방향**: 소스 → 변환 → 액션 +2. **타입 안전성**: 출력과 입력 타입이 호환되어야 함 +3. **논리적 정합성**: 의미 없는 연결 방지 + +--- + +## 노드 분류 + +### 1. 데이터 소스 노드 (Source) + +**역할**: 데이터를 생성하는 시작점 + +- `tableSource` - 내부 테이블 +- `externalDBSource` - 외부 DB +- `restAPISource` - REST API + +**특징**: + +- ✅ 출력만 가능 (소스 핸들) +- ❌ 입력 불가능 +- 플로우의 시작점 + +--- + +### 2. 변환/조건 노드 (Transform) + +**역할**: 데이터를 가공하거나 흐름을 제어 + +#### 2.1 데이터 변환 + +- `fieldMapping` - 필드 매핑 +- `dataTransform` - 데이터 변환 + +**특징**: + +- ✅ 입력 가능 (타겟 핸들) +- ✅ 출력 가능 (소스 핸들) +- 중간 파이프라인 역할 + +#### 2.2 조건 분기 + +- `condition` - 조건 분기 + +**특징**: + +- ✅ 입력 가능 (타겟 핸들) +- ✅ 출력 가능 (TRUE/FALSE 2개의 소스 핸들) +- 흐름을 분기 + +--- + +### 3. 액션 노드 (Action) + +**역할**: 실제 데이터베이스 작업 수행 + +- `insertAction` - INSERT +- `updateAction` - UPDATE +- `deleteAction` - DELETE +- `upsertAction` - UPSERT + +**특징**: + +- ✅ 입력 가능 (타겟 핸들) +- ⚠️ 출력 제한적 (성공/실패 결과만) +- 플로우의 종착점 또는 중간 액션 + +--- + +### 4. 유틸리티 노드 (Utility) + +**역할**: 보조적인 기능 제공 + +- `log` - 로그 출력 +- `comment` - 주석 + +**특징**: + +- `log`: 입력/출력 모두 가능 (패스스루) +- `comment`: 연결 불가능 (독립 노드) + +--- + +## 연결 규칙 매트릭스 + +### 출력(From) → 입력(To) 연결 가능 여부 + +| From ↓ / To → | tableSource | externalDB | restAPI | condition | fieldMapping | dataTransform | insert | update | delete | upsert | log | comment | +| ----------------- | ----------- | ---------- | ------- | --------- | ------------ | ------------- | ------ | ------ | ------ | ------ | --- | ------- | +| **tableSource** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | +| **externalDB** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | +| **restAPI** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | +| **condition** | ❌ | ❌ | ❌ | ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | +| **fieldMapping** | ❌ | ❌ | ❌ | ✅ | ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | +| **dataTransform** | ❌ | ❌ | ❌ | ✅ | ✅ | ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | +| **insert** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ❌ | +| **update** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ❌ | +| **delete** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ❌ | +| **upsert** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ❌ | +| **log** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | +| **comment** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | + +**범례**: + +- ✅ 허용 +- ❌ 금지 +- ⚠️ 조건부 허용 (경고 메시지와 함께) + +--- + +## 상세 연결 규칙 + +### 규칙 1: 소스 노드는 입력을 받을 수 없음 + +**금지되는 연결**: + +``` +❌ 어떤 노드 → tableSource +❌ 어떤 노드 → externalDBSource +❌ 어떤 노드 → restAPISource +``` + +**이유**: 소스 노드는 데이터의 시작점이므로 외부 입력이 의미 없음 + +**오류 메시지**: + +``` +"소스 노드는 입력을 받을 수 없습니다. 소스 노드는 플로우의 시작점입니다." +``` + +--- + +### 규칙 2: 소스 노드끼리 연결 불가 + +**금지되는 연결**: + +``` +❌ tableSource → externalDBSource +❌ restAPISource → tableSource +``` + +**이유**: 소스 노드는 독립적으로 데이터를 생성하므로 서로 연결 불필요 + +**오류 메시지**: + +``` +"소스 노드끼리는 연결할 수 없습니다. 각 소스는 독립적으로 동작합니다." +``` + +--- + +### 규칙 3: Comment 노드는 연결 불가 + +**금지되는 연결**: + +``` +❌ 어떤 노드 → comment +❌ comment → 어떤 노드 +``` + +**이유**: Comment는 설명 전용 노드로 데이터 흐름에 영향을 주지 않음 + +**오류 메시지**: + +``` +"주석 노드는 연결할 수 없습니다. 주석은 플로우 설명 용도로만 사용됩니다." +``` + +--- + +### 규칙 4: 동일한 타입의 변환 노드 연속 연결 경고 + +**경고가 필요한 연결**: + +``` +⚠️ fieldMapping → fieldMapping +⚠️ dataTransform → dataTransform +⚠️ condition → condition +``` + +**이유**: 논리적으로 가능하지만 비효율적일 수 있음 + +**경고 메시지**: + +``` +"동일한 타입의 변환 노드가 연속으로 연결되었습니다. 하나의 노드로 통합하는 것이 효율적입니다." +``` + +--- + +### 규칙 5: 액션 노드 연속 연결 경고 + +**경고가 필요한 연결**: + +``` +⚠️ insertAction → updateAction +⚠️ updateAction → deleteAction +⚠️ deleteAction → insertAction +``` + +**이유**: 트랜잭션 관리나 성능에 영향을 줄 수 있음 + +**경고 메시지**: + +``` +"액션 노드가 연속으로 연결되었습니다. 트랜잭션과 성능을 고려해주세요." +``` + +--- + +### 규칙 6: 자기 자신에게 연결 금지 + +**금지되는 연결**: + +``` +❌ 모든 노드 → 자기 자신 +``` + +**이유**: 무한 루프 방지 + +**오류 메시지**: + +``` +"노드는 자기 자신에게 연결할 수 없습니다." +``` + +--- + +### 규칙 7: Log 노드는 패스스루 + +**허용되는 연결**: + +``` +✅ 모든 노드 → log → 모든 노드 (소스 제외) +``` + +**특징**: + +- Log 노드는 데이터를 그대로 전달 +- 디버깅 및 모니터링 용도 +- 데이터 흐름에 영향 없음 + +--- + +## 구현 계획 + +### Phase 1: 기본 금지 규칙 (우선순위: 높음) + +**구현 위치**: `frontend/lib/stores/flowEditorStore.ts` - `validateConnection` 함수 + +```typescript +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: "노드를 찾을 수 없습니다" }; + } + + // 규칙 1: 소스 노드는 입력을 받을 수 없음 + if (isSourceNode(targetNode.type)) { + return { + valid: false, + error: + "소스 노드는 입력을 받을 수 없습니다. 소스 노드는 플로우의 시작점입니다.", + }; + } + + // 규칙 2: 소스 노드끼리 연결 불가 + if (isSourceNode(sourceNode.type) && isSourceNode(targetNode.type)) { + return { + valid: false, + error: "소스 노드끼리는 연결할 수 없습니다.", + }; + } + + // 규칙 3: Comment 노드는 연결 불가 + if (sourceNode.type === "comment" || targetNode.type === "comment") { + return { + valid: false, + error: "주석 노드는 연결할 수 없습니다.", + }; + } + + // 규칙 6: 자기 자신에게 연결 금지 + if (connection.source === connection.target) { + return { + valid: false, + error: "노드는 자기 자신에게 연결할 수 없습니다.", + }; + } + + return { valid: true }; +} +``` + +**예상 작업 시간**: 30분 + +--- + +### Phase 2: 경고 규칙 (우선순위: 중간) + +**구현 방법**: 연결은 허용하되 경고 표시 + +```typescript +function getConnectionWarning( + connection: Connection, + nodes: FlowNode[] +): string | null { + const sourceNode = nodes.find((n) => n.id === connection.source); + const targetNode = nodes.find((n) => n.id === connection.target); + + if (!sourceNode || !targetNode) return null; + + // 규칙 4: 동일한 타입의 변환 노드 연속 연결 + if (sourceNode.type === targetNode.type && isTransformNode(sourceNode.type)) { + return "동일한 타입의 변환 노드가 연속으로 연결되었습니다. 하나로 통합하는 것이 효율적입니다."; + } + + // 규칙 5: 액션 노드 연속 연결 + if (isActionNode(sourceNode.type) && isActionNode(targetNode.type)) { + return "액션 노드가 연속으로 연결되었습니다. 트랜잭션과 성능을 고려해주세요."; + } + + return null; +} +``` + +**UI 구현**: + +- 경고 아이콘을 연결선 위에 표시 +- 호버 시 경고 메시지 툴팁 표시 + +**예상 작업 시간**: 1시간 + +--- + +### Phase 3: 시각적 피드백 (우선순위: 낮음) + +**기능**: + +1. 드래그 중 호환 가능한 노드 하이라이트 +2. 불가능한 연결 시도 시 빨간색 표시 +3. 경고가 있는 연결은 노란색 표시 + +**예상 작업 시간**: 2시간 + +--- + +## 테스트 케이스 + +### 금지 테스트 + +- [ ] tableSource → tableSource (금지) +- [ ] fieldMapping → comment (금지) +- [ ] 자기 자신 → 자기 자신 (금지) + +### 경고 테스트 + +- [ ] fieldMapping → fieldMapping (경고) +- [ ] insertAction → updateAction (경고) + +### 정상 테스트 + +- [ ] tableSource → fieldMapping → insertAction +- [ ] externalDBSource → condition → (TRUE) → updateAction +- [ ] restAPISource → log → dataTransform → upsertAction + +--- + +## 향후 확장 + +### 추가 고려사항 + +1. **핸들별 제약**: + + - Condition 노드의 TRUE/FALSE 출력 구분 + - 특정 핸들만 특정 노드 타입과 연결 가능 + +2. **데이터 타입 검증**: + + - 숫자 필드만 계산 노드로 연결 가능 + - 문자열 필드만 텍스트 변환 노드로 연결 가능 + +3. **순서 제약**: + - UPDATE/DELETE 전에 반드시 SELECT 필요 + - 특정 변환 순서 강제 + +--- + +## 변경 이력 + +| 버전 | 날짜 | 변경 내용 | 작성자 | +| ---- | ---------- | --------- | ------ | +| 1.0 | 2025-01-02 | 초안 작성 | AI | + +--- + +**다음 단계**: Phase 1 구현 시작 diff --git a/frontend/app/(main)/admin/dataflow/node-editor/page.tsx b/frontend/app/(main)/admin/dataflow/node-editor/page.tsx new file mode 100644 index 00000000..6fdb0da7 --- /dev/null +++ b/frontend/app/(main)/admin/dataflow/node-editor/page.tsx @@ -0,0 +1,26 @@ +"use client"; + +/** + * 노드 기반 제어 시스템 페이지 + */ + +import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor"; + +export default function NodeEditorPage() { + return ( +
+ {/* 페이지 헤더 */} +
+
+

노드 기반 제어 시스템

+

+ 드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계하고 관리합니다 +

+
+
+ + {/* 에디터 */} + +
+ ); +} diff --git a/frontend/components/dataflow/node-editor/FlowEditor.tsx b/frontend/components/dataflow/node-editor/FlowEditor.tsx new file mode 100644 index 00000000..224a1f42 --- /dev/null +++ b/frontend/components/dataflow/node-editor/FlowEditor.tsx @@ -0,0 +1,195 @@ +"use client"; + +/** + * 노드 기반 플로우 에디터 메인 컴포넌트 + */ + +import { useCallback, useRef } from "react"; +import ReactFlow, { Background, Controls, MiniMap, Panel, ReactFlowProvider, useReactFlow } from "reactflow"; +import "reactflow/dist/style.css"; + +import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; +import { NodePalette } from "./sidebar/NodePalette"; +import { PropertiesPanel } from "./panels/PropertiesPanel"; +import { FlowToolbar } from "./FlowToolbar"; +import { TableSourceNode } from "./nodes/TableSourceNode"; +import { ExternalDBSourceNode } from "./nodes/ExternalDBSourceNode"; +import { ConditionNode } from "./nodes/ConditionNode"; +import { FieldMappingNode } from "./nodes/FieldMappingNode"; +import { InsertActionNode } from "./nodes/InsertActionNode"; +import { UpdateActionNode } from "./nodes/UpdateActionNode"; +import { DeleteActionNode } from "./nodes/DeleteActionNode"; +import { UpsertActionNode } from "./nodes/UpsertActionNode"; +import { DataTransformNode } from "./nodes/DataTransformNode"; +import { RestAPISourceNode } from "./nodes/RestAPISourceNode"; +import { CommentNode } from "./nodes/CommentNode"; +import { LogNode } from "./nodes/LogNode"; + +// 노드 타입들 +const nodeTypes = { + // 데이터 소스 + tableSource: TableSourceNode, + externalDBSource: ExternalDBSourceNode, + restAPISource: RestAPISourceNode, + // 변환/조건 + condition: ConditionNode, + fieldMapping: FieldMappingNode, + dataTransform: DataTransformNode, + // 액션 + insertAction: InsertActionNode, + updateAction: UpdateActionNode, + deleteAction: DeleteActionNode, + upsertAction: UpsertActionNode, + // 유틸리티 + comment: CommentNode, + log: LogNode, +}; + +/** + * FlowEditor 내부 컴포넌트 + */ +function FlowEditorInner() { + const reactFlowWrapper = useRef(null); + const { screenToFlowPosition } = useReactFlow(); + + const { + nodes, + edges, + onNodesChange, + onEdgesChange, + onConnect, + addNode, + showPropertiesPanel, + selectNodes, + selectedNodes, + removeNodes, + } = useFlowEditorStore(); + + /** + * 노드 선택 변경 핸들러 + */ + const onSelectionChange = useCallback( + ({ nodes: selectedNodes }: { nodes: any[] }) => { + const selectedIds = selectedNodes.map((node) => node.id); + selectNodes(selectedIds); + console.log("🔍 선택된 노드:", selectedIds); + }, + [selectNodes], + ); + + /** + * 키보드 이벤트 핸들러 (Delete/Backspace 키로 노드 삭제) + */ + const onKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if ((event.key === "Delete" || event.key === "Backspace") && selectedNodes.length > 0) { + event.preventDefault(); + console.log("🗑️ 선택된 노드 삭제:", selectedNodes); + removeNodes(selectedNodes); + } + }, + [selectedNodes, removeNodes], + ); + + /** + * 드래그 앤 드롭 핸들러 + */ + const onDragOver = useCallback((event: React.DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }, []); + + const onDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + + const type = event.dataTransfer.getData("application/reactflow"); + if (!type) return; + + const position = screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + + const newNode: any = { + id: `node_${Date.now()}`, + type, + position, + data: { + displayName: `새 ${type} 노드`, + }, + }; + + addNode(newNode); + }, + [screenToFlowPosition, addNode], + ); + + return ( +
+ {/* 좌측 노드 팔레트 */} +
+ +
+ + {/* 중앙 캔버스 */} +
+ + {/* 배경 그리드 */} + + + {/* 컨트롤 버튼 */} + + + {/* 미니맵 */} + { + // 노드 타입별 색상 (추후 구현) + return "#3B82F6"; + }} + maskColor="rgba(0, 0, 0, 0.1)" + /> + + {/* 상단 툴바 */} + + + + +
+ + {/* 우측 속성 패널 */} + {showPropertiesPanel && ( +
+ +
+ )} +
+ ); +} + +/** + * FlowEditor 메인 컴포넌트 (Provider로 감싸기) + */ +export function FlowEditor() { + return ( +
+ + + +
+ ); +} diff --git a/frontend/components/dataflow/node-editor/FlowToolbar.tsx b/frontend/components/dataflow/node-editor/FlowToolbar.tsx new file mode 100644 index 00000000..183b7090 --- /dev/null +++ b/frontend/components/dataflow/node-editor/FlowToolbar.tsx @@ -0,0 +1,187 @@ +"use client"; + +/** + * 플로우 에디터 상단 툴바 + */ + +import { useState } from "react"; +import { Play, Save, FileCheck, Undo2, Redo2, ZoomIn, ZoomOut, FolderOpen, Download, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; +import { useReactFlow } from "reactflow"; +import { LoadFlowDialog } from "./dialogs/LoadFlowDialog"; +import { getNodeFlow } from "@/lib/api/nodeFlows"; + +export function FlowToolbar() { + const { zoomIn, zoomOut, fitView } = useReactFlow(); + const { + flowName, + setFlowName, + validateFlow, + saveFlow, + exportFlow, + isExecuting, + isSaving, + selectedNodes, + removeNodes, + } = useFlowEditorStore(); + const [showLoadDialog, setShowLoadDialog] = useState(false); + + const handleValidate = () => { + const result = validateFlow(); + if (result.valid) { + alert("✅ 검증 성공! 오류가 없습니다."); + } else { + alert(`❌ 검증 실패\n\n${result.errors.map((e) => `- ${e.message}`).join("\n")}`); + } + }; + + const handleSave = async () => { + const result = await saveFlow(); + if (result.success) { + alert(`✅ ${result.message}\nFlow ID: ${result.flowId}`); + } else { + alert(`❌ 저장 실패\n\n${result.message}`); + } + }; + + const handleExport = () => { + const json = exportFlow(); + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${flowName || "flow"}.json`; + a.click(); + URL.revokeObjectURL(url); + alert("✅ JSON 파일로 내보내기 완료!"); + }; + + const handleLoad = async (flowId: number) => { + try { + const flow = await getNodeFlow(flowId); + + // flowData가 이미 객체인지 문자열인지 확인 + const parsedData = typeof flow.flowData === "string" ? JSON.parse(flow.flowData) : flow.flowData; + + // Zustand 스토어의 loadFlow 함수 호출 + useFlowEditorStore + .getState() + .loadFlow(flow.flowId, flow.flowName, flow.flowDescription, parsedData.nodes, parsedData.edges); + alert(`✅ "${flow.flowName}" 플로우를 불러왔습니다!`); + } catch (error) { + console.error("플로우 불러오기 오류:", error); + alert(error instanceof Error ? error.message : "플로우를 불러올 수 없습니다."); + } + }; + + const handleExecute = () => { + // TODO: 실행 로직 구현 + alert("실행 기능 구현 예정"); + }; + + const handleDelete = () => { + if (selectedNodes.length === 0) { + alert("삭제할 노드를 선택해주세요."); + return; + } + + if (confirm(`선택된 ${selectedNodes.length}개 노드를 삭제하시겠습니까?`)) { + removeNodes(selectedNodes); + alert(`✅ ${selectedNodes.length}개 노드가 삭제되었습니다.`); + } + }; + + return ( + <> + +
+ {/* 플로우 이름 */} + setFlowName(e.target.value)} + className="h-8 w-[200px] text-sm" + placeholder="플로우 이름" + /> + +
+ + {/* 실행 취소/다시 실행 */} + + + +
+ + {/* 삭제 버튼 */} + + +
+ + {/* 줌 컨트롤 */} + + + + +
+ + {/* 불러오기 */} + + + {/* 저장 */} + + + {/* 내보내기 */} + + +
+ + {/* 검증 */} + + + {/* 테스트 실행 */} + +
+ + ); +} diff --git a/frontend/components/dataflow/node-editor/dialogs/LoadFlowDialog.tsx b/frontend/components/dataflow/node-editor/dialogs/LoadFlowDialog.tsx new file mode 100644 index 00000000..d5cc9b18 --- /dev/null +++ b/frontend/components/dataflow/node-editor/dialogs/LoadFlowDialog.tsx @@ -0,0 +1,174 @@ +"use client"; + +/** + * 플로우 불러오기 다이얼로그 + */ + +import { useEffect, useState } from "react"; +import { Loader2, FileJson, Calendar, Trash2 } from "lucide-react"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { getNodeFlows, deleteNodeFlow } from "@/lib/api/nodeFlows"; + +interface Flow { + flowId: number; + flowName: string; + flowDescription: string; + createdAt: string; + updatedAt: string; +} + +interface LoadFlowDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onLoad: (flowId: number) => void; +} + +export function LoadFlowDialog({ open, onOpenChange, onLoad }: LoadFlowDialogProps) { + const [flows, setFlows] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedFlowId, setSelectedFlowId] = useState(null); + const [deleting, setDeleting] = useState(null); + + // 플로우 목록 조회 + const fetchFlows = async () => { + setLoading(true); + try { + const flows = await getNodeFlows(); + setFlows(flows); + } catch (error) { + console.error("플로우 목록 조회 오류:", error); + alert(error instanceof Error ? error.message : "플로우 목록을 불러올 수 없습니다."); + } finally { + setLoading(false); + } + }; + + // 플로우 삭제 + const handleDelete = async (flowId: number, flowName: string) => { + if (!confirm(`"${flowName}" 플로우를 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.`)) { + return; + } + + setDeleting(flowId); + try { + await deleteNodeFlow(flowId); + alert("✅ 플로우가 삭제되었습니다."); + fetchFlows(); // 목록 새로고침 + } catch (error) { + console.error("플로우 삭제 오류:", error); + alert(error instanceof Error ? error.message : "플로우를 삭제할 수 없습니다."); + } finally { + setDeleting(null); + } + }; + + // 플로우 불러오기 + const handleLoad = () => { + if (selectedFlowId === null) { + alert("불러올 플로우를 선택해주세요."); + return; + } + + onLoad(selectedFlowId); + onOpenChange(false); + }; + + // 다이얼로그 열릴 때 목록 조회 + useEffect(() => { + if (open) { + fetchFlows(); + setSelectedFlowId(null); + } + }, [open]); + + // 날짜 포맷팅 + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return new Intl.DateTimeFormat("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(date); + }; + + return ( + + + + 플로우 불러오기 + 저장된 플로우를 선택하여 불러옵니다. + + + {loading ? ( +
+ +
+ ) : flows.length === 0 ? ( +
+ +

저장된 플로우가 없습니다.

+
+ ) : ( + +
+ {flows.map((flow) => ( +
setSelectedFlowId(flow.flowId)} + > +
+
+
+

{flow.flowName}

+ #{flow.flowId} +
+ {flow.flowDescription &&

{flow.flowDescription}

} +
+
+ + 수정: {formatDate(flow.updatedAt)} +
+
+
+ +
+
+ ))} +
+
+ )} + +
+ + +
+
+
+ ); +} diff --git a/frontend/components/dataflow/node-editor/nodes/CommentNode.tsx b/frontend/components/dataflow/node-editor/nodes/CommentNode.tsx new file mode 100644 index 00000000..986cf399 --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/CommentNode.tsx @@ -0,0 +1,30 @@ +"use client"; + +/** + * 주석 노드 - 플로우 설명용 + */ + +import { memo } from "react"; +import { NodeProps } from "reactflow"; +import { MessageSquare } from "lucide-react"; +import type { CommentNodeData } from "@/types/node-editor"; + +export const CommentNode = memo(({ data, selected }: NodeProps) => { + return ( +
+
+
+ + 메모 +
+
{data.content || "메모를 입력하세요..."}
+
+
+ ); +}); + +CommentNode.displayName = "CommentNode"; diff --git a/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx b/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx new file mode 100644 index 00000000..e662d474 --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx @@ -0,0 +1,116 @@ +"use client"; + +/** + * 조건 분기 노드 + */ + +import { memo } from "react"; +import { Handle, Position, NodeProps } from "reactflow"; +import { Zap, Check, X } from "lucide-react"; +import type { ConditionNodeData } from "@/types/node-editor"; + +const OPERATOR_LABELS: Record = { + EQUALS: "=", + NOT_EQUALS: "≠", + GREATER_THAN: ">", + LESS_THAN: "<", + GREATER_THAN_OR_EQUAL: "≥", + LESS_THAN_OR_EQUAL: "≤", + LIKE: "포함", + NOT_LIKE: "미포함", + IN: "IN", + NOT_IN: "NOT IN", + IS_NULL: "NULL", + IS_NOT_NULL: "NOT NULL", +}; + +export const ConditionNode = memo(({ data, selected }: NodeProps) => { + return ( +
+ {/* 입력 핸들 */} + + + {/* 헤더 */} +
+ +
+
조건 검사
+
{data.displayName || "조건 분기"}
+
+
+ + {/* 본문 */} +
+ {data.conditions && data.conditions.length > 0 ? ( +
+
조건식: ({data.conditions.length}개)
+
+ {data.conditions.slice(0, 4).map((condition, idx) => ( +
+ {idx > 0 && ( +
{data.logic}
+ )} +
+ {condition.field} + + {OPERATOR_LABELS[condition.operator] || condition.operator} + + {condition.value !== null && condition.value !== undefined && ( + + {typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)} + + )} +
+
+ ))} + {data.conditions.length > 4 && ( +
... 외 {data.conditions.length - 4}개
+ )} +
+
+ ) : ( +
조건 없음
+ )} +
+ + {/* 분기 출력 핸들 */} +
+
+ {/* TRUE 출력 */} +
+
+ + TRUE +
+ +
+ + {/* FALSE 출력 */} +
+
+ + FALSE +
+ +
+
+
+
+ ); +}); + +ConditionNode.displayName = "ConditionNode"; diff --git a/frontend/components/dataflow/node-editor/nodes/DataTransformNode.tsx b/frontend/components/dataflow/node-editor/nodes/DataTransformNode.tsx new file mode 100644 index 00000000..f5ed2e77 --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/DataTransformNode.tsx @@ -0,0 +1,88 @@ +"use client"; + +/** + * 데이터 변환 노드 + */ + +import { memo } from "react"; +import { Handle, Position, NodeProps } from "reactflow"; +import { Wand2, ArrowRight } from "lucide-react"; +import type { DataTransformNodeData } from "@/types/node-editor"; + +export const DataTransformNode = memo(({ data, selected }: NodeProps) => { + return ( +
+ {/* 헤더 */} +
+ +
+
{data.displayName || "데이터 변환"}
+
{data.transformations?.length || 0}개 변환
+
+
+ + {/* 본문 */} +
+ {data.transformations && data.transformations.length > 0 ? ( +
+ {data.transformations.slice(0, 3).map((transform, idx) => { + const sourceLabel = transform.sourceFieldLabel || transform.sourceField || "소스"; + const targetField = transform.targetField || transform.sourceField; + const targetLabel = transform.targetFieldLabel || targetField; + const isInPlace = !transform.targetField || transform.targetField === transform.sourceField; + + return ( +
+
+ {transform.type} +
+
+ {sourceLabel} + + {isInPlace ? ( + (자기자신) + ) : ( + {targetLabel} + )} +
+ {/* 타입별 추가 정보 */} + {transform.type === "EXPLODE" && transform.delimiter && ( +
구분자: {transform.delimiter}
+ )} + {transform.type === "CONCAT" && transform.separator && ( +
구분자: {transform.separator}
+ )} + {transform.type === "REPLACE" && ( +
+ "{transform.searchValue}" → "{transform.replaceValue}" +
+ )} + {transform.expression && ( +
+ {transform.expression} +
+ )} +
+ ); + })} + {data.transformations.length > 3 && ( +
... 외 {data.transformations.length - 3}개
+ )} +
+ ) : ( +
변환 규칙 없음
+ )} +
+ + {/* 핸들 */} + + +
+ ); +}); + +DataTransformNode.displayName = "DataTransformNode"; diff --git a/frontend/components/dataflow/node-editor/nodes/DeleteActionNode.tsx b/frontend/components/dataflow/node-editor/nodes/DeleteActionNode.tsx new file mode 100644 index 00000000..0c76ed78 --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/DeleteActionNode.tsx @@ -0,0 +1,76 @@ +"use client"; + +/** + * DELETE 액션 노드 + */ + +import { memo } from "react"; +import { Handle, Position, NodeProps } from "reactflow"; +import { Trash2, AlertTriangle } from "lucide-react"; +import type { DeleteActionNodeData } from "@/types/node-editor"; + +export const DeleteActionNode = memo(({ data, selected }: NodeProps) => { + return ( +
+ {/* 입력 핸들 */} + + + {/* 헤더 */} +
+ +
+
DELETE
+
{data.displayName || data.targetTable}
+
+
+ + {/* 본문 */} +
+
타겟: {data.targetTable}
+ + {/* WHERE 조건 */} + {data.whereConditions && data.whereConditions.length > 0 ? ( +
+
WHERE 조건:
+
+ {data.whereConditions.map((condition, idx) => ( +
+ {condition.field} + {condition.operator} + {condition.sourceField || condition.staticValue || "?"} +
+ ))} +
+
+ ) : ( +
⚠️ 조건 없음 - 모든 데이터 삭제 주의!
+ )} + + {/* 경고 메시지 */} +
+ +
+
주의
+
삭제된 데이터는 복구할 수 없습니다
+
+
+ + {/* 옵션 */} + {data.options?.requireConfirmation && ( +
+ 실행 전 확인 필요 +
+ )} +
+ + {/* 출력 핸들 */} + +
+ ); +}); + +DeleteActionNode.displayName = "DeleteActionNode"; diff --git a/frontend/components/dataflow/node-editor/nodes/ExternalDBSourceNode.tsx b/frontend/components/dataflow/node-editor/nodes/ExternalDBSourceNode.tsx new file mode 100644 index 00000000..2088bced --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/ExternalDBSourceNode.tsx @@ -0,0 +1,88 @@ +"use client"; + +/** + * 외부 DB 소스 노드 + */ + +import { memo } from "react"; +import { Handle, Position, NodeProps } from "reactflow"; +import { Plug } from "lucide-react"; +import type { ExternalDBSourceNodeData } from "@/types/node-editor"; + +const DB_TYPE_COLORS: Record = { + PostgreSQL: "#336791", + MySQL: "#4479A1", + Oracle: "#F80000", + MSSQL: "#CC2927", + MariaDB: "#003545", +}; + +const DB_TYPE_ICONS: Record = { + PostgreSQL: "🐘", + MySQL: "🐬", + Oracle: "🔴", + MSSQL: "🟦", + MariaDB: "🦭", +}; + +export const ExternalDBSourceNode = memo(({ data, selected }: NodeProps) => { + const dbColor = (data.dbType && DB_TYPE_COLORS[data.dbType]) || "#F59E0B"; + const dbIcon = (data.dbType && DB_TYPE_ICONS[data.dbType]) || "🔌"; + + return ( +
+ {/* 헤더 */} +
+ +
+
{data.displayName || data.connectionName}
+
{data.tableName}
+
+ {dbIcon} +
+ + {/* 본문 */} +
+
+
{data.dbType || "DB"}
+
외부 DB
+
+ + {/* 필드 목록 */} +
+
출력 필드:
+
+ {data.fields && data.fields.length > 0 ? ( + data.fields.slice(0, 5).map((field) => ( +
+
+ {field.name} + ({field.type}) +
+ )) + ) : ( +
필드 없음
+ )} + {data.fields && data.fields.length > 5 && ( +
... 외 {data.fields.length - 5}개
+ )} +
+
+
+ + {/* 출력 핸들 */} + +
+ ); +}); + +ExternalDBSourceNode.displayName = "ExternalDBSourceNode"; diff --git a/frontend/components/dataflow/node-editor/nodes/FieldMappingNode.tsx b/frontend/components/dataflow/node-editor/nodes/FieldMappingNode.tsx new file mode 100644 index 00000000..2101aa76 --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/FieldMappingNode.tsx @@ -0,0 +1,66 @@ +"use client"; + +/** + * 필드 매핑 노드 + */ + +import { memo } from "react"; +import { Handle, Position, NodeProps } from "reactflow"; +import { ArrowLeftRight } from "lucide-react"; +import type { FieldMappingNodeData } from "@/types/node-editor"; + +export const FieldMappingNode = memo(({ data, selected }: NodeProps) => { + return ( +
+ {/* 입력 핸들 */} + + + {/* 헤더 */} +
+ +
+
필드 매핑
+
{data.displayName || "데이터 매핑"}
+
+
+ + {/* 본문 */} +
+ {data.mappings && data.mappings.length > 0 ? ( +
+
매핑 규칙: ({data.mappings.length}개)
+
+ {data.mappings.slice(0, 5).map((mapping) => ( +
+
+ {mapping.sourceField || "정적값"} + + {mapping.targetField} +
+ {mapping.transform &&
변환: {mapping.transform}
} + {mapping.staticValue !== undefined && ( +
값: {String(mapping.staticValue)}
+ )} +
+ ))} + {data.mappings.length > 5 && ( +
... 외 {data.mappings.length - 5}개
+ )} +
+
+ ) : ( +
매핑 규칙 없음
+ )} +
+ + {/* 출력 핸들 */} + +
+ ); +}); + +FieldMappingNode.displayName = "FieldMappingNode"; diff --git a/frontend/components/dataflow/node-editor/nodes/InsertActionNode.tsx b/frontend/components/dataflow/node-editor/nodes/InsertActionNode.tsx new file mode 100644 index 00000000..18f4d157 --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/InsertActionNode.tsx @@ -0,0 +1,82 @@ +"use client"; + +/** + * INSERT 액션 노드 + */ + +import { memo } from "react"; +import { Handle, Position, NodeProps } from "reactflow"; +import { Plus } from "lucide-react"; +import type { InsertActionNodeData } from "@/types/node-editor"; + +export const InsertActionNode = memo(({ data, selected }: NodeProps) => { + return ( +
+ {/* 입력 핸들 */} + + + {/* 헤더 */} +
+ +
+
INSERT
+
{data.displayName || data.targetTable}
+
+
+ + {/* 본문 */} +
+
+ 타겟: {data.displayName || data.targetTable} + {data.targetTable && data.displayName && data.displayName !== data.targetTable && ( + ({data.targetTable}) + )} +
+ + {/* 필드 매핑 */} + {data.fieldMappings && data.fieldMappings.length > 0 && ( +
+
삽입 필드:
+
+ {data.fieldMappings.slice(0, 4).map((mapping, idx) => ( +
+ + {mapping.sourceFieldLabel || mapping.sourceField || mapping.staticValue || "?"} + + + {mapping.targetFieldLabel || mapping.targetField} +
+ ))} + {data.fieldMappings.length > 4 && ( +
... 외 {data.fieldMappings.length - 4}개
+ )} +
+
+ )} + + {/* 옵션 */} + {data.options && ( +
+ {data.options.ignoreDuplicates && ( + 중복 무시 + )} + {data.options.batchSize && ( + + 배치 {data.options.batchSize}건 + + )} +
+ )} +
+ + {/* 출력 핸들 */} + +
+ ); +}); + +InsertActionNode.displayName = "InsertActionNode"; diff --git a/frontend/components/dataflow/node-editor/nodes/LogNode.tsx b/frontend/components/dataflow/node-editor/nodes/LogNode.tsx new file mode 100644 index 00000000..8f2fa295 --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/LogNode.tsx @@ -0,0 +1,59 @@ +"use client"; + +/** + * 로그 노드 - 디버깅 및 모니터링용 + */ + +import { memo } from "react"; +import { Handle, Position, NodeProps } from "reactflow"; +import { FileText, AlertCircle, Info, AlertTriangle } from "lucide-react"; +import type { LogNodeData } from "@/types/node-editor"; + +const LOG_LEVEL_CONFIG = { + debug: { icon: Info, color: "text-blue-600", bg: "bg-blue-50", border: "border-blue-200" }, + info: { icon: Info, color: "text-green-600", bg: "bg-green-50", border: "border-green-200" }, + warn: { icon: AlertTriangle, color: "text-yellow-600", bg: "bg-yellow-50", border: "border-yellow-200" }, + error: { icon: AlertCircle, color: "text-red-600", bg: "bg-red-50", border: "border-red-200" }, +}; + +export const LogNode = memo(({ data, selected }: NodeProps) => { + const config = LOG_LEVEL_CONFIG[data.level] || LOG_LEVEL_CONFIG.info; + const Icon = config.icon; + + return ( +
+ {/* 헤더 */} +
+ +
+
로그
+
{data.level.toUpperCase()}
+
+ +
+ + {/* 본문 */} +
+ {data.message ? ( +
{data.message}
+ ) : ( +
로그 메시지 없음
+ )} + + {data.includeData && ( +
✓ 데이터 포함
+ )} +
+ + {/* 핸들 */} + + +
+ ); +}); + +LogNode.displayName = "LogNode"; diff --git a/frontend/components/dataflow/node-editor/nodes/RestAPISourceNode.tsx b/frontend/components/dataflow/node-editor/nodes/RestAPISourceNode.tsx new file mode 100644 index 00000000..d6713f85 --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/RestAPISourceNode.tsx @@ -0,0 +1,81 @@ +"use client"; + +/** + * REST API 소스 노드 + */ + +import { memo } from "react"; +import { Handle, Position, NodeProps } from "reactflow"; +import { Globe, Lock } from "lucide-react"; +import type { RestAPISourceNodeData } from "@/types/node-editor"; + +const METHOD_COLORS: Record = { + GET: "bg-green-100 text-green-700", + POST: "bg-blue-100 text-blue-700", + PUT: "bg-yellow-100 text-yellow-700", + DELETE: "bg-red-100 text-red-700", + PATCH: "bg-purple-100 text-purple-700", +}; + +export const RestAPISourceNode = memo(({ data, selected }: NodeProps) => { + const methodColor = METHOD_COLORS[data.method] || "bg-gray-100 text-gray-700"; + + return ( +
+ {/* 헤더 */} +
+ +
+
{data.displayName || "REST API"}
+
{data.url || "URL 미설정"}
+
+ {data.authentication && } +
+ + {/* 본문 */} +
+ {/* HTTP 메서드 */} +
+ {data.method} + {data.timeout && {data.timeout}ms} +
+ + {/* 헤더 */} + {data.headers && Object.keys(data.headers).length > 0 && ( +
+
헤더:
+
+ {Object.entries(data.headers) + .slice(0, 2) + .map(([key, value]) => ( +
+ {key}: + {value} +
+ ))} + {Object.keys(data.headers).length > 2 && ( +
... 외 {Object.keys(data.headers).length - 2}개
+ )} +
+
+ )} + + {/* 응답 매핑 */} + {data.responseMapping && ( +
+ 응답 경로: {data.responseMapping} +
+ )} +
+ + {/* 핸들 */} + +
+ ); +}); + +RestAPISourceNode.displayName = "RestAPISourceNode"; diff --git a/frontend/components/dataflow/node-editor/nodes/TableSourceNode.tsx b/frontend/components/dataflow/node-editor/nodes/TableSourceNode.tsx new file mode 100644 index 00000000..f9a253c7 --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/TableSourceNode.tsx @@ -0,0 +1,70 @@ +"use client"; + +/** + * 테이블 소스 노드 + */ + +import { memo } from "react"; +import { Handle, Position, NodeProps } from "reactflow"; +import { Database } from "lucide-react"; +import type { TableSourceNodeData } from "@/types/node-editor"; + +export const TableSourceNode = memo(({ data, selected }: NodeProps) => { + // 디버깅: 필드 데이터 확인 + if (data.fields && data.fields.length > 0) { + console.log("🔍 TableSource 필드 데이터:", data.fields); + } + + return ( +
+ {/* 헤더 */} +
+ +
+
{data.displayName || data.tableName || "테이블 소스"}
+ {data.tableName && data.displayName !== data.tableName && ( +
{data.tableName}
+ )} +
+
+ + {/* 본문 */} +
+
📍 내부 데이터베이스
+ + {/* 필드 목록 */} +
+
출력 필드:
+
+ {data.fields && data.fields.length > 0 ? ( + data.fields.slice(0, 5).map((field) => ( +
+
+ {field.label || field.displayName || field.name} + {(field.label || field.displayName) && field.label !== field.name && ( + ({field.name}) + )} + {field.type} +
+ )) + ) : ( +
필드 없음
+ )} + {data.fields && data.fields.length > 5 && ( +
... 외 {data.fields.length - 5}개
+ )} +
+
+
+ + {/* 출력 핸들 */} + +
+ ); +}); + +TableSourceNode.displayName = "TableSourceNode"; diff --git a/frontend/components/dataflow/node-editor/nodes/UpdateActionNode.tsx b/frontend/components/dataflow/node-editor/nodes/UpdateActionNode.tsx new file mode 100644 index 00000000..b7b0dfee --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/UpdateActionNode.tsx @@ -0,0 +1,98 @@ +"use client"; + +/** + * UPDATE 액션 노드 + */ + +import { memo } from "react"; +import { Handle, Position, NodeProps } from "reactflow"; +import { Edit } from "lucide-react"; +import type { UpdateActionNodeData } from "@/types/node-editor"; + +export const UpdateActionNode = memo(({ data, selected }: NodeProps) => { + return ( +
+ {/* 입력 핸들 */} + + + {/* 헤더 */} +
+ +
+
UPDATE
+
{data.displayName || data.targetTable}
+
+
+ + {/* 본문 */} +
+
+ 타겟: {data.displayName || data.targetTable} + {data.targetTable && data.displayName && data.displayName !== data.targetTable && ( + ({data.targetTable}) + )} +
+ + {/* WHERE 조건 */} + {data.whereConditions && data.whereConditions.length > 0 && ( +
+
WHERE 조건:
+
+ {data.whereConditions.slice(0, 2).map((condition, idx) => ( +
+ {condition.fieldLabel || condition.field} + {condition.operator} + + {condition.sourceFieldLabel || condition.sourceField || condition.staticValue || "?"} + +
+ ))} + {data.whereConditions.length > 2 && ( +
... 외 {data.whereConditions.length - 2}개
+ )} +
+
+ )} + + {/* 필드 매핑 */} + {data.fieldMappings && data.fieldMappings.length > 0 && ( +
+
업데이트 필드:
+
+ {data.fieldMappings.slice(0, 3).map((mapping, idx) => ( +
+ + {mapping.sourceFieldLabel || mapping.sourceField || mapping.staticValue || "?"} + + + {mapping.targetFieldLabel || mapping.targetField} +
+ ))} + {data.fieldMappings.length > 3 && ( +
... 외 {data.fieldMappings.length - 3}개
+ )} +
+
+ )} + + {/* 옵션 */} + {data.options && data.options.batchSize && ( +
+ + 배치 {data.options.batchSize}건 + +
+ )} +
+ + {/* 출력 핸들 */} + +
+ ); +}); + +UpdateActionNode.displayName = "UpdateActionNode"; diff --git a/frontend/components/dataflow/node-editor/nodes/UpsertActionNode.tsx b/frontend/components/dataflow/node-editor/nodes/UpsertActionNode.tsx new file mode 100644 index 00000000..4e5bdeba --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/UpsertActionNode.tsx @@ -0,0 +1,94 @@ +"use client"; + +/** + * UPSERT 액션 노드 + * INSERT와 UPDATE를 결합한 노드 + */ + +import { memo } from "react"; +import { Handle, Position, NodeProps } from "reactflow"; +import { Database, RefreshCw } from "lucide-react"; +import type { UpsertActionNodeData } from "@/types/node-editor"; + +export const UpsertActionNode = memo(({ data, selected }: NodeProps) => { + return ( +
+ {/* 헤더 */} +
+ +
+
{data.displayName || "UPSERT 액션"}
+
{data.targetTable}
+
+ +
+ + {/* 본문 */} +
+
+ 타겟: {data.displayName || data.targetTable} + {data.targetTable && data.displayName && data.displayName !== data.targetTable && ( + ({data.targetTable}) + )} +
+ + {/* 충돌 키 */} + {data.conflictKeys && data.conflictKeys.length > 0 && ( +
+
충돌 키:
+
+ {data.conflictKeys.map((key, idx) => ( + + {data.conflictKeyLabels?.[idx] || key} + + ))} +
+
+ )} + + {/* 필드 매핑 */} + {data.fieldMappings && data.fieldMappings.length > 0 && ( +
+
필드 매핑:
+
+ {data.fieldMappings.slice(0, 3).map((mapping, idx) => ( +
+ + {mapping.sourceFieldLabel || mapping.sourceField || mapping.staticValue || "?"} + + + {mapping.targetFieldLabel || mapping.targetField} +
+ ))} + {data.fieldMappings.length > 3 && ( +
... 외 {data.fieldMappings.length - 3}개
+ )} +
+
+ )} + + {/* 옵션 */} +
+ {data.options?.updateOnConflict && ( + 충돌 시 업데이트 + )} + {data.options?.batchSize && ( + + 배치: {data.options.batchSize} + + )} +
+
+ + {/* 핸들 */} + + +
+ ); +}); + +UpsertActionNode.displayName = "UpsertActionNode"; diff --git a/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx new file mode 100644 index 00000000..0cdf68a6 --- /dev/null +++ b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx @@ -0,0 +1,150 @@ +"use client"; + +/** + * 노드 속성 편집 패널 + */ + +import { X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; +import { TableSourceProperties } from "./properties/TableSourceProperties"; +import { InsertActionProperties } from "./properties/InsertActionProperties"; +import { FieldMappingProperties } from "./properties/FieldMappingProperties"; +import { ConditionProperties } from "./properties/ConditionProperties"; +import { UpdateActionProperties } from "./properties/UpdateActionProperties"; +import { DeleteActionProperties } from "./properties/DeleteActionProperties"; +import { ExternalDBSourceProperties } from "./properties/ExternalDBSourceProperties"; +import { UpsertActionProperties } from "./properties/UpsertActionProperties"; +import { DataTransformProperties } from "./properties/DataTransformProperties"; +import { RestAPISourceProperties } from "./properties/RestAPISourceProperties"; +import { CommentProperties } from "./properties/CommentProperties"; +import { LogProperties } from "./properties/LogProperties"; +import type { NodeType } from "@/types/node-editor"; + +export function PropertiesPanel() { + const { nodes, selectedNodes, setShowPropertiesPanel } = useFlowEditorStore(); + + // 선택된 노드가 하나일 경우 해당 노드 데이터 가져오기 + const selectedNode = selectedNodes.length === 1 ? nodes.find((n) => n.id === selectedNodes[0]) : null; + + return ( +
+ {/* 헤더 */} +
+
+

속성

+ {selectedNode && ( +

{getNodeTypeLabel(selectedNode.type as NodeType)}

+ )} +
+ +
+ + {/* 내용 */} +
+ {selectedNodes.length === 0 ? ( +
+
+
📝
+

노드를 선택하여

+

속성을 편집하세요

+
+
+ ) : selectedNodes.length === 1 && selectedNode ? ( + + ) : ( +
+
+
📋
+

{selectedNodes.length}개의 노드가

+

선택되었습니다

+

한 번에 하나의 노드만 편집할 수 있습니다

+
+
+ )} +
+
+ ); +} + +/** + * 노드 타입별 속성 렌더러 + */ +function NodePropertiesRenderer({ node }: { node: any }) { + switch (node.type) { + case "tableSource": + return ; + + case "insertAction": + return ; + + case "fieldMapping": + return ; + + case "condition": + return ; + + case "updateAction": + return ; + + case "deleteAction": + return ; + + case "externalDBSource": + return ; + + case "upsertAction": + return ; + + case "dataTransform": + return ; + + case "restAPISource": + return ; + + case "comment": + return ; + + case "log": + return ; + + default: + return ( +
+
+

🚧 속성 편집 준비 중

+

+ {getNodeTypeLabel(node.type as NodeType)} 노드의 속성 편집 UI는 곧 구현될 예정입니다. +

+
+

노드 ID:

+

{node.id}

+
+
+
+ ); + } +} + +/** + * 노드 타입 라벨 가져오기 + */ +function getNodeTypeLabel(type: NodeType): string { + const labels: Record = { + tableSource: "테이블 소스", + externalDBSource: "외부 DB 소스", + restAPISource: "REST API 소스", + condition: "조건 분기", + fieldMapping: "필드 매핑", + dataTransform: "데이터 변환", + insertAction: "INSERT 액션", + updateAction: "UPDATE 액션", + deleteAction: "DELETE 액션", + upsertAction: "UPSERT 액션", + comment: "주석", + log: "로그", + }; + return labels[type] || type; +} diff --git a/frontend/components/dataflow/node-editor/panels/properties/CommentProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/CommentProperties.tsx new file mode 100644 index 00000000..a751fb20 --- /dev/null +++ b/frontend/components/dataflow/node-editor/panels/properties/CommentProperties.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; +import { CommentNodeData } from "@/types/node-editor"; +import { MessageSquare } from "lucide-react"; + +interface CommentPropertiesProps { + nodeId: string; + data: CommentNodeData; +} + +export function CommentProperties({ nodeId, data }: CommentPropertiesProps) { + const { updateNode } = useFlowEditorStore(); + + const [content, setContent] = useState(data.content || ""); + + useEffect(() => { + setContent(data.content || ""); + }, [data]); + + const handleApply = () => { + updateNode(nodeId, { + content, + }); + }; + + return ( +
+
+ + 주석 +
+ +
+ +