import { PrismaClient } from "@prisma/client"; import { logger } from "../utils/logger"; import { cache, CacheKeys } from "../utils/cache"; import { TableInfo, ColumnTypeInfo, ColumnSettings, TableLabels, ColumnLabels, EntityJoinResponse, EntityJoinConfig, } from "../types/tableManagement"; import { entityJoinService } from "./entityJoinService"; import { referenceCacheService } from "./referenceCacheService"; const prisma = new PrismaClient(); export class TableManagementService { constructor() {} /** * 테이블 목록 조회 (PostgreSQL information_schema 활용) * 메타데이터 조회는 Prisma로 변경 불가 */ async getTableList(): Promise { try { logger.info("테이블 목록 조회 시작"); // 캐시에서 먼저 확인 const cachedTables = cache.get(CacheKeys.TABLE_LIST); if (cachedTables) { logger.info(`테이블 목록 캐시에서 조회: ${cachedTables.length}개`); return cachedTables; } // information_schema는 여전히 $queryRaw 사용 const rawTables = await prisma.$queryRaw` SELECT t.table_name as "tableName", COALESCE(tl.table_label, t.table_name) as "displayName", COALESCE(tl.description, '') as "description", (SELECT COUNT(*) FROM information_schema.columns WHERE table_name = t.table_name AND table_schema = 'public') as "columnCount" FROM information_schema.tables t LEFT JOIN table_labels tl ON t.table_name = tl.table_name WHERE t.table_schema = 'public' AND t.table_type = 'BASE TABLE' AND t.table_name NOT LIKE 'pg_%' AND t.table_name NOT LIKE 'sql_%' ORDER BY t.table_name `; // BigInt를 Number로 변환하여 JSON 직렬화 문제 해결 const tables: TableInfo[] = rawTables.map((table) => ({ ...table, columnCount: Number(table.columnCount), // BigInt → Number 변환 })); // 캐시에 저장 (10분 TTL) cache.set(CacheKeys.TABLE_LIST, tables, 10 * 60 * 1000); logger.info(`테이블 목록 조회 완료: ${tables.length}개`); return tables; } catch (error) { logger.error("테이블 목록 조회 중 오류 발생:", error); throw new Error( `테이블 목록 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 테이블 컬럼 정보 조회 (페이지네이션 지원) * 메타데이터 조회는 Prisma로 변경 불가 */ async getColumnList( tableName: string, page: number = 1, size: number = 50 ): Promise<{ columns: ColumnTypeInfo[]; total: number; page: number; size: number; totalPages: number; }> { try { logger.info( `컬럼 정보 조회 시작: ${tableName} (page: ${page}, size: ${size})` ); // 캐시 키 생성 const cacheKey = CacheKeys.TABLE_COLUMNS(tableName, page, size); const countCacheKey = CacheKeys.TABLE_COLUMN_COUNT(tableName); // 캐시에서 먼저 확인 const cachedResult = cache.get<{ columns: ColumnTypeInfo[]; total: number; page: number; size: number; totalPages: number; }>(cacheKey); if (cachedResult) { logger.info( `컬럼 정보 캐시에서 조회: ${tableName}, ${cachedResult.columns.length}/${cachedResult.total}개` ); return cachedResult; } // 전체 컬럼 수 조회 (캐시 확인) let total = cache.get(countCacheKey); if (!total) { const totalResult = await prisma.$queryRaw<[{ count: bigint }]>` SELECT COUNT(*) as count FROM information_schema.columns c WHERE c.table_name = ${tableName} `; total = Number(totalResult[0].count); // 컬럼 수는 자주 변하지 않으므로 30분 캐시 cache.set(countCacheKey, total, 30 * 60 * 1000); } // 페이지네이션 적용한 컬럼 조회 const offset = (page - 1) * size; const rawColumns = await prisma.$queryRaw` SELECT c.column_name as "columnName", COALESCE(cl.column_label, c.column_name) as "displayName", c.data_type as "dataType", c.data_type as "dbType", COALESCE(cl.web_type, 'text') as "webType", COALESCE(cl.input_type, 'direct') as "inputType", COALESCE(cl.detail_settings, '') as "detailSettings", COALESCE(cl.description, '') as "description", c.is_nullable as "isNullable", CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey", c.column_default as "defaultValue", c.character_maximum_length as "maxLength", c.numeric_precision as "numericPrecision", c.numeric_scale as "numericScale", cl.code_category as "codeCategory", cl.code_value as "codeValue", cl.reference_table as "referenceTable", cl.reference_column as "referenceColumn", cl.display_column as "displayColumn", cl.display_order as "displayOrder", cl.is_visible as "isVisible" FROM information_schema.columns c LEFT JOIN column_labels cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name LEFT JOIN ( SELECT kcu.column_name, kcu.table_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_name = ${tableName} ) pk ON c.column_name = pk.column_name AND c.table_name = pk.table_name WHERE c.table_name = ${tableName} ORDER BY c.ordinal_position LIMIT ${size} OFFSET ${offset} `; // BigInt를 Number로 변환하여 JSON 직렬화 문제 해결 const columns: ColumnTypeInfo[] = rawColumns.map((column) => ({ ...column, maxLength: column.maxLength ? Number(column.maxLength) : null, numericPrecision: column.numericPrecision ? Number(column.numericPrecision) : null, numericScale: column.numericScale ? Number(column.numericScale) : null, displayOrder: column.displayOrder ? Number(column.displayOrder) : null, })); const totalPages = Math.ceil(total / size); const result = { columns, total, page, size, totalPages, }; // 캐시에 저장 (5분 TTL) cache.set(cacheKey, result, 5 * 60 * 1000); logger.info( `컬럼 정보 조회 완료: ${tableName}, ${columns.length}/${total}개 (${page}/${totalPages} 페이지)` ); return result; } catch (error) { logger.error(`컬럼 정보 조회 중 오류 발생: ${tableName}`, error); throw new Error( `컬럼 정보 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 테이블이 table_labels에 없으면 자동 추가 * Prisma ORM으로 변경 */ async insertTableIfNotExists(tableName: string): Promise { try { logger.info(`테이블 라벨 자동 추가 시작: ${tableName}`); await prisma.table_labels.upsert({ where: { table_name: tableName }, update: {}, // 이미 존재하면 변경하지 않음 create: { table_name: tableName, table_label: tableName, description: "", }, }); logger.info(`테이블 라벨 자동 추가 완료: ${tableName}`); } catch (error) { logger.error(`테이블 라벨 자동 추가 중 오류 발생: ${tableName}`, error); throw new Error( `테이블 라벨 자동 추가 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 테이블 라벨 업데이트 */ async updateTableLabel( tableName: string, displayName: string, description?: string ): Promise { try { logger.info(`테이블 라벨 업데이트 시작: ${tableName}`); // table_labels 테이블에 UPSERT await prisma.$executeRaw` INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) VALUES (${tableName}, ${displayName}, ${description || ""}, NOW(), NOW()) ON CONFLICT (table_name) DO UPDATE SET table_label = EXCLUDED.table_label, description = EXCLUDED.description, updated_date = NOW() `; // 캐시 무효화 cache.delete(CacheKeys.TABLE_LIST); logger.info(`테이블 라벨 업데이트 완료: ${tableName}`); } catch (error) { logger.error("테이블 라벨 업데이트 중 오류 발생:", error); throw new Error( `테이블 라벨 업데이트 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 컬럼 설정 업데이트 (UPSERT 방식) * Prisma ORM으로 변경 */ async updateColumnSettings( tableName: string, columnName: string, settings: ColumnSettings ): Promise { try { logger.info(`컬럼 설정 업데이트 시작: ${tableName}.${columnName}`); // 테이블이 table_labels에 없으면 자동 추가 await this.insertTableIfNotExists(tableName); // column_labels 업데이트 또는 생성 await prisma.column_labels.upsert({ where: { table_name_column_name: { table_name: tableName, column_name: columnName, }, }, update: { column_label: settings.columnLabel, web_type: settings.webType, detail_settings: settings.detailSettings, code_category: settings.codeCategory, code_value: settings.codeValue, reference_table: settings.referenceTable, reference_column: settings.referenceColumn, display_column: settings.displayColumn, // 🎯 Entity 조인에서 표시할 컬럼명 display_order: settings.displayOrder || 0, is_visible: settings.isVisible !== undefined ? settings.isVisible : true, updated_date: new Date(), }, create: { table_name: tableName, column_name: columnName, column_label: settings.columnLabel, web_type: settings.webType, detail_settings: settings.detailSettings, code_category: settings.codeCategory, code_value: settings.codeValue, reference_table: settings.referenceTable, reference_column: settings.referenceColumn, display_column: settings.displayColumn, // 🎯 Entity 조인에서 표시할 컬럼명 display_order: settings.displayOrder || 0, is_visible: settings.isVisible !== undefined ? settings.isVisible : true, }, }); // 캐시 무효화 - 해당 테이블의 컬럼 캐시 삭제 cache.deleteByPattern(`table_columns:${tableName}:`); cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName)); logger.info(`컬럼 설정 업데이트 완료: ${tableName}.${columnName}`); } catch (error) { logger.error( `컬럼 설정 업데이트 중 오류 발생: ${tableName}.${columnName}`, error ); throw new Error( `컬럼 설정 업데이트 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 전체 컬럼 설정 일괄 업데이트 * Prisma 트랜잭션으로 변경 */ async updateAllColumnSettings( tableName: string, columnSettings: ColumnSettings[] ): Promise { try { logger.info( `전체 컬럼 설정 일괄 업데이트 시작: ${tableName}, ${columnSettings.length}개` ); // Prisma 트랜잭션 사용 await prisma.$transaction(async (tx) => { // 테이블이 table_labels에 없으면 자동 추가 await this.insertTableIfNotExists(tableName); // 각 컬럼 설정을 순차적으로 업데이트 for (const columnSetting of columnSettings) { // columnName은 실제 DB 컬럼명을 유지해야 함 const columnName = columnSetting.columnName; if (columnName) { await this.updateColumnSettings( tableName, columnName, columnSetting ); } else { logger.warn( `컬럼명이 누락된 설정: ${JSON.stringify(columnSetting)}` ); } } }); // 캐시 무효화 - 해당 테이블의 컬럼 캐시 삭제 cache.deleteByPattern(`table_columns:${tableName}:`); cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName)); logger.info(`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}`); } catch (error) { logger.error( `전체 컬럼 설정 일괄 업데이트 중 오류 발생: ${tableName}`, error ); throw new Error( `전체 컬럼 설정 일괄 업데이트 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 테이블 라벨 정보 조회 * Prisma ORM으로 변경 */ async getTableLabels(tableName: string): Promise { try { logger.info(`테이블 라벨 정보 조회 시작: ${tableName}`); const tableLabel = await prisma.table_labels.findUnique({ where: { table_name: tableName }, select: { table_name: true, table_label: true, description: true, created_date: true, updated_date: true, }, }); if (!tableLabel) { return null; } const result: TableLabels = { tableName: tableLabel.table_name, tableLabel: tableLabel.table_label || undefined, description: tableLabel.description || undefined, createdDate: tableLabel.created_date || undefined, updatedDate: tableLabel.updated_date || undefined, }; logger.info(`테이블 라벨 정보 조회 완료: ${tableName}`); return result; } catch (error) { logger.error(`테이블 라벨 정보 조회 중 오류 발생: ${tableName}`, error); throw new Error( `테이블 라벨 정보 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 컬럼 라벨 정보 조회 * Prisma ORM으로 변경 */ async getColumnLabels( tableName: string, columnName: string ): Promise { try { logger.info(`컬럼 라벨 정보 조회 시작: ${tableName}.${columnName}`); const columnLabel = await prisma.column_labels.findUnique({ where: { table_name_column_name: { table_name: tableName, column_name: columnName, }, }, select: { id: true, table_name: true, column_name: true, column_label: true, web_type: true, detail_settings: true, description: true, display_order: true, is_visible: true, code_category: true, code_value: true, reference_table: true, reference_column: true, created_date: true, updated_date: true, }, }); if (!columnLabel) { return null; } const result: ColumnLabels = { id: columnLabel.id, tableName: columnLabel.table_name || "", columnName: columnLabel.column_name || "", columnLabel: columnLabel.column_label || undefined, webType: columnLabel.web_type || undefined, detailSettings: columnLabel.detail_settings || undefined, description: columnLabel.description || undefined, displayOrder: columnLabel.display_order || undefined, isVisible: columnLabel.is_visible || undefined, codeCategory: columnLabel.code_category || undefined, codeValue: columnLabel.code_value || undefined, referenceTable: columnLabel.reference_table || undefined, referenceColumn: columnLabel.reference_column || undefined, createdDate: columnLabel.created_date || undefined, updatedDate: columnLabel.updated_date || undefined, }; logger.info(`컬럼 라벨 정보 조회 완료: ${tableName}.${columnName}`); return result; } catch (error) { logger.error( `컬럼 라벨 정보 조회 중 오류 발생: ${tableName}.${columnName}`, error ); throw new Error( `컬럼 라벨 정보 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 컬럼 웹 타입 설정 */ async updateColumnWebType( tableName: string, columnName: string, webType: string, detailSettings?: Record, inputType?: string ): Promise { try { logger.info( `컬럼 웹 타입 설정 시작: ${tableName}.${columnName} = ${webType}` ); // 웹 타입별 기본 상세 설정 생성 const defaultDetailSettings = this.generateDefaultDetailSettings(webType); // 사용자 정의 설정과 기본 설정 병합 const finalDetailSettings = { ...defaultDetailSettings, ...detailSettings, }; // column_labels 테이블에 해당 컬럼이 있는지 확인 const existingColumn = await prisma.column_labels.findFirst({ where: { table_name: tableName, column_name: columnName, }, }); if (existingColumn) { // 기존 컬럼 라벨 업데이트 const updateData: any = { web_type: webType, detail_settings: JSON.stringify(finalDetailSettings), updated_date: new Date(), }; if (inputType) { updateData.input_type = inputType; } await prisma.column_labels.update({ where: { id: existingColumn.id, }, data: updateData, }); logger.info( `컬럼 웹 타입 업데이트 완료: ${tableName}.${columnName} = ${webType}` ); } else { // 새로운 컬럼 라벨 생성 const createData: any = { table_name: tableName, column_name: columnName, web_type: webType, detail_settings: JSON.stringify(finalDetailSettings), created_date: new Date(), updated_date: new Date(), }; if (inputType) { createData.input_type = inputType; } await prisma.column_labels.create({ data: createData, }); logger.info( `컬럼 라벨 생성 및 웹 타입 설정 완료: ${tableName}.${columnName} = ${webType}` ); } } catch (error) { logger.error( `컬럼 웹 타입 설정 중 오류 발생: ${tableName}.${columnName}`, error ); throw new Error( `컬럼 웹 타입 설정 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 웹 타입별 기본 상세 설정 생성 */ private generateDefaultDetailSettings(webType: string): Record { switch (webType) { case "text": return { maxLength: 255, pattern: null, placeholder: null, }; case "number": return { min: null, max: null, step: 1, precision: 2, }; case "date": return { format: "YYYY-MM-DD", minDate: null, maxDate: null, }; case "code": return { codeCategory: null, displayFormat: "label", searchable: true, multiple: false, }; case "entity": return { referenceTable: null, referenceColumn: null, searchable: true, multiple: false, }; case "textarea": return { rows: 3, maxLength: 1000, placeholder: null, }; case "select": return { options: [], multiple: false, searchable: false, }; case "checkbox": return { defaultChecked: false, label: null, }; case "radio": return { options: [], inline: false, }; case "file": return { accept: "*/*", maxSize: 10485760, // 10MB multiple: false, }; default: return {}; } } /** * 파일 데이터 보강 (attach_file_info에서 파일 정보 가져오기) */ private async enrichFileData( data: any[], fileColumns: string[], tableName: string ): Promise { try { logger.info( `파일 데이터 보강 시작: ${tableName}, ${fileColumns.join(", ")}` ); // 각 행의 파일 정보를 보강 const enrichedData = await Promise.all( data.map(async (row) => { const enrichedRow = { ...row }; // 각 파일 컬럼에 대해 처리 for (const fileColumn of fileColumns) { const filePath = row[fileColumn]; if (filePath && typeof filePath === "string") { // 🎯 컴포넌트별 파일 정보 조회 // 파일 경로에서 컴포넌트 ID 추출하거나 컬럼명 사용 const componentId = this.extractComponentIdFromPath(filePath) || fileColumn; const fileInfos = await this.getFileInfoByColumnAndTarget( componentId, row.id || row.objid || row.seq, // 기본키 값 tableName ); if (fileInfos && fileInfos.length > 0) { // 파일 정보를 JSON 형태로 저장 const totalSize = fileInfos.reduce( (sum, file) => sum + (file.size || 0), 0 ); enrichedRow[fileColumn] = JSON.stringify({ files: fileInfos, totalCount: fileInfos.length, totalSize: totalSize, }); } else { // 파일이 없으면 빈 상태로 설정 enrichedRow[fileColumn] = JSON.stringify({ files: [], totalCount: 0, totalSize: 0, }); } } } return enrichedRow; }) ); logger.info(`파일 데이터 보강 완료: ${enrichedData.length}개 행 처리`); return enrichedData; } catch (error) { logger.error("파일 데이터 보강 실패:", error); return data; // 실패 시 원본 데이터 반환 } } /** * 파일 경로에서 컴포넌트 ID 추출 (현재는 사용하지 않음) */ private extractComponentIdFromPath(filePath: string): string | null { // 현재는 파일 경로에서 컴포넌트 ID를 추출할 수 없으므로 null 반환 // 추후 필요시 구현 return null; } /** * 컬럼별 파일 정보 조회 (컬럼명과 target_objid로 구분) */ private async getFileInfoByColumnAndTarget( columnName: string, targetObjid: any, tableName: string ): Promise { try { logger.info( `컬럼별 파일 정보 조회: ${tableName}.${columnName}, target: ${targetObjid}` ); // 🎯 컬럼명을 doc_type으로 사용하여 파일 구분 const fileInfos = await prisma.attach_file_info.findMany({ where: { target_objid: String(targetObjid), doc_type: columnName, // 컬럼명으로 파일 구분 status: "ACTIVE", }, select: { objid: true, real_file_name: true, file_size: true, file_ext: true, file_path: true, doc_type: true, doc_type_name: true, regdate: true, writer: true, }, orderBy: { regdate: "desc", }, }); // 파일 정보 포맷팅 return fileInfos.map((fileInfo) => ({ name: fileInfo.real_file_name, size: Number(fileInfo.file_size) || 0, path: fileInfo.file_path, ext: fileInfo.file_ext, objid: String(fileInfo.objid), docType: fileInfo.doc_type, docTypeName: fileInfo.doc_type_name, regdate: fileInfo.regdate?.toISOString(), writer: fileInfo.writer, })); } catch (error) { logger.warn(`컬럼별 파일 정보 조회 실패: ${columnName}`, error); return []; } } /** * 파일 경로로 파일 정보 조회 (기존 메서드 - 호환성 유지) */ private async getFileInfoByPath(filePath: string): Promise { try { const fileInfo = await prisma.attach_file_info.findFirst({ where: { file_path: filePath, status: "ACTIVE", }, select: { objid: true, real_file_name: true, file_size: true, file_ext: true, file_path: true, doc_type: true, doc_type_name: true, regdate: true, writer: true, }, }); if (!fileInfo) { return null; } return { name: fileInfo.real_file_name, path: fileInfo.file_path, size: Number(fileInfo.file_size) || 0, type: fileInfo.file_ext, objid: fileInfo.objid.toString(), docType: fileInfo.doc_type, docTypeName: fileInfo.doc_type_name, regdate: fileInfo.regdate?.toISOString(), writer: fileInfo.writer, }; } catch (error) { logger.warn(`파일 정보 조회 실패: ${filePath}`, error); return null; } } /** * 파일 타입 컬럼 조회 */ private async getFileTypeColumns(tableName: string): Promise { try { const fileColumns = await prisma.column_labels.findMany({ where: { table_name: tableName, web_type: "file", }, select: { column_name: true, }, }); const columnNames = fileColumns.map((col: any) => col.column_name); logger.info(`파일 타입 컬럼 감지: ${tableName}`, columnNames); return columnNames; } catch (error) { logger.warn(`파일 타입 컬럼 조회 실패: ${tableName}`, error); return []; } } /** * 테이블 데이터 조회 (페이징 + 검색) */ async getTableData( tableName: string, options: { page: number; size: number; search?: Record; sortBy?: string; sortOrder?: string; } ): Promise<{ data: any[]; total: number; page: number; size: number; totalPages: number; }> { try { const { page, size, search = {}, sortBy, sortOrder = "asc" } = options; const offset = (page - 1) * size; logger.info(`테이블 데이터 조회: ${tableName}`, options); // 🎯 파일 타입 컬럼 감지 (비활성화됨 - 자동 파일 컬럼 생성 방지) // const fileColumns = await this.getFileTypeColumns(tableName); const fileColumns: string[] = []; // 자동 파일 컬럼 생성 비활성화 // WHERE 조건 구성 let whereConditions: string[] = []; let searchValues: any[] = []; let paramIndex = 1; if (search && Object.keys(search).length > 0) { for (const [column, value] of Object.entries(search)) { if (value !== null && value !== undefined && value !== "") { // 안전한 컬럼명 검증 (SQL 인젝션 방지) const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, ""); if (typeof value === "string") { whereConditions.push(`${safeColumn}::text ILIKE $${paramIndex}`); searchValues.push(`%${value}%`); } else { whereConditions.push(`${safeColumn} = $${paramIndex}`); searchValues.push(value); } paramIndex++; } } } const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; // ORDER BY 조건 구성 let orderClause = ""; if (sortBy) { const safeSortBy = sortBy.replace(/[^a-zA-Z0-9_]/g, ""); const safeSortOrder = sortOrder.toLowerCase() === "desc" ? "DESC" : "ASC"; orderClause = `ORDER BY ${safeSortBy} ${safeSortOrder}`; } // 안전한 테이블명 검증 const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, ""); // 전체 개수 조회 const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} ${whereClause}`; const countResult = await prisma.$queryRawUnsafe( countQuery, ...searchValues ); const total = parseInt(countResult[0].count); // 데이터 조회 const dataQuery = ` SELECT * FROM ${safeTableName} ${whereClause} ${orderClause} LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; let data = await prisma.$queryRawUnsafe( dataQuery, ...searchValues, size, offset ); // 🎯 파일 컬럼이 있으면 파일 정보 보강 if (fileColumns.length > 0) { data = await this.enrichFileData(data, fileColumns, safeTableName); } const totalPages = Math.ceil(total / size); logger.info( `테이블 데이터 조회 완료: ${tableName}, 총 ${total}건, ${data.length}개 반환` ); return { data, total, page, size, totalPages, }; } catch (error) { logger.error(`테이블 데이터 조회 오류: ${tableName}`, error); throw error; } } /** * 현재 사용자 정보 조회 (JWT 토큰에서) */ private getCurrentUserFromRequest(req?: any): { userId: string; userName: string; } { // 실제 프로젝트에서는 req 객체에서 JWT 토큰을 파싱하여 사용자 정보를 가져올 수 있습니다 // 현재는 기본값을 반환 return { userId: "system", userName: "시스템 사용자", }; } /** * 값을 PostgreSQL 타입에 맞게 변환 */ private convertValueForPostgreSQL(value: any, dataType: string): any { if (value === null || value === undefined || value === "") { return null; } const lowerDataType = dataType.toLowerCase(); // 날짜/시간 타입 처리 if ( lowerDataType.includes("timestamp") || lowerDataType.includes("datetime") ) { // YYYY-MM-DDTHH:mm:ss 형식을 PostgreSQL timestamp로 변환 if (typeof value === "string") { try { const date = new Date(value); return date.toISOString(); } catch { return null; } } return value; } // 날짜 타입 처리 if (lowerDataType.includes("date")) { if (typeof value === "string") { try { // YYYY-MM-DD 형식 유지 if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { return value; } const date = new Date(value); return date.toISOString().split("T")[0]; } catch { return null; } } return value; } // 시간 타입 처리 if (lowerDataType.includes("time")) { if (typeof value === "string") { // HH:mm:ss 형식 유지 if (/^\d{2}:\d{2}:\d{2}$/.test(value)) { return value; } } return value; } // 숫자 타입 처리 if ( lowerDataType.includes("integer") || lowerDataType.includes("bigint") || lowerDataType.includes("serial") ) { return parseInt(value) || null; } if ( lowerDataType.includes("numeric") || lowerDataType.includes("decimal") || lowerDataType.includes("real") || lowerDataType.includes("double") ) { return parseFloat(value) || null; } // 불린 타입 처리 if (lowerDataType.includes("boolean")) { if (typeof value === "string") { return value.toLowerCase() === "true" || value === "1"; } return Boolean(value); } // 기본적으로 문자열로 처리 return value; } /** * 테이블에 데이터 추가 */ async addTableData( tableName: string, data: Record ): Promise { try { logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`); logger.info(`추가할 데이터:`, data); // 테이블의 컬럼 정보 조회 const columnInfoQuery = ` SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_name = $1 ORDER BY ordinal_position `; const columnInfoResult = (await prisma.$queryRawUnsafe( columnInfoQuery, tableName )) as any[]; const columnTypeMap = new Map(); columnInfoResult.forEach((col: any) => { columnTypeMap.set(col.column_name, col.data_type); }); logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap)); // 컬럼명과 값을 분리하고 타입에 맞게 변환 const columns = Object.keys(data); const values = Object.values(data).map((value, index) => { const columnName = columns[index]; const dataType = columnTypeMap.get(columnName) || "text"; const convertedValue = this.convertValueForPostgreSQL(value, dataType); logger.info( `컬럼 "${columnName}" (${dataType}): "${value}" → "${convertedValue}"` ); return convertedValue; }); // 동적 INSERT 쿼리 생성 (타입 캐스팅 포함) const placeholders = columns .map((col, index) => { const dataType = columnTypeMap.get(col) || "text"; const lowerDataType = dataType.toLowerCase(); // PostgreSQL에서 직접 타입 캐스팅 if ( lowerDataType.includes("timestamp") || lowerDataType.includes("datetime") ) { return `$${index + 1}::timestamp`; } else if (lowerDataType.includes("date")) { return `$${index + 1}::date`; } else if (lowerDataType.includes("time")) { return `$${index + 1}::time`; } else if ( lowerDataType.includes("integer") || lowerDataType.includes("bigint") || lowerDataType.includes("serial") ) { return `$${index + 1}::integer`; } else if ( lowerDataType.includes("numeric") || lowerDataType.includes("decimal") ) { return `$${index + 1}::numeric`; } else if (lowerDataType.includes("boolean")) { return `$${index + 1}::boolean`; } return `$${index + 1}`; }) .join(", "); const columnNames = columns.map((col) => `"${col}"`).join(", "); const query = ` INSERT INTO "${tableName}" (${columnNames}) VALUES (${placeholders}) `; logger.info(`실행할 쿼리: ${query}`); logger.info(`쿼리 파라미터:`, values); await prisma.$queryRawUnsafe(query, ...values); logger.info(`테이블 데이터 추가 완료: ${tableName}`); } catch (error) { logger.error(`테이블 데이터 추가 오류: ${tableName}`, error); throw error; } } /** * 테이블 데이터 수정 */ async editTableData( tableName: string, originalData: Record, updatedData: Record ): Promise { try { logger.info(`=== 테이블 데이터 수정 시작: ${tableName} ===`); logger.info(`원본 데이터:`, originalData); logger.info(`수정할 데이터:`, updatedData); // 테이블의 컬럼 정보 조회 (PRIMARY KEY 찾기용) const columnInfoQuery = ` SELECT c.column_name, c.data_type, c.is_nullable, CASE WHEN tc.constraint_type = 'PRIMARY KEY' THEN 'YES' ELSE 'NO' END as is_primary_key FROM information_schema.columns c LEFT JOIN information_schema.key_column_usage kcu ON c.column_name = kcu.column_name AND c.table_name = kcu.table_name LEFT JOIN information_schema.table_constraints tc ON kcu.constraint_name = tc.constraint_name AND tc.table_name = c.table_name WHERE c.table_name = $1 ORDER BY c.ordinal_position `; const columnInfoResult = (await prisma.$queryRawUnsafe( columnInfoQuery, tableName )) as any[]; const columnTypeMap = new Map(); const primaryKeys: string[] = []; columnInfoResult.forEach((col: any) => { columnTypeMap.set(col.column_name, col.data_type); if (col.is_primary_key === "YES") { primaryKeys.push(col.column_name); } }); logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap)); logger.info(`PRIMARY KEY 컬럼들:`, primaryKeys); // SET 절 생성 (수정할 데이터) - 먼저 생성 const setConditions: string[] = []; const setValues: any[] = []; let paramIndex = 1; Object.keys(updatedData).forEach((column) => { const dataType = columnTypeMap.get(column) || "text"; setConditions.push( `"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}` ); setValues.push( this.convertValueForPostgreSQL(updatedData[column], dataType) ); paramIndex++; }); // WHERE 조건 생성 (PRIMARY KEY 우선, 없으면 모든 원본 데이터 사용) let whereConditions: string[] = []; let whereValues: any[] = []; if (primaryKeys.length > 0) { // PRIMARY KEY로 WHERE 조건 생성 primaryKeys.forEach((pkColumn) => { if (originalData[pkColumn] !== undefined) { const dataType = columnTypeMap.get(pkColumn) || "text"; whereConditions.push( `"${pkColumn}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}` ); whereValues.push( this.convertValueForPostgreSQL(originalData[pkColumn], dataType) ); paramIndex++; } }); } else { // PRIMARY KEY가 없으면 모든 원본 데이터로 WHERE 조건 생성 Object.keys(originalData).forEach((column) => { const dataType = columnTypeMap.get(column) || "text"; whereConditions.push( `"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}` ); whereValues.push( this.convertValueForPostgreSQL(originalData[column], dataType) ); paramIndex++; }); } // UPDATE 쿼리 생성 const query = ` UPDATE "${tableName}" SET ${setConditions.join(", ")} WHERE ${whereConditions.join(" AND ")} `; const allValues = [...setValues, ...whereValues]; logger.info(`실행할 UPDATE 쿼리: ${query}`); logger.info(`쿼리 파라미터:`, allValues); const result = await prisma.$queryRawUnsafe(query, ...allValues); logger.info(`테이블 데이터 수정 완료: ${tableName}`, result); } catch (error) { logger.error(`테이블 데이터 수정 오류: ${tableName}`, error); throw error; } } /** * PostgreSQL 타입명 반환 */ private getPostgreSQLType(dataType: string): string { const lowerDataType = dataType.toLowerCase(); if ( lowerDataType.includes("timestamp") || lowerDataType.includes("datetime") ) { return "timestamp"; } else if (lowerDataType.includes("date")) { return "date"; } else if (lowerDataType.includes("time")) { return "time"; } else if ( lowerDataType.includes("integer") || lowerDataType.includes("bigint") || lowerDataType.includes("serial") ) { return "integer"; } else if ( lowerDataType.includes("numeric") || lowerDataType.includes("decimal") ) { return "numeric"; } else if (lowerDataType.includes("boolean")) { return "boolean"; } return "text"; // 기본값 } /** * 테이블에서 데이터 삭제 */ async deleteTableData( tableName: string, dataToDelete: Record[] ): Promise { try { logger.info(`테이블 데이터 삭제: ${tableName}`, dataToDelete); if (!Array.isArray(dataToDelete) || dataToDelete.length === 0) { throw new Error("삭제할 데이터가 없습니다."); } let deletedCount = 0; // 테이블의 기본 키 컬럼 찾기 (정확한 식별을 위해) const primaryKeyQuery = ` SELECT column_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name WHERE tc.table_name = $1 AND tc.constraint_type = 'PRIMARY KEY' ORDER BY kcu.ordinal_position `; const primaryKeys = await prisma.$queryRawUnsafe< { column_name: string }[] >(primaryKeyQuery, tableName); if (primaryKeys.length === 0) { // 기본 키가 없는 경우, 모든 컬럼으로 삭제 조건 생성 logger.warn( `테이블 ${tableName}에 기본 키가 없습니다. 모든 컬럼으로 삭제 조건을 생성합니다.` ); for (const rowData of dataToDelete) { const conditions = Object.keys(rowData) .map((key, index) => `"${key}" = $${index + 1}`) .join(" AND "); const values = Object.values(rowData); const deleteQuery = `DELETE FROM "${tableName}" WHERE ${conditions}`; const result = await prisma.$queryRawUnsafe(deleteQuery, ...values); deletedCount += Number(result); } } else { // 기본 키를 사용한 삭제 const primaryKeyNames = primaryKeys.map((pk) => pk.column_name); for (const rowData of dataToDelete) { const conditions = primaryKeyNames .map((key, index) => `"${key}" = $${index + 1}`) .join(" AND "); const values = primaryKeyNames.map((key) => rowData[key]); // null 값이 있는 경우 스킵 if (values.some((val) => val === null || val === undefined)) { logger.warn(`기본 키 값이 null인 행을 스킵합니다:`, rowData); continue; } const deleteQuery = `DELETE FROM "${tableName}" WHERE ${conditions}`; const result = await prisma.$queryRawUnsafe(deleteQuery, ...values); deletedCount += Number(result); } } logger.info( `테이블 데이터 삭제 완료: ${tableName}, ${deletedCount}건 삭제` ); return deletedCount; } catch (error) { logger.error(`테이블 데이터 삭제 오류: ${tableName}`, error); throw error; } } // ======================================== // 🎯 Entity 조인 기능 // ======================================== /** * Entity 조인이 포함된 데이터 조회 */ async getTableDataWithEntityJoins( tableName: string, options: { page: number; size: number; search?: Record; sortBy?: string; sortOrder?: string; enableEntityJoin?: boolean; additionalJoinColumns?: Array<{ sourceTable: string; sourceColumn: string; joinAlias: string; }>; } ): Promise { const startTime = Date.now(); try { logger.info(`Entity 조인 데이터 조회 시작: ${tableName}`); // Entity 조인이 비활성화된 경우 기본 데이터 조회 if (!options.enableEntityJoin) { const basicResult = await this.getTableData(tableName, options); return { data: basicResult.data, total: basicResult.total, page: options.page, size: options.size, totalPages: Math.ceil(basicResult.total / options.size), }; } // Entity 조인 설정 감지 let joinConfigs = await entityJoinService.detectEntityJoins(tableName); // 추가 조인 컬럼 정보가 있으면 조인 설정에 추가 if ( options.additionalJoinColumns && options.additionalJoinColumns.length > 0 ) { logger.info( `추가 조인 컬럼 처리: ${options.additionalJoinColumns.length}개` ); for (const additionalColumn of options.additionalJoinColumns) { // 기존 조인 설정에서 같은 참조 테이블을 사용하는 설정 찾기 const baseJoinConfig = joinConfigs.find( (config) => config.referenceTable === additionalColumn.sourceTable ); if (baseJoinConfig) { // 추가 조인 컬럼 설정 생성 const additionalJoinConfig: EntityJoinConfig = { sourceTable: tableName, sourceColumn: baseJoinConfig.sourceColumn, // 원본 컬럼 (writer) referenceTable: additionalColumn.sourceTable, // 참조 테이블 (user_info) referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (user_id) displayColumn: additionalColumn.sourceColumn, // 표시할 컬럼 (email) aliasColumn: additionalColumn.joinAlias, // 별칭 (writer_email) }; joinConfigs.push(additionalJoinConfig); logger.info( `추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn}` ); } } } if (joinConfigs.length === 0) { logger.info(`Entity 조인 설정이 없음: ${tableName}`); const basicResult = await this.getTableData(tableName, options); return { data: basicResult.data, total: basicResult.total, page: options.page, size: options.size, totalPages: Math.ceil(basicResult.total / options.size), }; } // 조인 전략 결정 (테이블 크기 기반) const strategy = await entityJoinService.determineJoinStrategy(joinConfigs); console.log( `🎯 선택된 조인 전략: ${strategy} (${joinConfigs.length}개 Entity 조인)` ); // 테이블 컬럼 정보 조회 const columns = await this.getTableColumns(tableName); const selectColumns = columns.data.map((col: any) => col.column_name); // WHERE 절 구성 const whereClause = this.buildWhereClause(options.search); // ORDER BY 절 구성 const orderBy = options.sortBy ? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}` : ""; // 페이징 계산 const offset = (options.page - 1) * options.size; if (strategy === "full_join") { // SQL JOIN 방식 return await this.executeJoinQuery( tableName, joinConfigs, selectColumns, whereClause, orderBy, options.size, offset, startTime ); } else if (strategy === "cache_lookup") { // 캐시 룩업 방식 return await this.executeCachedLookup( tableName, joinConfigs, options, startTime ); } else { // 하이브리드 방식: 일부는 조인, 일부는 캐시 return await this.executeHybridJoin( tableName, joinConfigs, selectColumns, whereClause, orderBy, options.size, offset, startTime ); } } catch (error) { logger.error(`Entity 조인 데이터 조회 실패: ${tableName}`, error); // 에러 발생 시 기본 데이터 반환 const basicResult = await this.getTableData(tableName, options); return { data: basicResult.data, total: basicResult.total, page: options.page, size: options.size, totalPages: Math.ceil(basicResult.total / options.size), }; } } /** * SQL JOIN 방식으로 데이터 조회 */ private async executeJoinQuery( tableName: string, joinConfigs: EntityJoinConfig[], selectColumns: string[], whereClause: string, orderBy: string, limit: number, offset: number, startTime: number ): Promise { try { // 데이터 조회 쿼리 const dataQuery = entityJoinService.buildJoinQuery( tableName, joinConfigs, selectColumns, whereClause, orderBy, limit, offset ); // 카운트 쿼리 const countQuery = entityJoinService.buildCountQuery( tableName, joinConfigs, whereClause ); // 병렬 실행 const [dataResult, countResult] = await Promise.all([ prisma.$queryRawUnsafe(dataQuery), prisma.$queryRawUnsafe(countQuery), ]); const data = Array.isArray(dataResult) ? dataResult : []; const total = Array.isArray(countResult) && countResult.length > 0 ? Number((countResult[0] as any).total) : 0; const queryTime = Date.now() - startTime; return { data, total, page: Math.floor(offset / limit) + 1, size: limit, totalPages: Math.ceil(total / limit), entityJoinInfo: { joinConfigs, strategy: "full_join", performance: { queryTime, }, }, }; } catch (error) { logger.error("SQL JOIN 쿼리 실행 실패", error); throw error; } } /** * 캐시 룩업 방식으로 데이터 조회 */ private async executeCachedLookup( tableName: string, joinConfigs: EntityJoinConfig[], options: { page: number; size: number; search?: Record; sortBy?: string; sortOrder?: string; }, startTime: number ): Promise { try { // 캐시 데이터 미리 로드 for (const config of joinConfigs) { await referenceCacheService.getCachedReference( config.referenceTable, config.referenceColumn, config.displayColumn ); } // 기본 데이터 조회 const basicResult = await this.getTableData(tableName, options); // Entity 값들을 캐시에서 룩업하여 변환 const enhancedData = basicResult.data.map((row: any) => { const enhancedRow = { ...row }; for (const config of joinConfigs) { const sourceValue = row[config.sourceColumn]; if (sourceValue) { const lookupValue = referenceCacheService.getLookupValue( config.referenceTable, config.referenceColumn, config.displayColumn, String(sourceValue) ); // null이나 undefined인 경우 빈 문자열로 설정 enhancedRow[config.aliasColumn] = lookupValue || ""; } else { // sourceValue가 없는 경우도 빈 문자열로 설정 enhancedRow[config.aliasColumn] = ""; } } return enhancedRow; }); const queryTime = Date.now() - startTime; const cacheHitRate = referenceCacheService.getOverallCacheHitRate(); return { data: enhancedData, total: basicResult.total, page: options.page, size: options.size, totalPages: Math.ceil(basicResult.total / options.size), entityJoinInfo: { joinConfigs, strategy: "cache_lookup", performance: { queryTime, cacheHitRate, }, }, }; } catch (error) { logger.error("캐시 룩업 실행 실패", error); throw error; } } /** * WHERE 절 구성 */ private buildWhereClause(search?: Record): string { if (!search || Object.keys(search).length === 0) { return ""; } const conditions: string[] = []; for (const [key, value] of Object.entries(search)) { if (value !== undefined && value !== null && value !== "") { if (typeof value === "string") { conditions.push(`main.${key} ILIKE '%${value}%'`); } else { conditions.push(`main.${key} = '${value}'`); } } } return conditions.length > 0 ? conditions.join(" AND ") : ""; } /** * 테이블의 컬럼 정보 조회 */ async getTableColumns(tableName: string): Promise<{ data: Array<{ column_name: string; data_type: string }>; }> { try { const columns = await prisma.$queryRaw< Array<{ column_name: string; data_type: string; }> >` SELECT column_name, data_type FROM information_schema.columns WHERE table_name = ${tableName} ORDER BY ordinal_position `; return { data: columns }; } catch (error) { logger.error(`테이블 컬럼 조회 실패: ${tableName}`, error); throw new Error( `테이블 컬럼 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 참조 테이블의 표시 컬럼 목록 조회 */ async getReferenceTableColumns(tableName: string): Promise< Array<{ columnName: string; displayName: string; dataType: string; }> > { return await entityJoinService.getReferenceTableColumns(tableName); } /** * 컬럼 라벨 정보 업데이트 (display_column 추가) */ async updateColumnLabel( tableName: string, columnName: string, updates: Partial ): Promise { try { logger.info(`컬럼 라벨 업데이트: ${tableName}.${columnName}`); await prisma.column_labels.upsert({ where: { table_name_column_name: { table_name: tableName, column_name: columnName, }, }, update: { column_label: updates.columnLabel, web_type: updates.webType, detail_settings: updates.detailSettings, description: updates.description, display_order: updates.displayOrder, is_visible: updates.isVisible, code_category: updates.codeCategory, code_value: updates.codeValue, reference_table: updates.referenceTable, reference_column: updates.referenceColumn, // display_column: updates.displayColumn, // 🎯 새로 추가 (임시 주석) updated_date: new Date(), }, create: { table_name: tableName, column_name: columnName, column_label: updates.columnLabel || columnName, web_type: updates.webType || "text", detail_settings: updates.detailSettings, description: updates.description, display_order: updates.displayOrder || 0, is_visible: updates.isVisible !== false, code_category: updates.codeCategory, code_value: updates.codeValue, reference_table: updates.referenceTable, reference_column: updates.referenceColumn, // display_column: updates.displayColumn, // 🎯 새로 추가 (임시 주석) created_date: new Date(), updated_date: new Date(), }, }); logger.info(`컬럼 라벨 업데이트 완료: ${tableName}.${columnName}`); } catch (error) { logger.error( `컬럼 라벨 업데이트 실패: ${tableName}.${columnName}`, error ); throw new Error( `컬럼 라벨 업데이트 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } // ======================================== // 🎯 하이브리드 조인 전략 구현 // ======================================== /** * 하이브리드 조인 실행: 일부는 조인, 일부는 캐시 룩업 */ private async executeHybridJoin( tableName: string, joinConfigs: EntityJoinConfig[], selectColumns: string[], whereClause: string, orderBy: string, limit: number, offset: number, startTime: number ): Promise { try { logger.info(`🔀 하이브리드 조인 실행: ${tableName}`); // 각 조인 설정을 캐시 가능 여부에 따라 분류 const { cacheableJoins, dbJoins } = await this.categorizeJoins(joinConfigs); console.log( `📋 캐시 조인: ${cacheableJoins.length}개, DB 조인: ${dbJoins.length}개` ); // DB 조인이 있는 경우: 조인 쿼리 실행 후 캐시 룩업 적용 if (dbJoins.length > 0) { return await this.executeJoinThenCache( tableName, dbJoins, cacheableJoins, selectColumns, whereClause, orderBy, limit, offset, startTime ); } // 모든 조인이 캐시 가능한 경우: 기본 쿼리 + 캐시 룩업 else { return await this.executeCachedLookup( tableName, cacheableJoins, { page: Math.floor(offset / limit) + 1, size: limit, search: {} }, startTime ); } } catch (error) { logger.error("하이브리드 조인 실행 실패", error); throw error; } } /** * 조인 설정을 캐시 가능 여부에 따라 분류 */ private async categorizeJoins(joinConfigs: EntityJoinConfig[]): Promise<{ cacheableJoins: EntityJoinConfig[]; dbJoins: EntityJoinConfig[]; }> { const cacheableJoins: EntityJoinConfig[] = []; const dbJoins: EntityJoinConfig[] = []; for (const config of joinConfigs) { // 캐시 가능성 확인 const cachedData = await referenceCacheService.getCachedReference( config.referenceTable, config.referenceColumn, config.displayColumn ); if (cachedData && cachedData.size > 0) { cacheableJoins.push(config); console.log( `📋 캐시 사용: ${config.referenceTable} (${cachedData.size}건)` ); } else { dbJoins.push(config); console.log(`🔗 DB 조인: ${config.referenceTable}`); } } return { cacheableJoins, dbJoins }; } /** * DB 조인 실행 후 캐시 룩업 적용 */ private async executeJoinThenCache( tableName: string, dbJoins: EntityJoinConfig[], cacheableJoins: EntityJoinConfig[], selectColumns: string[], whereClause: string, orderBy: string, limit: number, offset: number, startTime: number ): Promise { // 1. DB 조인 먼저 실행 const joinResult = await this.executeJoinQuery( tableName, dbJoins, selectColumns, whereClause, orderBy, limit, offset, startTime ); // 2. 캐시 가능한 조인들을 결과에 추가 적용 if (cacheableJoins.length > 0) { const enhancedData = await this.applyCacheLookupToData( joinResult.data, cacheableJoins ); return { ...joinResult, data: enhancedData, entityJoinInfo: { ...joinResult.entityJoinInfo!, strategy: "hybrid", performance: { ...joinResult.entityJoinInfo!.performance, cacheHitRate: await this.calculateCacheHitRate(cacheableJoins), hybridBreakdown: { dbJoins: dbJoins.length, cacheJoins: cacheableJoins.length, }, }, }, }; } return joinResult; } /** * 데이터에 캐시 룩업 적용 */ private async applyCacheLookupToData( data: any[], cacheableJoins: EntityJoinConfig[] ): Promise { const enhancedData = [...data]; for (const config of cacheableJoins) { const cachedData = await referenceCacheService.getCachedReference( config.referenceTable, config.referenceColumn, config.displayColumn ); if (cachedData) { enhancedData.forEach((row) => { const keyValue = row[config.sourceColumn]; if (keyValue) { const lookupValue = cachedData.get(String(keyValue)); // null이나 undefined인 경우 빈 문자열로 설정 row[config.aliasColumn] = lookupValue || ""; } else { // sourceValue가 없는 경우도 빈 문자열로 설정 row[config.aliasColumn] = ""; } }); } else { // 캐시가 없는 경우 모든 행에 빈 문자열 설정 enhancedData.forEach((row) => { row[config.aliasColumn] = ""; }); } } return enhancedData; } /** * 캐시 적중률 계산 */ private async calculateCacheHitRate( cacheableJoins: EntityJoinConfig[] ): Promise { if (cacheableJoins.length === 0) return 0; let totalHitRate = 0; for (const config of cacheableJoins) { const hitRate = referenceCacheService.getCacheHitRate( config.referenceTable, config.referenceColumn, config.displayColumn ); totalHitRate += hitRate; } return totalHitRate / cacheableJoins.length; } }