314 lines
8.5 KiB
TypeScript
314 lines
8.5 KiB
TypeScript
|
|
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();
|