주요 수정사항: - 하이라이트 생성 시 color → highlight_color 필드명 수정으로 색상 전달 문제 해결 - 분홍색을 더 연하게 변경하여 글씨 가독성 향상 - 다중 하이라이트 렌더링을 위아래 균등 분할로 개선 - CSS highlight-span 클래스 추가 및 색상 적용 강화 - 하이라이트 생성/렌더링 과정에 상세한 디버깅 로그 추가 UI 개선: - 단일 하이라이트: 선택한 색상으로 정확히 표시 - 다중 하이라이트: 위아래로 균등하게 색상 분할 표시 - 메모 입력 모달에서 선택된 텍스트 표시 개선 버그 수정: - 프론트엔드-백엔드 API 스키마 불일치 해결 - CSS 스타일 우선순위 문제 해결 - 하이라이트 색상이 노랑색으로만 표시되던 문제 해결
397 lines
12 KiB
JavaScript
397 lines
12 KiB
JavaScript
/**
|
|
* CacheManager - 데이터 캐싱 및 로컬 스토리지 관리
|
|
* API 응답, 문서 데이터, 사용자 설정 등을 효율적으로 캐싱합니다.
|
|
*/
|
|
class CacheManager {
|
|
constructor() {
|
|
console.log('💾 CacheManager 초기화 시작');
|
|
|
|
// 캐시 설정
|
|
this.config = {
|
|
// 캐시 만료 시간 (밀리초)
|
|
ttl: {
|
|
document: 30 * 60 * 1000, // 문서: 30분
|
|
highlights: 10 * 60 * 1000, // 하이라이트: 10분
|
|
notes: 10 * 60 * 1000, // 메모: 10분
|
|
bookmarks: 15 * 60 * 1000, // 북마크: 15분
|
|
links: 15 * 60 * 1000, // 링크: 15분
|
|
navigation: 60 * 60 * 1000, // 네비게이션: 1시간
|
|
userSettings: 24 * 60 * 60 * 1000 // 사용자 설정: 24시간
|
|
},
|
|
// 캐시 키 접두사
|
|
prefix: 'docviewer_',
|
|
// 최대 캐시 크기 (항목 수)
|
|
maxItems: 100,
|
|
// 로컬 스토리지 사용 여부
|
|
useLocalStorage: true
|
|
};
|
|
|
|
// 메모리 캐시 (빠른 접근용)
|
|
this.memoryCache = new Map();
|
|
|
|
// 캐시 통계
|
|
this.stats = {
|
|
hits: 0,
|
|
misses: 0,
|
|
sets: 0,
|
|
evictions: 0
|
|
};
|
|
|
|
// 초기화 시 오래된 캐시 정리
|
|
this.cleanupExpiredCache();
|
|
|
|
console.log('✅ CacheManager 초기화 완료');
|
|
}
|
|
|
|
/**
|
|
* 캐시에서 데이터 가져오기
|
|
*/
|
|
get(key, category = 'default') {
|
|
const fullKey = this.getFullKey(key, category);
|
|
|
|
// 1. 메모리 캐시에서 먼저 확인
|
|
if (this.memoryCache.has(fullKey)) {
|
|
const cached = this.memoryCache.get(fullKey);
|
|
if (this.isValid(cached)) {
|
|
this.stats.hits++;
|
|
console.log(`💾 메모리 캐시 HIT: ${fullKey}`);
|
|
return cached.data;
|
|
} else {
|
|
// 만료된 캐시 제거
|
|
this.memoryCache.delete(fullKey);
|
|
}
|
|
}
|
|
|
|
// 2. 로컬 스토리지에서 확인
|
|
if (this.config.useLocalStorage) {
|
|
try {
|
|
const stored = localStorage.getItem(fullKey);
|
|
if (stored) {
|
|
const cached = JSON.parse(stored);
|
|
if (this.isValid(cached)) {
|
|
// 메모리 캐시에도 저장
|
|
this.memoryCache.set(fullKey, cached);
|
|
this.stats.hits++;
|
|
console.log(`💾 로컬 스토리지 캐시 HIT: ${fullKey}`);
|
|
return cached.data;
|
|
} else {
|
|
// 만료된 캐시 제거
|
|
localStorage.removeItem(fullKey);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('로컬 스토리지 읽기 오류:', error);
|
|
}
|
|
}
|
|
|
|
this.stats.misses++;
|
|
console.log(`💾 캐시 MISS: ${fullKey}`);
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 캐시에 데이터 저장
|
|
*/
|
|
set(key, data, category = 'default', customTtl = null) {
|
|
const fullKey = this.getFullKey(key, category);
|
|
const ttl = customTtl || this.config.ttl[category] || this.config.ttl.default || 10 * 60 * 1000;
|
|
|
|
const cached = {
|
|
data: data,
|
|
timestamp: Date.now(),
|
|
ttl: ttl,
|
|
category: category,
|
|
size: this.estimateSize(data)
|
|
};
|
|
|
|
// 메모리 캐시에 저장
|
|
this.memoryCache.set(fullKey, cached);
|
|
|
|
// 로컬 스토리지에 저장
|
|
if (this.config.useLocalStorage) {
|
|
try {
|
|
localStorage.setItem(fullKey, JSON.stringify(cached));
|
|
} catch (error) {
|
|
console.warn('로컬 스토리지 저장 오류 (용량 부족?):', error);
|
|
// 용량 부족 시 오래된 캐시 정리 후 재시도
|
|
this.cleanupOldCache();
|
|
try {
|
|
localStorage.setItem(fullKey, JSON.stringify(cached));
|
|
} catch (retryError) {
|
|
console.error('로컬 스토리지 저장 재시도 실패:', retryError);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.stats.sets++;
|
|
console.log(`💾 캐시 저장: ${fullKey} (TTL: ${ttl}ms)`);
|
|
|
|
// 캐시 크기 제한 확인
|
|
this.enforceMaxItems();
|
|
}
|
|
|
|
/**
|
|
* 특정 키 또는 카테고리의 캐시 삭제
|
|
*/
|
|
delete(key, category = 'default') {
|
|
const fullKey = this.getFullKey(key, category);
|
|
|
|
// 메모리 캐시에서 삭제
|
|
this.memoryCache.delete(fullKey);
|
|
|
|
// 로컬 스토리지에서 삭제
|
|
if (this.config.useLocalStorage) {
|
|
localStorage.removeItem(fullKey);
|
|
}
|
|
|
|
console.log(`💾 캐시 삭제: ${fullKey}`);
|
|
}
|
|
|
|
/**
|
|
* 카테고리별 캐시 전체 삭제
|
|
*/
|
|
deleteCategory(category) {
|
|
const prefix = this.getFullKey('', category);
|
|
|
|
// 메모리 캐시에서 삭제
|
|
for (const key of this.memoryCache.keys()) {
|
|
if (key.startsWith(prefix)) {
|
|
this.memoryCache.delete(key);
|
|
}
|
|
}
|
|
|
|
// 로컬 스토리지에서 삭제
|
|
if (this.config.useLocalStorage) {
|
|
for (let i = localStorage.length - 1; i >= 0; i--) {
|
|
const key = localStorage.key(i);
|
|
if (key && key.startsWith(prefix)) {
|
|
localStorage.removeItem(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`💾 카테고리 캐시 삭제: ${category}`);
|
|
}
|
|
|
|
/**
|
|
* 모든 캐시 삭제
|
|
*/
|
|
clear() {
|
|
// 메모리 캐시 삭제
|
|
this.memoryCache.clear();
|
|
|
|
// 로컬 스토리지에서 관련 캐시만 삭제
|
|
if (this.config.useLocalStorage) {
|
|
for (let i = localStorage.length - 1; i >= 0; i--) {
|
|
const key = localStorage.key(i);
|
|
if (key && key.startsWith(this.config.prefix)) {
|
|
localStorage.removeItem(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 통계 초기화
|
|
this.stats = { hits: 0, misses: 0, sets: 0, evictions: 0 };
|
|
|
|
console.log('💾 모든 캐시 삭제 완료');
|
|
}
|
|
|
|
/**
|
|
* 캐시 유효성 검사
|
|
*/
|
|
isValid(cached) {
|
|
if (!cached || !cached.timestamp || !cached.ttl) {
|
|
return false;
|
|
}
|
|
|
|
const age = Date.now() - cached.timestamp;
|
|
return age < cached.ttl;
|
|
}
|
|
|
|
/**
|
|
* 만료된 캐시 정리
|
|
*/
|
|
cleanupExpiredCache() {
|
|
console.log('🧹 만료된 캐시 정리 시작');
|
|
|
|
let cleanedCount = 0;
|
|
|
|
// 메모리 캐시 정리
|
|
for (const [key, cached] of this.memoryCache.entries()) {
|
|
if (!this.isValid(cached)) {
|
|
this.memoryCache.delete(key);
|
|
cleanedCount++;
|
|
}
|
|
}
|
|
|
|
// 로컬 스토리지 정리
|
|
if (this.config.useLocalStorage) {
|
|
for (let i = localStorage.length - 1; i >= 0; i--) {
|
|
const key = localStorage.key(i);
|
|
if (key && key.startsWith(this.config.prefix)) {
|
|
try {
|
|
const stored = localStorage.getItem(key);
|
|
if (stored) {
|
|
const cached = JSON.parse(stored);
|
|
if (!this.isValid(cached)) {
|
|
localStorage.removeItem(key);
|
|
cleanedCount++;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// 파싱 오류 시 해당 캐시 삭제
|
|
localStorage.removeItem(key);
|
|
cleanedCount++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`🧹 만료된 캐시 ${cleanedCount}개 정리 완료`);
|
|
}
|
|
|
|
/**
|
|
* 오래된 캐시 정리 (용량 부족 시)
|
|
*/
|
|
cleanupOldCache() {
|
|
console.log('🧹 오래된 캐시 정리 시작');
|
|
|
|
const items = [];
|
|
|
|
// 로컬 스토리지의 모든 캐시 항목 수집
|
|
for (let i = 0; i < localStorage.length; i++) {
|
|
const key = localStorage.key(i);
|
|
if (key && key.startsWith(this.config.prefix)) {
|
|
try {
|
|
const stored = localStorage.getItem(key);
|
|
if (stored) {
|
|
const cached = JSON.parse(stored);
|
|
items.push({ key, cached });
|
|
}
|
|
} catch (error) {
|
|
// 파싱 오류 시 해당 캐시 삭제
|
|
localStorage.removeItem(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 타임스탬프 기준으로 정렬 (오래된 것부터)
|
|
items.sort((a, b) => a.cached.timestamp - b.cached.timestamp);
|
|
|
|
// 오래된 항목의 절반 삭제
|
|
const deleteCount = Math.floor(items.length / 2);
|
|
for (let i = 0; i < deleteCount; i++) {
|
|
localStorage.removeItem(items[i].key);
|
|
this.memoryCache.delete(items[i].key);
|
|
}
|
|
|
|
console.log(`🧹 오래된 캐시 ${deleteCount}개 정리 완료`);
|
|
}
|
|
|
|
/**
|
|
* 최대 항목 수 제한 적용
|
|
*/
|
|
enforceMaxItems() {
|
|
if (this.memoryCache.size > this.config.maxItems) {
|
|
const excess = this.memoryCache.size - this.config.maxItems;
|
|
const keys = Array.from(this.memoryCache.keys());
|
|
|
|
// 오래된 항목부터 삭제
|
|
for (let i = 0; i < excess; i++) {
|
|
this.memoryCache.delete(keys[i]);
|
|
this.stats.evictions++;
|
|
}
|
|
|
|
console.log(`💾 캐시 크기 제한으로 ${excess}개 항목 제거`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 전체 키 생성
|
|
*/
|
|
getFullKey(key, category) {
|
|
return `${this.config.prefix}${category}_${key}`;
|
|
}
|
|
|
|
/**
|
|
* 데이터 크기 추정
|
|
*/
|
|
estimateSize(data) {
|
|
try {
|
|
return JSON.stringify(data).length;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 캐시 통계 조회
|
|
*/
|
|
getStats() {
|
|
const hitRate = this.stats.hits + this.stats.misses > 0
|
|
? (this.stats.hits / (this.stats.hits + this.stats.misses) * 100).toFixed(2)
|
|
: 0;
|
|
|
|
return {
|
|
...this.stats,
|
|
hitRate: `${hitRate}%`,
|
|
memoryItems: this.memoryCache.size,
|
|
localStorageItems: this.getLocalStorageItemCount()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 로컬 스토리지 항목 수 조회
|
|
*/
|
|
getLocalStorageItemCount() {
|
|
let count = 0;
|
|
for (let i = 0; i < localStorage.length; i++) {
|
|
const key = localStorage.key(i);
|
|
if (key && key.startsWith(this.config.prefix)) {
|
|
count++;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
/**
|
|
* 캐시 상태 리포트
|
|
*/
|
|
getReport() {
|
|
const stats = this.getStats();
|
|
const memoryUsage = Array.from(this.memoryCache.values())
|
|
.reduce((total, cached) => total + (cached.size || 0), 0);
|
|
|
|
return {
|
|
stats,
|
|
memoryUsage: `${(memoryUsage / 1024).toFixed(2)} KB`,
|
|
categories: this.getCategoryStats(),
|
|
config: this.config
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 카테고리별 통계
|
|
*/
|
|
getCategoryStats() {
|
|
const categories = {};
|
|
|
|
for (const [key, cached] of this.memoryCache.entries()) {
|
|
const category = cached.category || 'default';
|
|
if (!categories[category]) {
|
|
categories[category] = { count: 0, size: 0 };
|
|
}
|
|
categories[category].count++;
|
|
categories[category].size += cached.size || 0;
|
|
}
|
|
|
|
return categories;
|
|
}
|
|
}
|
|
|
|
// 전역 캐시 매니저 인스턴스
|
|
window.cacheManager = new CacheManager();
|
|
|
|
// 전역으로 내보내기
|
|
window.CacheManager = CacheManager;
|