테이블리스트 조인기능 구현

This commit is contained in:
kjs
2025-09-16 15:13:00 +09:00
parent 9a38c2aea9
commit 4a644f06c5
16 changed files with 2822 additions and 1560 deletions

View File

@@ -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);

View 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();

View 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;

View 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();

View 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();

View File

@@ -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

View File

@@ -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"}`
);
}
}
}

View File

@@ -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;