diff --git a/DEV_LOG.md b/DEV_LOG.md index 9f4ede8..0887e7c 100644 --- a/DEV_LOG.md +++ b/DEV_LOG.md @@ -27,6 +27,20 @@ 4. **테스트 계정 생성** - `tester` / `000000` 관리자(Leader) 계정 생성. +### 🛠️ 작업자 및 프로젝트 관리 기능 개선 (2026-01-06) +**개요**: 작업자/프로젝트의 비활성화(퇴사/종료) 처리가 즉시 반영되지 않는 문제 및 로직 오류 수정. + +1. **캐시 무효화 및 필터링 적용 (Cache & Filtering)** + - **문제**: 작업자/프로젝트 상태 변경 후에도 캐시가 남아있어 드롭다운 목록에서 사라지지 않음. + - **해결**: + - `WorkerController`, `ProjectController`: 생성/수정/삭제 시 `request` 단위의 캐시 즉시 무효화 로직 추가. + - `WorkerController`: 목록 조회 시 `status` 파라미터 지원 추가. + - `daily-work-report.js`: 작업보고서 작성 시 `active` 상태인 작업자만 필터링하여 조회하도록 수정. + +2. **작업자 비활성화 오류 수정 (Bug Fix)** + - **원인**: `workerModel.update` 쿼리에 DB에 존재하지 않는 `join_date` 컬럼을 업데이트하려는 시도가 있어 SQL 에러 발생. + - **해결**: `workerModel.js`에서 잘못된 컬럼(`join_date`) 참조 제거. (올바른 컬럼 `hire_date`는 유지) + --- ## 🛡보안 및 검토 리포트 (History) diff --git a/api.hyungi.net/controllers/projectController.js b/api.hyungi.net/controllers/projectController.js index b1510a0..5cfea4d 100644 --- a/api.hyungi.net/controllers/projectController.js +++ b/api.hyungi.net/controllers/projectController.js @@ -11,6 +11,7 @@ const projectModel = require('../models/projectModel'); const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors'); const { asyncHandler } = require('../middlewares/errorHandler'); const logger = require('../utils/logger'); +const cache = require('../utils/cache'); /** * 프로젝트 생성 @@ -27,6 +28,9 @@ exports.createProject = asyncHandler(async (req, res) => { }); }); + // 프로젝트 캐시 무효화 + await cache.invalidateCache.project(); + logger.info('프로젝트 생성 성공', { project_id: id }); res.status(201).json({ @@ -123,6 +127,9 @@ exports.updateProject = asyncHandler(async (req, res) => { throw new NotFoundError('프로젝트를 찾을 수 없습니다'); } + // 프로젝트 캐시 무효화 + await cache.invalidateCache.project(); + logger.info('프로젝트 수정 성공', { project_id: id }); res.json({ @@ -153,6 +160,9 @@ exports.removeProject = asyncHandler(async (req, res) => { throw new NotFoundError('프로젝트를 찾을 수 없습니다'); } + // 프로젝트 캐시 무효화 + await cache.invalidateCache.project(); + logger.info('프로젝트 삭제 성공', { project_id: id }); res.json({ diff --git a/api.hyungi.net/controllers/workerController.js b/api.hyungi.net/controllers/workerController.js index 2c82f62..b356073 100644 --- a/api.hyungi.net/controllers/workerController.js +++ b/api.hyungi.net/controllers/workerController.js @@ -45,9 +45,9 @@ exports.createWorker = asyncHandler(async (req, res) => { * 전체 작업자 조회 (캐싱 및 페이지네이션 적용) */ exports.getAllWorkers = asyncHandler(async (req, res) => { - const { page = 1, limit = 10, search = '' } = req.query; + const { page = 1, limit = 10, search = '', status = '' } = req.query; - const cacheKey = cache.createKey('workers', 'list', page, limit, search); + const cacheKey = cache.createKey('workers', 'list', page, limit, search, status); // 캐시에서 조회 const cachedData = await cache.get(cacheKey); @@ -62,7 +62,7 @@ exports.getAllWorkers = asyncHandler(async (req, res) => { } // 최적화된 쿼리 사용 - const result = await optimizedQueries.getWorkersPaged(page, limit, search); + const result = await optimizedQueries.getWorkersPaged(page, limit, search, status); // 캐시에 저장 (5분) await cache.set(cacheKey, result, cache.TTL.MEDIUM); @@ -127,6 +127,10 @@ exports.updateWorker = asyncHandler(async (req, res) => { throw new NotFoundError('작업자를 찾을 수 없습니다'); } + // 작업자 관련 캐시 무효화 + logger.info('작업자 수정 후 캐시 무효화', { worker_id: id }); + await cache.invalidateCache.worker(); + logger.info('작업자 수정 성공', { worker_id: id }); res.json({ diff --git a/api.hyungi.net/models/workerModel.js b/api.hyungi.net/models/workerModel.js index e39d3c3..96554dd 100644 --- a/api.hyungi.net/models/workerModel.js +++ b/api.hyungi.net/models/workerModel.js @@ -79,7 +79,6 @@ const update = async (worker, callback) => { worker_id, worker_name, job_type, - join_date, status, phone_number, email, @@ -92,7 +91,6 @@ const update = async (worker, callback) => { `UPDATE workers SET worker_name = ?, job_type = ?, - join_date = ?, status = ?, phone_number = ?, email = ?, @@ -103,7 +101,6 @@ const update = async (worker, callback) => { [ worker_name, job_type, - formatDate(join_date), status, phone_number, email, @@ -116,7 +113,7 @@ const update = async (worker, callback) => { callback(null, result.affectedRows); } catch (err) { - callback(new Error(err.message || String(err))); + callback(new Error(err.message || String(err))); } }; @@ -124,12 +121,12 @@ const update = async (worker, callback) => { const remove = async (worker_id, callback) => { const db = await getDb(); const conn = await db.getConnection(); - + try { await conn.beginTransaction(); - + console.log(`🗑️ 작업자 삭제 시작: worker_id=${worker_id}`); - + // 안전한 삭제: 각 테이블을 개별적으로 처리하고 오류가 발생해도 계속 진행 const tables = [ { name: 'users', query: 'UPDATE users SET worker_id = NULL WHERE worker_id = ?', action: '업데이트' }, @@ -142,7 +139,7 @@ const remove = async (worker_id, callback) => { { name: 'monthly_worker_status', query: 'DELETE FROM monthly_worker_status WHERE worker_id = ?', action: '삭제' }, { name: 'worker_groups', query: 'DELETE FROM worker_groups WHERE worker_id = ?', action: '삭제' } ]; - + for (const table of tables) { try { const [result] = await conn.query(table.query, [worker_id]); @@ -153,17 +150,17 @@ const remove = async (worker_id, callback) => { console.log(`⚠️ ${table.name} 테이블 ${table.action} 실패 (무시): ${tableError.message}`); } } - + // 마지막으로 작업자 삭제 const [result] = await conn.query( `DELETE FROM workers WHERE worker_id = ?`, [worker_id] ); console.log(`✅ 작업자 삭제 완료: ${result.affectedRows}건`); - + await conn.commit(); callback(null, result.affectedRows); - + } catch (err) { await conn.rollback(); console.error(`❌ 작업자 삭제 오류 (worker_id: ${worker_id}):`, err); diff --git a/api.hyungi.net/utils/queryOptimizer.js b/api.hyungi.net/utils/queryOptimizer.js index c6711da..9ded9f2 100644 --- a/api.hyungi.net/utils/queryOptimizer.js +++ b/api.hyungi.net/utils/queryOptimizer.js @@ -9,7 +9,7 @@ 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, @@ -23,21 +23,21 @@ const paginate = (page = 1, limit = 10) => { 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: { @@ -49,7 +49,7 @@ const executePagedQuery = async (baseQuery, countQuery, params = [], options = { hasPrevPage: pageNum > 1 } }; - + } catch (error) { throw new Error(`페이지네이션 쿼리 실행 오류: ${error.message}`); } @@ -61,20 +61,20 @@ const executePagedQuery = async (baseQuery, countQuery, params = [], options = { 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 => + const foreignKeyColumns = columns.filter(col => col.Field.endsWith('_id') && !indexes.some(idx => idx.Column_name === col.Field) ); - + foreignKeyColumns.forEach(col => { suggestions.push({ type: 'INDEX', @@ -83,13 +83,13 @@ const suggestIndexes = async (tableName) => { sql: `CREATE INDEX idx_${tableName}_${col.Field} ON ${tableName}(${col.Field});` }); }); - + // 날짜 컬럼에 인덱스 제안 - const dateColumns = columns.filter(col => - (col.Type.includes('date') || col.Type.includes('timestamp')) && + 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', @@ -98,7 +98,7 @@ const suggestIndexes = async (tableName) => { sql: `CREATE INDEX idx_${tableName}_${col.Field} ON ${tableName}(${col.Field});` }); }); - + return { tableName, currentIndexes: indexes.map(idx => ({ @@ -108,7 +108,7 @@ const suggestIndexes = async (tableName) => { })), suggestions }; - + } catch (error) { throw new Error(`인덱스 분석 오류: ${error.message}`); } @@ -120,23 +120,23 @@ const suggestIndexes = async (tableName) => { 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') { @@ -146,7 +146,7 @@ const analyzeQuery = async (query, params = []) => { suggestion: '적절한 인덱스 추가 권장' }); } - + if (row.rows > 1000) { analysis.recommendations.push({ type: 'WARNING', @@ -154,7 +154,7 @@ const analyzeQuery = async (query, params = []) => { suggestion: 'WHERE 조건 최적화 또는 인덱스 추가 권장' }); } - + if (row.Extra && row.Extra.includes('Using filesort')) { analysis.recommendations.push({ type: 'INFO', @@ -163,9 +163,9 @@ const analyzeQuery = async (query, params = []) => { }); } }); - + return analysis; - + } catch (error) { throw new Error(`쿼리 분석 오류: ${error.message}`); } @@ -178,39 +178,39 @@ 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}`); } @@ -225,7 +225,7 @@ const generateCacheKey = (query, params = [], prefix = 'query') => { .createHash('md5') .update(query + paramString) .digest('hex'); - + return `${prefix}:${queryHash}`; }; @@ -234,30 +234,43 @@ const generateCacheKey = (query, params = [], prefix = 'query') => { */ const optimizedQueries = { // 작업자 목록 (페이지네이션) - getWorkersPaged: async (page = 1, limit = 10, search = '') => { + getWorkersPaged: async (page = 1, limit = 10, search = '', status = '') => { 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 = []; - + let conditions = []; + + // 검색 조건 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'; + conditions.push('(w.worker_name LIKE ? OR w.position LIKE ?)'); + params.push(`%${search}%`, `%${search}%`); } - + + // 상태 조건 + if (status) { + conditions.push('w.status = ?'); + params.push(status); + } + + // 조건 조합 + if (conditions.length > 0) { + const whereClause = ' WHERE ' + conditions.join(' AND '); + baseQuery += whereClause; + countQuery += whereClause; + } + + 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 = ` @@ -266,10 +279,10 @@ const optimizedQueries = { 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'; @@ -278,12 +291,12 @@ const optimizedQueries = { } 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 = ` @@ -299,13 +312,13 @@ const optimizedQueries = { 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' }); @@ -318,11 +331,11 @@ const optimizedQueries = { 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 { @@ -331,10 +344,10 @@ const getPerformanceStats = async () => { } catch (error) { // MySQL 8.0+에서는 쿼리 캐시가 제거됨 } - + // 슬로우 쿼리 로그 상태 const [slowQueries] = await db.execute('SHOW STATUS LIKE "Slow_queries"'); - + return { connections: { current: parseInt(connections[0]?.Value || 0), @@ -344,7 +357,7 @@ const getPerformanceStats = async () => { slowQueries: parseInt(slowQueries[0]?.Value || 0), timestamp: new Date().toISOString() }; - + } catch (error) { throw new Error(`성능 통계 조회 오류: ${error.message}`); } diff --git a/web-ui/js/daily-work-report.js b/web-ui/js/daily-work-report.js index 894b59b..aeb8d53 100644 --- a/web-ui/js/daily-work-report.js +++ b/web-ui/js/daily-work-report.js @@ -30,7 +30,7 @@ function getCurrentUser() { try { const token = localStorage.getItem('token'); if (!token) return null; - + const payloadBase64 = token.split('.')[1]; if (payloadBase64) { const payload = JSON.parse(atob(payloadBase64)); @@ -40,7 +40,7 @@ function getCurrentUser() { } catch (error) { console.log('토큰에서 사용자 정보 추출 실패:', error); } - + try { const userInfo = localStorage.getItem('user') || localStorage.getItem('userInfo') || localStorage.getItem('currentUser'); if (userInfo) { @@ -51,7 +51,7 @@ function getCurrentUser() { } catch (error) { console.log('localStorage에서 사용자 정보 가져오기 실패:', error); } - + return null; } @@ -59,7 +59,7 @@ function getCurrentUser() { function showMessage(message, type = 'info') { const container = document.getElementById('message-container'); container.innerHTML = `
`; - + if (type === 'success') { setTimeout(() => { hideMessage(); @@ -76,7 +76,7 @@ function showSaveResultModal(type, title, message, details = null) { const modal = document.getElementById('saveResultModal'); const titleElement = document.getElementById('resultModalTitle'); const contentElement = document.getElementById('resultModalContent'); - + // 아이콘 설정 let icon = ''; switch (type) { @@ -92,14 +92,14 @@ function showSaveResultModal(type, title, message, details = null) { default: icon = 'ℹ️'; } - + // 모달 내용 구성 let content = `