diff --git a/backend-node/src/services/dataflowService.ts b/backend-node/src/services/dataflowService.ts index 0f711335..5f41f18d 100644 --- a/backend-node/src/services/dataflowService.ts +++ b/backend-node/src/services/dataflowService.ts @@ -821,7 +821,7 @@ export class DataflowService { relationships.forEach((rel) => { const diagramId = rel.diagram_id; - if (!diagramMap.has(diagramId)) { + if (diagramId && !diagramMap.has(diagramId)) { diagramMap.set(diagramId, { diagramId: diagramId, diagramName: rel.relationship_name, // 첫 번째 관계의 이름을 사용 @@ -837,15 +837,19 @@ export class DataflowService { }); } - const diagram = diagramMap.get(diagramId); - diagram.tableCount.add(rel.from_table_name); - diagram.tableCount.add(rel.to_table_name); - diagram.relationshipCount++; + if (diagramId) { + const diagram = diagramMap.get(diagramId); + if (diagram) { + diagram.tableCount.add(rel.from_table_name || ""); + diagram.tableCount.add(rel.to_table_name || ""); + } + diagram.relationshipCount++; - // 최신 업데이트 시간 유지 - if (rel.updated_date && rel.updated_date > diagram.updatedAt) { - diagram.updatedAt = rel.updated_date; - diagram.updatedBy = rel.updated_by; + // 최신 업데이트 시간 유지 + if (rel.updated_date && rel.updated_date > diagram.updatedAt) { + diagram.updatedAt = rel.updated_date; + diagram.updatedBy = rel.updated_by; + } } }); @@ -1110,10 +1114,14 @@ export class DataflowService { } // diagram_id로 모든 관계 조회 - return this.getDiagramRelationshipsByDiagramId( - companyCode, - targetRelationship.diagram_id - ); + if (targetRelationship.diagram_id) { + return this.getDiagramRelationshipsByDiagramId( + companyCode, + targetRelationship.diagram_id + ); + } else { + throw new Error("관계에 diagram_id가 없습니다."); + } } catch (error) { logger.error( `DataflowService: relationship_id로 관계도 관계 조회 실패 - ${relationshipId}`, diff --git a/frontend/lib/hooks/useEntityJoinOptimization.ts b/frontend/lib/hooks/useEntityJoinOptimization.ts new file mode 100644 index 00000000..2a19e316 --- /dev/null +++ b/frontend/lib/hooks/useEntityJoinOptimization.ts @@ -0,0 +1,125 @@ +import { useMemo } from "react"; +import codeCache from "@/lib/cache/codeCache"; + +/** + * 엔티티 조인 최적화 훅 + * 테이블 간의 관계를 분석하여 최적화된 조인 전략을 제공합니다. + */ + +interface JoinOptimization { + strategy: "eager" | "lazy" | "batch"; + priority: number; + estimatedCost: number; +} + +interface EntityRelation { + fromTable: string; + toTable: string; + joinType: "inner" | "left" | "right" | "full"; + cardinality: "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many"; +} + +export const useEntityJoinOptimization = ( + relations: EntityRelation[], + queryContext?: { + expectedResultSize?: number; + performanceProfile?: "fast" | "balanced" | "memory-efficient"; + }, +) => { + const optimization = useMemo(() => { + const cacheKey = `join-optimization:${JSON.stringify(relations)}:${JSON.stringify(queryContext)}`; + + // 캐시에서 먼저 확인 + const cached = codeCache.get(cacheKey); + if (cached) { + return cached; + } + + // 최적화 전략 계산 + const optimizations: Record = {}; + + relations.forEach((relation) => { + const key = `${relation.fromTable}-${relation.toTable}`; + + // 카디널리티에 따른 기본 전략 + let strategy: JoinOptimization["strategy"] = "eager"; + let priority = 1; + let estimatedCost = 1; + + switch (relation.cardinality) { + case "one-to-one": + strategy = "eager"; + priority = 3; + estimatedCost = 1; + break; + case "one-to-many": + strategy = "lazy"; + priority = 2; + estimatedCost = 2; + break; + case "many-to-one": + strategy = "eager"; + priority = 2; + estimatedCost = 1.5; + break; + case "many-to-many": + strategy = "batch"; + priority = 1; + estimatedCost = 3; + break; + } + + // 성능 프로필에 따른 조정 + if (queryContext?.performanceProfile === "fast") { + if (strategy === "lazy") strategy = "eager"; + priority += 1; + } else if (queryContext?.performanceProfile === "memory-efficient") { + if (strategy === "eager") strategy = "lazy"; + estimatedCost *= 0.8; + } + + // 예상 결과 크기에 따른 조정 + if (queryContext?.expectedResultSize && queryContext.expectedResultSize > 1000) { + if (strategy === "eager") strategy = "batch"; + estimatedCost *= 1.2; + } + + optimizations[key] = { + strategy, + priority, + estimatedCost, + }; + }); + + // 결과를 캐시에 저장 (1분 TTL) + codeCache.set(cacheKey, optimizations, 60 * 1000); + + return optimizations; + }, [relations, queryContext]); + + const getOptimizationForRelation = (fromTable: string, toTable: string): JoinOptimization | null => { + const key = `${fromTable}-${toTable}`; + return optimization[key] || null; + }; + + const getSortedRelations = (): Array<{ relation: EntityRelation; optimization: JoinOptimization }> => { + return relations + .map((relation) => ({ + relation, + optimization: getOptimizationForRelation(relation.fromTable, relation.toTable)!, + })) + .filter((item) => item.optimization) + .sort((a, b) => b.optimization.priority - a.optimization.priority); + }; + + const getTotalEstimatedCost = (): number => { + return Object.values(optimization).reduce((total, opt) => total + opt.estimatedCost, 0); + }; + + return { + optimization, + getOptimizationForRelation, + getSortedRelations, + getTotalEstimatedCost, + }; +}; diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx new file mode 100644 index 00000000..c76cfa1d --- /dev/null +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -0,0 +1,172 @@ +import React, { useMemo } from "react"; +import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization"; +import codeCache from "@/lib/cache/codeCache"; + +interface TableListProps { + data: any[]; + columns: Array<{ + key: string; + label: string; + type?: "text" | "number" | "date" | "boolean"; + sortable?: boolean; + filterable?: boolean; + }>; + relations?: Array<{ + fromTable: string; + toTable: string; + joinType: "inner" | "left" | "right" | "full"; + cardinality: "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many"; + }>; + onRowClick?: (row: any) => void; + onSort?: (column: string, direction: "asc" | "desc") => void; + onFilter?: (column: string, value: any) => void; + loading?: boolean; + pagination?: { + current: number; + total: number; + pageSize: number; + onChange: (page: number, size: number) => void; + }; + className?: string; +} + +export const TableListComponent: React.FC = ({ + data, + columns, + relations = [], + onRowClick, + onSort, + onFilter, + loading = false, + pagination, + className = "", +}) => { + // 조인 최적화 적용 + const { optimization, getSortedRelations } = useEntityJoinOptimization(relations, { + expectedResultSize: data.length, + performanceProfile: "balanced", + }); + + // 최적화된 데이터 처리 + const processedData = useMemo(() => { + if (!relations.length) return data; + + const cacheKey = `table-list-processed:${JSON.stringify(data.slice(0, 5))}:${JSON.stringify(relations)}`; + const cached = codeCache.get(cacheKey); + if (cached) return cached; + + // 관계 기반 데이터 처리 로직 + const sortedRelations = getSortedRelations(); + let processedResult = [...data]; + + // 여기에서 실제 조인 로직을 구현할 수 있습니다 + // 현재는 기본 데이터를 반환 + + codeCache.set(cacheKey, processedResult, 30 * 1000); // 30초 캐시 + return processedResult; + }, [data, relations, getSortedRelations]); + + if (loading) { + return ( +
+
+
+
+
+ ); + } + + return ( +
+
+ + + + {columns.map((column) => ( + + ))} + + + + {processedData.map((row, index) => ( + onRowClick?.(row)}> + {columns.map((column) => ( + + ))} + + ))} + +
onSort?.(column.key, "asc")} + > +
+ {column.label} + {column.sortable && ( + + + + )} +
+
+ {formatCellValue(row[column.key], column.type)} +
+
+ + {pagination && ( +
+
+ + 총 {pagination.total}개 중{" "} + {Math.min((pagination.current - 1) * pagination.pageSize + 1, pagination.total)}- + {Math.min(pagination.current * pagination.pageSize, pagination.total)}개 표시 + +
+
+ + + {pagination.current} / {Math.ceil(pagination.total / pagination.pageSize)} + + +
+
+ )} +
+ ); +}; + +// 셀 값 포맷팅 유틸리티 +function formatCellValue(value: any, type?: string): string { + if (value === null || value === undefined) return "-"; + + switch (type) { + case "date": + return new Date(value).toLocaleDateString(); + case "number": + return typeof value === "number" ? value.toLocaleString() : value; + case "boolean": + return value ? "예" : "아니오"; + default: + return String(value); + } +} + +export default TableListComponent; diff --git a/frontend/lib/registry/components/table-list/TableListRenderer.tsx b/frontend/lib/registry/components/table-list/TableListRenderer.tsx new file mode 100644 index 00000000..9a233d75 --- /dev/null +++ b/frontend/lib/registry/components/table-list/TableListRenderer.tsx @@ -0,0 +1,193 @@ +import React from "react"; +import { TableListComponent } from "./TableListComponent"; +import codeCache from "@/lib/cache/codeCache"; + +interface TableListRendererProps { + config: { + columns?: Array<{ + key: string; + label: string; + type?: "text" | "number" | "date" | "boolean"; + sortable?: boolean; + filterable?: boolean; + }>; + relations?: Array<{ + fromTable: string; + toTable: string; + joinType: "inner" | "left" | "right" | "full"; + cardinality: "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many"; + }>; + dataSource?: string; + pagination?: { + enabled: boolean; + pageSize: number; + }; + sorting?: { + enabled: boolean; + defaultColumn?: string; + defaultDirection?: "asc" | "desc"; + }; + filtering?: { + enabled: boolean; + }; + styling?: { + className?: string; + theme?: "default" | "compact" | "striped"; + }; + }; + data?: any[]; + onAction?: (action: string, payload: any) => void; + className?: string; +} + +export const TableListRenderer: React.FC = ({ + config, + data = [], + onAction, + className = "", +}) => { + const { columns = [], relations = [], pagination, sorting, filtering, styling } = config; + + // 기본 컬럼 설정 + const defaultColumns = React.useMemo(() => { + if (columns.length > 0) return columns; + + // 데이터에서 자동으로 컬럼 추출 + if (data.length > 0) { + const sampleRow = data[0]; + return Object.keys(sampleRow).map((key) => ({ + key, + label: key.charAt(0).toUpperCase() + key.slice(1), + type: inferColumnType(sampleRow[key]), + sortable: sorting?.enabled ?? true, + filterable: filtering?.enabled ?? true, + })); + } + + return []; + }, [columns, data, sorting?.enabled, filtering?.enabled]); + + // 페이지네이션 상태 + const [currentPage, setCurrentPage] = React.useState(1); + const [pageSize, setPageSize] = React.useState(pagination?.pageSize || 10); + + // 정렬 상태 + const [sortColumn, setSortColumn] = React.useState(sorting?.defaultColumn || ""); + const [sortDirection, setSortDirection] = React.useState<"asc" | "desc">(sorting?.defaultDirection || "asc"); + + // 필터 상태 + const [filters, setFilters] = React.useState>({}); + + // 데이터 처리 + const processedData = React.useMemo(() => { + let result = [...data]; + + // 필터링 적용 + if (filtering?.enabled && Object.keys(filters).length > 0) { + result = result.filter((row) => { + return Object.entries(filters).every(([column, value]) => { + if (!value) return true; + const rowValue = String(row[column]).toLowerCase(); + const filterValue = String(value).toLowerCase(); + return rowValue.includes(filterValue); + }); + }); + } + + // 정렬 적용 + if (sorting?.enabled && sortColumn) { + result.sort((a, b) => { + const aVal = a[sortColumn]; + const bVal = b[sortColumn]; + + let comparison = 0; + if (aVal < bVal) comparison = -1; + if (aVal > bVal) comparison = 1; + + return sortDirection === "desc" ? -comparison : comparison; + }); + } + + return result; + }, [data, filters, sortColumn, sortDirection, filtering?.enabled, sorting?.enabled]); + + // 페이지네이션 데이터 + const paginatedData = React.useMemo(() => { + if (!pagination?.enabled) return processedData; + + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + return processedData.slice(startIndex, endIndex); + }, [processedData, currentPage, pageSize, pagination?.enabled]); + + // 이벤트 핸들러 + const handleRowClick = (row: any) => { + onAction?.("rowClick", { row }); + }; + + const handleSort = (column: string, direction: "asc" | "desc") => { + setSortColumn(column); + setSortDirection(direction); + onAction?.("sort", { column, direction }); + }; + + const handleFilter = (column: string, value: any) => { + setFilters((prev) => ({ + ...prev, + [column]: value, + })); + onAction?.("filter", { column, value }); + }; + + const handlePageChange = (page: number, size: number) => { + setCurrentPage(page); + setPageSize(size); + onAction?.("pageChange", { page, size }); + }; + + // 테마 클래스 + const themeClass = React.useMemo(() => { + switch (styling?.theme) { + case "compact": + return "table-compact"; + case "striped": + return "table-striped"; + default: + return ""; + } + }, [styling?.theme]); + + return ( + + ); +}; + +// 컬럼 타입 추론 유틸리티 +function inferColumnType(value: any): "text" | "number" | "date" | "boolean" { + if (typeof value === "boolean") return "boolean"; + if (typeof value === "number") return "number"; + if (value instanceof Date || (typeof value === "string" && !isNaN(Date.parse(value)))) { + return "date"; + } + return "text"; +} + +export default TableListRenderer;