테이블리스트 조인기능 구현
This commit is contained in:
@@ -14,12 +14,13 @@ import authRoutes from "./routes/authRoutes";
|
||||
import adminRoutes from "./routes/adminRoutes";
|
||||
import multilangRoutes from "./routes/multilangRoutes";
|
||||
import tableManagementRoutes from "./routes/tableManagementRoutes";
|
||||
import entityJoinRoutes from "./routes/entityJoinRoutes";
|
||||
import screenManagementRoutes from "./routes/screenManagementRoutes";
|
||||
import commonCodeRoutes from "./routes/commonCodeRoutes";
|
||||
import dynamicFormRoutes from "./routes/dynamicFormRoutes";
|
||||
import fileRoutes from "./routes/fileRoutes";
|
||||
import companyManagementRoutes from "./routes/companyManagementRoutes";
|
||||
import dataflowRoutes from "./routes/dataflowRoutes";
|
||||
// import dataflowRoutes from "./routes/dataflowRoutes"; // 임시 주석
|
||||
import dataflowDiagramRoutes from "./routes/dataflowDiagramRoutes";
|
||||
import webTypeStandardRoutes from "./routes/webTypeStandardRoutes";
|
||||
import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes";
|
||||
@@ -105,12 +106,13 @@ app.use("/api/auth", authRoutes);
|
||||
app.use("/api/admin", adminRoutes);
|
||||
app.use("/api/multilang", multilangRoutes);
|
||||
app.use("/api/table-management", tableManagementRoutes);
|
||||
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
|
||||
app.use("/api/screen-management", screenManagementRoutes);
|
||||
app.use("/api/common-codes", commonCodeRoutes);
|
||||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||
app.use("/api/files", fileRoutes);
|
||||
app.use("/api/company-management", companyManagementRoutes);
|
||||
app.use("/api/dataflow", dataflowRoutes);
|
||||
// app.use("/api/dataflow", dataflowRoutes); // 임시 주석
|
||||
app.use("/api/dataflow-diagrams", dataflowDiagramRoutes);
|
||||
app.use("/api/admin/web-types", webTypeStandardRoutes);
|
||||
app.use("/api/admin/button-actions", buttonActionStandardRoutes);
|
||||
|
||||
326
backend-node/src/controllers/entityJoinController.ts
Normal file
326
backend-node/src/controllers/entityJoinController.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import { Request, Response } from "express";
|
||||
import { logger } from "../utils/logger";
|
||||
import { TableManagementService } from "../services/tableManagementService";
|
||||
import { entityJoinService } from "../services/entityJoinService";
|
||||
import { referenceCacheService } from "../services/referenceCacheService";
|
||||
|
||||
const tableManagementService = new TableManagementService();
|
||||
|
||||
/**
|
||||
* Entity 조인 기능 컨트롤러
|
||||
* ID값을 의미있는 데이터로 자동 변환하는 API 제공
|
||||
*/
|
||||
export class EntityJoinController {
|
||||
/**
|
||||
* Entity 조인이 포함된 테이블 데이터 조회
|
||||
* GET /api/table-management/tables/:tableName/data-with-joins
|
||||
*/
|
||||
async getTableDataWithJoins(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const {
|
||||
page = 1,
|
||||
size = 20,
|
||||
search,
|
||||
sortBy,
|
||||
sortOrder = "asc",
|
||||
enableEntityJoin = true,
|
||||
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
||||
...otherParams
|
||||
} = req.query;
|
||||
|
||||
logger.info(`Entity 조인 데이터 요청: ${tableName}`, {
|
||||
page,
|
||||
size,
|
||||
enableEntityJoin,
|
||||
search,
|
||||
});
|
||||
|
||||
// 검색 조건 처리
|
||||
let searchConditions: Record<string, any> = {};
|
||||
if (search) {
|
||||
try {
|
||||
// search가 문자열인 경우 JSON 파싱
|
||||
searchConditions =
|
||||
typeof search === "string" ? JSON.parse(search) : search;
|
||||
} catch (error) {
|
||||
logger.warn("검색 조건 파싱 오류:", error);
|
||||
searchConditions = {};
|
||||
}
|
||||
}
|
||||
|
||||
const result = await tableManagementService.getTableDataWithEntityJoins(
|
||||
tableName,
|
||||
{
|
||||
page: Number(page),
|
||||
size: Number(size),
|
||||
search:
|
||||
Object.keys(searchConditions).length > 0
|
||||
? searchConditions
|
||||
: undefined,
|
||||
sortBy: sortBy as string,
|
||||
sortOrder: sortOrder as string,
|
||||
enableEntityJoin:
|
||||
enableEntityJoin === "true" || enableEntityJoin === true,
|
||||
}
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "Entity 조인 데이터 조회 성공",
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Entity 조인 데이터 조회 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Entity 조인 데이터 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 Entity 조인 설정 조회
|
||||
* GET /api/table-management/tables/:tableName/entity-joins
|
||||
*/
|
||||
async getEntityJoinConfigs(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
|
||||
logger.info(`Entity 조인 설정 조회: ${tableName}`);
|
||||
|
||||
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "Entity 조인 설정 조회 성공",
|
||||
data: {
|
||||
tableName,
|
||||
joinConfigs,
|
||||
count: joinConfigs.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Entity 조인 설정 조회 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Entity 조인 설정 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조 테이블의 표시 가능한 컬럼 목록 조회
|
||||
* GET /api/table-management/reference-tables/:tableName/columns
|
||||
*/
|
||||
async getReferenceTableColumns(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
|
||||
logger.info(`참조 테이블 컬럼 조회: ${tableName}`);
|
||||
|
||||
const columns =
|
||||
await tableManagementService.getReferenceTableColumns(tableName);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "참조 테이블 컬럼 조회 성공",
|
||||
data: {
|
||||
tableName,
|
||||
columns,
|
||||
count: columns.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("참조 테이블 컬럼 조회 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "참조 테이블 컬럼 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 Entity 설정 업데이트 (display_column 포함)
|
||||
* PUT /api/table-management/tables/:tableName/columns/:columnName/entity-settings
|
||||
*/
|
||||
async updateEntitySettings(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName, columnName } = req.params;
|
||||
const {
|
||||
webType,
|
||||
referenceTable,
|
||||
referenceColumn,
|
||||
displayColumn,
|
||||
columnLabel,
|
||||
description,
|
||||
} = req.body;
|
||||
|
||||
logger.info(`Entity 설정 업데이트: ${tableName}.${columnName}`, req.body);
|
||||
|
||||
// Entity 타입인 경우 필수 필드 검증
|
||||
if (webType === "entity") {
|
||||
if (!referenceTable || !referenceColumn) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"Entity 타입의 경우 referenceTable과 referenceColumn이 필수입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await tableManagementService.updateColumnLabel(tableName, columnName, {
|
||||
webType,
|
||||
referenceTable,
|
||||
referenceColumn,
|
||||
displayColumn,
|
||||
columnLabel,
|
||||
description,
|
||||
});
|
||||
|
||||
// Entity 설정 변경 시 관련 캐시 무효화
|
||||
if (webType === "entity" && referenceTable) {
|
||||
referenceCacheService.invalidateCache(
|
||||
referenceTable,
|
||||
referenceColumn,
|
||||
displayColumn
|
||||
);
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "Entity 설정 업데이트 성공",
|
||||
data: {
|
||||
tableName,
|
||||
columnName,
|
||||
settings: {
|
||||
webType,
|
||||
referenceTable,
|
||||
referenceColumn,
|
||||
displayColumn,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Entity 설정 업데이트 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Entity 설정 업데이트 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 상태 조회
|
||||
* GET /api/table-management/cache/status
|
||||
*/
|
||||
async getCacheStatus(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
logger.info("캐시 상태 조회");
|
||||
|
||||
const cacheInfo = referenceCacheService.getCacheInfo();
|
||||
const overallHitRate = referenceCacheService.getOverallCacheHitRate();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "캐시 상태 조회 성공",
|
||||
data: {
|
||||
overallHitRate,
|
||||
caches: cacheInfo,
|
||||
summary: {
|
||||
totalCaches: cacheInfo.length,
|
||||
totalSize: cacheInfo.reduce((sum, cache) => sum + cache.size, 0),
|
||||
averageHitRate:
|
||||
cacheInfo.length > 0
|
||||
? cacheInfo.reduce((sum, cache) => sum + cache.hitRate, 0) /
|
||||
cacheInfo.length
|
||||
: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("캐시 상태 조회 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "캐시 상태 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 무효화
|
||||
* DELETE /api/table-management/cache
|
||||
*/
|
||||
async invalidateCache(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { table, keyColumn, displayColumn } = req.query;
|
||||
|
||||
logger.info("캐시 무효화 요청", { table, keyColumn, displayColumn });
|
||||
|
||||
if (table && keyColumn && displayColumn) {
|
||||
// 특정 캐시만 무효화
|
||||
referenceCacheService.invalidateCache(
|
||||
table as string,
|
||||
keyColumn as string,
|
||||
displayColumn as string
|
||||
);
|
||||
} else {
|
||||
// 전체 캐시 무효화
|
||||
referenceCacheService.invalidateCache();
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "캐시 무효화 완료",
|
||||
data: {
|
||||
target: table ? `${table}.${keyColumn}.${displayColumn}` : "전체",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("캐시 무효화 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "캐시 무효화 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 공통 참조 테이블 자동 캐싱
|
||||
* POST /api/table-management/cache/preload
|
||||
*/
|
||||
async preloadCommonCaches(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
logger.info("공통 참조 테이블 자동 캐싱 시작");
|
||||
|
||||
await referenceCacheService.autoPreloadCommonTables();
|
||||
|
||||
const cacheInfo = referenceCacheService.getCacheInfo();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "공통 참조 테이블 캐싱 완료",
|
||||
data: {
|
||||
preloadedCaches: cacheInfo.length,
|
||||
caches: cacheInfo,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("공통 참조 테이블 캐싱 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "공통 참조 테이블 캐싱 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const entityJoinController = new EntityJoinController();
|
||||
235
backend-node/src/routes/entityJoinRoutes.ts
Normal file
235
backend-node/src/routes/entityJoinRoutes.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { Router } from "express";
|
||||
import { entityJoinController } from "../controllers/entityJoinController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용 (테스트 시에는 주석 처리)
|
||||
// router.use(authenticateToken);
|
||||
|
||||
/**
|
||||
* Entity 조인 기능 라우트
|
||||
*
|
||||
* 🎯 핵심 기능:
|
||||
* - Entity 조인이 포함된 테이블 데이터 조회
|
||||
* - Entity 조인 설정 관리
|
||||
* - 참조 테이블 컬럼 정보 조회
|
||||
* - 캐시 상태 및 관리
|
||||
*/
|
||||
|
||||
// ========================================
|
||||
// 🎯 Entity 조인 데이터 조회
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Entity 조인이 포함된 테이블 데이터 조회
|
||||
* GET /api/table-management/tables/:tableName/data-with-joins
|
||||
*
|
||||
* Query Parameters:
|
||||
* - page: 페이지 번호 (default: 1)
|
||||
* - size: 페이지 크기 (default: 20)
|
||||
* - sortBy: 정렬 컬럼
|
||||
* - sortOrder: 정렬 순서 (asc/desc)
|
||||
* - enableEntityJoin: Entity 조인 활성화 (default: true)
|
||||
* - [기타]: 검색 조건 (컬럼명=값)
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: true,
|
||||
* data: {
|
||||
* data: [...], // 조인된 데이터
|
||||
* total: 100,
|
||||
* page: 1,
|
||||
* size: 20,
|
||||
* totalPages: 5,
|
||||
* entityJoinInfo?: {
|
||||
* joinConfigs: [...],
|
||||
* strategy: "full_join" | "cache_lookup",
|
||||
* performance: { queryTime: 50, cacheHitRate?: 0.95 }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
router.get(
|
||||
"/tables/:tableName/data-with-joins",
|
||||
entityJoinController.getTableDataWithJoins.bind(entityJoinController)
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 🎯 Entity 조인 설정 관리
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 테이블의 Entity 조인 설정 조회
|
||||
* GET /api/table-management/tables/:tableName/entity-joins
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: true,
|
||||
* data: {
|
||||
* tableName: "companies",
|
||||
* joinConfigs: [
|
||||
* {
|
||||
* sourceTable: "companies",
|
||||
* sourceColumn: "writer",
|
||||
* referenceTable: "user_info",
|
||||
* referenceColumn: "user_id",
|
||||
* displayColumn: "user_name",
|
||||
* aliasColumn: "writer_name"
|
||||
* }
|
||||
* ],
|
||||
* count: 1
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
router.get(
|
||||
"/tables/:tableName/entity-joins",
|
||||
entityJoinController.getEntityJoinConfigs.bind(entityJoinController)
|
||||
);
|
||||
|
||||
/**
|
||||
* 컬럼 Entity 설정 업데이트
|
||||
* PUT /api/table-management/tables/:tableName/columns/:columnName/entity-settings
|
||||
*
|
||||
* Body:
|
||||
* {
|
||||
* webType: "entity",
|
||||
* referenceTable: "user_info",
|
||||
* referenceColumn: "user_id",
|
||||
* displayColumn: "user_name", // 🎯 새로 추가된 필드
|
||||
* columnLabel?: "작성자",
|
||||
* description?: "작성자 정보"
|
||||
* }
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: true,
|
||||
* data: {
|
||||
* tableName: "companies",
|
||||
* columnName: "writer",
|
||||
* settings: { ... }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
router.put(
|
||||
"/tables/:tableName/columns/:columnName/entity-settings",
|
||||
entityJoinController.updateEntitySettings.bind(entityJoinController)
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 🎯 참조 테이블 정보
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 참조 테이블의 표시 가능한 컬럼 목록 조회
|
||||
* GET /api/table-management/reference-tables/:tableName/columns
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: true,
|
||||
* data: {
|
||||
* tableName: "user_info",
|
||||
* columns: [
|
||||
* {
|
||||
* columnName: "user_id",
|
||||
* displayName: "user_id",
|
||||
* dataType: "character varying"
|
||||
* },
|
||||
* {
|
||||
* columnName: "user_name",
|
||||
* displayName: "user_name",
|
||||
* dataType: "character varying"
|
||||
* }
|
||||
* ],
|
||||
* count: 2
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
router.get(
|
||||
"/reference-tables/:tableName/columns",
|
||||
entityJoinController.getReferenceTableColumns.bind(entityJoinController)
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 🎯 캐시 관리
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 캐시 상태 조회
|
||||
* GET /api/table-management/cache/status
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: true,
|
||||
* data: {
|
||||
* overallHitRate: 0.95,
|
||||
* caches: [
|
||||
* {
|
||||
* cacheKey: "user_info.user_id.user_name",
|
||||
* size: 150,
|
||||
* hitRate: 0.98,
|
||||
* lastUpdated: "2024-01-15T10:30:00Z"
|
||||
* }
|
||||
* ],
|
||||
* summary: {
|
||||
* totalCaches: 3,
|
||||
* totalSize: 450,
|
||||
* averageHitRate: 0.93
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
router.get(
|
||||
"/cache/status",
|
||||
entityJoinController.getCacheStatus.bind(entityJoinController)
|
||||
);
|
||||
|
||||
/**
|
||||
* 캐시 무효화
|
||||
* DELETE /api/table-management/cache
|
||||
*
|
||||
* Query Parameters (선택적):
|
||||
* - table: 특정 테이블 캐시만 무효화
|
||||
* - keyColumn: 키 컬럼
|
||||
* - displayColumn: 표시 컬럼
|
||||
*
|
||||
* 모든 파라미터가 없으면 전체 캐시 무효화
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: true,
|
||||
* data: {
|
||||
* target: "user_info.user_id.user_name" | "전체"
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
router.delete(
|
||||
"/cache",
|
||||
entityJoinController.invalidateCache.bind(entityJoinController)
|
||||
);
|
||||
|
||||
/**
|
||||
* 공통 참조 테이블 자동 캐싱
|
||||
* POST /api/table-management/cache/preload
|
||||
*
|
||||
* 일반적으로 자주 사용되는 참조 테이블들을 자동으로 캐싱
|
||||
* - user_info (사용자 정보)
|
||||
* - comm_code (공통 코드)
|
||||
* - dept_info (부서 정보)
|
||||
* - companies (회사 정보)
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: true,
|
||||
* data: {
|
||||
* preloadedCaches: 4,
|
||||
* caches: [...]
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
router.post(
|
||||
"/cache/preload",
|
||||
entityJoinController.preloadCommonCaches.bind(entityJoinController)
|
||||
);
|
||||
|
||||
export default router;
|
||||
297
backend-node/src/services/entityJoinService.ts
Normal file
297
backend-node/src/services/entityJoinService.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { logger } from "../utils/logger";
|
||||
import {
|
||||
EntityJoinConfig,
|
||||
BatchLookupRequest,
|
||||
BatchLookupResponse,
|
||||
} from "../types/tableManagement";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Entity 조인 기능을 제공하는 서비스
|
||||
* ID값을 의미있는 데이터로 자동 변환하는 스마트 테이블 시스템
|
||||
*/
|
||||
export class EntityJoinService {
|
||||
/**
|
||||
* 테이블의 Entity 컬럼들을 감지하여 조인 설정 생성
|
||||
*/
|
||||
async detectEntityJoins(tableName: string): Promise<EntityJoinConfig[]> {
|
||||
try {
|
||||
logger.info(`Entity 컬럼 감지 시작: ${tableName}`);
|
||||
|
||||
// column_labels에서 entity 타입인 컬럼들 조회
|
||||
const entityColumns = await prisma.column_labels.findMany({
|
||||
where: {
|
||||
table_name: tableName,
|
||||
web_type: "entity",
|
||||
reference_table: { not: null },
|
||||
reference_column: { not: null },
|
||||
},
|
||||
select: {
|
||||
column_name: true,
|
||||
reference_table: true,
|
||||
reference_column: true,
|
||||
display_column: true,
|
||||
},
|
||||
});
|
||||
|
||||
const joinConfigs: EntityJoinConfig[] = [];
|
||||
|
||||
for (const column of entityColumns) {
|
||||
if (
|
||||
!column.column_name ||
|
||||
!column.reference_table ||
|
||||
!column.reference_column
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// display_column이 없으면 reference_column 사용
|
||||
const displayColumn = column.display_column || column.reference_column;
|
||||
|
||||
// 별칭 컬럼명 생성 (writer -> writer_name)
|
||||
const aliasColumn = `${column.column_name}_name`;
|
||||
|
||||
const joinConfig: EntityJoinConfig = {
|
||||
sourceTable: tableName,
|
||||
sourceColumn: column.column_name,
|
||||
referenceTable: column.reference_table,
|
||||
referenceColumn: column.reference_column,
|
||||
displayColumn: displayColumn,
|
||||
aliasColumn: aliasColumn,
|
||||
};
|
||||
|
||||
// 조인 설정 유효성 검증
|
||||
if (await this.validateJoinConfig(joinConfig)) {
|
||||
joinConfigs.push(joinConfig);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Entity 조인 설정 생성 완료: ${joinConfigs.length}개`);
|
||||
return joinConfigs;
|
||||
} catch (error) {
|
||||
logger.error(`Entity 조인 감지 실패: ${tableName}`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity 조인이 포함된 SQL 쿼리 생성
|
||||
*/
|
||||
buildJoinQuery(
|
||||
tableName: string,
|
||||
joinConfigs: EntityJoinConfig[],
|
||||
selectColumns: string[],
|
||||
whereClause: string = "",
|
||||
orderBy: string = "",
|
||||
limit?: number,
|
||||
offset?: number
|
||||
): string {
|
||||
try {
|
||||
// 기본 SELECT 컬럼들
|
||||
const baseColumns = selectColumns.map((col) => `main.${col}`).join(", ");
|
||||
|
||||
// Entity 조인 컬럼들
|
||||
const joinColumns = joinConfigs
|
||||
.map(
|
||||
(config) =>
|
||||
`${config.referenceTable.substring(0, 3)}.${config.displayColumn} AS ${config.aliasColumn}`
|
||||
)
|
||||
.join(", ");
|
||||
|
||||
// SELECT 절 구성
|
||||
const selectClause = joinColumns
|
||||
? `${baseColumns}, ${joinColumns}`
|
||||
: baseColumns;
|
||||
|
||||
// FROM 절 (메인 테이블)
|
||||
const fromClause = `FROM ${tableName} main`;
|
||||
|
||||
// LEFT JOIN 절들
|
||||
const joinClauses = joinConfigs
|
||||
.map((config, index) => {
|
||||
const alias = config.referenceTable.substring(0, 3); // user_info -> use, companies -> com
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
// WHERE 절
|
||||
const whereSQL = whereClause ? `WHERE ${whereClause}` : "";
|
||||
|
||||
// ORDER BY 절
|
||||
const orderSQL = orderBy ? `ORDER BY ${orderBy}` : "";
|
||||
|
||||
// LIMIT 및 OFFSET
|
||||
let limitSQL = "";
|
||||
if (limit !== undefined) {
|
||||
limitSQL = `LIMIT ${limit}`;
|
||||
if (offset !== undefined) {
|
||||
limitSQL += ` OFFSET ${offset}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 최종 쿼리 조합
|
||||
const query = [
|
||||
`SELECT ${selectClause}`,
|
||||
fromClause,
|
||||
joinClauses,
|
||||
whereSQL,
|
||||
orderSQL,
|
||||
limitSQL,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
logger.debug(`생성된 Entity 조인 쿼리:`, query);
|
||||
return query;
|
||||
} catch (error) {
|
||||
logger.error("Entity 조인 쿼리 생성 실패", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 조인 설정 유효성 검증
|
||||
*/
|
||||
private async validateJoinConfig(config: EntityJoinConfig): Promise<boolean> {
|
||||
try {
|
||||
// 참조 테이블 존재 확인
|
||||
const tableExists = await prisma.$queryRaw`
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = ${config.referenceTable}
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
if (!Array.isArray(tableExists) || tableExists.length === 0) {
|
||||
logger.warn(`참조 테이블이 존재하지 않음: ${config.referenceTable}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 참조 컬럼 존재 확인
|
||||
const columnExists = await prisma.$queryRaw`
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = ${config.referenceTable}
|
||||
AND column_name = ${config.displayColumn}
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
if (!Array.isArray(columnExists) || columnExists.length === 0) {
|
||||
logger.warn(
|
||||
`표시 컬럼이 존재하지 않음: ${config.referenceTable}.${config.displayColumn}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error("조인 설정 검증 실패", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카운트 쿼리 생성 (페이징용)
|
||||
*/
|
||||
buildCountQuery(
|
||||
tableName: string,
|
||||
joinConfigs: EntityJoinConfig[],
|
||||
whereClause: string = ""
|
||||
): string {
|
||||
try {
|
||||
// JOIN 절들 (COUNT에서는 SELECT 컬럼 불필요)
|
||||
const joinClauses = joinConfigs
|
||||
.map((config, index) => {
|
||||
const alias = config.referenceTable.substring(0, 3);
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
// WHERE 절
|
||||
const whereSQL = whereClause ? `WHERE ${whereClause}` : "";
|
||||
|
||||
// COUNT 쿼리 조합
|
||||
const query = [
|
||||
`SELECT COUNT(*) as total`,
|
||||
`FROM ${tableName} main`,
|
||||
joinClauses,
|
||||
whereSQL,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return query;
|
||||
} catch (error) {
|
||||
logger.error("COUNT 쿼리 생성 실패", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조 테이블의 컬럼 목록 조회 (UI용)
|
||||
*/
|
||||
async getReferenceTableColumns(tableName: string): Promise<
|
||||
Array<{
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
dataType: string;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const columns = (await prisma.$queryRaw`
|
||||
SELECT
|
||||
column_name,
|
||||
column_name as display_name,
|
||||
data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = ${tableName}
|
||||
AND data_type IN ('character varying', 'varchar', 'text', 'char')
|
||||
ORDER BY ordinal_position
|
||||
`) as Array<{
|
||||
column_name: string;
|
||||
display_name: string;
|
||||
data_type: string;
|
||||
}>;
|
||||
|
||||
return columns.map((col) => ({
|
||||
columnName: col.column_name,
|
||||
displayName: col.display_name,
|
||||
dataType: col.data_type,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error(`참조 테이블 컬럼 조회 실패: ${tableName}`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity 조인 전략 결정 (full_join vs cache_lookup)
|
||||
*/
|
||||
async determineJoinStrategy(
|
||||
joinConfigs: EntityJoinConfig[]
|
||||
): Promise<"full_join" | "cache_lookup"> {
|
||||
try {
|
||||
// 참조 테이블 크기 확인
|
||||
for (const config of joinConfigs) {
|
||||
const result = (await prisma.$queryRawUnsafe(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM ${config.referenceTable}
|
||||
`)) as Array<{ count: bigint }>;
|
||||
|
||||
const count = Number(result[0]?.count || 0);
|
||||
|
||||
// 1000건 이상이면 조인 방식 사용
|
||||
if (count > 1000) {
|
||||
return "full_join";
|
||||
}
|
||||
}
|
||||
|
||||
return "cache_lookup";
|
||||
} catch (error) {
|
||||
logger.error("조인 전략 결정 실패", error);
|
||||
return "full_join"; // 기본값
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const entityJoinService = new EntityJoinService();
|
||||
313
backend-node/src/services/referenceCacheService.ts
Normal file
313
backend-node/src/services/referenceCacheService.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { logger } from "../utils/logger";
|
||||
import {
|
||||
BatchLookupRequest,
|
||||
BatchLookupResponse,
|
||||
} from "../types/tableManagement";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* 참조 테이블 데이터 캐싱 서비스
|
||||
* 작은 참조 테이블의 성능 최적화를 위한 메모리 캐시
|
||||
*/
|
||||
export class ReferenceCacheService {
|
||||
private cache = new Map<string, Map<string, any>>();
|
||||
private cacheStats = new Map<
|
||||
string,
|
||||
{ hits: number; misses: number; lastUpdated: Date }
|
||||
>();
|
||||
private readonly MAX_CACHE_SIZE = 1000; // 테이블당 최대 캐시 크기
|
||||
private readonly CACHE_TTL = 5 * 60 * 1000; // 5분 TTL
|
||||
|
||||
/**
|
||||
* 작은 참조 테이블 전체 캐싱
|
||||
*/
|
||||
async preloadReferenceTable(
|
||||
tableName: string,
|
||||
keyColumn: string,
|
||||
displayColumn: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info(`참조 테이블 캐싱 시작: ${tableName}`);
|
||||
|
||||
// 테이블 크기 확인
|
||||
const countResult = (await prisma.$queryRawUnsafe(`
|
||||
SELECT COUNT(*) as count FROM ${tableName}
|
||||
`)) as Array<{ count: bigint }>;
|
||||
|
||||
const count = Number(countResult[0]?.count || 0);
|
||||
|
||||
if (count > this.MAX_CACHE_SIZE) {
|
||||
logger.warn(`테이블이 너무 큼, 캐싱 건너뜀: ${tableName} (${count}건)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 조회 및 캐싱
|
||||
const data = (await prisma.$queryRawUnsafe(`
|
||||
SELECT ${keyColumn} as key, ${displayColumn} as value
|
||||
FROM ${tableName}
|
||||
WHERE ${keyColumn} IS NOT NULL
|
||||
AND ${displayColumn} IS NOT NULL
|
||||
`)) as Array<{ key: any; value: any }>;
|
||||
|
||||
const tableCache = new Map<string, any>();
|
||||
|
||||
for (const row of data) {
|
||||
tableCache.set(String(row.key), row.value);
|
||||
}
|
||||
|
||||
// 캐시 저장
|
||||
const cacheKey = `${tableName}.${keyColumn}.${displayColumn}`;
|
||||
this.cache.set(cacheKey, tableCache);
|
||||
|
||||
// 통계 초기화
|
||||
this.cacheStats.set(cacheKey, {
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
lastUpdated: new Date(),
|
||||
});
|
||||
|
||||
logger.info(`참조 테이블 캐싱 완료: ${tableName} (${data.length}건)`);
|
||||
} catch (error) {
|
||||
logger.error(`참조 테이블 캐싱 실패: ${tableName}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시에서 참조 값 조회
|
||||
*/
|
||||
getLookupValue(
|
||||
table: string,
|
||||
keyColumn: string,
|
||||
displayColumn: string,
|
||||
key: string
|
||||
): any | null {
|
||||
const cacheKey = `${table}.${keyColumn}.${displayColumn}`;
|
||||
const tableCache = this.cache.get(cacheKey);
|
||||
|
||||
if (!tableCache) {
|
||||
this.updateCacheStats(cacheKey, false);
|
||||
return null;
|
||||
}
|
||||
|
||||
// TTL 확인
|
||||
const stats = this.cacheStats.get(cacheKey);
|
||||
if (stats && Date.now() - stats.lastUpdated.getTime() > this.CACHE_TTL) {
|
||||
logger.debug(`캐시 TTL 만료: ${cacheKey}`);
|
||||
this.cache.delete(cacheKey);
|
||||
this.cacheStats.delete(cacheKey);
|
||||
this.updateCacheStats(cacheKey, false);
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = tableCache.get(String(key));
|
||||
this.updateCacheStats(cacheKey, value !== undefined);
|
||||
|
||||
return value || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 룩업 (성능 최적화)
|
||||
*/
|
||||
async batchLookup(
|
||||
requests: BatchLookupRequest[]
|
||||
): Promise<BatchLookupResponse[]> {
|
||||
const responses: BatchLookupResponse[] = [];
|
||||
const missingLookups = new Map<string, BatchLookupRequest[]>();
|
||||
|
||||
// 캐시에서 먼저 조회
|
||||
for (const request of requests) {
|
||||
const cacheKey = `${request.table}.${request.key}.${request.displayColumn}`;
|
||||
const value = this.getLookupValue(
|
||||
request.table,
|
||||
request.key,
|
||||
request.displayColumn,
|
||||
request.key
|
||||
);
|
||||
|
||||
if (value !== null) {
|
||||
responses.push({ key: request.key, value });
|
||||
} else {
|
||||
// 캐시 미스 - DB 조회 필요
|
||||
if (!missingLookups.has(request.table)) {
|
||||
missingLookups.set(request.table, []);
|
||||
}
|
||||
missingLookups.get(request.table)!.push(request);
|
||||
}
|
||||
}
|
||||
|
||||
// 캐시 미스된 항목들 DB에서 조회
|
||||
for (const [tableName, missingRequests] of missingLookups) {
|
||||
try {
|
||||
const keys = missingRequests.map((req) => req.key);
|
||||
const displayColumn = missingRequests[0].displayColumn; // 같은 테이블이므로 동일
|
||||
|
||||
const data = (await prisma.$queryRaw`
|
||||
SELECT key_column as key, ${displayColumn} as value
|
||||
FROM ${tableName}
|
||||
WHERE key_column = ANY(${keys})
|
||||
`) as Array<{ key: any; value: any }>;
|
||||
|
||||
// 결과를 응답에 추가
|
||||
for (const row of data) {
|
||||
responses.push({ key: String(row.key), value: row.value });
|
||||
}
|
||||
|
||||
// 없는 키들은 null로 응답
|
||||
const foundKeys = new Set(data.map((row) => String(row.key)));
|
||||
for (const req of missingRequests) {
|
||||
if (!foundKeys.has(req.key)) {
|
||||
responses.push({ key: req.key, value: null });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`배치 룩업 실패: ${tableName}`, error);
|
||||
|
||||
// 에러 발생 시 null로 응답
|
||||
for (const req of missingRequests) {
|
||||
responses.push({ key: req.key, value: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return responses;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 통계 업데이트
|
||||
*/
|
||||
private updateCacheStats(cacheKey: string, isHit: boolean): void {
|
||||
let stats = this.cacheStats.get(cacheKey);
|
||||
if (!stats) {
|
||||
stats = { hits: 0, misses: 0, lastUpdated: new Date() };
|
||||
this.cacheStats.set(cacheKey, stats);
|
||||
}
|
||||
|
||||
if (isHit) {
|
||||
stats.hits++;
|
||||
} else {
|
||||
stats.misses++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 적중률 조회
|
||||
*/
|
||||
getCacheHitRate(
|
||||
table: string,
|
||||
keyColumn: string,
|
||||
displayColumn: string
|
||||
): number {
|
||||
const cacheKey = `${table}.${keyColumn}.${displayColumn}`;
|
||||
const stats = this.cacheStats.get(cacheKey);
|
||||
|
||||
if (!stats || stats.hits + stats.misses === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return stats.hits / (stats.hits + stats.misses);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 캐시 적중률 조회
|
||||
*/
|
||||
getOverallCacheHitRate(): number {
|
||||
let totalHits = 0;
|
||||
let totalRequests = 0;
|
||||
|
||||
for (const stats of this.cacheStats.values()) {
|
||||
totalHits += stats.hits;
|
||||
totalRequests += stats.hits + stats.misses;
|
||||
}
|
||||
|
||||
return totalRequests > 0 ? totalHits / totalRequests : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 무효화
|
||||
*/
|
||||
invalidateCache(
|
||||
table?: string,
|
||||
keyColumn?: string,
|
||||
displayColumn?: string
|
||||
): void {
|
||||
if (table && keyColumn && displayColumn) {
|
||||
const cacheKey = `${table}.${keyColumn}.${displayColumn}`;
|
||||
this.cache.delete(cacheKey);
|
||||
this.cacheStats.delete(cacheKey);
|
||||
logger.info(`캐시 무효화: ${cacheKey}`);
|
||||
} else {
|
||||
// 전체 캐시 무효화
|
||||
this.cache.clear();
|
||||
this.cacheStats.clear();
|
||||
logger.info("전체 캐시 무효화");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 상태 조회
|
||||
*/
|
||||
getCacheInfo(): Array<{
|
||||
cacheKey: string;
|
||||
size: number;
|
||||
hitRate: number;
|
||||
lastUpdated: Date;
|
||||
}> {
|
||||
const info: Array<{
|
||||
cacheKey: string;
|
||||
size: number;
|
||||
hitRate: number;
|
||||
lastUpdated: Date;
|
||||
}> = [];
|
||||
|
||||
for (const [cacheKey, tableCache] of this.cache) {
|
||||
const stats = this.cacheStats.get(cacheKey);
|
||||
const hitRate = stats
|
||||
? stats.hits + stats.misses > 0
|
||||
? stats.hits / (stats.hits + stats.misses)
|
||||
: 0
|
||||
: 0;
|
||||
|
||||
info.push({
|
||||
cacheKey,
|
||||
size: tableCache.size,
|
||||
hitRate,
|
||||
lastUpdated: stats?.lastUpdated || new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 자주 사용되는 참조 테이블들 자동 캐싱
|
||||
*/
|
||||
async autoPreloadCommonTables(): Promise<void> {
|
||||
try {
|
||||
logger.info("공통 참조 테이블 자동 캐싱 시작");
|
||||
|
||||
// 일반적인 참조 테이블들
|
||||
const commonTables = [
|
||||
{ table: "user_info", key: "user_id", display: "user_name" },
|
||||
{ table: "comm_code", key: "code_id", display: "code_name" },
|
||||
{ table: "dept_info", key: "dept_code", display: "dept_name" },
|
||||
{ table: "companies", key: "company_code", display: "company_name" },
|
||||
];
|
||||
|
||||
for (const { table, key, display } of commonTables) {
|
||||
try {
|
||||
await this.preloadReferenceTable(table, key, display);
|
||||
} catch (error) {
|
||||
logger.warn(`공통 테이블 캐싱 실패 (무시함): ${table}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("공통 참조 테이블 자동 캐싱 완료");
|
||||
} catch (error) {
|
||||
logger.error("공통 참조 테이블 자동 캐싱 실패", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const referenceCacheService = new ReferenceCacheService();
|
||||
@@ -1401,6 +1401,7 @@ export class ScreenManagementService {
|
||||
cl.code_category,
|
||||
cl.reference_table,
|
||||
cl.reference_column,
|
||||
cl.display_column,
|
||||
cl.is_visible,
|
||||
cl.display_order,
|
||||
cl.description
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
ColumnSettings,
|
||||
TableLabels,
|
||||
ColumnLabels,
|
||||
EntityJoinResponse,
|
||||
EntityJoinConfig,
|
||||
} from "../types/tableManagement";
|
||||
import { entityJoinService } from "./entityJoinService";
|
||||
import { referenceCacheService } from "./referenceCacheService";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -139,6 +143,7 @@ export class TableManagementService {
|
||||
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
|
||||
@@ -285,6 +290,7 @@ export class TableManagementService {
|
||||
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,
|
||||
@@ -300,6 +306,7 @@ export class TableManagementService {
|
||||
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,
|
||||
@@ -1388,4 +1395,375 @@ export class TableManagementService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 🎯 Entity 조인 기능
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Entity 조인이 포함된 데이터 조회
|
||||
*/
|
||||
async getTableDataWithEntityJoins(
|
||||
tableName: string,
|
||||
options: {
|
||||
page: number;
|
||||
size: number;
|
||||
search?: Record<string, any>;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
enableEntityJoin?: boolean;
|
||||
}
|
||||
): Promise<EntityJoinResponse> {
|
||||
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 조인 설정 감지
|
||||
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
||||
|
||||
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);
|
||||
|
||||
// 테이블 컬럼 정보 조회
|
||||
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 {
|
||||
// 캐시 룩업 방식
|
||||
return await this.executeCachedLookup(
|
||||
tableName,
|
||||
joinConfigs,
|
||||
options,
|
||||
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<EntityJoinResponse> {
|
||||
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<string, any>;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
},
|
||||
startTime: number
|
||||
): Promise<EntityJoinResponse> {
|
||||
try {
|
||||
// 캐시 데이터 미리 로드
|
||||
for (const config of joinConfigs) {
|
||||
await referenceCacheService.preloadReferenceTable(
|
||||
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)
|
||||
);
|
||||
|
||||
enhancedRow[config.aliasColumn] = lookupValue || sourceValue;
|
||||
}
|
||||
}
|
||||
|
||||
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, any>): 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<ColumnLabels>
|
||||
): Promise<void> {
|
||||
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"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface ColumnTypeInfo {
|
||||
codeValue?: string;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
displayOrder?: number;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
@@ -39,6 +40,7 @@ export interface ColumnSettings {
|
||||
codeValue: string; // 코드 값
|
||||
referenceTable: string; // 참조 테이블
|
||||
referenceColumn: string; // 참조 컬럼
|
||||
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
displayOrder?: number; // 표시 순서
|
||||
isVisible?: boolean; // 표시 여부
|
||||
}
|
||||
@@ -65,10 +67,48 @@ export interface ColumnLabels {
|
||||
codeValue?: string;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
createdDate?: Date;
|
||||
updatedDate?: Date;
|
||||
}
|
||||
|
||||
// 🎯 Entity 조인 관련 타입 정의
|
||||
export interface EntityJoinConfig {
|
||||
sourceTable: string; // companies
|
||||
sourceColumn: string; // writer
|
||||
referenceTable: string; // user_info
|
||||
referenceColumn: string; // user_id (조인 키)
|
||||
displayColumn: string; // user_name (표시할 값)
|
||||
aliasColumn: string; // writer_name (결과 컬럼명)
|
||||
}
|
||||
|
||||
export interface EntityJoinResponse {
|
||||
data: Record<string, any>[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
entityJoinInfo?: {
|
||||
joinConfigs: EntityJoinConfig[];
|
||||
strategy: "full_join" | "cache_lookup";
|
||||
performance: {
|
||||
queryTime: number;
|
||||
cacheHitRate?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface BatchLookupRequest {
|
||||
table: string;
|
||||
key: string;
|
||||
displayColumn: string;
|
||||
}
|
||||
|
||||
export interface BatchLookupResponse {
|
||||
key: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
// API 응답 타입
|
||||
export interface TableListResponse {
|
||||
success: boolean;
|
||||
|
||||
Reference in New Issue
Block a user