- monthly_worker_status 조회 시 GROUP BY로 중복 데이터 합산 - 작업보고서 삭제 권한을 그룹장 이상으로 제한 (admin, system, group_leader) - 중복 데이터 정리를 위한 마이그레이션 SQL 추가 (009_fix_duplicate_monthly_status.sql) - synology_deployment 버전에도 동일 수정 적용
285 lines
6.5 KiB
JavaScript
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
|
|
};
|