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

@@ -2,6 +2,8 @@
const workerModel = require('../models/workerModel');
const { ApiError, asyncHandler, handleDatabaseError, handleNotFoundError } = require('../utils/errorHandler');
const { validateSchema, schemas } = require('../utils/validator');
const cache = require('../utils/cache');
const { optimizedQueries } = require('../utils/queryOptimizer');
// 1. 작업자 생성
exports.createWorker = asyncHandler(async (req, res) => {
@@ -18,23 +20,38 @@ exports.createWorker = asyncHandler(async (req, res) => {
});
});
// 작업자 관련 캐시 무효화
await cache.invalidateCache.worker();
res.created({ worker_id: lastID }, '작업자가 성공적으로 생성되었습니다.');
} catch (err) {
handleDatabaseError(err, '작업자 생성');
}
});
// 2. 전체 작업자 조회
// 2. 전체 작업자 조회 (캐싱 및 페이지네이션 적용)
exports.getAllWorkers = asyncHandler(async (req, res) => {
const { page = 1, limit = 10, search = '' } = req.query;
// 캐시 키 생성
const cacheKey = cache.createKey('workers', 'list', page, limit, search);
try {
const rows = await new Promise((resolve, reject) => {
workerModel.getAll((err, data) => {
if (err) reject(err);
else resolve(data);
});
});
// 캐시에서 조회
const cachedData = await cache.get(cacheKey);
if (cachedData) {
console.log(`🎯 캐시 히트: ${cacheKey}`);
return res.paginated(cachedData.data, cachedData.pagination.totalCount, page, limit, '작업자 목록 조회 성공 (캐시)');
}
res.list(rows, '작업자 목록 조회 성공');
// 최적화된 쿼리 사용
const result = await optimizedQueries.getWorkersPaged(page, limit, search);
// 캐시에 저장 (5분)
await cache.set(cacheKey, result, cache.TTL.MEDIUM);
console.log(`💾 캐시 저장: ${cacheKey}`);
res.paginated(result.data, result.pagination.totalCount, page, limit, '작업자 목록 조회 성공');
} catch (err) {
handleDatabaseError(err, '작업자 목록 조회');
}

View File

@@ -13,6 +13,10 @@ const { responseMiddleware } = require('./utils/responseFormatter');
const swaggerUi = require('swagger-ui-express');
const swaggerSpec = require('./config/swagger');
// 성능 최적화 모듈
const compression = require('compression');
const cache = require('./utils/cache');
const app = express();
// 헬스체크와 개발용 엔드포인트는 CORS 이후에 등록
@@ -36,6 +40,18 @@ app.use(helmet({
}
}));
// ✅ 성능 최적화 미들웨어
app.use(compression({
filter: (req, res) => {
if (req.headers['x-no-compression']) {
return false;
}
return compression.filter(req, res);
},
level: 6, // 압축 레벨 (1-9, 6이 기본값)
threshold: 1024 // 1KB 이상만 압축
}));
// ✅ 요청 바디 용량 제한 확장
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
app.use(express.json({ limit: '50mb' }));
@@ -198,6 +214,7 @@ const dailyWorkReportRoutes = require('./routes/dailyWorkReportRoutes');
const workAnalysisRoutes = require('./routes/workAnalysisRoutes');
const analysisRoutes = require('./routes/analysisRoutes');
const systemRoutes = require('./routes/systemRoutes'); // 새로운 분석 라우트
const performanceRoutes = require('./routes/performanceRoutes'); // 성능 모니터링 라우트
// 🔒 인증 미들웨어 가져오기
const { verifyToken } = require('./middlewares/authMiddleware');
@@ -318,6 +335,9 @@ app.use('/api/workreports', workReportRoutes);
app.use('/api/system', systemRoutes);
app.use('/api/uploads', uploadRoutes);
// 📊 성능 모니터링 (관리자 권한)
app.use('/api/performance', performanceRoutes);
// ⚙️ 시스템 데이터들 (모든 인증된 사용자)
app.use('/api/projects', projectRoutes);
app.use('/api/tasks', taskRoutes);
@@ -569,4 +589,14 @@ process.on('uncaughtException', (error) => {
gracefulShutdown();
});
// ✅ 캐시 시스템 초기화
(async () => {
try {
await cache.initRedis();
console.log('🚀 캐시 시스템 초기화 완료');
} catch (error) {
console.warn('캐시 시스템 초기화 실패:', error.message);
}
})();
module.exports = app;

View File

@@ -11,17 +11,20 @@
"@simplewebauthn/server": "^13.1.1",
"async-retry": "^1.3.3",
"bcryptjs": "^2.4.3",
"compression": "^1.8.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.18.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.1",
"express-validator": "^7.2.1",
"helmet": "^7.2.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.14.1",
"node-cache": "^5.1.2",
"pm2": "^5.3.0",
"qrcode": "^1.5.4",
"redis": "^5.9.0",
"sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1"
@@ -394,6 +397,66 @@
"debug": "^4.3.1"
}
},
"node_modules/@redis/bloom": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.9.0.tgz",
"integrity": "sha512-W9D8yfKTWl4tP8lkC3MRYkMz4OfbuzE/W8iObe0jFgoRmgMfkBV+Vj38gvIqZPImtY0WB34YZkX3amYuQebvRQ==",
"license": "MIT",
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"@redis/client": "^5.9.0"
}
},
"node_modules/@redis/client": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-5.9.0.tgz",
"integrity": "sha512-EI0Ti5pojD2p7TmcS7RRa+AJVahdQvP/urpcSbK/K9Rlk6+dwMJTQ354pCNGCwfke8x4yKr5+iH85wcERSkwLQ==",
"license": "MIT",
"dependencies": {
"cluster-key-slot": "1.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/@redis/json": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/@redis/json/-/json-5.9.0.tgz",
"integrity": "sha512-Bm2jjLYaXdUWPb9RaEywxnjmzw7dWKDZI4MS79mTWPV16R982jVWBj6lY2ZGelJbwxHtEVg4/FSVgYDkuO/MxA==",
"license": "MIT",
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"@redis/client": "^5.9.0"
}
},
"node_modules/@redis/search": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/@redis/search/-/search-5.9.0.tgz",
"integrity": "sha512-jdk2csmJ29DlpvCIb2ySjix2co14/0iwIT3C0I+7ZaToXgPbgBMB+zfEilSuncI2F9JcVxHki0YtLA0xX3VdpA==",
"license": "MIT",
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"@redis/client": "^5.9.0"
}
},
"node_modules/@redis/time-series": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.9.0.tgz",
"integrity": "sha512-W6ILxcyOqhnI7ELKjJXOktIg3w4+aBHugDbVpgVLPZ+YDjObis1M0v7ZzwlpXhlpwsfePfipeSK+KWNuymk52w==",
"license": "MIT",
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"@redis/client": "^5.9.0"
}
},
"node_modules/@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
@@ -1049,6 +1112,24 @@
"wrap-ansi": "^6.2.0"
}
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1080,6 +1161,54 @@
"resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz",
"integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag=="
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -1595,9 +1724,10 @@
}
},
"node_modules/express-rate-limit": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz",
"integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==",
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
@@ -1605,7 +1735,7 @@
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": "^4.11 || 5 || ^5.0.0-beta.1"
"express": ">= 4.11"
}
},
"node_modules/express-validator": {
@@ -1985,6 +2115,7 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz",
"integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
@@ -2833,6 +2964,18 @@
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/node-cache": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz",
"integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==",
"license": "MIT",
"dependencies": {
"clone": "2.x"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/node-gyp": {
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz",
@@ -2949,6 +3092,15 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -3589,6 +3741,22 @@
"node": ">=8.10.0"
}
},
"node_modules/redis": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/redis/-/redis-5.9.0.tgz",
"integrity": "sha512-E8dQVLSyH6UE/C9darFuwq4usOPrqfZ1864kI4RFbr5Oj9ioB9qPF0oJMwX7s8mf6sPYrz84x/Dx1PGF3/0EaQ==",
"license": "MIT",
"dependencies": {
"@redis/bloom": "5.9.0",
"@redis/client": "5.9.0",
"@redis/json": "5.9.0",
"@redis/search": "5.9.0",
"@redis/time-series": "5.9.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",

View File

@@ -10,17 +10,20 @@
"@simplewebauthn/server": "^13.1.1",
"async-retry": "^1.3.3",
"bcryptjs": "^2.4.3",
"compression": "^1.8.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.18.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.1",
"express-validator": "^7.2.1",
"helmet": "^7.2.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.14.1",
"node-cache": "^5.1.2",
"pm2": "^5.3.0",
"qrcode": "^1.5.4",
"redis": "^5.9.0",
"sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1"

View File

@@ -0,0 +1,388 @@
/**
* @swagger
* tags:
* name: Performance
* description: 성능 모니터링 및 최적화 API
*/
const express = require('express');
const router = express.Router();
const { asyncHandler } = require('../utils/errorHandler');
const cache = require('../utils/cache');
const { getPerformanceStats, suggestIndexes, analyzeQuery } = require('../utils/queryOptimizer');
/**
* @swagger
* /api/performance/cache/stats:
* get:
* tags: [Performance]
* summary: 캐시 통계 조회
* description: 현재 캐시 시스템의 상태와 통계를 조회합니다.
* security:
* - bearerAuth: []
* responses:
* 200:
* description: 캐시 통계 조회 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* message:
* type: string
* example: "캐시 통계 조회 성공"
* data:
* type: object
* properties:
* type:
* type: string
* example: "memory"
* connected:
* type: boolean
* example: true
* keys:
* type: integer
* example: 42
* hits:
* type: integer
* example: 150
* misses:
* type: integer
* example: 25
* hitRate:
* type: number
* example: 0.857
* 401:
* description: 인증 필요
* 500:
* description: 서버 오류
*/
router.get('/cache/stats', asyncHandler(async (req, res) => {
const stats = cache.getStats();
res.success(stats, '캐시 통계 조회 성공');
}));
/**
* @swagger
* /api/performance/cache/flush:
* post:
* tags: [Performance]
* summary: 캐시 초기화
* description: 모든 캐시 데이터를 삭제합니다. (관리자 전용)
* security:
* - bearerAuth: []
* responses:
* 200:
* description: 캐시 초기화 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* message:
* type: string
* example: "캐시가 성공적으로 초기화되었습니다."
* 401:
* description: 인증 필요
* 403:
* description: 권한 부족
* 500:
* description: 서버 오류
*/
router.post('/cache/flush', asyncHandler(async (req, res) => {
// 관리자 권한 확인
if (req.user?.access_level !== 'admin' && req.user?.access_level !== 'system') {
return res.status(403).json({
success: false,
error: '캐시 초기화 권한이 없습니다.'
});
}
await cache.flush();
res.success(null, '캐시가 성공적으로 초기화되었습니다.');
}));
/**
* @swagger
* /api/performance/database/stats:
* get:
* tags: [Performance]
* summary: 데이터베이스 성능 통계
* description: 데이터베이스 연결 상태와 성능 지표를 조회합니다.
* security:
* - bearerAuth: []
* responses:
* 200:
* description: DB 성능 통계 조회 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* message:
* type: string
* example: "DB 성능 통계 조회 성공"
* data:
* type: object
* properties:
* connections:
* type: object
* properties:
* current:
* type: integer
* example: 5
* max:
* type: integer
* example: 151
* slowQueries:
* type: integer
* example: 0
* timestamp:
* type: string
* format: date-time
* 401:
* description: 인증 필요
* 500:
* description: 서버 오류
*/
router.get('/database/stats', asyncHandler(async (req, res) => {
const stats = await getPerformanceStats();
res.success(stats, 'DB 성능 통계 조회 성공');
}));
/**
* @swagger
* /api/performance/database/indexes/{tableName}:
* get:
* tags: [Performance]
* summary: 인덱스 최적화 제안
* description: 특정 테이블의 인덱스 상태를 분석하고 최적화 제안을 제공합니다.
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: tableName
* required: true
* schema:
* type: string
* description: 분석할 테이블명
* example: "workers"
* responses:
* 200:
* description: 인덱스 분석 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* message:
* type: string
* example: "인덱스 분석 완료"
* data:
* type: object
* properties:
* tableName:
* type: string
* example: "workers"
* currentIndexes:
* type: array
* items:
* type: object
* properties:
* name:
* type: string
* column:
* type: string
* unique:
* type: boolean
* suggestions:
* type: array
* items:
* type: object
* properties:
* type:
* type: string
* column:
* type: string
* reason:
* type: string
* sql:
* type: string
* 401:
* description: 인증 필요
* 500:
* description: 서버 오류
*/
router.get('/database/indexes/:tableName', asyncHandler(async (req, res) => {
const { tableName } = req.params;
const analysis = await suggestIndexes(tableName);
res.success(analysis, '인덱스 분석 완료');
}));
/**
* @swagger
* /api/performance/query/analyze:
* post:
* tags: [Performance]
* summary: 쿼리 성능 분석
* description: SQL 쿼리의 실행 계획과 성능을 분석합니다. (관리자 전용)
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - query
* properties:
* query:
* type: string
* example: "SELECT * FROM workers WHERE department = ?"
* params:
* type: array
* items:
* type: string
* example: ["생산부"]
* responses:
* 200:
* description: 쿼리 분석 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* message:
* type: string
* example: "쿼리 분석 완료"
* data:
* type: object
* properties:
* executionTime:
* type: integer
* example: 15
* explainResult:
* type: array
* items:
* type: object
* recommendations:
* type: array
* items:
* type: object
* properties:
* type:
* type: string
* message:
* type: string
* suggestion:
* type: string
* 401:
* description: 인증 필요
* 403:
* description: 권한 부족
* 500:
* description: 서버 오류
*/
router.post('/query/analyze', asyncHandler(async (req, res) => {
// 관리자 권한 확인
if (req.user?.access_level !== 'admin' && req.user?.access_level !== 'system') {
return res.status(403).json({
success: false,
error: '쿼리 분석 권한이 없습니다.'
});
}
const { query, params = [] } = req.body;
if (!query) {
return res.status(400).json({
success: false,
error: '분석할 쿼리가 필요합니다.'
});
}
const analysis = await analyzeQuery(query, params);
res.success(analysis, '쿼리 분석 완료');
}));
/**
* @swagger
* /api/performance/system/info:
* get:
* tags: [Performance]
* summary: 시스템 정보 조회
* description: 서버의 메모리, CPU, 업타임 등 시스템 정보를 조회합니다.
* security:
* - bearerAuth: []
* responses:
* 200:
* description: 시스템 정보 조회 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* message:
* type: string
* example: "시스템 정보 조회 성공"
* data:
* type: object
* properties:
* uptime:
* type: number
* example: 3600.5
* memory:
* type: object
* properties:
* rss:
* type: integer
* heapTotal:
* type: integer
* heapUsed:
* type: integer
* external:
* type: integer
* nodeVersion:
* type: string
* example: "v18.17.0"
* platform:
* type: string
* example: "linux"
* 401:
* description: 인증 필요
* 500:
* description: 서버 오류
*/
router.get('/system/info', asyncHandler(async (req, res) => {
const systemInfo = {
uptime: process.uptime(),
memory: process.memoryUsage(),
nodeVersion: process.version,
platform: process.platform,
cpuUsage: process.cpuUsage(),
timestamp: new Date().toISOString()
};
res.success(systemInfo, '시스템 정보 조회 성공');
}));
module.exports = router;

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
};