Files
Hyungi Ahn a9bce9d20b fix: 캘린더 모달 중복 카드 문제 및 삭제 권한 개선
- monthly_worker_status 조회 시 GROUP BY로 중복 데이터 합산
- 작업보고서 삭제 권한을 그룹장 이상으로 제한 (admin, system, group_leader)
- 중복 데이터 정리를 위한 마이그레이션 SQL 추가 (009_fix_duplicate_monthly_status.sql)
- synology_deployment 버전에도 동일 수정 적용
2025-12-02 13:08:44 +09:00

285 lines
6.5 KiB
JavaScript

// utils/cache.js - 통합 캐싱 시스템
const NodeCache = require('node-cache');
// 메모리 캐시 (Redis가 없을 때 fallback)
const memoryCache = new NodeCache({
stdTTL: 600, // 기본 10분
checkperiod: 120, // 2분마다 만료된 키 정리
useClones: false // 성능 향상을 위해 복사본 생성 안함
});
// Redis 클라이언트 (선택적)
let redisClient = null;
/**
* Redis 연결 초기화 (선택적)
*/
const initRedis = async () => {
try {
const redis = require('redis');
redisClient = redis.createClient({
socket: {
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
reconnectStrategy: (retries) => {
if (retries > 10) {
console.warn('Redis 재시도 횟수 초과. 메모리 캐시를 사용합니다.');
return false; // Redis 연결 포기
}
return Math.min(retries * 100, 3000);
}
},
password: process.env.REDIS_PASSWORD || undefined,
database: process.env.REDIS_DB || 0
});
redisClient.on('error', (err) => {
console.warn('Redis 오류:', err.message);
redisClient = null; // Redis 사용 중단, 메모리 캐시로 fallback
});
redisClient.on('connect', () => {
console.log('✅ Redis 캐시 연결 성공');
});
await redisClient.connect();
} catch (error) {
console.warn('Redis 초기화 실패, 메모리 캐시 사용:', error.message);
redisClient = null;
}
};
/**
* 캐시에서 값 조회
*/
const get = async (key) => {
try {
if (redisClient && redisClient.isOpen) {
const value = await redisClient.get(key);
return value ? JSON.parse(value) : null;
} else {
return memoryCache.get(key) || null;
}
} catch (error) {
console.warn(`캐시 조회 오류 (${key}):`, error.message);
return null;
}
};
/**
* 캐시에 값 저장
*/
const set = async (key, value, ttl = 600) => {
try {
if (redisClient && redisClient.isOpen) {
await redisClient.setEx(key, ttl, JSON.stringify(value));
} else {
memoryCache.set(key, value, ttl);
}
return true;
} catch (error) {
console.warn(`캐시 저장 오류 (${key}):`, error.message);
return false;
}
};
/**
* 캐시에서 값 삭제
*/
const del = async (key) => {
try {
if (redisClient && redisClient.isOpen) {
await redisClient.del(key);
} else {
memoryCache.del(key);
}
return true;
} catch (error) {
console.warn(`캐시 삭제 오류 (${key}):`, error.message);
return false;
}
};
/**
* 패턴으로 캐시 키 삭제
*/
const delPattern = async (pattern) => {
try {
if (redisClient && redisClient.isOpen) {
const keys = await redisClient.keys(pattern);
if (keys.length > 0) {
await redisClient.del(keys);
}
} else {
const keys = memoryCache.keys();
const matchingKeys = keys.filter(key => {
const regex = new RegExp(pattern.replace('*', '.*'));
return regex.test(key);
});
memoryCache.del(matchingKeys);
}
return true;
} catch (error) {
console.warn(`패턴 캐시 삭제 오류 (${pattern}):`, error.message);
return false;
}
};
/**
* 전체 캐시 초기화
*/
const flush = async () => {
try {
if (redisClient && redisClient.isOpen) {
await redisClient.flushDb();
} else {
memoryCache.flushAll();
}
return true;
} catch (error) {
console.warn('캐시 초기화 오류:', error.message);
return false;
}
};
/**
* 캐시 통계 조회
*/
const getStats = () => {
if (redisClient && redisClient.isOpen) {
return {
type: 'redis',
connected: true,
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379
};
} else {
const stats = memoryCache.getStats();
return {
type: 'memory',
connected: true,
keys: stats.keys,
hits: stats.hits,
misses: stats.misses,
hitRate: stats.hits / (stats.hits + stats.misses) || 0
};
}
};
/**
* 캐시 키 생성 헬퍼
*/
const createKey = (prefix, ...parts) => {
return `${prefix}:${parts.join(':')}`;
};
/**
* TTL 상수 정의
*/
const TTL = {
SHORT: 60, // 1분
MEDIUM: 300, // 5분
LONG: 600, // 10분
HOUR: 3600, // 1시간
DAY: 86400 // 24시간
};
/**
* 캐시 미들웨어 생성기
*/
const createCacheMiddleware = (keyGenerator, ttl = TTL.MEDIUM) => {
return async (req, res, next) => {
try {
const cacheKey = typeof keyGenerator === 'function'
? keyGenerator(req)
: keyGenerator;
const cachedData = await get(cacheKey);
if (cachedData) {
console.log(`🎯 캐시 히트: ${cacheKey}`);
return res.json(cachedData);
}
// 원본 res.json을 저장
const originalJson = res.json;
// res.json을 오버라이드하여 응답을 캐시에 저장
res.json = function(data) {
// 성공 응답만 캐시
if (res.statusCode >= 200 && res.statusCode < 300) {
set(cacheKey, data, ttl).then(() => {
console.log(`💾 캐시 저장: ${cacheKey}`);
});
}
// 원본 응답 실행
return originalJson.call(this, data);
};
next();
} catch (error) {
console.warn('캐시 미들웨어 오류:', error.message);
next();
}
};
};
/**
* 캐시 무효화 헬퍼
*/
const invalidateCache = {
// 사용자 관련 캐시 무효화
user: async (userId) => {
await delPattern(`user:${userId}:*`);
await delPattern('users:*');
},
// 작업자 관련 캐시 무효화
worker: async (workerId) => {
await delPattern(`worker:${workerId}:*`);
await delPattern('workers:*');
},
// 프로젝트 관련 캐시 무효화
project: async (projectId) => {
await delPattern(`project:${projectId}:*`);
await delPattern('projects:*');
},
// 작업 관련 캐시 무효화
task: async (taskId) => {
await delPattern(`task:${taskId}:*`);
await delPattern('tasks:*');
},
// 일일 작업 보고서 관련 캐시 무효화
dailyWorkReport: async (date) => {
await delPattern(`daily-work-report:${date}:*`);
await delPattern('daily-work-reports:*');
},
// 전체 캐시 무효화
all: async () => {
await flush();
}
};
module.exports = {
initRedis,
get,
set,
del,
delPattern,
flush,
getStats,
createKey,
TTL,
createCacheMiddleware,
invalidateCache
};