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>(); 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 { 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(); 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 { const responses: BatchLookupResponse[] = []; const missingLookups = new Map(); // 캐시에서 먼저 조회 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 { 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();