import { query, queryOne } from "../database/db"; import { logger } from "../utils/logger"; import { BatchLookupRequest, BatchLookupResponse, } from "../types/tableManagement"; interface CacheEntry { data: Map; expiry: number; size: number; stats: { hits: number; misses: number; created: Date }; } /** * 향상된 참조 테이블 데이터 캐싱 서비스 * 작은 참조 테이블의 성능 최적화를 위한 메모리 캐시 * - TTL 기반 만료 관리 * - 테이블 크기 기반 자동 전략 선택 * - 메모리 사용량 최적화 * - 배경 갱신 지원 */ export class ReferenceCacheService { private cache = new Map(); private loadingPromises = new Map>>(); // 설정값들 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 메모리 사용 /** * 테이블 크기 조회 */ private async getTableRowCount(tableName: string): Promise { try { const countResult = await query<{ count: string }>( `SELECT COUNT(*) as count FROM ${tableName}`, [] ); return parseInt(countResult[0]?.count || "0", 10); } catch (error) { logger.error(`테이블 크기 조회 실패: ${tableName}`, error); return 0; } } /** * 캐시 전략 결정 */ 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 | 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> { const cacheKey = `${tableName}.${keyColumn}.${displayColumn}`; try { logger.info(`참조 테이블 캐싱 시작: ${tableName}`); // 데이터 조회 const data = await query<{ key: any; value: any }>( `SELECT ${keyColumn} as key, ${displayColumn} as value FROM ${tableName} WHERE ${keyColumn} IS NOT NULL AND ${displayColumn} IS NOT NULL ORDER BY ${keyColumn}`, [] ); const dataMap = new Map(); for (const row of data) { dataMap.set(String(row.key), row.value); } // 메모리 사용량 계산 (근사치) const estimatedSize = data.length * 50; // 대략 50바이트 per row // 캐시에 저장 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}건, ~${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 { 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, keyColumn: string, displayColumn: string, key: string ): any | null { const cacheKey = `${table}.${keyColumn}.${displayColumn}`; const cached = this.cache.get(cacheKey); if (!cached || cached.expiry < Date.now()) { // 캐시 미스 또는 만료 if (cached) { cached.stats.misses++; } return null; } const value = cached.data.get(String(key)); if (value !== undefined) { cached.stats.hits++; return value; } else { cached.stats.misses++; return 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 query<{ key: any; value: any }>( `SELECT key_column as key, ${displayColumn} as value FROM ${tableName} WHERE key_column = ANY($1)`, [keys] ); // 결과를 응답에 추가 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; } /** * 캐시 적중률 조회 */ getCacheHitRate( table: string, keyColumn: string, displayColumn: string ): number { const cacheKey = `${table}.${keyColumn}.${displayColumn}`; const cached = this.cache.get(cacheKey); if (!cached || cached.stats.hits + cached.stats.misses === 0) { return 0; } return cached.stats.hits / (cached.stats.hits + cached.stats.misses); } /** * 전체 캐시 적중률 조회 */ getOverallCacheHitRate(): number { let totalHits = 0; let totalRequests = 0; for (const entry of this.cache.values()) { totalHits += entry.stats.hits; totalRequests += entry.stats.hits + entry.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); logger.info(`캐시 무효화: ${cacheKey}`); } else { // 전체 캐시 무효화 this.cache.clear(); logger.info("전체 캐시 무효화"); } } /** * 향상된 캐시 상태 조회 */ getCacheInfo(): Array<{ cacheKey: string; dataSize: number; memorySizeKB: number; hitRate: number; expiresIn: number; created: Date; strategy: string; }> { const info: Array<{ cacheKey: string; dataSize: number; memorySizeKB: number; hitRate: number; expiresIn: number; created: Date; strategy: string; }> = []; 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, dataSize: entry.data.size, memorySizeKB: Math.round(entry.size / 1024), hitRate, expiresIn, created: entry.stats.created, strategy: entry.data.size <= this.SMALL_TABLE_THRESHOLD ? "full_cache" : "selective_cache", }); } 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, // 초 단위 }; } /** * 자주 사용되는 참조 테이블들 자동 캐싱 */ 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.getCachedReference(table, key, display); } catch (error) { logger.warn(`공통 테이블 캐싱 실패 (무시함): ${table}`, error); } } logger.info("공통 참조 테이블 자동 캐싱 완료"); } catch (error) { logger.error("공통 참조 테이블 자동 캐싱 실패", error); } } } export const referenceCacheService = new ReferenceCacheService();