mirror of
https://github.com/asyouplz/SpeechNote.git
synced 2026-07-22 06:43:33 +00:00
* fix: address PR #8004 review feedback and improve type safety * fix: restore legacy password factors and fix formatting * fix: finalize PR feedback following Obsidian review compliance * fix: finalize PR feedback with technical refinements and obsidian compliance * fix: finalize PR refinements following Claude Code feedback and repo cleanup * fix: remove legacy migration logic and further simplify codebase for obsidian compliance * refactor: final cleanup of unused variables and improved type safety for PR #44 * style: fix formatting issues to resolve CI pipeline error
362 lines
8.5 KiB
TypeScript
362 lines
8.5 KiB
TypeScript
/**
|
|
* MemoryCache - Phase 4 Performance Optimization
|
|
*
|
|
* LRU (Least Recently Used) 캐시 구현
|
|
* - 메모리 효율적인 캐싱
|
|
* - TTL (Time To Live) 지원
|
|
* - 자동 만료 및 정리
|
|
*/
|
|
|
|
export interface CacheEntry<T> {
|
|
data: T;
|
|
timestamp: number;
|
|
accessCount: number;
|
|
size?: number;
|
|
}
|
|
|
|
export interface CacheOptions {
|
|
maxSize?: number; // 최대 항목 수
|
|
maxMemory?: number; // 최대 메모리 (bytes)
|
|
ttl?: number; // Time To Live (ms)
|
|
onEvict?: <T>(key: string, value: CacheEntry<T>) => void;
|
|
}
|
|
|
|
export interface CacheStats {
|
|
hits: number;
|
|
misses: number;
|
|
evictions: number;
|
|
size: number;
|
|
memoryUsage: number;
|
|
hitRate: number;
|
|
}
|
|
|
|
export class MemoryCache<T = unknown> {
|
|
private cache = new Map<string, CacheEntry<T>>();
|
|
private accessOrder: string[] = [];
|
|
private stats: CacheStats = {
|
|
hits: 0,
|
|
misses: 0,
|
|
evictions: 0,
|
|
size: 0,
|
|
memoryUsage: 0,
|
|
hitRate: 0,
|
|
};
|
|
|
|
private readonly maxSize: number;
|
|
private readonly maxMemory: number;
|
|
private readonly ttl: number;
|
|
private readonly onEvict?: <T>(key: string, value: CacheEntry<T>) => void;
|
|
private cleanupInterval?: number;
|
|
|
|
constructor(options: CacheOptions = {}) {
|
|
this.maxSize = options.maxSize || 100;
|
|
this.maxMemory = options.maxMemory || 10 * 1024 * 1024; // 10MB
|
|
this.ttl = options.ttl || 5 * 60 * 1000; // 5분
|
|
this.onEvict = options.onEvict;
|
|
|
|
// 주기적인 만료 항목 정리
|
|
this.startCleanup();
|
|
}
|
|
|
|
/**
|
|
* 캐시에서 값 가져오기
|
|
*/
|
|
get(key: string): T | undefined {
|
|
const entry = this.cache.get(key);
|
|
|
|
if (!entry) {
|
|
this.stats.misses++;
|
|
this.updateHitRate();
|
|
return undefined;
|
|
}
|
|
|
|
// TTL 체크
|
|
if (this.isExpired(entry)) {
|
|
this.delete(key);
|
|
this.stats.misses++;
|
|
this.updateHitRate();
|
|
return undefined;
|
|
}
|
|
|
|
// LRU 업데이트
|
|
this.updateAccessOrder(key);
|
|
entry.accessCount++;
|
|
|
|
this.stats.hits++;
|
|
this.updateHitRate();
|
|
|
|
return entry.data;
|
|
}
|
|
|
|
/**
|
|
* 캐시에 값 저장
|
|
*/
|
|
set(key: string, data: T, _ttl?: number): void {
|
|
const size = this.estimateSize(data);
|
|
|
|
// 메모리 제한 체크
|
|
if (size > this.maxMemory) {
|
|
console.warn(`Cache entry too large: ${key} (${size} bytes)`);
|
|
return;
|
|
}
|
|
|
|
// 기존 항목 삭제
|
|
if (this.cache.has(key)) {
|
|
this.delete(key);
|
|
}
|
|
|
|
// 공간 확보
|
|
this.makeSpace(size);
|
|
|
|
// 새 항목 추가
|
|
const entry: CacheEntry<T> = {
|
|
data,
|
|
timestamp: Date.now(),
|
|
accessCount: 0,
|
|
size,
|
|
};
|
|
|
|
this.cache.set(key, entry);
|
|
this.accessOrder.push(key);
|
|
this.stats.size++;
|
|
this.stats.memoryUsage += size;
|
|
}
|
|
|
|
/**
|
|
* 캐시에서 값 삭제
|
|
*/
|
|
delete(key: string): boolean {
|
|
const entry = this.cache.get(key);
|
|
if (!entry) return false;
|
|
|
|
this.cache.delete(key);
|
|
this.accessOrder = this.accessOrder.filter((k) => k !== key);
|
|
|
|
this.stats.size--;
|
|
this.stats.memoryUsage -= entry.size || 0;
|
|
|
|
if (this.onEvict) {
|
|
this.onEvict(key, entry);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 캐시 전체 삭제
|
|
*/
|
|
clear(): void {
|
|
this.cache.clear();
|
|
this.accessOrder = [];
|
|
this.stats = {
|
|
hits: 0,
|
|
misses: 0,
|
|
evictions: 0,
|
|
size: 0,
|
|
memoryUsage: 0,
|
|
hitRate: 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 패턴 매칭으로 캐시 무효화
|
|
*/
|
|
invalidate(pattern: string | RegExp): number {
|
|
let count = 0;
|
|
const regex =
|
|
typeof pattern === 'string' ? new RegExp(pattern.replace(/\*/g, '.*')) : pattern;
|
|
|
|
for (const key of this.cache.keys()) {
|
|
if (regex.test(key)) {
|
|
this.delete(key);
|
|
count++;
|
|
}
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
/**
|
|
* 캐시 키 존재 확인
|
|
*/
|
|
has(key: string): boolean {
|
|
const entry = this.cache.get(key);
|
|
if (!entry) return false;
|
|
|
|
if (this.isExpired(entry)) {
|
|
this.delete(key);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 모든 캐시 키 반환
|
|
*/
|
|
keys(): string[] {
|
|
return Array.from(this.cache.keys());
|
|
}
|
|
|
|
/**
|
|
* 캐시 통계 반환
|
|
*/
|
|
getStats(): CacheStats {
|
|
return { ...this.stats };
|
|
}
|
|
|
|
/**
|
|
* 캐시 상태 덤프 (디버깅용)
|
|
*/
|
|
dump(): Record<string, unknown> {
|
|
const entries: Record<string, unknown> = {};
|
|
|
|
for (const [key, entry] of this.cache.entries()) {
|
|
entries[key] = {
|
|
timestamp: entry.timestamp,
|
|
accessCount: entry.accessCount,
|
|
size: entry.size,
|
|
age: Date.now() - entry.timestamp,
|
|
data: entry.data,
|
|
};
|
|
}
|
|
|
|
return {
|
|
stats: this.getStats(),
|
|
entries,
|
|
config: {
|
|
maxSize: this.maxSize,
|
|
maxMemory: this.maxMemory,
|
|
ttl: this.ttl,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 공간 확보 (LRU 정책)
|
|
*/
|
|
private makeSpace(requiredSize: number): void {
|
|
// 크기 제한 체크
|
|
while (this.cache.size >= this.maxSize) {
|
|
this.evictLRU();
|
|
}
|
|
|
|
// 메모리 제한 체크
|
|
while (this.stats.memoryUsage + requiredSize > this.maxMemory) {
|
|
if (!this.evictLRU()) break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* LRU 항목 제거
|
|
*/
|
|
private evictLRU(): boolean {
|
|
if (this.accessOrder.length === 0) return false;
|
|
|
|
const key = this.accessOrder[0];
|
|
this.delete(key);
|
|
this.stats.evictions++;
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 접근 순서 업데이트
|
|
*/
|
|
private updateAccessOrder(key: string): void {
|
|
const index = this.accessOrder.indexOf(key);
|
|
if (index > -1) {
|
|
this.accessOrder.splice(index, 1);
|
|
}
|
|
this.accessOrder.push(key);
|
|
}
|
|
|
|
/**
|
|
* 만료 여부 확인
|
|
*/
|
|
private isExpired(entry: CacheEntry<T>): boolean {
|
|
return Date.now() - entry.timestamp > this.ttl;
|
|
}
|
|
|
|
/**
|
|
* 데이터 크기 추정
|
|
*/
|
|
private estimateSize(data: unknown): number {
|
|
try {
|
|
const str = JSON.stringify(data);
|
|
return str.length * 2; // UTF-16 기준
|
|
} catch {
|
|
return 1024; // 기본값 1KB
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hit rate 업데이트
|
|
*/
|
|
private updateHitRate(): void {
|
|
const total = this.stats.hits + this.stats.misses;
|
|
this.stats.hitRate = total > 0 ? this.stats.hits / total : 0;
|
|
}
|
|
|
|
/**
|
|
* 주기적인 만료 항목 정리
|
|
*/
|
|
private startCleanup(): void {
|
|
this.cleanupInterval = window.setInterval(() => {
|
|
for (const [key, entry] of this.cache.entries()) {
|
|
if (this.isExpired(entry)) {
|
|
this.delete(key);
|
|
}
|
|
}
|
|
}, 60000); // 1분마다
|
|
}
|
|
|
|
/**
|
|
* 정리 작업 중지
|
|
*/
|
|
destroy(): void {
|
|
if (this.cleanupInterval) {
|
|
clearInterval(this.cleanupInterval);
|
|
}
|
|
this.clear();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 전역 캐시 인스턴스
|
|
*/
|
|
export const globalCache = new MemoryCache({
|
|
maxSize: 200,
|
|
maxMemory: 20 * 1024 * 1024, // 20MB
|
|
ttl: 10 * 60 * 1000, // 10분
|
|
});
|
|
|
|
/**
|
|
* 캐시 데코레이터
|
|
*/
|
|
export function Cacheable(options: { ttl?: number; key?: string } = {}) {
|
|
return function (target: object, propertyName: string, descriptor: PropertyDescriptor) {
|
|
const originalMethod = descriptor.value;
|
|
|
|
descriptor.value = async function (...args: unknown[]) {
|
|
const cacheKey =
|
|
options.key || `${target.constructor.name}.${propertyName}:${JSON.stringify(args)}`;
|
|
|
|
// 캐시 확인
|
|
const cached = globalCache.get(cacheKey);
|
|
if (cached !== undefined) {
|
|
return cached;
|
|
}
|
|
|
|
// 원본 메서드 실행
|
|
const result = await originalMethod.apply(this, args);
|
|
|
|
// 결과 캐싱
|
|
globalCache.set(cacheKey, result, options.ttl);
|
|
|
|
return result;
|
|
};
|
|
|
|
return descriptor;
|
|
};
|
|
}
|