501 lines
14 KiB
TypeScript
501 lines
14 KiB
TypeScript
import { query, queryOne } from "../database/db";
|
|
import { logger } from "../utils/logger";
|
|
import {
|
|
BatchLookupRequest,
|
|
BatchLookupResponse,
|
|
} from "../types/tableManagement";
|
|
|
|
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, 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 메모리 사용
|
|
|
|
/**
|
|
* 테이블 크기 조회
|
|
*/
|
|
private async getTableRowCount(tableName: string): Promise<number> {
|
|
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<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 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<string, any>();
|
|
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<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,
|
|
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<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 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<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.getCachedReference(table, key, display);
|
|
} catch (error) {
|
|
logger.warn(`공통 테이블 캐싱 실패 (무시함): ${table}`, error);
|
|
}
|
|
}
|
|
|
|
logger.info("공통 참조 테이블 자동 캐싱 완료");
|
|
} catch (error) {
|
|
logger.error("공통 참조 테이블 자동 캐싱 실패", error);
|
|
}
|
|
}
|
|
}
|
|
|
|
export const referenceCacheService = new ReferenceCacheService();
|