feat: 포괄적인 성능 최적화 시스템 구축

- 통합 캐싱 시스템 구축:
  * utils/cache.js: Redis + 메모리 캐시 하이브리드 시스템
  * Redis 연결 실패 시 자동 메모리 캐시 fallback
  * 캐시 키 생성, TTL 관리, 패턴 기반 무효화
  * 캐시 미들웨어 및 무효화 헬퍼 함수

- 데이터베이스 쿼리 최적화:
  * utils/queryOptimizer.js: 쿼리 성능 분석 및 최적화
  * 페이지네이션 헬퍼 (최대 100개 제한)
  * 인덱스 최적화 제안 시스템
  * 배치 삽입 최적화 (100개 단위)
  * 최적화된 쿼리 템플릿 (작업자, 프로젝트, 작업보고서)

- 응답 압축 및 최적화:
  * gzip 압축 미들웨어 (1KB 이상, 레벨 6)
  * 압축 제외 헤더 지원 (x-no-compression)
  * 성능 모니터링 시스템

- 성능 모니터링 API:
  * /api/performance/* 엔드포인트 추가
  * 캐시 통계 및 관리 (조회, 초기화)
  * DB 성능 통계 (연결 수, 슬로우 쿼리)
  * 인덱스 분석 및 최적화 제안
  * 쿼리 실행 계획 분석 (EXPLAIN)
  * 시스템 리소스 모니터링

- 실제 적용 사례:
  * workerController.js에 캐싱 및 페이지네이션 적용
  * 캐시 히트/미스 로깅
  * 캐시 무효화 자동 처리

- 보안 및 권한:
  * 성능 관련 API는 관리자 권한 필요
  * 쿼리 분석은 시스템/관리자만 접근 가능
  * 캐시 초기화는 관리자 전용

- Swagger 문서화:
  * 모든 성능 API 완전 문서화
  * 요청/응답 스키마 및 예시 포함
This commit is contained in:
Hyungi Ahn
2025-11-03 11:05:07 +09:00
parent dea325739a
commit 2b97844ed1
7 changed files with 1270 additions and 14 deletions

View File

@@ -0,0 +1,288 @@
// 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({
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
password: process.env.REDIS_PASSWORD || undefined,
db: process.env.REDIS_DB || 0,
retry_strategy: (options) => {
if (options.error && options.error.code === 'ECONNREFUSED') {
console.warn('Redis 서버에 연결할 수 없습니다. 메모리 캐시를 사용합니다.');
return undefined; // Redis 연결 포기
}
if (options.total_retry_time > 1000 * 60 * 60) {
return new Error('Redis 재시도 시간 초과');
}
if (options.attempt > 10) {
return undefined;
}
return Math.min(options.attempt * 100, 3000);
}
});
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
};

View File

@@ -0,0 +1,362 @@
// utils/queryOptimizer.js - 데이터베이스 쿼리 최적화 유틸리티
const { getDb } = require('../dbPool');
/**
* 페이지네이션 헬퍼
*/
const paginate = (page = 1, limit = 10) => {
const pageNum = Math.max(1, parseInt(page));
const limitNum = Math.min(100, Math.max(1, parseInt(limit))); // 최대 100개 제한
const offset = (pageNum - 1) * limitNum;
return {
limit: limitNum,
offset,
page: pageNum
};
};
/**
* 페이지네이션된 쿼리 실행
*/
const executePagedQuery = async (baseQuery, countQuery, params = [], options = {}) => {
const { page = 1, limit = 10, orderBy = 'id', orderDirection = 'DESC' } = options;
const { limit: limitNum, offset, page: pageNum } = paginate(page, limit);
try {
const db = await getDb();
// 전체 개수 조회
const [countResult] = await db.execute(countQuery, params);
const totalCount = countResult[0]?.total || 0;
// 데이터 조회 (ORDER BY와 LIMIT 추가)
const pagedQuery = `${baseQuery} ORDER BY ${orderBy} ${orderDirection} LIMIT ${limitNum} OFFSET ${offset}`;
const [rows] = await db.execute(pagedQuery, params);
// 페이지네이션 메타데이터 계산
const totalPages = Math.ceil(totalCount / limitNum);
return {
data: rows,
pagination: {
currentPage: pageNum,
totalPages,
totalCount,
limit: limitNum,
hasNextPage: pageNum < totalPages,
hasPrevPage: pageNum > 1
}
};
} catch (error) {
throw new Error(`페이지네이션 쿼리 실행 오류: ${error.message}`);
}
};
/**
* 인덱스 최적화 제안
*/
const suggestIndexes = async (tableName) => {
try {
const db = await getDb();
// 현재 인덱스 조회
const [indexes] = await db.execute(`SHOW INDEX FROM ${tableName}`);
// 테이블 구조 조회
const [columns] = await db.execute(`DESCRIBE ${tableName}`);
const suggestions = [];
// 외래키 컬럼에 인덱스 제안
const foreignKeyColumns = columns.filter(col =>
col.Field.endsWith('_id') && !indexes.some(idx => idx.Column_name === col.Field)
);
foreignKeyColumns.forEach(col => {
suggestions.push({
type: 'INDEX',
column: col.Field,
reason: '외래키 컬럼에 인덱스 추가로 JOIN 성능 향상',
sql: `CREATE INDEX idx_${tableName}_${col.Field} ON ${tableName}(${col.Field});`
});
});
// 날짜 컬럼에 인덱스 제안
const dateColumns = columns.filter(col =>
(col.Type.includes('date') || col.Type.includes('timestamp')) &&
!indexes.some(idx => idx.Column_name === col.Field)
);
dateColumns.forEach(col => {
suggestions.push({
type: 'INDEX',
column: col.Field,
reason: '날짜 범위 검색 성능 향상',
sql: `CREATE INDEX idx_${tableName}_${col.Field} ON ${tableName}(${col.Field});`
});
});
return {
tableName,
currentIndexes: indexes.map(idx => ({
name: idx.Key_name,
column: idx.Column_name,
unique: idx.Non_unique === 0
})),
suggestions
};
} catch (error) {
throw new Error(`인덱스 분석 오류: ${error.message}`);
}
};
/**
* 쿼리 성능 분석
*/
const analyzeQuery = async (query, params = []) => {
try {
const db = await getDb();
// EXPLAIN 실행
const explainQuery = `EXPLAIN ${query}`;
const [explainResult] = await db.execute(explainQuery, params);
// 쿼리 실행 시간 측정
const startTime = Date.now();
await db.execute(query, params);
const executionTime = Date.now() - startTime;
// 성능 분석
const analysis = {
executionTime,
explainResult,
recommendations: []
};
// 성능 권장사항 생성
explainResult.forEach(row => {
if (row.type === 'ALL') {
analysis.recommendations.push({
type: 'WARNING',
message: `테이블 전체 스캔 발생: ${row.table}`,
suggestion: '적절한 인덱스 추가 권장'
});
}
if (row.rows > 1000) {
analysis.recommendations.push({
type: 'WARNING',
message: `많은 행 검사: ${row.rows}`,
suggestion: 'WHERE 조건 최적화 또는 인덱스 추가 권장'
});
}
if (row.Extra && row.Extra.includes('Using filesort')) {
analysis.recommendations.push({
type: 'INFO',
message: '파일 정렬 사용 중',
suggestion: 'ORDER BY 컬럼에 인덱스 추가 고려'
});
}
});
return analysis;
} catch (error) {
throw new Error(`쿼리 분석 오류: ${error.message}`);
}
};
/**
* 배치 삽입 최적화
*/
const batchInsert = async (tableName, data, batchSize = 100) => {
if (!Array.isArray(data) || data.length === 0) {
throw new Error('삽입할 데이터가 없습니다.');
}
try {
const db = await getDb();
const connection = await db.getConnection();
await connection.beginTransaction();
const columns = Object.keys(data[0]);
const placeholders = columns.map(() => '?').join(', ');
const insertQuery = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`;
let insertedCount = 0;
// 배치 단위로 처리
for (let i = 0; i < data.length; i += batchSize) {
const batch = data.slice(i, i + batchSize);
for (const row of batch) {
const values = columns.map(col => row[col]);
await connection.execute(insertQuery, values);
insertedCount++;
}
}
await connection.commit();
connection.release();
return {
insertedCount,
batchSize,
totalBatches: Math.ceil(data.length / batchSize)
};
} catch (error) {
throw new Error(`배치 삽입 오류: ${error.message}`);
}
};
/**
* 쿼리 캐시 키 생성
*/
const generateCacheKey = (query, params = [], prefix = 'query') => {
const paramString = params.length > 0 ? JSON.stringify(params) : '';
const queryHash = require('crypto')
.createHash('md5')
.update(query + paramString)
.digest('hex');
return `${prefix}:${queryHash}`;
};
/**
* 자주 사용되는 최적화된 쿼리들
*/
const optimizedQueries = {
// 작업자 목록 (페이지네이션)
getWorkersPaged: async (page = 1, limit = 10, search = '') => {
let baseQuery = `
SELECT w.*, COUNT(dwr.id) as report_count
FROM workers w
LEFT JOIN daily_work_reports dwr ON w.worker_id = dwr.worker_id
`;
let countQuery = 'SELECT COUNT(*) as total FROM workers w';
let params = [];
if (search) {
const searchCondition = ' WHERE w.worker_name LIKE ? OR w.position LIKE ?';
baseQuery += searchCondition + ' GROUP BY w.worker_id';
countQuery += searchCondition;
params = [`%${search}%`, `%${search}%`];
} else {
baseQuery += ' GROUP BY w.worker_id';
}
return executePagedQuery(baseQuery, countQuery, params, {
page, limit, orderBy: 'w.worker_id', orderDirection: 'DESC'
});
},
// 프로젝트 목록 (페이지네이션)
getProjectsPaged: async (page = 1, limit = 10, status = '') => {
let baseQuery = `
SELECT p.*, COUNT(dwr.id) as report_count,
SUM(dwr.work_hours) as total_hours
FROM projects p
LEFT JOIN daily_work_reports dwr ON p.project_id = dwr.project_id
`;
let countQuery = 'SELECT COUNT(*) as total FROM projects p';
let params = [];
if (status) {
const statusCondition = ' WHERE p.status = ?';
baseQuery += statusCondition + ' GROUP BY p.project_id';
countQuery += statusCondition;
params = [status];
} else {
baseQuery += ' GROUP BY p.project_id';
}
return executePagedQuery(baseQuery, countQuery, params, {
page, limit, orderBy: 'p.project_id', orderDirection: 'DESC'
});
},
// 일일 작업 보고서 (날짜 범위, 페이지네이션)
getDailyWorkReportsPaged: async (startDate, endDate, page = 1, limit = 10) => {
const baseQuery = `
SELECT dwr.*, w.worker_name, p.project_name,
wt.name as work_type_name, wst.name as work_status_name,
et.name as error_type_name, u.name as created_by_name
FROM daily_work_reports dwr
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
LEFT JOIN projects p ON dwr.project_id = p.project_id
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
LEFT JOIN work_status_types wst ON dwr.work_status_id = wst.id
LEFT JOIN error_types et ON dwr.error_type_id = et.id
LEFT JOIN users u ON dwr.created_by = u.user_id
WHERE dwr.report_date BETWEEN ? AND ?
`;
const countQuery = `
SELECT COUNT(*) as total
FROM daily_work_reports dwr
WHERE dwr.report_date BETWEEN ? AND ?
`;
return executePagedQuery(baseQuery, countQuery, [startDate, endDate], {
page, limit, orderBy: 'dwr.report_date', orderDirection: 'DESC'
});
}
};
/**
* 데이터베이스 성능 모니터링
*/
const getPerformanceStats = async () => {
try {
const db = await getDb();
// 연결 상태 조회
const [connections] = await db.execute('SHOW STATUS LIKE "Threads_connected"');
const [maxConnections] = await db.execute('SHOW VARIABLES LIKE "max_connections"');
// 쿼리 캐시 상태 (MySQL 8.0 이전 버전)
let queryCacheStats = null;
try {
const [qcStats] = await db.execute('SHOW STATUS LIKE "Qcache%"');
queryCacheStats = qcStats;
} catch (error) {
// MySQL 8.0+에서는 쿼리 캐시가 제거됨
}
// 슬로우 쿼리 로그 상태
const [slowQueries] = await db.execute('SHOW STATUS LIKE "Slow_queries"');
return {
connections: {
current: parseInt(connections[0]?.Value || 0),
max: parseInt(maxConnections[0]?.Value || 0)
},
queryCacheStats,
slowQueries: parseInt(slowQueries[0]?.Value || 0),
timestamp: new Date().toISOString()
};
} catch (error) {
throw new Error(`성능 통계 조회 오류: ${error.message}`);
}
};
module.exports = {
paginate,
executePagedQuery,
suggestIndexes,
analyzeQuery,
batchInsert,
generateCacheKey,
optimizedQueries,
getPerformanceStats
};