조인기능 최적화
This commit is contained in:
@@ -234,7 +234,10 @@ export class EntityJoinController {
|
||||
caches: cacheInfo,
|
||||
summary: {
|
||||
totalCaches: cacheInfo.length,
|
||||
totalSize: cacheInfo.reduce((sum, cache) => sum + cache.size, 0),
|
||||
totalSize: cacheInfo.reduce(
|
||||
(sum, cache) => sum + cache.dataSize,
|
||||
0
|
||||
),
|
||||
averageHitRate:
|
||||
cacheInfo.length > 0
|
||||
? cacheInfo.reduce((sum, cache) => sum + cache.hitRate, 0) /
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
BatchLookupRequest,
|
||||
BatchLookupResponse,
|
||||
} from "../types/tableManagement";
|
||||
import { referenceCacheService } from "./referenceCacheService";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -151,6 +152,44 @@ export class EntityJoinService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 조인 전략 결정 (테이블 크기 기반)
|
||||
*/
|
||||
async determineJoinStrategy(
|
||||
joinConfigs: EntityJoinConfig[]
|
||||
): Promise<"full_join" | "cache_lookup" | "hybrid"> {
|
||||
try {
|
||||
const strategies = await Promise.all(
|
||||
joinConfigs.map(async (config) => {
|
||||
// 참조 테이블의 캐시 가능성 확인
|
||||
const cachedData = await referenceCacheService.getCachedReference(
|
||||
config.referenceTable,
|
||||
config.referenceColumn,
|
||||
config.displayColumn
|
||||
);
|
||||
|
||||
return cachedData ? "cache" : "join";
|
||||
})
|
||||
);
|
||||
|
||||
// 모두 캐시 가능한 경우
|
||||
if (strategies.every((s) => s === "cache")) {
|
||||
return "cache_lookup";
|
||||
}
|
||||
|
||||
// 혼합인 경우
|
||||
if (strategies.includes("cache") && strategies.includes("join")) {
|
||||
return "hybrid";
|
||||
}
|
||||
|
||||
// 기본은 조인
|
||||
return "full_join";
|
||||
} catch (error) {
|
||||
logger.error("조인 전략 결정 실패", error);
|
||||
return "full_join"; // 안전한 기본값
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 조인 설정 유효성 검증
|
||||
*/
|
||||
@@ -263,35 +302,6 @@ export class EntityJoinService {
|
||||
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();
|
||||
|
||||
@@ -7,75 +7,236 @@ import {
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface CacheEntry {
|
||||
data: Map<string, any>;
|
||||
expiry: number;
|
||||
size: number;
|
||||
stats: { hits: number; misses: number; created: Date };
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조 테이블 데이터 캐싱 서비스
|
||||
* 향상된 참조 테이블 데이터 캐싱 서비스
|
||||
* 작은 참조 테이블의 성능 최적화를 위한 메모리 캐시
|
||||
* - TTL 기반 만료 관리
|
||||
* - 테이블 크기 기반 자동 전략 선택
|
||||
* - 메모리 사용량 최적화
|
||||
* - 배경 갱신 지원
|
||||
*/
|
||||
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
|
||||
private cache = new Map<string, CacheEntry>();
|
||||
private loadingPromises = new Map<string, Promise<Map<string, any>>>();
|
||||
|
||||
// 설정값들
|
||||
private readonly SMALL_TABLE_THRESHOLD = 1000; // 1000건 이하는 전체 캐싱
|
||||
private readonly MEDIUM_TABLE_THRESHOLD = 5000; // 5000건 이하는 선택적 캐싱
|
||||
private readonly TTL = 10 * 60 * 1000; // 10분 TTL
|
||||
private readonly BACKGROUND_REFRESH_THRESHOLD = 0.8; // TTL의 80% 지점에서 배경 갱신
|
||||
private readonly MAX_MEMORY_MB = 50; // 최대 50MB 메모리 사용
|
||||
|
||||
/**
|
||||
* 작은 참조 테이블 전체 캐싱
|
||||
* 테이블 크기 조회
|
||||
*/
|
||||
async preloadReferenceTable(
|
||||
tableName: string,
|
||||
keyColumn: string,
|
||||
displayColumn: string
|
||||
): Promise<void> {
|
||||
private async getTableRowCount(tableName: string): Promise<number> {
|
||||
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);
|
||||
return Number(countResult[0]?.count || 0);
|
||||
} catch (error) {
|
||||
logger.error(`테이블 크기 조회 실패: ${tableName}`, error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (count > this.MAX_CACHE_SIZE) {
|
||||
logger.warn(`테이블이 너무 큼, 캐싱 건너뜀: ${tableName} (${count}건)`);
|
||||
return;
|
||||
/**
|
||||
* 캐시 전략 결정
|
||||
*/
|
||||
private determineCacheStrategy(
|
||||
rowCount: number
|
||||
): "full_cache" | "selective_cache" | "no_cache" {
|
||||
if (rowCount <= this.SMALL_TABLE_THRESHOLD) {
|
||||
return "full_cache";
|
||||
} else if (rowCount <= this.MEDIUM_TABLE_THRESHOLD) {
|
||||
return "selective_cache";
|
||||
} else {
|
||||
return "no_cache";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조 테이블 캐시 조회 (자동 로딩 포함)
|
||||
*/
|
||||
async getCachedReference(
|
||||
tableName: string,
|
||||
keyColumn: string,
|
||||
displayColumn: string
|
||||
): Promise<Map<string, any> | null> {
|
||||
const cacheKey = `${tableName}.${keyColumn}.${displayColumn}`;
|
||||
const cached = this.cache.get(cacheKey);
|
||||
const now = Date.now();
|
||||
|
||||
// 캐시가 있고 만료되지 않았으면 반환
|
||||
if (cached && cached.expiry > now) {
|
||||
cached.stats.hits++;
|
||||
|
||||
// 배경 갱신 체크 (TTL의 80% 지점)
|
||||
const age = now - cached.stats.created.getTime();
|
||||
if (age > this.TTL * this.BACKGROUND_REFRESH_THRESHOLD) {
|
||||
// 배경에서 갱신 시작 (비동기)
|
||||
this.refreshCacheInBackground(
|
||||
tableName,
|
||||
keyColumn,
|
||||
displayColumn
|
||||
).catch((err) => logger.warn(`배경 캐시 갱신 실패: ${cacheKey}`, err));
|
||||
}
|
||||
|
||||
// 데이터 조회 및 캐싱
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// 이미 로딩 중인 경우 기존 Promise 반환
|
||||
if (this.loadingPromises.has(cacheKey)) {
|
||||
return await this.loadingPromises.get(cacheKey)!;
|
||||
}
|
||||
|
||||
// 테이블 크기 확인 후 전략 결정
|
||||
const rowCount = await this.getTableRowCount(tableName);
|
||||
const strategy = this.determineCacheStrategy(rowCount);
|
||||
|
||||
if (strategy === "no_cache") {
|
||||
logger.debug(
|
||||
`테이블이 너무 큼, 캐싱하지 않음: ${tableName} (${rowCount}건)`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 새로운 데이터 로드
|
||||
const loadPromise = this.loadReferenceData(
|
||||
tableName,
|
||||
keyColumn,
|
||||
displayColumn
|
||||
);
|
||||
this.loadingPromises.set(cacheKey, loadPromise);
|
||||
|
||||
try {
|
||||
const result = await loadPromise;
|
||||
return result;
|
||||
} finally {
|
||||
this.loadingPromises.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 참조 데이터 로드
|
||||
*/
|
||||
private async loadReferenceData(
|
||||
tableName: string,
|
||||
keyColumn: string,
|
||||
displayColumn: string
|
||||
): Promise<Map<string, any>> {
|
||||
const cacheKey = `${tableName}.${keyColumn}.${displayColumn}`;
|
||||
|
||||
try {
|
||||
logger.info(`참조 테이블 캐싱 시작: ${tableName}`);
|
||||
|
||||
// 데이터 조회
|
||||
const data = (await prisma.$queryRawUnsafe(`
|
||||
SELECT ${keyColumn} as key, ${displayColumn} as value
|
||||
FROM ${tableName}
|
||||
WHERE ${keyColumn} IS NOT NULL
|
||||
AND ${displayColumn} IS NOT NULL
|
||||
ORDER BY ${keyColumn}
|
||||
`)) as Array<{ key: any; value: any }>;
|
||||
|
||||
const tableCache = new Map<string, any>();
|
||||
|
||||
const dataMap = new Map<string, any>();
|
||||
for (const row of data) {
|
||||
tableCache.set(String(row.key), row.value);
|
||||
dataMap.set(String(row.key), row.value);
|
||||
}
|
||||
|
||||
// 캐시 저장
|
||||
const cacheKey = `${tableName}.${keyColumn}.${displayColumn}`;
|
||||
this.cache.set(cacheKey, tableCache);
|
||||
// 메모리 사용량 계산 (근사치)
|
||||
const estimatedSize = data.length * 50; // 대략 50바이트 per row
|
||||
|
||||
// 통계 초기화
|
||||
this.cacheStats.set(cacheKey, {
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
lastUpdated: new Date(),
|
||||
// 캐시에 저장
|
||||
this.cache.set(cacheKey, {
|
||||
data: dataMap,
|
||||
expiry: Date.now() + this.TTL,
|
||||
size: estimatedSize,
|
||||
stats: { hits: 0, misses: 0, created: new Date() },
|
||||
});
|
||||
|
||||
logger.info(`참조 테이블 캐싱 완료: ${tableName} (${data.length}건)`);
|
||||
logger.info(
|
||||
`참조 테이블 캐싱 완료: ${tableName} (${data.length}건, ~${Math.round(estimatedSize / 1024)}KB)`
|
||||
);
|
||||
|
||||
// 메모리 사용량 체크
|
||||
this.checkMemoryUsage();
|
||||
|
||||
return dataMap;
|
||||
} catch (error) {
|
||||
logger.error(`참조 테이블 캐싱 실패: ${tableName}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시에서 참조 값 조회
|
||||
* 배경에서 캐시 갱신
|
||||
*/
|
||||
private async refreshCacheInBackground(
|
||||
tableName: string,
|
||||
keyColumn: string,
|
||||
displayColumn: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.debug(`배경 캐시 갱신 시작: ${tableName}`);
|
||||
await this.loadReferenceData(tableName, keyColumn, displayColumn);
|
||||
logger.debug(`배경 캐시 갱신 완료: ${tableName}`);
|
||||
} catch (error) {
|
||||
logger.warn(`배경 캐시 갱신 실패: ${tableName}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메모리 사용량 체크 및 정리
|
||||
*/
|
||||
private checkMemoryUsage(): void {
|
||||
const totalSize = Array.from(this.cache.values()).reduce(
|
||||
(sum, entry) => sum + entry.size,
|
||||
0
|
||||
);
|
||||
const totalSizeMB = totalSize / (1024 * 1024);
|
||||
|
||||
if (totalSizeMB > this.MAX_MEMORY_MB) {
|
||||
logger.warn(
|
||||
`캐시 메모리 사용량 초과: ${totalSizeMB.toFixed(2)}MB / ${this.MAX_MEMORY_MB}MB`
|
||||
);
|
||||
this.evictLeastUsedCaches();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 가장 적게 사용된 캐시 제거
|
||||
*/
|
||||
private evictLeastUsedCaches(): void {
|
||||
const entries = Array.from(this.cache.entries())
|
||||
.map(([key, entry]) => ({
|
||||
key,
|
||||
entry,
|
||||
score:
|
||||
entry.stats.hits / Math.max(entry.stats.hits + entry.stats.misses, 1), // 히트율
|
||||
}))
|
||||
.sort((a, b) => a.score - b.score); // 낮은 히트율부터
|
||||
|
||||
const toEvict = Math.ceil(entries.length * 0.3); // 30% 제거
|
||||
for (let i = 0; i < toEvict && i < entries.length; i++) {
|
||||
this.cache.delete(entries[i].key);
|
||||
logger.debug(
|
||||
`캐시 제거됨: ${entries[i].key} (히트율: ${(entries[i].score * 100).toFixed(1)}%)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시에서 참조 값 조회 (동기식)
|
||||
*/
|
||||
getLookupValue(
|
||||
table: string,
|
||||
@@ -84,27 +245,24 @@ export class ReferenceCacheService {
|
||||
key: string
|
||||
): any | null {
|
||||
const cacheKey = `${table}.${keyColumn}.${displayColumn}`;
|
||||
const tableCache = this.cache.get(cacheKey);
|
||||
const cached = this.cache.get(cacheKey);
|
||||
|
||||
if (!tableCache) {
|
||||
this.updateCacheStats(cacheKey, false);
|
||||
if (!cached || cached.expiry < Date.now()) {
|
||||
// 캐시 미스 또는 만료
|
||||
if (cached) {
|
||||
cached.stats.misses++;
|
||||
}
|
||||
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);
|
||||
const value = cached.data.get(String(key));
|
||||
if (value !== undefined) {
|
||||
cached.stats.hits++;
|
||||
return value;
|
||||
} else {
|
||||
cached.stats.misses++;
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = tableCache.get(String(key));
|
||||
this.updateCacheStats(cacheKey, value !== undefined);
|
||||
|
||||
return value || null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,23 +332,6 @@ export class ReferenceCacheService {
|
||||
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++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 적중률 조회
|
||||
*/
|
||||
@@ -200,13 +341,13 @@ export class ReferenceCacheService {
|
||||
displayColumn: string
|
||||
): number {
|
||||
const cacheKey = `${table}.${keyColumn}.${displayColumn}`;
|
||||
const stats = this.cacheStats.get(cacheKey);
|
||||
const cached = this.cache.get(cacheKey);
|
||||
|
||||
if (!stats || stats.hits + stats.misses === 0) {
|
||||
if (!cached || cached.stats.hits + cached.stats.misses === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return stats.hits / (stats.hits + stats.misses);
|
||||
return cached.stats.hits / (cached.stats.hits + cached.stats.misses);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,9 +357,9 @@ export class ReferenceCacheService {
|
||||
let totalHits = 0;
|
||||
let totalRequests = 0;
|
||||
|
||||
for (const stats of this.cacheStats.values()) {
|
||||
totalHits += stats.hits;
|
||||
totalRequests += stats.hits + stats.misses;
|
||||
for (const entry of this.cache.values()) {
|
||||
totalHits += entry.stats.hits;
|
||||
totalRequests += entry.stats.hits + entry.stats.misses;
|
||||
}
|
||||
|
||||
return totalRequests > 0 ? totalHits / totalRequests : 0;
|
||||
@@ -235,49 +376,94 @@ export class ReferenceCacheService {
|
||||
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;
|
||||
dataSize: number;
|
||||
memorySizeKB: number;
|
||||
hitRate: number;
|
||||
lastUpdated: Date;
|
||||
expiresIn: number;
|
||||
created: Date;
|
||||
strategy: string;
|
||||
}> {
|
||||
const info: Array<{
|
||||
cacheKey: string;
|
||||
size: number;
|
||||
dataSize: number;
|
||||
memorySizeKB: number;
|
||||
hitRate: number;
|
||||
lastUpdated: Date;
|
||||
expiresIn: number;
|
||||
created: Date;
|
||||
strategy: string;
|
||||
}> = [];
|
||||
|
||||
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;
|
||||
const now = Date.now();
|
||||
|
||||
for (const [cacheKey, entry] of this.cache) {
|
||||
const hitRate =
|
||||
entry.stats.hits + entry.stats.misses > 0
|
||||
? entry.stats.hits / (entry.stats.hits + entry.stats.misses)
|
||||
: 0;
|
||||
|
||||
const expiresIn = Math.max(0, entry.expiry - now);
|
||||
|
||||
info.push({
|
||||
cacheKey,
|
||||
size: tableCache.size,
|
||||
dataSize: entry.data.size,
|
||||
memorySizeKB: Math.round(entry.size / 1024),
|
||||
hitRate,
|
||||
lastUpdated: stats?.lastUpdated || new Date(),
|
||||
expiresIn,
|
||||
created: entry.stats.created,
|
||||
strategy:
|
||||
entry.data.size <= this.SMALL_TABLE_THRESHOLD
|
||||
? "full_cache"
|
||||
: "selective_cache",
|
||||
});
|
||||
}
|
||||
|
||||
return info;
|
||||
return info.sort((a, b) => b.hitRate - a.hitRate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 성능 요약 정보
|
||||
*/
|
||||
getCachePerformanceSummary(): {
|
||||
totalCaches: number;
|
||||
totalMemoryKB: number;
|
||||
overallHitRate: number;
|
||||
expiredCaches: number;
|
||||
averageAge: number;
|
||||
} {
|
||||
const now = Date.now();
|
||||
let totalMemory = 0;
|
||||
let expiredCount = 0;
|
||||
let totalAge = 0;
|
||||
|
||||
for (const entry of this.cache.values()) {
|
||||
totalMemory += entry.size;
|
||||
if (entry.expiry < now) {
|
||||
expiredCount++;
|
||||
}
|
||||
totalAge += now - entry.stats.created.getTime();
|
||||
}
|
||||
|
||||
return {
|
||||
totalCaches: this.cache.size,
|
||||
totalMemoryKB: Math.round(totalMemory / 1024),
|
||||
overallHitRate: this.getOverallCacheHitRate(),
|
||||
expiredCaches: expiredCount,
|
||||
averageAge:
|
||||
this.cache.size > 0 ? Math.round(totalAge / this.cache.size / 1000) : 0, // 초 단위
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -297,7 +483,7 @@ export class ReferenceCacheService {
|
||||
|
||||
for (const { table, key, display } of commonTables) {
|
||||
try {
|
||||
await this.preloadReferenceTable(table, key, display);
|
||||
await this.getCachedReference(table, key, display);
|
||||
} catch (error) {
|
||||
logger.warn(`공통 테이블 캐싱 실패 (무시함): ${table}`, error);
|
||||
}
|
||||
|
||||
@@ -1446,9 +1446,12 @@ export class TableManagementService {
|
||||
};
|
||||
}
|
||||
|
||||
// 조인 전략 결정
|
||||
// 조인 전략 결정 (테이블 크기 기반)
|
||||
const strategy =
|
||||
await entityJoinService.determineJoinStrategy(joinConfigs);
|
||||
console.log(
|
||||
`🎯 선택된 조인 전략: ${strategy} (${joinConfigs.length}개 Entity 조인)`
|
||||
);
|
||||
|
||||
// 테이블 컬럼 정보 조회
|
||||
const columns = await this.getTableColumns(tableName);
|
||||
@@ -1477,7 +1480,7 @@ export class TableManagementService {
|
||||
offset,
|
||||
startTime
|
||||
);
|
||||
} else {
|
||||
} else if (strategy === "cache_lookup") {
|
||||
// 캐시 룩업 방식
|
||||
return await this.executeCachedLookup(
|
||||
tableName,
|
||||
@@ -1485,6 +1488,18 @@ export class TableManagementService {
|
||||
options,
|
||||
startTime
|
||||
);
|
||||
} else {
|
||||
// 하이브리드 방식: 일부는 조인, 일부는 캐시
|
||||
return await this.executeHybridJoin(
|
||||
tableName,
|
||||
joinConfigs,
|
||||
selectColumns,
|
||||
whereClause,
|
||||
orderBy,
|
||||
options.size,
|
||||
offset,
|
||||
startTime
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Entity 조인 데이터 조회 실패: ${tableName}`, error);
|
||||
@@ -1585,7 +1600,7 @@ export class TableManagementService {
|
||||
try {
|
||||
// 캐시 데이터 미리 로드
|
||||
for (const config of joinConfigs) {
|
||||
await referenceCacheService.preloadReferenceTable(
|
||||
await referenceCacheService.getCachedReference(
|
||||
config.referenceTable,
|
||||
config.referenceColumn,
|
||||
config.displayColumn
|
||||
@@ -1766,4 +1781,200 @@ export class TableManagementService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 🎯 하이브리드 조인 전략 구현
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 하이브리드 조인 실행: 일부는 조인, 일부는 캐시 룩업
|
||||
*/
|
||||
private async executeHybridJoin(
|
||||
tableName: string,
|
||||
joinConfigs: EntityJoinConfig[],
|
||||
selectColumns: string[],
|
||||
whereClause: string,
|
||||
orderBy: string,
|
||||
limit: number,
|
||||
offset: number,
|
||||
startTime: number
|
||||
): Promise<EntityJoinResponse> {
|
||||
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<EntityJoinResponse> {
|
||||
// 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<any[]> {
|
||||
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));
|
||||
if (lookupValue) {
|
||||
row[config.aliasColumn] = lookupValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return enhancedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 적중률 계산
|
||||
*/
|
||||
private async calculateCacheHitRate(
|
||||
cacheableJoins: EntityJoinConfig[]
|
||||
): Promise<number> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,10 +90,14 @@ export interface EntityJoinResponse {
|
||||
totalPages: number;
|
||||
entityJoinInfo?: {
|
||||
joinConfigs: EntityJoinConfig[];
|
||||
strategy: "full_join" | "cache_lookup";
|
||||
strategy: "full_join" | "cache_lookup" | "hybrid";
|
||||
performance: {
|
||||
queryTime: number;
|
||||
cacheHitRate?: number;
|
||||
hybridBreakdown?: {
|
||||
dbJoins: number;
|
||||
cacheJoins: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user