데이터 매핑 설정 중간커밋

This commit is contained in:
kjs 2025-09-26 17:52:11 +09:00
parent bf7fc6cfb8
commit e0777d0fc3
10 changed files with 2129 additions and 6 deletions

View File

@ -0,0 +1,34 @@
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
async function addDataMappingColumn() {
try {
console.log(
"🔄 external_call_configs 테이블에 data_mapping_config 컬럼 추가 중..."
);
// data_mapping_config JSONB 컬럼 추가
await prisma.$executeRaw`
ALTER TABLE external_call_configs
ADD COLUMN IF NOT EXISTS data_mapping_config JSONB
`;
console.log("✅ data_mapping_config 컬럼이 성공적으로 추가되었습니다.");
// 기존 레코드에 기본값 설정
await prisma.$executeRaw`
UPDATE external_call_configs
SET data_mapping_config = '{"direction": "none"}'::jsonb
WHERE data_mapping_config IS NULL
`;
console.log("✅ 기존 레코드에 기본값이 설정되었습니다.");
} catch (error) {
console.error("❌ 컬럼 추가 실패:", error);
} finally {
await prisma.$disconnect();
}
}
addDataMappingColumn();

View File

@ -0,0 +1,575 @@
import { PrismaClient } from "@prisma/client";
import {
DataMappingConfig,
InboundMapping,
OutboundMapping,
FieldMapping,
DataMappingResult,
MappingValidationResult,
FieldTransform,
DataType,
} from "../types/dataMappingTypes";
export class DataMappingService {
private prisma: PrismaClient;
constructor() {
this.prisma = new PrismaClient();
}
/**
* Inbound ( )
*/
async processInboundData(
externalData: any,
mapping: InboundMapping
): Promise<DataMappingResult> {
const startTime = Date.now();
const result: DataMappingResult = {
success: false,
direction: "inbound",
recordsProcessed: 0,
recordsInserted: 0,
recordsUpdated: 0,
recordsSkipped: 0,
errors: [],
executionTime: 0,
timestamp: new Date().toISOString(),
};
try {
console.log(`📥 [DataMappingService] Inbound 매핑 시작:`, {
targetTable: mapping.targetTable,
insertMode: mapping.insertMode,
fieldMappings: mapping.fieldMappings.length,
});
// 데이터 배열로 변환
const dataArray = Array.isArray(externalData)
? externalData
: [externalData];
result.recordsProcessed = dataArray.length;
// 각 레코드 처리
for (const record of dataArray) {
try {
const mappedData = await this.mapInboundRecord(record, mapping);
if (Object.keys(mappedData).length === 0) {
result.recordsSkipped!++;
continue;
}
// 데이터베이스에 저장
await this.saveInboundRecord(mappedData, mapping);
if (mapping.insertMode === "insert") {
result.recordsInserted!++;
} else {
result.recordsUpdated!++;
}
} catch (error) {
console.error(`❌ [DataMappingService] 레코드 처리 실패:`, error);
result.errors!.push(
`레코드 처리 실패: ${error instanceof Error ? error.message : String(error)}`
);
result.recordsSkipped!++;
}
}
result.success =
result.errors!.length === 0 ||
result.recordsInserted! > 0 ||
result.recordsUpdated! > 0;
} catch (error) {
console.error(`❌ [DataMappingService] Inbound 매핑 실패:`, error);
result.errors!.push(
`매핑 처리 실패: ${error instanceof Error ? error.message : String(error)}`
);
}
result.executionTime = Date.now() - startTime;
console.log(`✅ [DataMappingService] Inbound 매핑 완료:`, result);
return result;
}
/**
* Outbound ( )
*/
async processOutboundData(
mapping: OutboundMapping,
filter?: any
): Promise<any> {
console.log(`📤 [DataMappingService] Outbound 매핑 시작:`, {
sourceTable: mapping.sourceTable,
fieldMappings: mapping.fieldMappings.length,
filter,
});
try {
// 소스 데이터 조회
const sourceData = await this.getSourceData(mapping, filter);
if (
!sourceData ||
(Array.isArray(sourceData) && sourceData.length === 0)
) {
console.log(`⚠️ [DataMappingService] 소스 데이터가 없습니다.`);
return null;
}
// 데이터 매핑
const mappedData = Array.isArray(sourceData)
? await Promise.all(
sourceData.map((record) => this.mapOutboundRecord(record, mapping))
)
: await this.mapOutboundRecord(sourceData, mapping);
console.log(`✅ [DataMappingService] Outbound 매핑 완료:`, {
recordCount: Array.isArray(mappedData) ? mappedData.length : 1,
});
return mappedData;
} catch (error) {
console.error(`❌ [DataMappingService] Outbound 매핑 실패:`, error);
throw error;
}
}
/**
* Inbound
*/
private async mapInboundRecord(
sourceRecord: any,
mapping: InboundMapping
): Promise<Record<string, any>> {
const mappedRecord: Record<string, any> = {};
for (const fieldMapping of mapping.fieldMappings) {
try {
const sourceValue = sourceRecord[fieldMapping.sourceField];
// 필수 필드 체크
if (
fieldMapping.required &&
(sourceValue === undefined || sourceValue === null)
) {
if (fieldMapping.defaultValue !== undefined) {
mappedRecord[fieldMapping.targetField] = fieldMapping.defaultValue;
} else {
throw new Error(
`필수 필드 '${fieldMapping.sourceField}'가 누락되었습니다.`
);
}
continue;
}
// 값이 없으면 기본값 사용
if (sourceValue === undefined || sourceValue === null) {
if (fieldMapping.defaultValue !== undefined) {
mappedRecord[fieldMapping.targetField] = fieldMapping.defaultValue;
}
continue;
}
// 데이터 변환 적용
const transformedValue = await this.transformFieldValue(
sourceValue,
fieldMapping.dataType,
fieldMapping.transform
);
mappedRecord[fieldMapping.targetField] = transformedValue;
} catch (error) {
console.error(
`❌ [DataMappingService] 필드 매핑 실패 (${fieldMapping.sourceField}${fieldMapping.targetField}):`,
error
);
throw error;
}
}
return mappedRecord;
}
/**
* Outbound
*/
private async mapOutboundRecord(
sourceRecord: any,
mapping: OutboundMapping
): Promise<Record<string, any>> {
const mappedRecord: Record<string, any> = {};
for (const fieldMapping of mapping.fieldMappings) {
try {
const sourceValue = sourceRecord[fieldMapping.sourceField];
// 데이터 변환 적용
const transformedValue = await this.transformFieldValue(
sourceValue,
fieldMapping.dataType,
fieldMapping.transform
);
mappedRecord[fieldMapping.targetField] = transformedValue;
} catch (error) {
console.error(
`❌ [DataMappingService] 필드 매핑 실패 (${fieldMapping.sourceField}${fieldMapping.targetField}):`,
error
);
throw error;
}
}
return mappedRecord;
}
/**
*
*/
private async transformFieldValue(
value: any,
targetDataType: DataType,
transform?: FieldTransform
): Promise<any> {
let transformedValue = value;
// 1. 변환 함수 적용
if (transform) {
switch (transform.type) {
case "constant":
transformedValue = transform.value;
break;
case "format":
if (targetDataType === "date" && transform.format) {
transformedValue = this.formatDate(value, transform.format);
}
break;
case "function":
if (transform.functionName) {
transformedValue = await this.applyCustomFunction(
value,
transform.functionName
);
}
break;
}
}
// 2. 데이터 타입 변환
return this.convertDataType(transformedValue, targetDataType);
}
/**
*
*/
private convertDataType(value: any, targetType: DataType): any {
if (value === null || value === undefined) return value;
switch (targetType) {
case "string":
return String(value);
case "number":
const num = Number(value);
return isNaN(num) ? null : num;
case "boolean":
if (typeof value === "boolean") return value;
if (typeof value === "string") {
return (
value.toLowerCase() === "true" || value === "1" || value === "Y"
);
}
return Boolean(value);
case "date":
return new Date(value);
case "json":
return typeof value === "string" ? JSON.parse(value) : value;
default:
return value;
}
}
/**
*
*/
private formatDate(value: any, format: string): string {
const date = new Date(value);
if (isNaN(date.getTime())) return value;
// 간단한 날짜 포맷 변환
switch (format) {
case "YYYY-MM-DD":
return date.toISOString().split("T")[0];
case "YYYY-MM-DD HH:mm:ss":
return date
.toISOString()
.replace("T", " ")
.replace(/\.\d{3}Z$/, "");
default:
return date.toISOString();
}
}
/**
*
*/
private async applyCustomFunction(
value: any,
functionName: string
): Promise<any> {
// 추후 확장 가능한 커스텀 함수들
switch (functionName) {
case "upperCase":
return String(value).toUpperCase();
case "lowerCase":
return String(value).toLowerCase();
case "trim":
return String(value).trim();
default:
console.warn(
`⚠️ [DataMappingService] 알 수 없는 함수: ${functionName}`
);
return value;
}
}
/**
* Inbound
*/
private async saveInboundRecord(
mappedData: Record<string, any>,
mapping: InboundMapping
): Promise<void> {
const tableName = mapping.targetTable;
try {
switch (mapping.insertMode) {
case "insert":
await this.executeInsert(tableName, mappedData);
break;
case "upsert":
await this.executeUpsert(
tableName,
mappedData,
mapping.keyFields || []
);
break;
case "update":
await this.executeUpdate(
tableName,
mappedData,
mapping.keyFields || []
);
break;
}
} catch (error) {
console.error(
`❌ [DataMappingService] 데이터 저장 실패 (${tableName}):`,
error
);
throw error;
}
}
/**
*
*/
private async getSourceData(
mapping: OutboundMapping,
filter?: any
): Promise<any> {
const tableName = mapping.sourceTable;
try {
// 동적 테이블 쿼리 (Prisma의 경우 런타임에서 제한적)
// 실제 구현에서는 각 테이블별 모델을 사용하거나 Raw SQL을 사용해야 함
let whereClause = {};
if (mapping.sourceFilter) {
// 간단한 필터 파싱 (실제로는 더 정교한 파싱 필요)
console.log(
`🔍 [DataMappingService] 필터 조건: ${mapping.sourceFilter}`
);
// TODO: 필터 조건 파싱 및 적용
}
if (filter) {
whereClause = { ...whereClause, ...filter };
}
// Raw SQL을 사용한 동적 쿼리
const query = `SELECT * FROM ${tableName}${mapping.sourceFilter ? ` WHERE ${mapping.sourceFilter}` : ""}`;
console.log(`🔍 [DataMappingService] 쿼리 실행: ${query}`);
const result = await this.prisma.$queryRawUnsafe(query);
return result;
} catch (error) {
console.error(
`❌ [DataMappingService] 소스 데이터 조회 실패 (${tableName}):`,
error
);
throw error;
}
}
/**
* INSERT
*/
private async executeInsert(
tableName: string,
data: Record<string, any>
): Promise<void> {
const columns = Object.keys(data);
const values = Object.values(data);
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`;
console.log(`📝 [DataMappingService] INSERT 실행:`, {
table: tableName,
columns,
query,
});
await this.prisma.$executeRawUnsafe(query, ...values);
}
/**
* UPSERT
*/
private async executeUpsert(
tableName: string,
data: Record<string, any>,
keyFields: string[]
): Promise<void> {
if (keyFields.length === 0) {
throw new Error("UPSERT 모드에서는 키 필드가 필요합니다.");
}
const columns = Object.keys(data);
const values = Object.values(data);
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const updateClauses = columns
.filter((col) => !keyFields.includes(col))
.map((col) => `${col} = EXCLUDED.${col}`)
.join(", ");
const query = `
INSERT INTO ${tableName} (${columns.join(", ")})
VALUES (${placeholders})
ON CONFLICT (${keyFields.join(", ")})
DO UPDATE SET ${updateClauses}
`;
console.log(`🔄 [DataMappingService] UPSERT 실행:`, {
table: tableName,
keyFields,
query,
});
await this.prisma.$executeRawUnsafe(query, ...values);
}
/**
* UPDATE
*/
private async executeUpdate(
tableName: string,
data: Record<string, any>,
keyFields: string[]
): Promise<void> {
if (keyFields.length === 0) {
throw new Error("UPDATE 모드에서는 키 필드가 필요합니다.");
}
const updateColumns = Object.keys(data).filter(
(col) => !keyFields.includes(col)
);
const updateClauses = updateColumns
.map((col, i) => `${col} = $${i + 1}`)
.join(", ");
const whereConditions = keyFields
.map((field, i) => `${field} = $${updateColumns.length + i + 1}`)
.join(" AND ");
const values = [
...updateColumns.map((col) => data[col]),
...keyFields.map((field) => data[field]),
];
const query = `UPDATE ${tableName} SET ${updateClauses} WHERE ${whereConditions}`;
console.log(`✏️ [DataMappingService] UPDATE 실행:`, {
table: tableName,
keyFields,
query,
});
await this.prisma.$executeRawUnsafe(query, ...values);
}
/**
*
*/
validateMappingConfig(config: DataMappingConfig): MappingValidationResult {
const result: MappingValidationResult = {
isValid: true,
errors: [],
warnings: [],
};
if (config.direction === "none") {
return result;
}
// Inbound 매핑 검증
if (
(config.direction === "inbound" ||
config.direction === "bidirectional") &&
config.inboundMapping
) {
if (!config.inboundMapping.targetTable) {
result.errors.push("Inbound 매핑에 대상 테이블이 필요합니다.");
}
if (config.inboundMapping.fieldMappings.length === 0) {
result.errors.push("Inbound 매핑에 필드 매핑이 필요합니다.");
}
if (
config.inboundMapping.insertMode !== "insert" &&
(!config.inboundMapping.keyFields ||
config.inboundMapping.keyFields.length === 0)
) {
result.errors.push("UPSERT/UPDATE 모드에서는 키 필드가 필요합니다.");
}
}
// Outbound 매핑 검증
if (
(config.direction === "outbound" ||
config.direction === "bidirectional") &&
config.outboundMapping
) {
if (!config.outboundMapping.sourceTable) {
result.errors.push("Outbound 매핑에 소스 테이블이 필요합니다.");
}
if (config.outboundMapping.fieldMappings.length === 0) {
result.errors.push("Outbound 매핑에 필드 매핑이 필요합니다.");
}
}
result.isValid = result.errors.length === 0;
return result;
}
/**
*
*/
async disconnect(): Promise<void> {
await this.prisma.$disconnect();
}
}

View File

@ -10,6 +10,11 @@ import {
SupportedExternalCallSettings,
TemplateOptions,
} from "../types/externalCallTypes";
import { DataMappingService } from "./dataMappingService";
import {
DataMappingConfig,
DataMappingResult,
} from "../types/dataMappingTypes";
/**
*
@ -18,10 +23,149 @@ import {
export class ExternalCallService {
private readonly DEFAULT_TIMEOUT = 30000; // 30초
private readonly DEFAULT_RETRY_COUNT = 3;
private dataMappingService: DataMappingService;
private readonly DEFAULT_RETRY_DELAY = 1000; // 1초
constructor() {
this.dataMappingService = new DataMappingService();
}
/**
*
*
*/
async executeWithDataMapping(
config: ExternalCallConfig,
dataMappingConfig?: DataMappingConfig,
triggerData?: any
): Promise<{
callResult: ExternalCallResult;
mappingResult?: DataMappingResult;
}> {
const startTime = Date.now();
console.log(`🚀 [ExternalCallService] 데이터 매핑 포함 외부 호출 시작:`, {
callType: config.callType,
hasMappingConfig: !!dataMappingConfig,
mappingDirection: dataMappingConfig?.direction,
});
try {
let requestData = config;
// Outbound 매핑 처리 (내부 → 외부)
if (
dataMappingConfig?.direction === "outbound" &&
dataMappingConfig.outboundMapping
) {
console.log(`📤 [ExternalCallService] Outbound 매핑 처리 시작`);
const outboundData = await this.dataMappingService.processOutboundData(
dataMappingConfig.outboundMapping,
triggerData
);
// API 요청 바디에 매핑된 데이터 포함
if (config.callType === "rest-api") {
// GenericApiSettings로 타입 캐스팅
const apiConfig = config as GenericApiSettings;
const bodyTemplate = apiConfig.body || "{}";
// 템플릿에 데이터 삽입
const processedBody = this.processTemplate(bodyTemplate, {
mappedData: outboundData,
triggerData,
...outboundData,
});
requestData = {
...config,
body: processedBody,
} as GenericApiSettings;
}
}
// 외부 호출 실행
const callRequest: ExternalCallRequest = {
diagramId: 0, // 임시값
relationshipId: "data-mapping", // 임시값
settings: requestData,
templateData: triggerData,
};
const callResult = await this.executeExternalCall(callRequest);
let mappingResult: DataMappingResult | undefined;
// Inbound 매핑 처리 (외부 → 내부)
if (
callResult.success &&
dataMappingConfig?.direction === "inbound" &&
dataMappingConfig.inboundMapping
) {
console.log(`📥 [ExternalCallService] Inbound 매핑 처리 시작`);
try {
// 응답 데이터 파싱
let responseData = callResult.response;
if (typeof responseData === "string") {
try {
responseData = JSON.parse(responseData);
} catch {
console.warn(
`⚠️ [ExternalCallService] 응답 데이터 JSON 파싱 실패, 문자열로 처리`
);
}
}
mappingResult = await this.dataMappingService.processInboundData(
responseData,
dataMappingConfig.inboundMapping
);
console.log(`✅ [ExternalCallService] Inbound 매핑 완료:`, {
recordsProcessed: mappingResult.recordsProcessed,
recordsInserted: mappingResult.recordsInserted,
});
} catch (error) {
console.error(`❌ [ExternalCallService] Inbound 매핑 실패:`, error);
mappingResult = {
success: false,
direction: "inbound",
errors: [error instanceof Error ? error.message : String(error)],
executionTime: Date.now() - startTime,
timestamp: new Date().toISOString(),
};
}
}
// 양방향 매핑 처리
if (dataMappingConfig?.direction === "bidirectional") {
// 필요한 경우 양방향 매핑 로직 구현
console.log(`🔄 [ExternalCallService] 양방향 매핑은 향후 구현 예정`);
}
const result = {
callResult,
mappingResult,
};
console.log(`✅ [ExternalCallService] 데이터 매핑 포함 외부 호출 완료:`, {
callSuccess: callResult.success,
mappingSuccess: mappingResult?.success,
totalExecutionTime: Date.now() - startTime,
});
return result;
} catch (error) {
console.error(
`❌ [ExternalCallService] 데이터 매핑 포함 외부 호출 실패:`,
error
);
throw error;
}
}
/**
* ( )
*/
async executeExternalCall(
request: ExternalCallRequest

View File

@ -0,0 +1,82 @@
/**
*
*/
export type DataDirection = "none" | "inbound" | "outbound" | "bidirectional";
export type InsertMode = "insert" | "upsert" | "update";
export type TransformType = "none" | "constant" | "format" | "function";
export type DataType = "string" | "number" | "boolean" | "date" | "json";
export interface FieldTransform {
type: TransformType;
value?: any;
format?: string;
functionName?: string;
}
export interface FieldMapping {
id: string;
sourceField: string;
targetField: string;
dataType: DataType;
transform?: FieldTransform;
required?: boolean;
defaultValue?: any;
}
export interface InboundMapping {
targetTable: string;
targetSchema?: string;
fieldMappings: FieldMapping[];
insertMode: InsertMode;
keyFields?: string[];
batchSize?: number;
}
export interface OutboundMapping {
sourceTable: string;
sourceSchema?: string;
sourceFilter?: string;
fieldMappings: FieldMapping[];
triggerCondition?: string;
}
export interface DataMappingConfig {
direction: DataDirection;
inboundMapping?: InboundMapping;
outboundMapping?: OutboundMapping;
}
export interface TableInfo {
name: string;
schema?: string;
displayName?: string;
fields: FieldInfo[];
}
export interface FieldInfo {
name: string;
dataType: DataType;
nullable: boolean;
isPrimaryKey?: boolean;
displayName?: string;
description?: string;
}
export interface DataMappingResult {
success: boolean;
direction: DataDirection;
recordsProcessed?: number;
recordsInserted?: number;
recordsUpdated?: number;
recordsSkipped?: number;
errors?: string[];
executionTime: number;
timestamp: string;
}
export interface MappingValidationResult {
isValid: boolean;
errors: string[];
warnings: string[];
}

View File

@ -0,0 +1,382 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Plus, Trash2, Database, ArrowRight, Settings } from "lucide-react";
import {
DataMappingConfig,
DataDirection,
TableInfo,
FieldMapping,
InboundMapping,
OutboundMapping,
DATA_DIRECTION_OPTIONS,
INSERT_MODE_OPTIONS,
} from "@/types/external-call/DataMappingTypes";
import { FieldMappingEditor } from "./FieldMappingEditor";
interface DataMappingSettingsProps {
config: DataMappingConfig;
onConfigChange: (config: DataMappingConfig) => void;
httpMethod: string;
availableTables?: TableInfo[];
readonly?: boolean;
}
export const DataMappingSettings: React.FC<DataMappingSettingsProps> = ({
config,
onConfigChange,
httpMethod,
availableTables = [],
readonly = false,
}) => {
const [localConfig, setLocalConfig] = useState<DataMappingConfig>(config);
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
setLocalConfig(config);
}, [config]);
// HTTP 메서드에 따른 권장 방향 결정
const getRecommendedDirection = useCallback((method: string): DataDirection => {
const upperMethod = method.toUpperCase();
if (upperMethod === "GET") return "inbound";
if (["POST", "PUT", "PATCH"].includes(upperMethod)) return "outbound";
return "none";
}, []);
// 방향 변경 핸들러
const handleDirectionChange = useCallback(
(direction: DataDirection) => {
const newConfig = {
...localConfig,
direction,
// 방향에 따라 불필요한 매핑 제거
inboundMapping:
direction === "inbound" || direction === "bidirectional"
? localConfig.inboundMapping || {
targetTable: "",
fieldMappings: [],
insertMode: "insert" as const,
}
: undefined,
outboundMapping:
direction === "outbound" || direction === "bidirectional"
? localConfig.outboundMapping || {
sourceTable: "",
fieldMappings: [],
}
: undefined,
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
},
[localConfig, onConfigChange],
);
// Inbound 매핑 업데이트
const handleInboundMappingChange = useCallback(
(mapping: Partial<InboundMapping>) => {
const newConfig = {
...localConfig,
inboundMapping: {
...localConfig.inboundMapping!,
...mapping,
},
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
},
[localConfig, onConfigChange],
);
// Outbound 매핑 업데이트
const handleOutboundMappingChange = useCallback(
(mapping: Partial<OutboundMapping>) => {
const newConfig = {
...localConfig,
outboundMapping: {
...localConfig.outboundMapping!,
...mapping,
},
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
},
[localConfig, onConfigChange],
);
// 필드 매핑 업데이트 (Inbound)
const handleInboundFieldMappingsChange = useCallback(
(fieldMappings: FieldMapping[]) => {
handleInboundMappingChange({ fieldMappings });
},
[handleInboundMappingChange],
);
// 필드 매핑 업데이트 (Outbound)
const handleOutboundFieldMappingsChange = useCallback(
(fieldMappings: FieldMapping[]) => {
handleOutboundMappingChange({ fieldMappings });
},
[handleOutboundMappingChange],
);
// 검증 함수
const isConfigValid = useCallback(() => {
if (localConfig.direction === "none") return true;
if (
(localConfig.direction === "inbound" || localConfig.direction === "bidirectional") &&
localConfig.inboundMapping
) {
if (!localConfig.inboundMapping.targetTable) return false;
if (localConfig.inboundMapping.fieldMappings.length === 0) return false;
}
if (
(localConfig.direction === "outbound" || localConfig.direction === "bidirectional") &&
localConfig.outboundMapping
) {
if (!localConfig.outboundMapping.sourceTable) return false;
if (localConfig.outboundMapping.fieldMappings.length === 0) return false;
}
return true;
}, [localConfig]);
const recommendedDirection = getRecommendedDirection(httpMethod);
return (
<Card className="w-full">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
{!isConfigValid() && <Badge variant="destructive"> </Badge>}
{isConfigValid() && localConfig.direction !== "none" && <Badge variant="default"> </Badge>}
</CardTitle>
<p className="text-muted-foreground text-sm"> API와 .</p>
</CardHeader>
<CardContent className="space-y-4">
{/* 매핑 방향 선택 */}
<div className="space-y-2">
<Label> </Label>
<Select value={localConfig.direction} onValueChange={handleDirectionChange} disabled={readonly}>
<SelectTrigger>
<SelectValue placeholder="매핑 방향을 선택하세요" />
</SelectTrigger>
<SelectContent>
{DATA_DIRECTION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
{option.label}
{option.value === recommendedDirection && (
<Badge variant="secondary" className="text-xs">
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{localConfig.direction !== recommendedDirection && recommendedDirection !== "none" && (
<p className="text-xs text-amber-600">
💡 {httpMethod} "{DATA_DIRECTION_OPTIONS.find((o) => o.value === recommendedDirection)?.label}"
.
</p>
)}
</div>
{/* 매핑 설정 탭 */}
{localConfig.direction !== "none" && (
<Tabs
defaultValue={localConfig.direction === "bidirectional" ? "inbound" : localConfig.direction}
className="w-full"
>
<TabsList className="grid w-full grid-cols-2">
{(localConfig.direction === "inbound" || localConfig.direction === "bidirectional") && (
<TabsTrigger value="inbound">
<ArrowRight className="mr-1 h-4 w-4" />
</TabsTrigger>
)}
{(localConfig.direction === "outbound" || localConfig.direction === "bidirectional") && (
<TabsTrigger value="outbound">
<ArrowRight className="mr-1 h-4 w-4 rotate-180" />
</TabsTrigger>
)}
</TabsList>
{/* Inbound 매핑 설정 */}
{(localConfig.direction === "inbound" || localConfig.direction === "bidirectional") && (
<TabsContent value="inbound" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label> </Label>
<Select
value={localConfig.inboundMapping?.targetTable || ""}
onValueChange={(value) => handleInboundMappingChange({ targetTable: value })}
disabled={readonly}
>
<SelectTrigger>
<SelectValue placeholder="저장할 테이블을 선택하세요" />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table.name} value={table.name}>
{table.displayName || table.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> </Label>
<Select
value={localConfig.inboundMapping?.insertMode || "insert"}
onValueChange={(value) => handleInboundMappingChange({ insertMode: value as any })}
disabled={readonly}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{INSERT_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 키 필드 설정 (upsert/update 모드일 때) */}
{localConfig.inboundMapping?.insertMode !== "insert" && (
<div className="space-y-2">
<Label> </Label>
<Input
value={localConfig.inboundMapping?.keyFields?.join(", ") || ""}
onChange={(e) =>
handleInboundMappingChange({
keyFields: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
}
placeholder="id, code"
disabled={readonly}
/>
<p className="text-muted-foreground text-xs">
/ .
</p>
</div>
)}
{/* 필드 매핑 에디터 */}
{localConfig.inboundMapping?.targetTable && (
<FieldMappingEditor
mappings={localConfig.inboundMapping.fieldMappings}
onMappingsChange={handleInboundFieldMappingsChange}
direction="inbound"
targetTable={availableTables.find((t) => t.name === localConfig.inboundMapping?.targetTable)}
readonly={readonly}
/>
)}
</TabsContent>
)}
{/* Outbound 매핑 설정 */}
{(localConfig.direction === "outbound" || localConfig.direction === "bidirectional") && (
<TabsContent value="outbound" className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label> </Label>
<Select
value={localConfig.outboundMapping?.sourceTable || ""}
onValueChange={(value) => handleOutboundMappingChange({ sourceTable: value })}
disabled={readonly}
>
<SelectTrigger>
<SelectValue placeholder="데이터를 가져올 테이블을 선택하세요" />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table.name} value={table.name}>
{table.displayName || table.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 소스 필터 조건 */}
<div className="space-y-2">
<Label> ()</Label>
<Textarea
value={localConfig.outboundMapping?.sourceFilter || ""}
onChange={(e) => handleOutboundMappingChange({ sourceFilter: e.target.value })}
placeholder="status = 'active' AND created_at >= '2024-01-01'"
disabled={readonly}
rows={2}
/>
<p className="text-muted-foreground text-xs">
WHERE . .
</p>
</div>
{/* 필드 매핑 에디터 */}
{localConfig.outboundMapping?.sourceTable && (
<FieldMappingEditor
mappings={localConfig.outboundMapping.fieldMappings}
onMappingsChange={handleOutboundFieldMappingsChange}
direction="outbound"
sourceTable={availableTables.find((t) => t.name === localConfig.outboundMapping?.sourceTable)}
readonly={readonly}
/>
)}
</TabsContent>
)}
</Tabs>
)}
{/* 설정 요약 */}
{localConfig.direction !== "none" && (
<div className="bg-muted mt-4 rounded-lg p-3">
<h4 className="mb-2 text-sm font-medium"> </h4>
<div className="text-muted-foreground space-y-1 text-xs">
<div>: {DATA_DIRECTION_OPTIONS.find((o) => o.value === localConfig.direction)?.label}</div>
{localConfig.inboundMapping && (
<div>
{localConfig.inboundMapping.targetTable}({localConfig.inboundMapping.fieldMappings.length}
)
</div>
)}
{localConfig.outboundMapping && (
<div>
{localConfig.outboundMapping.sourceTable} ({localConfig.outboundMapping.fieldMappings.length}
)
</div>
)}
</div>
</div>
)}
</CardContent>
</Card>
);
};

View File

@ -15,10 +15,12 @@ import {
RestApiSettings as RestApiSettingsType,
ApiTestResult,
} from "@/types/external-call/ExternalCallTypes";
import { DataMappingConfig, TableInfo } from "@/types/external-call/DataMappingTypes";
// 하위 컴포넌트 import
import RestApiSettings from "./RestApiSettings";
import ExternalCallTestPanel from "./ExternalCallTestPanel";
import { DataMappingSettings } from "./DataMappingSettings";
/**
* 🌐
@ -68,6 +70,50 @@ const ExternalCallPanel: React.FC<ExternalCallPanelProps> = ({
const [lastTestResult, setLastTestResult] = useState<ApiTestResult | null>(null);
const [isConfigValid, setIsConfigValid] = useState<boolean>(false);
// 데이터 매핑 상태
const [dataMappingConfig, setDataMappingConfig] = useState<DataMappingConfig>(() => ({
direction: "none",
}));
// 사용 가능한 테이블 목록 (임시 데이터)
const [availableTables] = useState<TableInfo[]>([
{
name: "customers",
displayName: "고객",
fields: [
{ name: "id", dataType: "number", nullable: false, isPrimaryKey: true },
{ name: "name", dataType: "string", nullable: false },
{ name: "email", dataType: "string", nullable: true },
{ name: "phone", dataType: "string", nullable: true },
{ name: "created_at", dataType: "date", nullable: false },
],
},
{
name: "orders",
displayName: "주문",
fields: [
{ name: "id", dataType: "number", nullable: false, isPrimaryKey: true },
{ name: "customer_id", dataType: "number", nullable: false },
{ name: "product_name", dataType: "string", nullable: false },
{ name: "quantity", dataType: "number", nullable: false },
{ name: "price", dataType: "number", nullable: false },
{ name: "status", dataType: "string", nullable: false },
{ name: "order_date", dataType: "date", nullable: false },
],
},
{
name: "products",
displayName: "제품",
fields: [
{ name: "id", dataType: "number", nullable: false, isPrimaryKey: true },
{ name: "name", dataType: "string", nullable: false },
{ name: "price", dataType: "number", nullable: false },
{ name: "stock", dataType: "number", nullable: false },
{ name: "category", dataType: "string", nullable: true },
],
},
]);
// 설정 변경 핸들러
const handleRestApiSettingsChange = useCallback(
(newSettings: RestApiSettingsType) => {
@ -82,9 +128,12 @@ const ExternalCallPanel: React.FC<ExternalCallPanelProps> = ({
};
setConfig(updatedConfig);
onSettingsChange(updatedConfig);
onSettingsChange({
...updatedConfig,
dataMappingConfig,
});
},
[config, onSettingsChange],
[config, onSettingsChange, dataMappingConfig],
);
// 테스트 결과 핸들러
@ -141,10 +190,13 @@ const ExternalCallPanel: React.FC<ExternalCallPanelProps> = ({
{/* 메인 탭 컨텐츠 */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex w-full flex-1 flex-col overflow-hidden">
<TabsList className="grid w-full grid-cols-4">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="settings" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
API
</TabsTrigger>
<TabsTrigger value="mapping" className="flex items-center gap-2">
🔄
</TabsTrigger>
<TabsTrigger value="test" className="flex items-center gap-2">
<TestTube className="h-4 w-4" />
@ -160,7 +212,7 @@ const ExternalCallPanel: React.FC<ExternalCallPanelProps> = ({
</TabsTrigger>
</TabsList>
{/* 설정 탭 */}
{/* API 설정 탭 */}
<TabsContent value="settings" className="flex-1 space-y-2 overflow-y-auto">
<RestApiSettings
settings={config.restApiSettings}
@ -169,6 +221,17 @@ const ExternalCallPanel: React.FC<ExternalCallPanelProps> = ({
/>
</TabsContent>
{/* 데이터 매핑 탭 */}
<TabsContent value="mapping" className="flex-1 space-y-2 overflow-y-auto">
<DataMappingSettings
config={dataMappingConfig}
onConfigChange={setDataMappingConfig}
httpMethod={config.restApiSettings?.httpMethod || "GET"}
availableTables={availableTables}
readonly={readonly}
/>
</TabsContent>
{/* 테스트 탭 */}
<TabsContent value="test" className="flex-1 space-y-4 overflow-y-auto">
{isConfigValid ? (

View File

@ -0,0 +1,400 @@
"use client";
import React, { useState, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Plus, Trash2, ArrowRight, Settings, Eye, EyeOff, RefreshCw, Database, Globe } from "lucide-react";
import {
FieldMapping,
TableInfo,
FieldInfo,
DataDirection,
DATA_TYPE_OPTIONS,
TRANSFORM_TYPE_OPTIONS,
} from "@/types/external-call/DataMappingTypes";
interface FieldMappingEditorProps {
mappings: FieldMapping[];
onMappingsChange: (mappings: FieldMapping[]) => void;
direction: "inbound" | "outbound";
sourceTable?: TableInfo; // outbound용
targetTable?: TableInfo; // inbound용
readonly?: boolean;
}
export const FieldMappingEditor: React.FC<FieldMappingEditorProps> = ({
mappings,
onMappingsChange,
direction,
sourceTable,
targetTable,
readonly = false,
}) => {
const [showAdvanced, setShowAdvanced] = useState(false);
const [sampleApiData, setSampleApiData] = useState("");
// 새 매핑 추가
const addMapping = useCallback(() => {
const newMapping: FieldMapping = {
id: `mapping-${Date.now()}`,
sourceField: "",
targetField: "",
dataType: "string",
required: false,
};
onMappingsChange([...mappings, newMapping]);
}, [mappings, onMappingsChange]);
// 매핑 삭제
const removeMapping = useCallback(
(id: string) => {
onMappingsChange(mappings.filter((m) => m.id !== id));
},
[mappings, onMappingsChange],
);
// 매핑 업데이트
const updateMapping = useCallback(
(id: string, updates: Partial<FieldMapping>) => {
onMappingsChange(mappings.map((m) => (m.id === id ? { ...m, ...updates } : m)));
},
[mappings, onMappingsChange],
);
// 자동 매핑 (이름 기반)
const autoMapFields = useCallback(() => {
const currentTable = direction === "inbound" ? targetTable : sourceTable;
if (!currentTable) return;
const newMappings: FieldMapping[] = [];
currentTable.fields.forEach((field) => {
// 이미 매핑된 필드는 건너뛰기
const existingMapping = mappings.find((m) =>
direction === "inbound" ? m.targetField === field.name : m.sourceField === field.name,
);
if (existingMapping) return;
const mapping: FieldMapping = {
id: `auto-${field.name}-${Date.now()}`,
sourceField: direction === "inbound" ? field.name : field.name,
targetField: direction === "inbound" ? field.name : field.name,
dataType: field.dataType,
required: !field.nullable,
};
newMappings.push(mapping);
});
onMappingsChange([...mappings, ...newMappings]);
}, [direction, targetTable, sourceTable, mappings, onMappingsChange]);
// 샘플 데이터에서 필드 추출
const extractFieldsFromSample = useCallback(() => {
if (!sampleApiData.trim()) return;
try {
const parsed = JSON.parse(sampleApiData);
const fields = Object.keys(parsed);
const newMappings: FieldMapping[] = [];
fields.forEach((fieldName) => {
// 이미 매핑된 필드는 건너뛰기
const existingMapping = mappings.find((m) =>
direction === "inbound" ? m.sourceField === fieldName : m.targetField === fieldName,
);
if (existingMapping) return;
// 데이터 타입 추론
const value = parsed[fieldName];
let dataType: any = "string";
if (typeof value === "number") dataType = "number";
else if (typeof value === "boolean") dataType = "boolean";
else if (value instanceof Date || /^\d{4}-\d{2}-\d{2}/.test(value)) dataType = "date";
const mapping: FieldMapping = {
id: `sample-${fieldName}-${Date.now()}`,
sourceField: direction === "inbound" ? fieldName : "",
targetField: direction === "inbound" ? "" : fieldName,
dataType,
required: false,
};
newMappings.push(mapping);
});
onMappingsChange([...mappings, ...newMappings]);
setSampleApiData("");
} catch (error) {
console.error("샘플 데이터 파싱 실패:", error);
}
}, [sampleApiData, direction, mappings, onMappingsChange]);
const currentTable = direction === "inbound" ? targetTable : sourceTable;
return (
<Card className="w-full">
<CardHeader className="pb-3">
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4" />
<Badge variant="outline">{mappings.length} </Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setShowAdvanced(!showAdvanced)}>
{showAdvanced ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{showAdvanced ? "간단히" : "고급"}
</Button>
{!readonly && (
<Button variant="outline" size="sm" onClick={autoMapFields} disabled={!currentTable}>
<RefreshCw className="h-3 w-3" />
</Button>
)}
</div>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 샘플 데이터 입력 (고급 모드) */}
{showAdvanced && !readonly && (
<div className="bg-muted space-y-2 rounded-lg p-3">
<Label className="text-sm"> API (JSON)</Label>
<Textarea
value={sampleApiData}
onChange={(e) => setSampleApiData(e.target.value)}
placeholder='{"name": "홍길동", "age": 30, "email": "test@example.com"}'
rows={3}
/>
<Button size="sm" onClick={extractFieldsFromSample} disabled={!sampleApiData.trim()}>
</Button>
</div>
)}
{/* 매핑 목록 */}
<div className="space-y-3">
{mappings.map((mapping) => (
<Card key={mapping.id} className="p-3">
<div className="grid grid-cols-12 items-center gap-2">
{/* 소스 필드 */}
<div className="col-span-4">
<Label className="text-xs">{direction === "inbound" ? "외부 필드" : "내부 필드"}</Label>
<div className="mt-1 flex items-center gap-1">
{direction === "inbound" ? (
<Globe className="h-3 w-3 text-blue-500" />
) : (
<Database className="h-3 w-3 text-green-500" />
)}
<Input
value={mapping.sourceField}
onChange={(e) => updateMapping(mapping.id, { sourceField: e.target.value })}
placeholder={direction === "inbound" ? "API 필드명" : "테이블 컬럼명"}
size="sm"
disabled={readonly}
/>
</div>
</div>
{/* 화살표 */}
<div className="col-span-1 flex justify-center">
<ArrowRight className="text-muted-foreground h-4 w-4" />
</div>
{/* 타겟 필드 */}
<div className="col-span-4">
<Label className="text-xs">{direction === "inbound" ? "내부 필드" : "외부 필드"}</Label>
<div className="mt-1 flex items-center gap-1">
{direction === "inbound" ? (
<Database className="h-3 w-3 text-green-500" />
) : (
<Globe className="h-3 w-3 text-blue-500" />
)}
{direction === "inbound" && currentTable ? (
<Select
value={mapping.targetField}
onValueChange={(value) => {
const field = currentTable.fields.find((f) => f.name === value);
updateMapping(mapping.id, {
targetField: value,
dataType: field?.dataType || mapping.dataType,
});
}}
disabled={readonly}
>
<SelectTrigger size="sm">
<SelectValue placeholder="테이블 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{currentTable.fields.map((field) => (
<SelectItem key={field.name} value={field.name}>
<div className="flex items-center gap-2">
{field.name}
<Badge variant="outline" className="text-xs">
{field.dataType}
</Badge>
{field.isPrimaryKey && (
<Badge variant="default" className="text-xs">
PK
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={mapping.targetField}
onChange={(e) => updateMapping(mapping.id, { targetField: e.target.value })}
placeholder={direction === "inbound" ? "테이블 컬럼명" : "API 필드명"}
size="sm"
disabled={readonly}
/>
)}
</div>
</div>
{/* 데이터 타입 */}
<div className="col-span-2">
<Label className="text-xs"></Label>
<Select
value={mapping.dataType}
onValueChange={(value: any) => updateMapping(mapping.id, { dataType: value })}
disabled={readonly}
>
<SelectTrigger size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 삭제 버튼 */}
<div className="col-span-1">
{!readonly && (
<Button variant="ghost" size="sm" onClick={() => removeMapping(mapping.id)} className="h-8 w-8 p-0">
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</div>
{/* 고급 설정 */}
{showAdvanced && (
<div className="mt-3 grid grid-cols-2 gap-3 border-t pt-3">
<div className="flex items-center space-x-2">
<Checkbox
id={`required-${mapping.id}`}
checked={mapping.required || false}
onCheckedChange={(checked) => updateMapping(mapping.id, { required: checked as boolean })}
disabled={readonly}
/>
<Label htmlFor={`required-${mapping.id}`} className="text-xs">
</Label>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={mapping.defaultValue || ""}
onChange={(e) => updateMapping(mapping.id, { defaultValue: e.target.value })}
placeholder="기본값"
size="sm"
disabled={readonly}
/>
</div>
{/* 변환 설정 */}
<div className="col-span-2 space-y-2">
<Label className="text-xs"> </Label>
<div className="grid grid-cols-3 gap-2">
<Select
value={mapping.transform?.type || "none"}
onValueChange={(value: any) =>
updateMapping(mapping.id, {
transform: { ...mapping.transform, type: value },
})
}
disabled={readonly}
>
<SelectTrigger size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRANSFORM_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{mapping.transform?.type === "constant" && (
<Input
value={mapping.transform.value || ""}
onChange={(e) =>
updateMapping(mapping.id, {
transform: { ...mapping.transform, value: e.target.value },
})
}
placeholder="상수값"
size="sm"
disabled={readonly}
/>
)}
{mapping.transform?.type === "format" && (
<Input
value={mapping.transform.format || ""}
onChange={(e) =>
updateMapping(mapping.id, {
transform: { ...mapping.transform, format: e.target.value },
})
}
placeholder="YYYY-MM-DD"
size="sm"
disabled={readonly}
/>
)}
</div>
</div>
</div>
)}
</Card>
))}
</div>
{/* 매핑 추가 버튼 */}
{!readonly && (
<Button variant="outline" onClick={addMapping} className="w-full">
<Plus className="mr-2 h-4 w-4" />
</Button>
)}
{/* 매핑 상태 */}
{mappings.length === 0 && (
<div className="text-muted-foreground py-6 text-center">
<Database className="mx-auto mb-2 h-8 w-8 opacity-50" />
<p className="text-sm"> .</p>
<p className="text-xs"> .</p>
</div>
)}
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,150 @@
/**
*
*/
export type DataDirection = "none" | "inbound" | "outbound" | "bidirectional";
export type InsertMode = "insert" | "upsert" | "update";
export type TransformType = "none" | "constant" | "format" | "function";
export type DataType = "string" | "number" | "boolean" | "date" | "json";
/**
*
*/
export interface FieldTransform {
type: TransformType;
value?: any;
format?: string; // 날짜 포맷 등 (예: "YYYY-MM-DD")
functionName?: string; // 커스텀 변환 함수명
}
/**
*
*/
export interface FieldMapping {
id: string; // 매핑 고유 ID
sourceField: string; // 소스 필드명 (외부 API 또는 내부 테이블)
targetField: string; // 타겟 필드명 (내부 테이블 또는 외부 API)
dataType: DataType;
transform?: FieldTransform;
required?: boolean;
defaultValue?: any;
}
/**
* Inbound ( )
*/
export interface InboundMapping {
targetTable: string;
targetSchema?: string;
fieldMappings: FieldMapping[];
insertMode: InsertMode;
keyFields?: string[]; // upsert/update 시 키 필드
batchSize?: number; // 배치 처리 크기
}
/**
* Outbound ( )
*/
export interface OutboundMapping {
sourceTable: string;
sourceSchema?: string;
sourceFilter?: string; // WHERE 조건
fieldMappings: FieldMapping[];
triggerCondition?: string; // 트리거 조건
}
/**
*
*/
export interface DataMappingConfig {
direction: DataDirection;
inboundMapping?: InboundMapping;
outboundMapping?: OutboundMapping;
}
/**
*
*/
export interface TableInfo {
name: string;
schema?: string;
displayName?: string;
fields: FieldInfo[];
}
/**
*
*/
export interface FieldInfo {
name: string;
dataType: DataType;
nullable: boolean;
isPrimaryKey?: boolean;
displayName?: string;
description?: string;
}
/**
*
*/
export interface DataMappingResult {
success: boolean;
direction: DataDirection;
recordsProcessed?: number;
recordsInserted?: number;
recordsUpdated?: number;
recordsSkipped?: number;
errors?: string[];
executionTime: number;
timestamp: string;
}
/**
*
*/
export interface MappingValidationResult {
isValid: boolean;
errors: string[];
warnings: string[];
}
/**
* 릿
*/
export interface MappingTemplate {
id: string;
name: string;
description: string;
direction: DataDirection;
config: DataMappingConfig;
tags?: string[];
}
// 상수 정의
export const DATA_DIRECTION_OPTIONS = [
{ value: "none", label: "매핑 없음" },
{ value: "inbound", label: "외부 → 내부 (GET)" },
{ value: "outbound", label: "내부 → 외부 (POST)" },
{ value: "bidirectional", label: "양방향" },
] as const;
export const INSERT_MODE_OPTIONS = [
{ value: "insert", label: "삽입만" },
{ value: "upsert", label: "삽입/업데이트" },
{ value: "update", label: "업데이트만" },
] as const;
export const TRANSFORM_TYPE_OPTIONS = [
{ value: "none", label: "변환 없음" },
{ value: "constant", label: "상수값" },
{ value: "format", label: "포맷 변환" },
{ value: "function", label: "커스텀 함수" },
] as const;
export const DATA_TYPE_OPTIONS = [
{ value: "string", label: "문자열" },
{ value: "number", label: "숫자" },
{ value: "boolean", label: "불린" },
{ value: "date", label: "날짜" },
{ value: "json", label: "JSON" },
] as const;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -0,0 +1,293 @@
# 외부호출 데이터 매핑 시스템 설계서
## 1. 개요
외부 API 호출 시 데이터를 송수신하고, 이를 내부 테이블과 매핑하는 시스템을 구현합니다.
## 2. 현재 상황 분석
### 2.1 기존 기능
- ✅ REST API 호출 기본 기능
- ✅ 인증 처리 (API Key, Basic, Bearer 등)
- ✅ 요청/응답 테스트 기능
- ✅ 외부호출 설정 저장
### 2.2 필요한 확장 기능
- 🔄 GET 요청 시 응답 데이터를 내부 테이블에 저장
- 🔄 POST 요청 시 내부 테이블 데이터를 외부로 전송
- 🔄 필드 매핑 설정 (외부 필드 ↔ 내부 필드)
- 🔄 데이터 변환 및 검증
## 3. 시스템 아키텍처
### 3.1 데이터 플로우
```
GET 요청 플로우:
내부 이벤트 → 외부 API 호출 → 응답 데이터 → 필드 매핑 → 내부 테이블 저장
POST 요청 플로우:
내부 이벤트 → 내부 테이블 조회 → 필드 매핑 → 외부 API 전송 → 응답 처리
```
### 3.2 컴포넌트 구조
```
ExternalCallPanel
├── RestApiSettings (기존)
├── DataMappingSettings (신규)
│ ├── SourceTableSelector
│ ├── TargetTableSelector
│ ├── FieldMappingEditor
│ └── DataTransformEditor
└── ExternalCallTestPanel (확장)
```
## 4. 데이터베이스 스키마 확장
### 4.1 external_call_configs 테이블 확장
```sql
ALTER TABLE external_call_configs ADD COLUMN IF NOT EXISTS data_mapping_config JSONB;
```
### 4.2 data_mapping_config JSON 구조
```typescript
interface DataMappingConfig {
direction: "inbound" | "outbound" | "bidirectional";
// GET 요청용 - 외부 → 내부
inboundMapping?: {
targetTable: string;
targetSchema?: string;
fieldMappings: FieldMapping[];
insertMode: "insert" | "upsert" | "update";
keyFields?: string[]; // upsert/update 시 키 필드
};
// POST 요청용 - 내부 → 외부
outboundMapping?: {
sourceTable: string;
sourceSchema?: string;
sourceFilter?: string; // WHERE 조건
fieldMappings: FieldMapping[];
};
}
interface FieldMapping {
sourceField: string; // 외부 API 필드명 또는 내부 테이블 컬럼명
targetField: string; // 내부 테이블 컬럼명 또는 외부 API 필드명
dataType: "string" | "number" | "boolean" | "date" | "json";
transform?: {
type: "none" | "constant" | "format" | "function";
value?: any;
format?: string; // 날짜 포맷 등
functionName?: string; // 커스텀 변환 함수
};
required?: boolean;
defaultValue?: any;
}
```
## 5. 프론트엔드 컴포넌트 설계
### 5.1 DataMappingSettings.tsx
```typescript
interface DataMappingSettingsProps {
config: DataMappingConfig;
onConfigChange: (config: DataMappingConfig) => void;
httpMethod: string;
availableTables: TableInfo[];
}
// 주요 기능:
// - 방향 선택 (inbound/outbound/bidirectional)
// - 소스/타겟 테이블 선택
// - 필드 매핑 에디터
// - 데이터 변환 설정
```
### 5.2 FieldMappingEditor.tsx
```typescript
interface FieldMappingEditorProps {
mappings: FieldMapping[];
sourceFields: FieldInfo[];
targetFields: FieldInfo[];
onMappingsChange: (mappings: FieldMapping[]) => void;
}
// 주요 기능:
// - 드래그 앤 드롭으로 필드 매핑
// - 데이터 타입 자동 추론
// - 변환 함수 설정
// - 필수 필드 검증
```
### 5.3 DataTransformEditor.tsx
```typescript
// 데이터 변환 규칙 설정
// - 상수값 할당
// - 날짜 포맷 변환
// - 문자열 변환 (대소문자, 트림 등)
// - 커스텀 함수 적용
```
## 6. 백엔드 서비스 확장
### 6.1 ExternalCallExecutor 확장
```typescript
class ExternalCallExecutor {
async executeWithDataMapping(
config: ExternalCallConfig,
triggerData?: any
): Promise<ExternalCallResult> {
const result = await this.executeApiCall(config);
if (result.success && config.dataMappingConfig) {
if (config.restApiSettings.httpMethod === "GET") {
await this.processInboundData(result, config.dataMappingConfig);
}
}
return result;
}
private async processInboundData(
result: ExternalCallResult,
mappingConfig: DataMappingConfig
) {
// 1. 응답 데이터 파싱
// 2. 필드 매핑 적용
// 3. 데이터 변환
// 4. 데이터베이스 저장
}
private async prepareOutboundData(
mappingConfig: DataMappingConfig,
triggerData?: any
): Promise<any> {
// 1. 소스 테이블 조회
// 2. 필드 매핑 적용
// 3. 데이터 변환
// 4. API 요청 바디 생성
}
}
```
### 6.2 DataMappingService.ts (신규)
```typescript
class DataMappingService {
async mapInboundData(
sourceData: any,
mapping: InboundMapping
): Promise<any[]> {
// 외부 데이터 → 내부 테이블 매핑
}
async mapOutboundData(
sourceTable: string,
mapping: OutboundMapping,
filter?: any
): Promise<any> {
// 내부 테이블 → 외부 API 매핑
}
private transformFieldValue(value: any, transform: FieldTransform): any {
// 필드 변환 로직
}
}
```
## 7. 구현 단계
### Phase 1: 기본 매핑 시스템 (1-2주)
1. 데이터베이스 스키마 확장
2. DataMappingSettings 컴포넌트 개발
3. 기본 필드 매핑 기능
4. GET 요청 응답 데이터 저장
### Phase 2: 고급 매핑 기능 (1-2주)
1. POST 요청 데이터 송신
2. 필드 변환 기능
3. upsert/update 모드
4. 배치 처리
### Phase 3: UI/UX 개선 (1주)
1. 드래그 앤 드롭 매핑 에디터
2. 실시간 미리보기
3. 매핑 템플릿
4. 에러 처리 및 로깅
## 8. 사용 시나리오
### 8.1 외부 API에서 데이터 가져오기 (GET)
```
고객사 API → 우리 customer 테이블
- 고객 정보 동기화
- 주문 정보 수집
- 재고 정보 업데이트
```
### 8.2 외부 API로 데이터 보내기 (POST)
```
우리 order 테이블 → 배송사 API
- 주문 정보 전달
- 재고 변동 알림
- 상태 업데이트 전송
```
## 9. 기술적 고려사항
### 9.1 데이터 일관성
- 트랜잭션 처리
- 롤백 메커니즘
- 중복 데이터 처리
### 9.2 성능 최적화
- 배치 처리
- 비동기 처리
- 캐싱 전략
### 9.3 보안
- 데이터 검증
- SQL 인젝션 방지
- 민감 데이터 마스킹
### 9.4 모니터링
- 매핑 실행 로그
- 에러 추적
- 성능 메트릭
## 10. 성공 지표
- ✅ 외부 API 응답 데이터를 내부 테이블에 정확히 저장
- ✅ 내부 테이블 데이터를 외부 API로 정확히 전송
- ✅ 필드 매핑 설정이 직관적이고 사용하기 쉬움
- ✅ 데이터 변환이 정확하고 안정적
- ✅ 에러 발생 시 적절한 처리 및 알림
## 11. 다음 단계
1. **우선순위 결정**: GET/POST 중 어느 것부터 구현할지
2. **테이블 선택**: 매핑할 주요 테이블들 식별
3. **프로토타입**: 간단한 매핑 시나리오로 POC 개발
4. **점진적 확장**: 기본 → 고급 기능 순서로 개발
이 설계서를 바탕으로 단계별로 구현해 나가면 됩니다. 어떤 부분부터 시작하고 싶으신가요?