diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index e03bfe25..9459e1f6 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -66,11 +66,12 @@ export class FlowController { return; } - // REST API인 경우 테이블 존재 확인 스킵 - const isRestApi = dbSourceType === "restapi"; + // REST API 또는 다중 연결인 경우 테이블 존재 확인 스킵 + const isRestApi = dbSourceType === "restapi" || dbSourceType === "multi_restapi"; + const isMultiConnection = dbSourceType === "multi_restapi" || dbSourceType === "multi_external_db"; - // 테이블 이름이 제공된 경우에만 존재 확인 (REST API 제외) - if (tableName && !isRestApi && !tableName.startsWith("_restapi_")) { + // 테이블 이름이 제공된 경우에만 존재 확인 (REST API 및 다중 연결 제외) + if (tableName && !isRestApi && !isMultiConnection && !tableName.startsWith("_restapi_") && !tableName.startsWith("_multi_restapi_") && !tableName.startsWith("_multi_external_db_")) { const tableExists = await this.flowDefinitionService.checkTableExists(tableName); if (!tableExists) { @@ -92,6 +93,7 @@ export class FlowController { restApiConnectionId, restApiEndpoint, restApiJsonPath, + restApiConnections: req.body.restApiConnections, // 다중 REST API 설정 }, userId, userCompanyCode diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts index af37eff1..89096fbb 100644 --- a/backend-node/src/services/externalRestApiConnectionService.ts +++ b/backend-node/src/services/externalRestApiConnectionService.ts @@ -1091,4 +1091,150 @@ export class ExternalRestApiConnectionService { throw new Error("올바르지 않은 인증 타입입니다."); } } + + /** + * 다중 REST API 데이터 조회 및 병합 + * 여러 REST API의 응답을 병합하여 하나의 데이터셋으로 반환 + */ + static async fetchMultipleData( + configs: Array<{ + connectionId: number; + endpoint: string; + jsonPath: string; + alias: string; + }>, + userCompanyCode?: string + ): Promise; + total: number; + sources: Array<{ connectionId: number; connectionName: string; rowCount: number }>; + }>> { + try { + logger.info(`다중 REST API 데이터 조회 시작: ${configs.length}개 API`); + + // 각 API에서 데이터 조회 + const results = await Promise.all( + configs.map(async (config) => { + try { + const result = await this.fetchData( + config.connectionId, + config.endpoint, + config.jsonPath, + userCompanyCode + ); + + if (result.success && result.data) { + return { + success: true, + connectionId: config.connectionId, + connectionName: result.data.connectionInfo.connectionName, + alias: config.alias, + rows: result.data.rows, + columns: result.data.columns, + }; + } else { + logger.warn(`API ${config.connectionId} 조회 실패:`, result.message); + return { + success: false, + connectionId: config.connectionId, + connectionName: "", + alias: config.alias, + rows: [], + columns: [], + error: result.message, + }; + } + } catch (error) { + logger.error(`API ${config.connectionId} 조회 오류:`, error); + return { + success: false, + connectionId: config.connectionId, + connectionName: "", + alias: config.alias, + rows: [], + columns: [], + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + }) + ); + + // 성공한 결과만 필터링 + const successfulResults = results.filter(r => r.success); + + if (successfulResults.length === 0) { + return { + success: false, + message: "모든 REST API 조회에 실패했습니다.", + error: { + code: "ALL_APIS_FAILED", + details: results.map(r => ({ connectionId: r.connectionId, error: r.error })), + }, + }; + } + + // 컬럼 병합 (별칭 적용) + const mergedColumns: Array<{ columnName: string; columnLabel: string; dataType: string; sourceApi: string }> = []; + + for (const result of successfulResults) { + for (const col of result.columns) { + const prefixedColumnName = result.alias ? `${result.alias}${col.columnName}` : col.columnName; + mergedColumns.push({ + columnName: prefixedColumnName, + columnLabel: `${col.columnLabel} (${result.connectionName})`, + dataType: col.dataType, + sourceApi: result.connectionName, + }); + } + } + + // 데이터 병합 (가로 병합: 각 API의 첫 번째 행끼리 병합) + // 참고: 실제 사용 시에는 조인 키가 필요할 수 있음 + const maxRows = Math.max(...successfulResults.map(r => r.rows.length)); + const mergedRows: any[] = []; + + for (let i = 0; i < maxRows; i++) { + const mergedRow: any = {}; + + for (const result of successfulResults) { + const row = result.rows[i] || {}; + + for (const [key, value] of Object.entries(row)) { + const prefixedKey = result.alias ? `${result.alias}${key}` : key; + mergedRow[prefixedKey] = value; + } + } + + mergedRows.push(mergedRow); + } + + logger.info(`다중 REST API 데이터 병합 완료: ${mergedRows.length}개 행, ${mergedColumns.length}개 컬럼`); + + return { + success: true, + data: { + rows: mergedRows, + columns: mergedColumns, + total: mergedRows.length, + sources: successfulResults.map(r => ({ + connectionId: r.connectionId, + connectionName: r.connectionName, + rowCount: r.rows.length, + })), + }, + message: `${successfulResults.length}개 API에서 총 ${mergedRows.length}개 데이터를 조회했습니다.`, + }; + } catch (error) { + logger.error("다중 REST API 데이터 조회 오류:", error); + return { + success: false, + message: "다중 REST API 데이터 조회에 실패했습니다.", + error: { + code: "MULTI_FETCH_ERROR", + details: error instanceof Error ? error.message : "알 수 없는 오류", + }, + }; + } + } } diff --git a/backend-node/src/services/flowDefinitionService.ts b/backend-node/src/services/flowDefinitionService.ts index 4416faa0..80c920ad 100644 --- a/backend-node/src/services/flowDefinitionService.ts +++ b/backend-node/src/services/flowDefinitionService.ts @@ -30,6 +30,7 @@ export class FlowDefinitionService { restApiConnectionId: request.restApiConnectionId, restApiEndpoint: request.restApiEndpoint, restApiJsonPath: request.restApiJsonPath, + restApiConnections: request.restApiConnections, companyCode, userId, }); @@ -38,9 +39,9 @@ export class FlowDefinitionService { INSERT INTO flow_definition ( name, description, table_name, db_source_type, db_connection_id, rest_api_connection_id, rest_api_endpoint, rest_api_json_path, - company_code, created_by + rest_api_connections, company_code, created_by ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING * `; @@ -52,7 +53,8 @@ export class FlowDefinitionService { request.dbConnectionId || null, request.restApiConnectionId || null, request.restApiEndpoint || null, - request.restApiJsonPath || "data", + request.restApiJsonPath || "response", + request.restApiConnections ? JSON.stringify(request.restApiConnections) : null, companyCode, userId, ]; @@ -209,6 +211,19 @@ export class FlowDefinitionService { * DB 행을 FlowDefinition 객체로 변환 */ private mapToFlowDefinition(row: any): FlowDefinition { + // rest_api_connections 파싱 (JSONB → 배열) + let restApiConnections = undefined; + if (row.rest_api_connections) { + try { + restApiConnections = typeof row.rest_api_connections === 'string' + ? JSON.parse(row.rest_api_connections) + : row.rest_api_connections; + } catch (e) { + console.warn("Failed to parse rest_api_connections:", e); + restApiConnections = []; + } + } + return { id: row.id, name: row.name, @@ -216,10 +231,12 @@ export class FlowDefinitionService { tableName: row.table_name, dbSourceType: row.db_source_type || "internal", dbConnectionId: row.db_connection_id, - // REST API 관련 필드 + // REST API 관련 필드 (단일) restApiConnectionId: row.rest_api_connection_id, restApiEndpoint: row.rest_api_endpoint, restApiJsonPath: row.rest_api_json_path, + // 다중 REST API 관련 필드 + restApiConnections: restApiConnections, companyCode: row.company_code || "*", isActive: row.is_active, createdBy: row.created_by, diff --git a/backend-node/src/types/flow.ts b/backend-node/src/types/flow.ts index c877a2b3..9f105a49 100644 --- a/backend-node/src/types/flow.ts +++ b/backend-node/src/types/flow.ts @@ -2,18 +2,38 @@ * 플로우 관리 시스템 타입 정의 */ +// 다중 REST API 연결 설정 +export interface RestApiConnectionConfig { + connectionId: number; + connectionName: string; + endpoint: string; + jsonPath: string; + alias: string; // 컬럼 접두어 (예: "api1_") +} + +// 다중 외부 DB 연결 설정 +export interface ExternalDbConnectionConfig { + connectionId: number; + connectionName: string; + dbType: string; + tableName: string; + alias: string; // 컬럼 접두어 (예: "db1_") +} + // 플로우 정의 export interface FlowDefinition { id: number; name: string; description?: string; tableName: string; - dbSourceType?: "internal" | "external" | "restapi"; // 데이터 소스 타입 + dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db"; // 데이터 소스 타입 dbConnectionId?: number; // 외부 DB 연결 ID (external인 경우) - // REST API 관련 필드 + // REST API 관련 필드 (단일) restApiConnectionId?: number; // REST API 연결 ID (restapi인 경우) restApiEndpoint?: string; // REST API 엔드포인트 restApiJsonPath?: string; // JSON 응답에서 데이터 경로 (기본: data) + // 다중 REST API 관련 필드 + restApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정 배열 companyCode: string; // 회사 코드 (* = 공통) isActive: boolean; createdBy?: string; @@ -26,12 +46,14 @@ export interface CreateFlowDefinitionRequest { name: string; description?: string; tableName: string; - dbSourceType?: "internal" | "external" | "restapi"; // 데이터 소스 타입 + dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db"; // 데이터 소스 타입 dbConnectionId?: number; // 외부 DB 연결 ID - // REST API 관련 필드 + // REST API 관련 필드 (단일) restApiConnectionId?: number; // REST API 연결 ID restApiEndpoint?: string; // REST API 엔드포인트 restApiJsonPath?: string; // JSON 응답에서 데이터 경로 + // 다중 REST API 관련 필드 + restApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정 배열 companyCode?: string; // 회사 코드 (미제공 시 사용자의 company_code 사용) } diff --git a/frontend/app/(main)/admin/flow-management/[id]/page.tsx b/frontend/app/(main)/admin/flow-management/[id]/page.tsx index a311bc63..b8d14e19 100644 --- a/frontend/app/(main)/admin/flow-management/[id]/page.tsx +++ b/frontend/app/(main)/admin/flow-management/[id]/page.tsx @@ -319,6 +319,10 @@ export default function FlowEditorPage() { flowTableName={flowDefinition?.tableName} // 플로우 정의의 테이블명 전달 flowDbSourceType={flowDefinition?.dbSourceType} // DB 소스 타입 전달 flowDbConnectionId={flowDefinition?.dbConnectionId} // 외부 DB 연결 ID 전달 + flowRestApiConnectionId={flowDefinition?.restApiConnectionId} // REST API 연결 ID 전달 + flowRestApiEndpoint={flowDefinition?.restApiEndpoint} // REST API 엔드포인트 전달 + flowRestApiJsonPath={flowDefinition?.restApiJsonPath} // REST API JSON 경로 전달 + flowRestApiConnections={flowDefinition?.restApiConnections} // 다중 REST API 설정 전달 onClose={() => setSelectedStep(null)} onUpdate={loadFlowData} /> diff --git a/frontend/app/(main)/admin/flow-management/page.tsx b/frontend/app/(main)/admin/flow-management/page.tsx index 5a335daf..d283f72d 100644 --- a/frontend/app/(main)/admin/flow-management/page.tsx +++ b/frontend/app/(main)/admin/flow-management/page.tsx @@ -64,7 +64,30 @@ export default function FlowManagementPage() { // REST API 연결 관련 상태 const [restApiConnections, setRestApiConnections] = useState([]); const [restApiEndpoint, setRestApiEndpoint] = useState(""); - const [restApiJsonPath, setRestApiJsonPath] = useState("data"); + const [restApiJsonPath, setRestApiJsonPath] = useState("response"); + + // 다중 REST API 선택 상태 + interface RestApiConfig { + connectionId: number; + connectionName: string; + endpoint: string; + jsonPath: string; + alias: string; // 컬럼 접두어 (예: "api1_") + } + const [selectedRestApis, setSelectedRestApis] = useState([]); + const [isMultiRestApi, setIsMultiRestApi] = useState(false); // 다중 REST API 모드 + + // 다중 외부 DB 선택 상태 + interface ExternalDbConfig { + connectionId: number; + connectionName: string; + dbType: string; + tableName: string; + alias: string; // 컬럼 접두어 (예: "db1_") + } + const [selectedExternalDbs, setSelectedExternalDbs] = useState([]); + const [isMultiExternalDb, setIsMultiExternalDb] = useState(false); // 다중 외부 DB 모드 + const [multiDbTableLists, setMultiDbTableLists] = useState>({}); // 각 DB별 테이블 목록 // 생성 폼 상태 const [formData, setFormData] = useState({ @@ -207,25 +230,161 @@ export default function FlowManagementPage() { } }, [selectedDbSource]); + // 다중 외부 DB 추가 + const addExternalDbConfig = async (connectionId: number) => { + const connection = externalConnections.find(c => c.id === connectionId); + if (!connection) return; + + // 이미 추가된 경우 스킵 + if (selectedExternalDbs.some(db => db.connectionId === connectionId)) { + toast({ + title: "이미 추가됨", + description: "해당 외부 DB가 이미 추가되어 있습니다.", + variant: "destructive", + }); + return; + } + + // 해당 DB의 테이블 목록 로드 + try { + const data = await ExternalDbConnectionAPI.getTables(connectionId); + if (data.success && data.data) { + const tables = Array.isArray(data.data) ? data.data : []; + const tableNames = tables + .map((t: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) => + typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name, + ) + .filter(Boolean); + setMultiDbTableLists(prev => ({ ...prev, [connectionId]: tableNames })); + } + } catch (error) { + console.error("외부 DB 테이블 목록 조회 오류:", error); + } + + const newConfig: ExternalDbConfig = { + connectionId, + connectionName: connection.connection_name, + dbType: connection.db_type, + tableName: "", + alias: `db${selectedExternalDbs.length + 1}_`, // 자동 별칭 생성 + }; + + setSelectedExternalDbs([...selectedExternalDbs, newConfig]); + }; + + // 다중 외부 DB 삭제 + const removeExternalDbConfig = (connectionId: number) => { + setSelectedExternalDbs(selectedExternalDbs.filter(db => db.connectionId !== connectionId)); + }; + + // 다중 외부 DB 설정 업데이트 + const updateExternalDbConfig = (connectionId: number, field: keyof ExternalDbConfig, value: string) => { + setSelectedExternalDbs(selectedExternalDbs.map(db => + db.connectionId === connectionId ? { ...db, [field]: value } : db + )); + }; + + // 다중 REST API 추가 + const addRestApiConfig = (connectionId: number) => { + const connection = restApiConnections.find(c => c.id === connectionId); + if (!connection) return; + + // 이미 추가된 경우 스킵 + if (selectedRestApis.some(api => api.connectionId === connectionId)) { + toast({ + title: "이미 추가됨", + description: "해당 REST API가 이미 추가되어 있습니다.", + variant: "destructive", + }); + return; + } + + // 연결 테이블의 기본값 사용 + const newConfig: RestApiConfig = { + connectionId, + connectionName: connection.connection_name, + endpoint: connection.endpoint_path || "", // 연결 테이블의 기본 엔드포인트 + jsonPath: "response", // 기본값 + alias: `api${selectedRestApis.length + 1}_`, // 자동 별칭 생성 + }; + + setSelectedRestApis([...selectedRestApis, newConfig]); + }; + + // 다중 REST API 삭제 + const removeRestApiConfig = (connectionId: number) => { + setSelectedRestApis(selectedRestApis.filter(api => api.connectionId !== connectionId)); + }; + + // 다중 REST API 설정 업데이트 + const updateRestApiConfig = (connectionId: number, field: keyof RestApiConfig, value: string) => { + setSelectedRestApis(selectedRestApis.map(api => + api.connectionId === connectionId ? { ...api, [field]: value } : api + )); + }; + // 플로우 생성 const handleCreate = async () => { console.log("🚀 handleCreate called with formData:", formData); - // REST API인 경우 테이블 이름 검증 스킵 - const isRestApi = selectedDbSource.startsWith("restapi_"); + // REST API 또는 다중 선택인 경우 테이블 이름 검증 스킵 + const isRestApi = selectedDbSource.startsWith("restapi_") || isMultiRestApi; + const isMultiMode = isMultiRestApi || isMultiExternalDb; - if (!formData.name || (!isRestApi && !formData.tableName)) { - console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName, isRestApi }); + if (!formData.name || (!isRestApi && !isMultiMode && !formData.tableName)) { + console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName, isRestApi, isMultiMode }); toast({ title: "입력 오류", - description: isRestApi ? "플로우 이름은 필수입니다." : "플로우 이름과 테이블 이름은 필수입니다.", + description: (isRestApi || isMultiMode) ? "플로우 이름은 필수입니다." : "플로우 이름과 테이블 이름은 필수입니다.", variant: "destructive", }); return; } - // REST API인 경우 엔드포인트 검증 - if (isRestApi && !restApiEndpoint) { + // 다중 REST API 모드인 경우 검증 + if (isMultiRestApi) { + if (selectedRestApis.length === 0) { + toast({ + title: "입력 오류", + description: "최소 하나의 REST API를 추가해주세요.", + variant: "destructive", + }); + return; + } + + // 각 API의 엔드포인트 검증 + const missingEndpoint = selectedRestApis.find(api => !api.endpoint); + if (missingEndpoint) { + toast({ + title: "입력 오류", + description: `${missingEndpoint.connectionName}의 엔드포인트를 입력해주세요.`, + variant: "destructive", + }); + return; + } + } else if (isMultiExternalDb) { + // 다중 외부 DB 모드인 경우 검증 + if (selectedExternalDbs.length === 0) { + toast({ + title: "입력 오류", + description: "최소 하나의 외부 DB를 추가해주세요.", + variant: "destructive", + }); + return; + } + + // 각 DB의 테이블 선택 검증 + const missingTable = selectedExternalDbs.find(db => !db.tableName); + if (missingTable) { + toast({ + title: "입력 오류", + description: `${missingTable.connectionName}의 테이블을 선택해주세요.`, + variant: "destructive", + }); + return; + } + } else if (isRestApi && !restApiEndpoint) { + // 단일 REST API인 경우 엔드포인트 검증 toast({ title: "입력 오류", description: "REST API 엔드포인트는 필수입니다.", @@ -236,11 +395,15 @@ export default function FlowManagementPage() { try { // 데이터 소스 타입 및 ID 파싱 - let dbSourceType: "internal" | "external" | "restapi" = "internal"; + let dbSourceType: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db" = "internal"; let dbConnectionId: number | undefined = undefined; let restApiConnectionId: number | undefined = undefined; - if (selectedDbSource === "internal") { + if (isMultiRestApi) { + dbSourceType = "multi_restapi"; + } else if (isMultiExternalDb) { + dbSourceType = "multi_external_db"; + } else if (selectedDbSource === "internal") { dbSourceType = "internal"; } else if (selectedDbSource.startsWith("external_db_")) { dbSourceType = "external"; @@ -257,11 +420,27 @@ export default function FlowManagementPage() { dbConnectionId, }; - // REST API인 경우 추가 정보 - if (dbSourceType === "restapi") { + // 다중 REST API인 경우 + if (dbSourceType === "multi_restapi") { + requestData.restApiConnections = selectedRestApis; + // 다중 REST API는 첫 번째 API의 ID를 기본으로 사용 + requestData.restApiConnectionId = selectedRestApis[0]?.connectionId; + requestData.restApiEndpoint = selectedRestApis[0]?.endpoint; + requestData.restApiJsonPath = selectedRestApis[0]?.jsonPath || "response"; + // 가상 테이블명: 모든 연결 ID를 조합 + requestData.tableName = `_multi_restapi_${selectedRestApis.map(a => a.connectionId).join("_")}`; + } else if (dbSourceType === "multi_external_db") { + // 다중 외부 DB인 경우 + requestData.externalDbConnections = selectedExternalDbs; + // 첫 번째 DB의 ID를 기본으로 사용 + requestData.dbConnectionId = selectedExternalDbs[0]?.connectionId; + // 가상 테이블명: 모든 연결 ID와 테이블명 조합 + requestData.tableName = `_multi_external_db_${selectedExternalDbs.map(db => `${db.connectionId}_${db.tableName}`).join("_")}`; + } else if (dbSourceType === "restapi") { + // 단일 REST API인 경우 requestData.restApiConnectionId = restApiConnectionId; requestData.restApiEndpoint = restApiEndpoint; - requestData.restApiJsonPath = restApiJsonPath || "data"; + requestData.restApiJsonPath = restApiJsonPath || "response"; // REST API는 가상 테이블명 사용 requestData.tableName = `_restapi_${restApiConnectionId}`; } @@ -277,7 +456,11 @@ export default function FlowManagementPage() { setFormData({ name: "", description: "", tableName: "" }); setSelectedDbSource("internal"); setRestApiEndpoint(""); - setRestApiJsonPath("data"); + setRestApiJsonPath("response"); + setSelectedRestApis([]); + setSelectedExternalDbs([]); + setIsMultiRestApi(false); + setIsMultiExternalDb(false); loadFlows(); } else { toast({ @@ -485,13 +668,27 @@ export default function FlowManagementPage() {

@@ -535,8 +751,160 @@ export default function FlowManagementPage() {

- {/* REST API인 경우 엔드포인트 설정 */} - {selectedDbSource.startsWith("restapi_") ? ( + {/* 다중 REST API 선택 UI */} + {isMultiRestApi && ( +
+
+ + +
+ + {selectedRestApis.length === 0 ? ( +
+

+ 위에서 REST API를 추가해주세요 +

+
+ ) : ( +
+ {selectedRestApis.map((api) => ( +
+
+ {api.connectionName} + + ({api.endpoint || "기본 엔드포인트"}) + +
+ +
+ ))} +
+ )} +

+ 선택한 REST API들의 데이터가 자동으로 병합됩니다. +

+
+ )} + + {/* 다중 외부 DB 선택 UI */} + {isMultiExternalDb && ( +
+
+ + +
+ + {selectedExternalDbs.length === 0 ? ( +
+

+ 위에서 외부 DB를 추가해주세요 +

+
+ ) : ( +
+ {selectedExternalDbs.map((db) => ( +
+
+ + {db.connectionName} ({db.dbType?.toUpperCase()}) + + +
+
+
+ + +
+
+ + updateExternalDbConfig(db.connectionId, "alias", e.target.value)} + placeholder="db1_" + className="h-7 text-xs" + /> +
+
+
+ ))} +
+ )} +

+ 선택한 외부 DB들의 데이터가 자동으로 병합됩니다. 각 DB별 테이블을 선택해주세요. +

+
+ )} + + {/* 단일 REST API인 경우 엔드포인트 설정 */} + {!isMultiRestApi && selectedDbSource.startsWith("restapi_") && ( <>
- ) : ( - /* 테이블 선택 (내부 DB 또는 외부 DB) */ + )} + + {/* 테이블 선택 (내부 DB 또는 단일 외부 DB - 다중 선택 모드가 아닌 경우만) */} + {!isMultiRestApi && !isMultiExternalDb && !selectedDbSource.startsWith("restapi_") && (
)} + + {/* REST API 연동 설정 */} + {formData.integrationType === "rest_api" && ( +
+
+ + +
+ + {formData.integrationConfig?.connectionId && ( + <> +
+ + +
+ +
+ + + setFormData({ + ...formData, + integrationConfig: { + ...formData.integrationConfig!, + endpoint: e.target.value, + } as any, + }) + } + placeholder="/api/update" + /> +

+ 데이터 이동 시 호출할 API 엔드포인트 +

+
+ +
+ +