조인기능 최적화

This commit is contained in:
kjs
2025-09-16 16:53:03 +09:00
parent 6a3a7b915d
commit 1d05965a55
8 changed files with 1082 additions and 201 deletions

View File

@@ -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);
}