diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 291847f8..c6a31aa8 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -21,7 +21,7 @@ "imap": "^0.8.19", "joi": "^17.11.0", "jsonwebtoken": "^9.0.2", - "mailparser": "^3.7.4", + "mailparser": "^3.7.5", "mssql": "^11.0.1", "multer": "^1.4.5-lts.1", "mysql2": "^3.15.0", @@ -7723,48 +7723,52 @@ } }, "node_modules/mailparser": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.4.tgz", - "integrity": "sha512-Beh4yyR4jLq3CZZ32asajByrXnW8dLyKCAQD3WvtTiBnMtFWhxO+wa93F6sJNjDmfjxXs4NRNjw3XAGLqZR3Vg==", + "version": "3.7.5", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.5.tgz", + "integrity": "sha512-o59RgZC+4SyCOn4xRH1mtRiZ1PbEmi6si6Ufnd3tbX/V9zmZN1qcqu8xbXY62H6CwIclOT3ppm5u/wV2nujn4g==", "license": "MIT", "dependencies": { "encoding-japanese": "2.2.0", "he": "1.2.0", "html-to-text": "9.0.5", - "iconv-lite": "0.6.3", + "iconv-lite": "0.7.0", "libmime": "5.3.7", "linkify-it": "5.0.0", - "mailsplit": "5.4.5", - "nodemailer": "7.0.4", + "mailsplit": "5.4.6", + "nodemailer": "7.0.9", "punycode.js": "2.3.1", - "tlds": "1.259.0" + "tlds": "1.260.0" } }, "node_modules/mailparser/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/mailparser/node_modules/nodemailer": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.4.tgz", - "integrity": "sha512-9O00Vh89/Ld2EcVCqJ/etd7u20UhME0f/NToPfArwPEe1Don1zy4mAIz6ariRr7mJ2RDxtaDzN0WJVdVXPtZaw==", + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz", + "integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==", "license": "MIT-0", "engines": { "node": ">=6.0.0" } }, "node_modules/mailsplit": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.5.tgz", - "integrity": "sha512-oMfhmvclR689IIaQmIcR5nODnZRRVwAKtqFT407TIvmhX2OLUBnshUTcxzQBt3+96sZVDud9NfSe1NxAkUNXEQ==", + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.6.tgz", + "integrity": "sha512-M+cqmzaPG/mEiCDmqQUz8L177JZLZmXAUpq38owtpq2xlXlTSw+kntnxRt2xsxVFFV6+T8Mj/U0l5s7s6e0rNw==", "license": "(MIT OR EUPL-1.1+)", "dependencies": { "libbase64": "1.3.0", @@ -9787,9 +9791,9 @@ "license": "MIT" }, "node_modules/tlds": { - "version": "1.259.0", - "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.259.0.tgz", - "integrity": "sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==", + "version": "1.260.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.260.0.tgz", + "integrity": "sha512-78+28EWBhCEE7qlyaHA9OR3IPvbCLiDh3Ckla593TksfFc9vfTsgvH7eS+dr3o9qr31gwGbogcI16yN91PoRjQ==", "license": "MIT", "bin": { "tlds": "bin.js" diff --git a/backend-node/package.json b/backend-node/package.json index 28829607..910269c1 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -35,7 +35,7 @@ "imap": "^0.8.19", "joi": "^17.11.0", "jsonwebtoken": "^9.0.2", - "mailparser": "^3.7.4", + "mailparser": "^3.7.5", "mssql": "^11.0.1", "multer": "^1.4.5-lts.1", "mysql2": "^3.15.0", diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index b27e6784..cb5767bf 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -10,6 +10,7 @@ import { query, queryOne, transaction } from "../database/db"; import { logger } from "../utils/logger"; +import axios from "axios"; // ===== 타입 정의 ===== @@ -410,6 +411,9 @@ export class NodeFlowExecutionService { case "tableSource": return this.executeTableSource(node, context); + case "restAPISource": + return this.executeRestAPISource(node, context); + case "dataTransform": return this.executeDataTransform(node, inputData, context); @@ -440,6 +444,123 @@ export class NodeFlowExecutionService { } } + /** + * REST API 소스 노드 실행 + */ + private static async executeRestAPISource( + node: FlowNode, + context: ExecutionContext + ): Promise { + const { + url, + method = "GET", + headers = {}, + body, + timeout = 30000, + responseMapping, + authentication, + } = node.data; + + if (!url) { + throw new Error("REST API URL이 설정되지 않았습니다."); + } + + logger.info(`🌐 REST API 호출: ${method} ${url}`); + + try { + // 헤더 설정 + const requestHeaders: any = { ...headers }; + + // 인증 헤더 추가 + if (authentication) { + if (authentication.type === "bearer" && authentication.token) { + requestHeaders["Authorization"] = `Bearer ${authentication.token}`; + } else if ( + authentication.type === "basic" && + authentication.username && + authentication.password + ) { + const credentials = Buffer.from( + `${authentication.username}:${authentication.password}` + ).toString("base64"); + requestHeaders["Authorization"] = `Basic ${credentials}`; + } else if (authentication.type === "apikey" && authentication.token) { + const headerName = authentication.apiKeyHeader || "X-API-Key"; + requestHeaders[headerName] = authentication.token; + } + } + + if (!requestHeaders["Content-Type"]) { + requestHeaders["Content-Type"] = "application/json"; + } + + // API 호출 + const response = await axios({ + method: method.toLowerCase(), + url, + headers: requestHeaders, + data: body, + timeout, + }); + + logger.info(`✅ REST API 응답 수신: ${response.status}`); + + let responseData = response.data; + + // 🔥 표준 API 응답 형식 자동 감지 { success, message, data } + if ( + !responseMapping && + responseData && + typeof responseData === "object" && + "success" in responseData && + "data" in responseData + ) { + logger.info("🔍 표준 API 응답 형식 감지, data 속성 자동 추출"); + responseData = responseData.data; + } + + // responseMapping이 있으면 해당 경로의 데이터 추출 + if (responseMapping && responseData) { + logger.info(`🔍 응답 매핑 적용: ${responseMapping}`); + const path = responseMapping.split("."); + for (const key of path) { + if ( + responseData && + typeof responseData === "object" && + key in responseData + ) { + responseData = responseData[key]; + } else { + logger.warn( + `⚠️ 응답 매핑 경로를 찾을 수 없습니다: ${responseMapping}` + ); + break; + } + } + } + + // 배열이 아니면 배열로 변환 + if (!Array.isArray(responseData)) { + logger.info("🔄 단일 객체를 배열로 변환"); + responseData = [responseData]; + } + + logger.info(`📦 REST API 데이터 ${responseData.length}건 반환`); + + // 첫 번째 데이터 샘플 상세 로깅 + if (responseData.length > 0) { + console.log("🔍 REST API 응답 데이터 샘플 (첫 번째 항목):"); + console.log(JSON.stringify(responseData[0], null, 2)); + console.log("🔑 사용 가능한 필드명:", Object.keys(responseData[0])); + } + + return responseData; + } catch (error: any) { + logger.error(`❌ REST API 호출 실패:`, error.message); + throw new Error(`REST API 호출 실패: ${error.message}`); + } + } + /** * 테이블 소스 노드 실행 */ @@ -521,6 +642,19 @@ export class NodeFlowExecutionService { ): Promise { const { targetTable, fieldMappings } = node.data; + logger.info(`💾 INSERT 노드 실행: ${targetTable}`); + console.log( + "📥 입력 데이터 타입:", + typeof inputData, + Array.isArray(inputData) ? `배열(${inputData.length}건)` : "단일 객체" + ); + + if (inputData && inputData.length > 0) { + console.log("📄 첫 번째 입력 데이터:"); + console.log(JSON.stringify(inputData[0], null, 2)); + console.log("🔑 입력 데이터 필드명:", Object.keys(inputData[0])); + } + return transaction(async (client) => { const dataArray = Array.isArray(inputData) ? inputData : [inputData]; let insertedCount = 0; @@ -529,12 +663,17 @@ export class NodeFlowExecutionService { const fields: string[] = []; const values: any[] = []; + console.log("🗺️ 필드 매핑 처리 중..."); fieldMappings.forEach((mapping: any) => { fields.push(mapping.targetField); const value = mapping.staticValue !== undefined ? mapping.staticValue : data[mapping.sourceField]; + + console.log( + ` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}` + ); values.push(value); }); @@ -543,6 +682,9 @@ export class NodeFlowExecutionService { VALUES (${fields.map((_, i) => `$${i + 1}`).join(", ")}) `; + console.log("📝 실행할 SQL:", sql); + console.log("📊 바인딩 값:", values); + await client.query(sql, values); insertedCount++; } @@ -682,7 +824,6 @@ export class NodeFlowExecutionService { logger.info(`🌐 REST API INSERT 시작: ${apiMethod} ${apiEndpoint}`); - const axios = require("axios"); const dataArray = Array.isArray(inputData) ? inputData : [inputData]; const results: any[] = []; @@ -895,6 +1036,19 @@ export class NodeFlowExecutionService { ): Promise { const { targetTable, fieldMappings, whereConditions } = node.data; + logger.info(`🔄 UPDATE 노드 실행: ${targetTable}`); + console.log( + "📥 입력 데이터 타입:", + typeof inputData, + Array.isArray(inputData) ? `배열(${inputData.length}건)` : "단일 객체" + ); + + if (inputData && inputData.length > 0) { + console.log("📄 첫 번째 입력 데이터:"); + console.log(JSON.stringify(inputData[0], null, 2)); + console.log("🔑 입력 데이터 필드명:", Object.keys(inputData[0])); + } + return transaction(async (client) => { const dataArray = Array.isArray(inputData) ? inputData : [inputData]; let updatedCount = 0; @@ -904,11 +1058,16 @@ export class NodeFlowExecutionService { const values: any[] = []; let paramIndex = 1; + console.log("🗺️ 필드 매핑 처리 중..."); fieldMappings.forEach((mapping: any) => { const value = mapping.staticValue !== undefined ? mapping.staticValue : data[mapping.sourceField]; + + console.log( + ` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}` + ); setClauses.push(`${mapping.targetField} = $${paramIndex}`); values.push(value); paramIndex++; @@ -926,6 +1085,9 @@ export class NodeFlowExecutionService { ${whereClause} `; + console.log("📝 실행할 SQL:", sql); + console.log("📊 바인딩 값:", values); + const result = await client.query(sql, values); updatedCount += result.rowCount || 0; } @@ -1086,7 +1248,6 @@ export class NodeFlowExecutionService { logger.info(`🌐 REST API UPDATE 시작: ${apiMethod} ${apiEndpoint}`); - const axios = require("axios"); const dataArray = Array.isArray(inputData) ? inputData : [inputData]; const results: any[] = []; @@ -1197,15 +1358,31 @@ export class NodeFlowExecutionService { ): Promise { const { targetTable, whereConditions } = node.data; + logger.info(`🗑️ DELETE 노드 실행: ${targetTable}`); + console.log( + "📥 입력 데이터 타입:", + typeof inputData, + Array.isArray(inputData) ? `배열(${inputData.length}건)` : "단일 객체" + ); + + if (inputData && inputData.length > 0) { + console.log("📄 첫 번째 입력 데이터:"); + console.log(JSON.stringify(inputData[0], null, 2)); + console.log("🔑 입력 데이터 필드명:", Object.keys(inputData[0])); + } + return transaction(async (client) => { const dataArray = Array.isArray(inputData) ? inputData : [inputData]; let deletedCount = 0; for (const data of dataArray) { + console.log("🔍 WHERE 조건 처리 중..."); const whereClause = this.buildWhereClause(whereConditions, data, 1); const sql = `DELETE FROM ${targetTable} ${whereClause}`; + console.log("📝 실행할 SQL:", sql); + const result = await client.query(sql, []); deletedCount += result.rowCount || 0; } @@ -1339,7 +1516,6 @@ export class NodeFlowExecutionService { logger.info(`🌐 REST API DELETE 시작: ${apiEndpoint}`); - const axios = require("axios"); const dataArray = Array.isArray(inputData) ? inputData : [inputData]; const results: any[] = []; @@ -1440,6 +1616,20 @@ export class NodeFlowExecutionService { throw new Error("UPSERT 액션에 충돌 키(Conflict Keys)가 필요합니다."); } + logger.info(`🔀 UPSERT 노드 실행: ${targetTable}`); + console.log( + "📥 입력 데이터 타입:", + typeof inputData, + Array.isArray(inputData) ? `배열(${inputData.length}건)` : "단일 객체" + ); + + if (inputData && inputData.length > 0) { + console.log("📄 첫 번째 입력 데이터:"); + console.log(JSON.stringify(inputData[0], null, 2)); + console.log("🔑 입력 데이터 필드명:", Object.keys(inputData[0])); + } + console.log("🔑 충돌 키:", conflictKeys); + return transaction(async (client) => { const dataArray = Array.isArray(inputData) ? inputData : [inputData]; let insertedCount = 0; @@ -1466,7 +1656,10 @@ export class NodeFlowExecutionService { (key: string) => conflictKeyValues[key] ); - const checkSql = `SELECT id FROM ${targetTable} WHERE ${whereConditions} LIMIT 1`; + console.log("🔍 존재 여부 확인 - WHERE 조건:", whereConditions); + console.log("🔍 존재 여부 확인 - 바인딩 값:", whereValues); + + const checkSql = `SELECT 1 FROM ${targetTable} WHERE ${whereConditions} LIMIT 1`; const existingRow = await client.query(checkSql, whereValues); if (existingRow.rows.length > 0) { @@ -1780,7 +1973,6 @@ export class NodeFlowExecutionService { logger.info(`🌐 REST API UPSERT 시작: ${apiMethod} ${apiEndpoint}`); - const axios = require("axios"); const dataArray = Array.isArray(inputData) ? inputData : [inputData]; const results: any[] = []; @@ -1977,6 +2169,20 @@ export class NodeFlowExecutionService { const success = summary.failed === 0; + // 실패한 노드 상세 로깅 + if (!success) { + const failedNodes = nodeSummaries.filter((n) => n.status === "failed"); + logger.error( + `❌ 실패한 노드들:`, + failedNodes.map((n) => ({ + nodeId: n.nodeId, + nodeName: n.nodeName, + nodeType: n.nodeType, + error: n.error, + })) + ); + } + return { success, message: success diff --git a/docker-compose.backend.win.yml b/docker-compose.backend.win.yml index bef844dc..35dbf42a 100644 --- a/docker-compose.backend.win.yml +++ b/docker-compose.backend.win.yml @@ -15,6 +15,7 @@ services: - DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm - JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024 - JWT_EXPIRES_IN=24h + - ENCRYPTION_KEY=ilshin-plm-encryption-key-2024-secure-32bytes - CORS_ORIGIN=http://localhost:9771 - CORS_CREDENTIALS=true - LOG_LEVEL=debug @@ -26,7 +27,18 @@ services: - pms-network restart: unless-stopped healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health", "||", "exit", "1"] + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://localhost:8080/health", + "||", + "exit", + "1", + ] interval: 30s timeout: 15s retries: 5 diff --git a/frontend/components/dataflow/node-editor/FlowEditor.tsx b/frontend/components/dataflow/node-editor/FlowEditor.tsx index bf1b03a6..62211179 100644 --- a/frontend/components/dataflow/node-editor/FlowEditor.tsx +++ b/frontend/components/dataflow/node-editor/FlowEditor.tsx @@ -118,6 +118,16 @@ function FlowEditorInner() { displayName: `새 ${type} 노드`, }; + // REST API 소스 노드의 경우 + if (type === "restAPISource") { + defaultData.method = "GET"; + defaultData.url = ""; + defaultData.headers = {}; + defaultData.timeout = 30000; + defaultData.responseFields = []; // 빈 배열로 초기화 + defaultData.responseMapping = ""; + } + // 액션 노드의 경우 targetType 기본값 설정 if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) { defaultData.targetType = "internal"; // 기본값: 내부 DB diff --git a/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx b/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx index e662d474..5418fcab 100644 --- a/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx +++ b/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx @@ -78,35 +78,33 @@ export const ConditionNode = memo(({ data, selected }: NodeProps {/* 분기 출력 핸들 */} -
-
- {/* TRUE 출력 */} -
-
- - TRUE -
- +
+ {/* TRUE 출력 - 오른쪽 위 */} +
+
+ + TRUE
+ +
- {/* FALSE 출력 */} -
-
- - FALSE -
- + {/* FALSE 출력 - 오른쪽 아래 */} +
+
+ + FALSE
+
diff --git a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx index a73b410b..0ee91d06 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx @@ -64,6 +64,8 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP // 소스 필드 목록 (연결된 입력 노드에서 가져오기) const [sourceFields, setSourceFields] = useState>([]); + // REST API 소스 노드 연결 여부 + const [hasRestAPISource, setHasRestAPISource] = useState(false); // 🔥 외부 DB 관련 상태 const [externalConnections, setExternalConnections] = useState([]); @@ -135,9 +137,9 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP const getAllSourceFields = ( targetNodeId: string, visitedNodes = new Set(), - ): Array<{ name: string; label?: string }> => { + ): { fields: Array<{ name: string; label?: string }>; hasRestAPI: boolean } => { if (visitedNodes.has(targetNodeId)) { - return []; + return { fields: [], hasRestAPI: false }; } visitedNodes.add(targetNodeId); @@ -146,6 +148,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id)); const fields: Array<{ name: string; label?: string }> = []; + let foundRestAPI = false; sourceNodes.forEach((node) => { console.log(`🔍 노드 ${node.id} 타입: ${node.type}`); @@ -153,18 +156,20 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP // 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드 if (node.type === "dataTransform") { - console.log(`✅ 데이터 변환 노드 발견`); + console.log("✅ 데이터 변환 노드 발견"); // 상위 노드의 원본 필드 먼저 수집 - const upperFields = getAllSourceFields(node.id, visitedNodes); + const upperResult = getAllSourceFields(node.id, visitedNodes); + const upperFields = upperResult.fields; + foundRestAPI = foundRestAPI || upperResult.hasRestAPI; console.log(` 📤 상위 노드에서 ${upperFields.length}개 필드 가져옴`); // 변환된 필드 추가 (in-place 변환 고려) - if (node.data.transformations && Array.isArray(node.data.transformations)) { - console.log(` 📊 ${node.data.transformations.length}개 변환 발견`); + if ((node.data as any).transformations && Array.isArray((node.data as any).transformations)) { + console.log(` 📊 ${(node.data as any).transformations.length}개 변환 발견`); const inPlaceFields = new Set(); // in-place 변환된 필드 추적 - node.data.transformations.forEach((transform: any) => { + (node.data as any).transformations.forEach((transform: any) => { const targetField = transform.targetField || transform.sourceField; const isInPlace = !transform.targetField || transform.targetField === transform.sourceField; @@ -196,9 +201,31 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP fields.push(...upperFields); } } - // 일반 소스 노드인 경우 + // REST API 소스 노드인 경우 + else if (node.type === "restAPISource") { + console.log("✅ REST API 소스 노드 발견"); + foundRestAPI = true; + const responseFields = (node.data as any).responseFields; + + if (responseFields && Array.isArray(responseFields)) { + console.log(`✅ REST API 노드에서 ${responseFields.length}개 필드 발견`); + responseFields.forEach((field: any) => { + const fieldName = field.name || field.fieldName; + const fieldLabel = field.label || field.displayName; + if (fieldName) { + fields.push({ + name: fieldName, + label: fieldLabel, + }); + } + }); + } else { + console.log("⚠️ REST API 노드에 responseFields 없음"); + } + } + // 일반 소스 노드인 경우 (테이블 소스 등) else { - const nodeFields = node.data.fields || node.data.outputFields; + const nodeFields = (node.data as any).fields || (node.data as any).outputFields; if (nodeFields && Array.isArray(nodeFields)) { console.log(`✅ 노드 ${node.id}에서 ${nodeFields.length}개 필드 발견`); @@ -218,17 +245,19 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP } }); - return fields; + return { fields, hasRestAPI: foundRestAPI }; }; console.log("🔍 INSERT 노드 ID:", nodeId); - const allFields = getAllSourceFields(nodeId); + const result = getAllSourceFields(nodeId); // 중복 제거 - const uniqueFields = Array.from(new Map(allFields.map((field) => [field.name, field])).values()); + const uniqueFields = Array.from(new Map(result.fields.map((field) => [field.name, field])).values()); setSourceFields(uniqueFields); + setHasRestAPISource(result.hasRestAPI); console.log("✅ 최종 소스 필드 목록:", uniqueFields); + console.log("✅ REST API 소스 연결:", result.hasRestAPI); }, [nodeId, nodes, edges]); /** @@ -924,10 +953,10 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP