// utils/queryOptimizer.js - 데이터베이스 쿼리 최적화 유틸리티 const { getDb } = require('../dbPool'); /** * SQL Injection 방지를 위한 화이트리스트 검증 */ const ALLOWED_ORDER_DIRECTIONS = ['ASC', 'DESC']; const ALLOWED_TABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; const ALLOWED_COLUMN_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_.]*$/; const validateOrderDirection = (direction) => { const normalized = (direction || 'DESC').toUpperCase(); if (!ALLOWED_ORDER_DIRECTIONS.includes(normalized)) { throw new Error(`Invalid order direction: ${direction}`); } return normalized; }; const validateIdentifier = (identifier, type = 'column') => { if (!identifier || typeof identifier !== 'string') { throw new Error(`Invalid ${type} name`); } if (!ALLOWED_COLUMN_NAME_PATTERN.test(identifier)) { throw new Error(`Invalid ${type} name: ${identifier}`); } return identifier; }; const validateTableName = (tableName) => { if (!tableName || typeof tableName !== 'string') { throw new Error('Invalid table name'); } if (!ALLOWED_TABLE_NAME_PATTERN.test(tableName)) { throw new Error(`Invalid table name: ${tableName}`); } return tableName; }; /** * 페이지네이션 헬퍼 */ 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); // SQL Injection 방지: 컬럼명과 정렬방향 검증 const safeOrderBy = validateIdentifier(orderBy, 'column'); const safeOrderDirection = validateOrderDirection(orderDirection); 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 ${safeOrderBy} ${safeOrderDirection} 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) => { // SQL Injection 방지: 테이블명 검증 const safeTableName = validateTableName(tableName); try { const db = await getDb(); // 현재 인덱스 조회 - 검증된 테이블명 사용 const [indexes] = await db.execute(`SHOW INDEX FROM \`${safeTableName}\``); // 테이블 구조 조회 - 검증된 테이블명 사용 const [columns] = await db.execute(`DESCRIBE \`${safeTableName}\``); 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_${safeTableName}_${col.Field} ON \`${safeTableName}\`(\`${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_${safeTableName}_${col.Field} ON \`${safeTableName}\`(\`${col.Field}\`);` }); }); return { tableName: safeTableName, 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('삽입할 데이터가 없습니다.'); } // SQL Injection 방지: 테이블명 검증 const safeTableName = validateTableName(tableName); try { const db = await getDb(); const connection = await db.getConnection(); await connection.beginTransaction(); const columns = Object.keys(data[0]); // 컬럼명도 검증 const safeColumns = columns.map(col => validateIdentifier(col, 'column')); const placeholders = safeColumns.map(() => '?').join(', '); const columnList = safeColumns.map(col => `\`${col}\``).join(', '); const insertQuery = `INSERT INTO \`${safeTableName}\` (${columnList}) 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 = '', status = '', departmentId = null) => { let baseQuery = ` SELECT w.*, d.department_name, COUNT(dwr.id) as report_count FROM workers w LEFT JOIN daily_work_reports dwr ON w.user_id = dwr.user_id LEFT JOIN departments d ON w.department_id = d.department_id `; let countQuery = ` SELECT COUNT(*) as total FROM workers w LEFT JOIN departments d ON w.department_id = d.department_id `; let params = []; let conditions = []; // 검색 조건 if (search) { conditions.push('(w.worker_name LIKE ? OR w.position LIKE ?)'); params.push(`%${search}%`, `%${search}%`); } // 상태 조건 if (status) { conditions.push('w.status = ?'); params.push(status); } // 부서 조건 if (departmentId) { conditions.push('w.department_id = ?'); params.push(departmentId); } // 조건 조합 if (conditions.length > 0) { const whereClause = ' WHERE ' + conditions.join(' AND '); baseQuery += whereClause; countQuery += whereClause; } baseQuery += ' GROUP BY w.user_id'; return executePagedQuery(baseQuery, countQuery, params, { page, limit, orderBy: 'w.user_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.user_id = w.user_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 };