feat: 3-System 분리 프로젝트 초기 코드 작성

TK-FB(공장관리+신고)와 M-Project(부적합관리)를 3개 독립 시스템으로
분리하기 위한 전체 코드 구조 작성.
- SSO 인증 서비스 (bcrypt + pbkdf2 이중 해시 지원)
- System 1: 공장관리 (TK-FB 기반, 신고 코드 제거)
- System 2: 신고 (TK-FB에서 workIssue 코드 추출)
- System 3: 부적합관리 (M-Project 기반)
- Gateway 포털 (path-based 라우팅)
- 통합 docker-compose.yml 및 배포 스크립트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-09 14:40:11 +09:00
commit 550633b89d
824 changed files with 1071683 additions and 0 deletions

View File

@@ -0,0 +1,178 @@
/**
* @deprecated 이 파일의 미들웨어 함수들은 하위 호환성을 위해 유지됩니다.
* 새로운 코드에서는 '../middlewares/auth'를 사용하세요.
*/
// utils/access.js - 유틸리티 함수와 레거시 호환성
const ACCESS_LEVELS = {
worker: 1,
group_leader: 2,
support_team: 3,
admin: 4,
system: 5
};
const ACCESS_LEVEL_NAMES = {
worker: '작업자',
group_leader: '그룹장',
support_team: '지원팀',
admin: '관리자',
system: '시스템'
};
/**
* 권한 레벨 비교 (유틸리티 함수)
* @param {string} userLevel - 사용자의 권한 레벨
* @param {string} requiredLevel - 필요한 권한 레벨
* @returns {boolean} - 권한 여부
*/
const hasPermission = (userLevel, requiredLevel) => {
const userOrder = ACCESS_LEVELS[userLevel] || 0;
const requiredOrder = ACCESS_LEVELS[requiredLevel] || 999;
return userOrder >= requiredOrder;
};
/**
* 권한 레벨을 숫자로 변환
* @param {string} level - 권한 레벨 문자열
* @returns {number} - 권한 레벨 숫자
*/
const getLevelNumber = (level) => {
return ACCESS_LEVELS[level] || 0;
};
/**
* 권한 레벨을 한글명으로 변환
* @param {string} level - 권한 레벨 문자열
* @returns {string} - 한글 권한명
*/
const getLevelName = (level) => {
return ACCESS_LEVEL_NAMES[level] || '알 수 없음';
};
/**
* 사용자가 특정 권한들 중 하나라도 가지고 있는지 확인
* @param {string} userLevel - 사용자의 권한 레벨
* @param {string[]} allowedLevels - 허용된 권한 레벨들
* @returns {boolean} - 권한 여부
*/
const hasAnyPermission = (userLevel, allowedLevels) => {
return allowedLevels.some(level => hasPermission(userLevel, level));
};
/**
* 모든 권한 레벨 목록 반환
* @returns {string[]} - 권한 레벨 배열
*/
const getAllLevels = () => {
return Object.keys(ACCESS_LEVELS);
};
/**
* 특정 권한 레벨 이상의 모든 권한 반환
* @param {string} minLevel - 최소 권한 레벨
* @returns {string[]} - 해당 레벨 이상의 권한들
*/
const getLevelsAbove = (minLevel) => {
const minOrder = ACCESS_LEVELS[minLevel] || 0;
return Object.keys(ACCESS_LEVELS).filter(level => ACCESS_LEVELS[level] >= minOrder);
};
// ===== 프론트엔드용 권한 체크 함수들 =====
/**
* 페이지 접근 권한 체크 (프론트엔드에서 사용)
* @param {string} userLevel - 사용자 권한
* @param {object} pageConfig - 페이지 설정 {minLevel?, allowedRoles?, deniedRoles?}
* @returns {boolean} - 접근 가능 여부
*/
const canAccessPage = (userLevel, pageConfig) => {
const { minLevel, allowedRoles, deniedRoles = [] } = pageConfig;
// 거부 목록 체크
if (deniedRoles.includes(userLevel)) {
return false;
}
// 허용 목록 체크
if (allowedRoles && allowedRoles.length > 0) {
return allowedRoles.includes(userLevel);
}
// 최소 레벨 체크
if (minLevel) {
return hasPermission(userLevel, minLevel);
}
return true;
};
/**
* CRUD 권한 체크 (프론트엔드에서 사용)
* @param {string} userLevel - 사용자 권한
* @param {string} table - 테이블명
* @param {string} action - 액션 (CREATE, READ, UPDATE, DELETE)
* @returns {boolean} - 권한 여부
*/
const canPerformAction = (userLevel, table, action) => {
// 기본적으로 모든 인증된 사용자에게 권한 부여
// 특별한 제한이 필요한 경우에만 여기서 체크
// 예시: 사용자 관리는 admin 이상만
if (table === 'Users' && ['CREATE', 'UPDATE', 'DELETE'].includes(action)) {
return hasPermission(userLevel, 'admin');
}
// 예시: 시스템 테이블은 system만
if (table === 'SystemConfig') {
return hasPermission(userLevel, 'system');
}
// 나머지는 모든 인증된 사용자에게 허용
return userLevel && userLevel !== 'anonymous';
};
// ===== 레거시 호환성: 미들웨어 함수 =====
/**
* @deprecated 이 미들웨어는 하위 호환성을 위해 유지됩니다.
* 새로운 코드에서는 '../middlewares/auth'의 requireRole을 사용하세요.
*
* @example
* // 이전 방식 (deprecated)
* const { requireAccess } = require('../utils/access');
* router.get('/admin', requireAccess('admin'), handler);
*
* // 새로운 방식 (권장)
* const { requireAuth, requireRole } = require('../middlewares/auth');
* router.get('/admin', requireAuth, requireRole('admin'), handler);
*/
const requireAccess = (...allowed) => {
return (req, res, next) => {
if (!req.user || !allowed.includes(req.user.access_level)) {
return res.status(403).json({ error: '접근 권한이 없습니다' });
}
next();
};
};
module.exports = {
// 기본 유틸리티 함수들
hasPermission,
getLevelNumber,
getLevelName,
hasAnyPermission,
getAllLevels,
getLevelsAbove,
// 프론트엔드용 함수들
canAccessPage,
canPerformAction,
// 레거시 호환성 (deprecated)
requireAccess,
// 상수들
ACCESS_LEVELS,
ACCESS_LEVEL_NAMES
};

View File

@@ -0,0 +1,284 @@
// 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
};

View File

@@ -0,0 +1,131 @@
/**
* 날짜/시간 유틸리티 함수
*
* 중요: 이 프로젝트는 한국(Asia/Seoul, UTC+9) 시간대 기준으로 운영됩니다.
* DB에 저장되는 비즈니스 날짜(report_date, session_date 등)는 반드시 한국 시간 기준이어야 합니다.
*
* @author TK-FB-Project
* @since 2026-02-03
*/
const KOREA_TIMEZONE_OFFSET = 9; // UTC+9
/**
* 한국 시간(KST) 기준 현재 Date 객체 반환
* @returns {Date} 한국 시간 기준 Date 객체
*/
function getKoreaDate() {
const now = new Date();
return new Date(now.getTime() + (KOREA_TIMEZONE_OFFSET * 60 * 60 * 1000));
}
/**
* 한국 시간(KST) 기준 현재 날짜 문자열 반환
* @returns {string} 'YYYY-MM-DD' 형식
*/
function getKoreaDateString() {
const koreaDate = getKoreaDate();
const year = koreaDate.getUTCFullYear();
const month = String(koreaDate.getUTCMonth() + 1).padStart(2, '0');
const day = String(koreaDate.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 한국 시간(KST) 기준 현재 datetime 문자열 반환
* @returns {string} 'YYYY-MM-DD HH:mm:ss' 형식 (MySQL DATETIME 호환)
*/
function getKoreaDatetime() {
const koreaDate = getKoreaDate();
const year = koreaDate.getUTCFullYear();
const month = String(koreaDate.getUTCMonth() + 1).padStart(2, '0');
const day = String(koreaDate.getUTCDate()).padStart(2, '0');
const hours = String(koreaDate.getUTCHours()).padStart(2, '0');
const minutes = String(koreaDate.getUTCMinutes()).padStart(2, '0');
const seconds = String(koreaDate.getUTCSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
/**
* 한국 시간(KST) 기준 현재 시간 문자열 반환
* @returns {string} 'HH:mm:ss' 형식
*/
function getKoreaTimeString() {
const koreaDate = getKoreaDate();
const hours = String(koreaDate.getUTCHours()).padStart(2, '0');
const minutes = String(koreaDate.getUTCMinutes()).padStart(2, '0');
const seconds = String(koreaDate.getUTCSeconds()).padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
}
/**
* UTC Date를 한국 시간 datetime 문자열로 변환
* @param {Date} date - UTC 기준 Date 객체
* @returns {string} 'YYYY-MM-DD HH:mm:ss' 형식
*/
function toKoreaDatetime(date) {
if (!date) return null;
const koreaDate = new Date(date.getTime() + (KOREA_TIMEZONE_OFFSET * 60 * 60 * 1000));
const year = koreaDate.getUTCFullYear();
const month = String(koreaDate.getUTCMonth() + 1).padStart(2, '0');
const day = String(koreaDate.getUTCDate()).padStart(2, '0');
const hours = String(koreaDate.getUTCHours()).padStart(2, '0');
const minutes = String(koreaDate.getUTCMinutes()).padStart(2, '0');
const seconds = String(koreaDate.getUTCSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
/**
* UTC Date를 한국 날짜 문자열로 변환
* @param {Date} date - UTC 기준 Date 객체
* @returns {string} 'YYYY-MM-DD' 형식
*/
function toKoreaDateString(date) {
if (!date) return null;
const koreaDate = new Date(date.getTime() + (KOREA_TIMEZONE_OFFSET * 60 * 60 * 1000));
const year = koreaDate.getUTCFullYear();
const month = String(koreaDate.getUTCMonth() + 1).padStart(2, '0');
const day = String(koreaDate.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 한국 시간 datetime 문자열을 Date 객체로 변환
* @param {string} koreaDatetimeStr - 'YYYY-MM-DD HH:mm:ss' 형식
* @returns {Date} UTC 기준 Date 객체
*/
function fromKoreaDatetime(koreaDatetimeStr) {
if (!koreaDatetimeStr) return null;
// 한국 시간 문자열을 UTC로 변환
const date = new Date(koreaDatetimeStr + '+09:00');
return date;
}
/**
* 연도 반환 (한국 시간 기준)
* @returns {number} 현재 연도
*/
function getKoreaYear() {
return getKoreaDate().getUTCFullYear();
}
/**
* 월 반환 (한국 시간 기준, 1-12)
* @returns {number} 현재 월 (1-12)
*/
function getKoreaMonth() {
return getKoreaDate().getUTCMonth() + 1;
}
module.exports = {
KOREA_TIMEZONE_OFFSET,
getKoreaDate,
getKoreaDateString,
getKoreaDatetime,
getKoreaTimeString,
toKoreaDatetime,
toKoreaDateString,
fromKoreaDatetime,
getKoreaYear,
getKoreaMonth,
};

View File

@@ -0,0 +1,143 @@
// utils/errorHandler.js - 통합 에러 처리 유틸리티
/**
* 표준화된 에러 응답 생성
*/
class ApiError extends Error {
constructor(message, statusCode = 500, errorCode = null) {
super(message);
this.statusCode = statusCode;
this.errorCode = errorCode;
this.timestamp = new Date().toISOString();
}
}
/**
* 에러 응답 포맷터
*/
const formatErrorResponse = (error, req = null) => {
const response = {
success: false,
error: error.message || '알 수 없는 오류가 발생했습니다.',
timestamp: error.timestamp || new Date().toISOString()
};
// 개발 환경에서만 상세 정보 포함
if (process.env.NODE_ENV === 'development') {
response.stack = error.stack;
response.errorCode = error.errorCode;
if (req) {
response.requestInfo = {
method: req.method,
url: req.originalUrl,
userAgent: req.get('User-Agent'),
ip: req.ip
};
}
}
return response;
};
/**
* 데이터베이스 에러 처리
*/
const handleDatabaseError = (error, operation = 'database operation') => {
console.error(`[DB Error] ${operation}:`, error);
// 일반적인 DB 에러 코드 매핑
const errorMappings = {
'ER_DUP_ENTRY': { message: '중복된 데이터입니다.', statusCode: 409 },
'ER_NO_REFERENCED_ROW_2': { message: '참조된 데이터가 존재하지 않습니다.', statusCode: 400 },
'ER_ROW_IS_REFERENCED_2': { message: '다른 데이터에서 참조되고 있어 삭제할 수 없습니다.', statusCode: 409 },
'ER_BAD_FIELD_ERROR': { message: '잘못된 필드명입니다.', statusCode: 400 },
'ER_NO_SUCH_TABLE': { message: '테이블이 존재하지 않습니다.', statusCode: 500 },
'ECONNREFUSED': { message: '데이터베이스 연결에 실패했습니다.', statusCode: 503 }
};
const mapping = errorMappings[error.code] || errorMappings[error.errno];
if (mapping) {
throw new ApiError(mapping.message, mapping.statusCode, error.code);
}
// 기본 에러
throw new ApiError(`${operation} 중 오류가 발생했습니다.`, 500, error.code);
};
/**
* 유효성 검사 에러 처리
*/
const handleValidationError = (field, value, rule) => {
const message = `${field} 필드가 유효하지 않습니다. (값: ${value}, 규칙: ${rule})`;
throw new ApiError(message, 400, 'VALIDATION_ERROR');
};
/**
* 권한 에러 처리
*/
const handleAuthorizationError = (requiredLevel, userLevel) => {
const message = `접근 권한이 부족합니다. (필요: ${requiredLevel}, 현재: ${userLevel})`;
throw new ApiError(message, 403, 'AUTHORIZATION_ERROR');
};
/**
* 리소스 없음 에러 처리
*/
const handleNotFoundError = (resource, identifier = null) => {
const message = identifier
? `${resource}(${identifier})을(를) 찾을 수 없습니다.`
: `${resource}을(를) 찾을 수 없습니다.`;
throw new ApiError(message, 404, 'NOT_FOUND');
};
/**
* Express 에러 핸들러 미들웨어
*/
const errorMiddleware = (error, req, res, next) => {
// ApiError가 아닌 경우 변환
if (!(error instanceof ApiError)) {
error = new ApiError(error.message || '서버 내부 오류', 500);
}
const response = formatErrorResponse(error, req);
// 로깅
if (error.statusCode >= 500) {
console.error('[Server Error]', {
message: error.message,
stack: error.stack,
url: req.originalUrl,
method: req.method,
user: req.user?.username || 'anonymous'
});
} else {
console.warn('[Client Error]', {
message: error.message,
url: req.originalUrl,
method: req.method,
user: req.user?.username || 'anonymous'
});
}
res.status(error.statusCode).json(response);
};
/**
* 비동기 함수 래퍼 (에러 자동 처리)
*/
const asyncHandler = (fn) => {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
module.exports = {
ApiError,
formatErrorResponse,
handleDatabaseError,
handleValidationError,
handleAuthorizationError,
handleNotFoundError,
errorMiddleware,
asyncHandler
};

View File

@@ -0,0 +1,186 @@
/**
* 커스텀 에러 클래스
*
* 애플리케이션 전체에서 사용하는 표준화된 에러 클래스들
*
* @author TK-FB-Project
* @since 2025-12-11
*/
/**
* 기본 애플리케이션 에러 클래스
* 모든 커스텀 에러의 부모 클래스
*/
class AppError extends Error {
/**
* @param {string} message - 에러 메시지
* @param {number} statusCode - HTTP 상태 코드
* @param {string} code - 에러 코드 (예: 'VALIDATION_ERROR')
* @param {object} details - 추가 세부 정보
*/
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR', details = null) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.code = code;
this.details = details;
this.isOperational = true; // 운영 에러 (예상된 에러)
Error.captureStackTrace(this, this.constructor);
}
/**
* JSON 형태로 에러 정보 반환
*/
toJSON() {
return {
name: this.name,
message: this.message,
statusCode: this.statusCode,
code: this.code,
details: this.details
};
}
}
/**
* 검증 에러 (400 Bad Request)
* 입력값 검증 실패 시 사용
*/
class ValidationError extends AppError {
/**
* @param {string} message - 에러 메시지
* @param {object} details - 검증 실패 세부 정보
*/
constructor(message = '입력값이 올바르지 않습니다', details = null) {
super(message, 400, 'VALIDATION_ERROR', details);
}
}
/**
* 인증 에러 (401 Unauthorized)
* 인증이 필요하거나 인증 실패 시 사용
*/
class AuthenticationError extends AppError {
/**
* @param {string} message - 에러 메시지
*/
constructor(message = '인증이 필요합니다') {
super(message, 401, 'AUTHENTICATION_ERROR');
}
}
/**
* 권한 에러 (403 Forbidden)
* 권한이 부족할 때 사용
*/
class ForbiddenError extends AppError {
/**
* @param {string} message - 에러 메시지
*/
constructor(message = '권한이 없습니다') {
super(message, 403, 'FORBIDDEN');
}
}
/**
* 리소스 없음 에러 (404 Not Found)
* 요청한 리소스를 찾을 수 없을 때 사용
*/
class NotFoundError extends AppError {
/**
* @param {string} message - 에러 메시지
* @param {string} resource - 찾을 수 없는 리소스명
*/
constructor(message = '리소스를 찾을 수 없습니다', resource = null) {
super(message, 404, 'NOT_FOUND', resource ? { resource } : null);
}
}
/**
* 충돌 에러 (409 Conflict)
* 중복된 리소스 등 충돌 발생 시 사용
*/
class ConflictError extends AppError {
/**
* @param {string} message - 에러 메시지
* @param {object} details - 충돌 세부 정보
*/
constructor(message = '이미 존재하는 데이터입니다', details = null) {
super(message, 409, 'CONFLICT', details);
}
}
/**
* 서버 에러 (500 Internal Server Error)
* 예상하지 못한 서버 오류 시 사용
*/
class InternalServerError extends AppError {
/**
* @param {string} message - 에러 메시지
*/
constructor(message = '서버 오류가 발생했습니다') {
super(message, 500, 'INTERNAL_SERVER_ERROR');
}
}
/**
* 데이터베이스 에러 (500 Internal Server Error)
* DB 관련 오류 시 사용
*/
class DatabaseError extends AppError {
/**
* @param {string} message - 에러 메시지
* @param {Error} originalError - 원본 DB 에러
*/
constructor(message = '데이터베이스 오류가 발생했습니다', originalError = null) {
super(
message,
500,
'DATABASE_ERROR',
originalError ? { originalMessage: originalError.message } : null
);
this.originalError = originalError;
}
}
/**
* 외부 API 에러 (502 Bad Gateway)
* 외부 서비스 호출 실패 시 사용
*/
class ExternalApiError extends AppError {
/**
* @param {string} message - 에러 메시지
* @param {string} service - 외부 서비스명
*/
constructor(message = '외부 서비스 호출에 실패했습니다', service = null) {
super(message, 502, 'EXTERNAL_API_ERROR', service ? { service } : null);
}
}
/**
* 타임아웃 에러 (504 Gateway Timeout)
* 요청 처리 시간 초과 시 사용
*/
class TimeoutError extends AppError {
/**
* @param {string} message - 에러 메시지
* @param {number} timeout - 타임아웃 시간 (ms)
*/
constructor(message = '요청 처리 시간이 초과되었습니다', timeout = null) {
super(message, 504, 'TIMEOUT_ERROR', timeout ? { timeout } : null);
}
}
module.exports = {
AppError,
ValidationError,
AuthenticationError,
ForbiddenError,
NotFoundError,
ConflictError,
InternalServerError,
DatabaseError,
ExternalApiError,
TimeoutError
};

View File

@@ -0,0 +1,315 @@
/**
* File Upload Security - 파일 업로드 보안 유틸리티
*
* - Magic number (파일 시그니처) 검증
* - 파일명 sanitize
* - 확장자 화이트리스트 검증
* - 파일 크기 제한
*
* @author TK-FB-Project
* @since 2026-02-04
*/
const path = require('path');
const crypto = require('crypto');
const fs = require('fs').promises;
/**
* 파일 시그니처 (Magic Numbers)
* 파일의 실제 타입을 확인하기 위한 바이너리 시그니처
*/
const FILE_SIGNATURES = {
// 이미지
'ffd8ff': { mime: 'image/jpeg', ext: ['.jpg', '.jpeg'] },
'89504e47': { mime: 'image/png', ext: ['.png'] },
'47494638': { mime: 'image/gif', ext: ['.gif'] },
'52494646': { mime: 'image/webp', ext: ['.webp'] }, // RIFF (WebP 시작)
// 문서
'25504446': { mime: 'application/pdf', ext: ['.pdf'] },
'504b0304': { mime: 'application/zip', ext: ['.zip', '.xlsx', '.docx', '.pptx'] },
// 주의: BMP, TIFF 등 추가 가능
};
/**
* 허용된 이미지 확장자
*/
const ALLOWED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
/**
* 허용된 문서 확장자
*/
const ALLOWED_DOCUMENT_EXTENSIONS = ['.pdf', '.xlsx', '.docx', '.pptx', '.zip'];
/**
* 위험한 확장자 (절대 허용 안 함)
*/
const DANGEROUS_EXTENSIONS = [
'.exe', '.bat', '.cmd', '.sh', '.ps1', '.vbs', '.js', '.jar',
'.php', '.asp', '.aspx', '.jsp', '.cgi', '.pl', '.py', '.rb',
'.htaccess', '.htpasswd', '.ini', '.config', '.env'
];
/**
* 파일 시그니처(Magic Number) 검증
*
* @param {Buffer} buffer - 파일 버퍼 (최소 8바이트)
* @returns {Object|null} 매칭된 파일 정보 또는 null
*/
const checkMagicNumber = (buffer) => {
if (!buffer || buffer.length < 4) {
return null;
}
// 처음 8바이트를 hex로 변환
const hex = buffer.slice(0, 8).toString('hex').toLowerCase();
// 시그니처 매칭
for (const [signature, info] of Object.entries(FILE_SIGNATURES)) {
if (hex.startsWith(signature)) {
return info;
}
}
return null;
};
/**
* 파일 버퍼에서 실제 MIME 타입 검증
*
* @param {Buffer} buffer - 파일 버퍼
* @param {string} declaredMime - 선언된 MIME 타입
* @returns {Object} { valid: boolean, actualType: string|null, message: string }
*/
const validateFileType = (buffer, declaredMime) => {
const detected = checkMagicNumber(buffer);
if (!detected) {
return {
valid: false,
actualType: null,
message: '알 수 없는 파일 형식입니다.'
};
}
// MIME 타입이 일치하는지 확인
if (detected.mime !== declaredMime) {
return {
valid: false,
actualType: detected.mime,
message: `파일 형식이 일치하지 않습니다. (선언: ${declaredMime}, 실제: ${detected.mime})`
};
}
return {
valid: true,
actualType: detected.mime,
message: 'OK'
};
};
/**
* 파일명 sanitize
* 경로 조작 및 특수문자 제거
*
* @param {string} filename - 원본 파일명
* @returns {string} 안전한 파일명
*/
const sanitizeFilename = (filename) => {
if (!filename || typeof filename !== 'string') {
return 'unnamed';
}
// 경로 구분자 제거 (path traversal 방지)
let safe = path.basename(filename);
// 특수문자 제거 (영문, 숫자, -, _, . 만 허용)
safe = safe.replace(/[^a-zA-Z0-9가-힣._-]/g, '_');
// 연속된 점 제거 (이중 확장자 방지)
safe = safe.replace(/\.{2,}/g, '.');
// 앞뒤 점/공백 제거
safe = safe.replace(/^[\s.]+|[\s.]+$/g, '');
// 빈 파일명 처리
if (!safe || safe === '') {
safe = 'unnamed';
}
// 최대 길이 제한 (255자)
if (safe.length > 255) {
const ext = path.extname(safe);
const name = path.basename(safe, ext);
safe = name.slice(0, 255 - ext.length) + ext;
}
return safe;
};
/**
* 확장자 검증
*
* @param {string} filename - 파일명
* @param {string[]} allowedExtensions - 허용된 확장자 배열
* @returns {Object} { valid: boolean, extension: string, message: string }
*/
const validateExtension = (filename, allowedExtensions = ALLOWED_IMAGE_EXTENSIONS) => {
const ext = path.extname(filename).toLowerCase();
// 위험한 확장자 체크
if (DANGEROUS_EXTENSIONS.includes(ext)) {
return {
valid: false,
extension: ext,
message: `보안상 허용되지 않는 파일 형식입니다: ${ext}`
};
}
// 허용된 확장자 체크
if (!allowedExtensions.includes(ext)) {
return {
valid: false,
extension: ext,
message: `허용된 파일 형식: ${allowedExtensions.join(', ')}`
};
}
return {
valid: true,
extension: ext,
message: 'OK'
};
};
/**
* 안전한 랜덤 파일명 생성
*
* @param {string} originalFilename - 원본 파일명 (확장자 추출용)
* @returns {string} 랜덤 파일명
*/
const generateSafeFilename = (originalFilename) => {
const ext = path.extname(originalFilename).toLowerCase();
const randomName = crypto.randomBytes(16).toString('hex');
const timestamp = Date.now();
return `${timestamp}_${randomName}${ext}`;
};
/**
* 안전한 업로드 경로 생성
* 경로 조작(path traversal) 방지
*
* @param {string} baseDir - 기본 업로드 디렉토리
* @param {string} filename - 파일명
* @returns {string} 안전한 전체 경로
*/
const getSafeUploadPath = (baseDir, filename) => {
const safeName = sanitizeFilename(filename);
const fullPath = path.join(baseDir, safeName);
// 결과 경로가 baseDir 안에 있는지 확인
const resolvedBase = path.resolve(baseDir);
const resolvedFull = path.resolve(fullPath);
if (!resolvedFull.startsWith(resolvedBase)) {
throw new Error('경로 조작이 감지되었습니다.');
}
return resolvedFull;
};
/**
* Multer 파일 필터 생성
*
* @param {Object} options - 옵션
* @param {string[]} options.allowedExtensions - 허용된 확장자
* @param {string[]} options.allowedMimes - 허용된 MIME 타입
* @param {boolean} options.checkMagicNumber - Magic number 검증 여부
* @returns {Function} Multer fileFilter 함수
*/
const createFileFilter = (options = {}) => {
const {
allowedExtensions = ALLOWED_IMAGE_EXTENSIONS,
allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
checkMagicNumber = false // Multer에서는 버퍼 접근이 제한적이므로 기본 false
} = options;
return (req, file, cb) => {
// 확장자 검증
const extResult = validateExtension(file.originalname, allowedExtensions);
if (!extResult.valid) {
return cb(new Error(extResult.message), false);
}
// MIME 타입 검증
if (!allowedMimes.includes(file.mimetype)) {
return cb(new Error(`허용된 MIME 타입: ${allowedMimes.join(', ')}`), false);
}
cb(null, true);
};
};
/**
* 업로드된 파일 검증 (후처리용)
* Multer 업로드 후 파일 내용을 검증
*
* @param {string} filePath - 업로드된 파일 경로
* @param {string} declaredMime - 선언된 MIME 타입
* @returns {Promise<Object>} 검증 결과
*/
const validateUploadedFile = async (filePath, declaredMime) => {
try {
// 파일 시작 부분 읽기
const fd = await fs.open(filePath, 'r');
const buffer = Buffer.alloc(8);
await fd.read(buffer, 0, 8, 0);
await fd.close();
// Magic number 검증
const typeResult = validateFileType(buffer, declaredMime);
if (!typeResult.valid) {
// 위험한 파일이면 삭제
await fs.unlink(filePath);
return {
valid: false,
deleted: true,
message: typeResult.message
};
}
return {
valid: true,
deleted: false,
message: 'OK',
actualType: typeResult.actualType
};
} catch (error) {
return {
valid: false,
deleted: false,
message: `파일 검증 중 오류: ${error.message}`
};
}
};
module.exports = {
// 상수
ALLOWED_IMAGE_EXTENSIONS,
ALLOWED_DOCUMENT_EXTENSIONS,
DANGEROUS_EXTENSIONS,
FILE_SIGNATURES,
// 함수
checkMagicNumber,
validateFileType,
sanitizeFilename,
validateExtension,
generateSafeFilename,
getSafeUploadPath,
createFileFilter,
validateUploadedFile
};

View File

@@ -0,0 +1,166 @@
/**
* 한글 이름을 영문(로마자)으로 변환하는 유틸리티
*
* 사용 예시:
* hangulToRoman('홍길동') => 'hong.gildong'
* hangulToRoman('김철수') => 'kim.cheolsu'
*/
// 한글 로마자 변환 매핑 (국립국어원 표기법 기준)
const CHOSUNG_MAP = {
'ㄱ': 'g', 'ㄲ': 'kk', 'ㄴ': 'n', 'ㄷ': 'd', 'ㄸ': 'tt',
'ㄹ': 'r', 'ㅁ': 'm', 'ㅂ': 'b', 'ㅃ': 'pp', 'ㅅ': 's',
'ㅆ': 'ss', 'ㅇ': '', 'ㅈ': 'j', 'ㅉ': 'jj', 'ㅊ': 'ch',
'ㅋ': 'k', 'ㅌ': 't', 'ㅍ': 'p', 'ㅎ': 'h'
};
const JUNGSUNG_MAP = {
'ㅏ': 'a', 'ㅐ': 'ae', 'ㅑ': 'ya', 'ㅒ': 'yae', 'ㅓ': 'eo',
'ㅔ': 'e', 'ㅕ': 'yeo', 'ㅖ': 'ye', 'ㅗ': 'o', 'ㅘ': 'wa',
'ㅙ': 'wae', 'ㅚ': 'oe', 'ㅛ': 'yo', 'ㅜ': 'u', 'ㅝ': 'wo',
'ㅞ': 'we', 'ㅟ': 'wi', 'ㅠ': 'yu', 'ㅡ': 'eu', 'ㅢ': 'ui',
'ㅣ': 'i'
};
const JONGSUNG_MAP = {
'': '', 'ㄱ': 'k', 'ㄲ': 'k', 'ㄳ': 'k', 'ㄴ': 'n', 'ㄵ': 'n',
'ㄶ': 'n', 'ㄷ': 't', 'ㄹ': 'l', 'ㄺ': 'k', 'ㄻ': 'm', 'ㄼ': 'p',
'ㄽ': 'l', 'ㄾ': 'l', 'ㄿ': 'p', 'ㅀ': 'l', 'ㅁ': 'm', 'ㅂ': 'p',
'ㅄ': 'p', 'ㅅ': 't', 'ㅆ': 't', 'ㅇ': 'ng', 'ㅈ': 't', 'ㅊ': 't',
'ㅋ': 'k', 'ㅌ': 't', 'ㅍ': 'p', 'ㅎ': 't'
};
const CHOSUNG = [
'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ',
'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'
];
const JUNGSUNG = [
'ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ',
'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ'
];
const JONGSUNG = [
'', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ',
'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ',
'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'
];
/**
* 한글 한 글자를 초성, 중성, 종성으로 분리
* @param {string} char - 한글 한 글자
* @returns {object} { cho, jung, jong }
*/
function decomposeHangul(char) {
const code = char.charCodeAt(0) - 0xAC00;
if (code < 0 || code > 11171) {
return null; // 한글이 아님
}
const choIndex = Math.floor(code / 588);
const jungIndex = Math.floor((code % 588) / 28);
const jongIndex = code % 28;
return {
cho: CHOSUNG[choIndex],
jung: JUNGSUNG[jungIndex],
jong: JONGSUNG[jongIndex]
};
}
/**
* 한글 이름을 로마자로 변환
* @param {string} koreanName - 한글 이름
* @returns {string} 로마자 이름 (예: 'hong.gildong')
*/
function hangulToRoman(koreanName) {
if (!koreanName || typeof koreanName !== 'string') {
return '';
}
// 공백 제거
const trimmed = koreanName.trim();
// 성과 이름 분리 (첫 글자를 성으로 간주)
const surname = trimmed[0];
const givenName = trimmed.substring(1);
// 각 부분을 로마자로 변환
const romanSurname = convertToRoman(surname);
const romanGivenName = convertToRoman(givenName);
// 점(.)으로 연결
return `${romanSurname}.${romanGivenName}`.toLowerCase();
}
/**
* 한글 문자열을 로마자로 변환
* @param {string} text - 한글 문자열
* @returns {string} 로마자 문자열
*/
function convertToRoman(text) {
let result = '';
for (let i = 0; i < text.length; i++) {
const char = text[i];
const decomposed = decomposeHangul(char);
if (decomposed) {
result += CHOSUNG_MAP[decomposed.cho] || '';
result += JUNGSUNG_MAP[decomposed.jung] || '';
result += JONGSUNG_MAP[decomposed.jong] || '';
} else {
// 한글이 아닌 경우 그대로 추가
result += char;
}
}
return result;
}
/**
* 사용자명 생성 (중복 확인 및 처리)
* @param {string} koreanName - 한글 이름
* @param {object} db - Database connection (mysql2 pool or knex)
* @returns {Promise<string>} 고유한 username
*/
async function generateUniqueUsername(koreanName, db) {
const baseUsername = hangulToRoman(koreanName);
let username = baseUsername;
let counter = 1;
// 중복 확인
while (true) {
let existing;
// mysql2 pool 또는 knex 모두 지원
if (typeof db === 'function') {
// Knex
existing = await db('users')
.where('username', username)
.first();
} else {
// mysql2 pool
const [rows] = await db.query('SELECT username FROM users WHERE username = ?', [username]);
existing = rows[0];
}
if (!existing) {
break; // 중복 없음
}
// 중복 시 숫자 추가
username = `${baseUsername}${counter}`;
counter++;
}
return username;
}
module.exports = {
hangulToRoman,
convertToRoman,
generateUniqueUsername,
decomposeHangul
};

View File

@@ -0,0 +1,197 @@
/**
* 로깅 유틸리티
*
* 애플리케이션 전체에서 사용하는 통합 로거
*
* @author TK-FB-Project
* @since 2025-12-11
*/
const fs = require('fs');
const path = require('path');
/**
* 로그 레벨 정의
*/
const LogLevel = {
ERROR: 'ERROR',
WARN: 'WARN',
INFO: 'INFO',
DEBUG: 'DEBUG'
};
/**
* 로그 레벨별 이모지
*/
const LogEmoji = {
ERROR: '❌',
WARN: '⚠️',
INFO: '',
DEBUG: '🔍'
};
/**
* 로그 레벨별 색상 (콘솔)
*/
const LogColor = {
ERROR: '\x1b[31m', // Red
WARN: '\x1b[33m', // Yellow
INFO: '\x1b[36m', // Cyan
DEBUG: '\x1b[90m', // Gray
RESET: '\x1b[0m'
};
class Logger {
constructor() {
this.logDir = path.join(__dirname, '../logs');
this.logFile = path.join(this.logDir, 'app.log');
this.errorFile = path.join(this.logDir, 'error.log');
this.ensureLogDirectory();
}
/**
* 로그 디렉토리 생성
*/
ensureLogDirectory() {
if (!fs.existsSync(this.logDir)) {
fs.mkdirSync(this.logDir, { recursive: true });
}
}
/**
* 타임스탬프 생성
*/
getTimestamp() {
return new Date().toISOString();
}
/**
* 로그 포맷팅
*/
formatLog(level, message, context = {}) {
const timestamp = this.getTimestamp();
const emoji = LogEmoji[level] || '';
const contextStr = Object.keys(context).length > 0
? `\n Context: ${JSON.stringify(context, null, 2)}`
: '';
return `[${timestamp}] [${level}] ${emoji} ${message}${contextStr}`;
}
/**
* 콘솔에 컬러 로그 출력
*/
logToConsole(level, message, context = {}) {
const color = LogColor[level] || LogColor.RESET;
const formattedLog = this.formatLog(level, message, context);
if (level === LogLevel.ERROR) {
console.error(`${color}${formattedLog}${LogColor.RESET}`);
} else if (level === LogLevel.WARN) {
console.warn(`${color}${formattedLog}${LogColor.RESET}`);
} else {
console.log(`${color}${formattedLog}${LogColor.RESET}`);
}
}
/**
* 파일에 로그 기록
*/
logToFile(level, message, context = {}) {
const formattedLog = this.formatLog(level, message, context);
const logEntry = `${formattedLog}\n`;
try {
// 모든 로그를 app.log에 기록
fs.appendFileSync(this.logFile, logEntry, 'utf8');
// 에러는 error.log에도 기록
if (level === LogLevel.ERROR) {
fs.appendFileSync(this.errorFile, logEntry, 'utf8');
}
} catch (err) {
console.error('로그 파일 기록 실패:', err);
}
}
/**
* 로그 기록 메인 함수
*/
log(level, message, context = {}) {
// 개발 환경에서는 콘솔에 출력
if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV !== 'production') {
this.logToConsole(level, message, context);
}
// 프로덕션에서는 파일에만 기록
if (process.env.NODE_ENV === 'production') {
this.logToFile(level, message, context);
}
}
/**
* 에러 로그
*/
error(message, context = {}) {
this.log(LogLevel.ERROR, message, context);
}
/**
* 경고 로그
*/
warn(message, context = {}) {
this.log(LogLevel.WARN, message, context);
}
/**
* 정보 로그
*/
info(message, context = {}) {
this.log(LogLevel.INFO, message, context);
}
/**
* 디버그 로그
*/
debug(message, context = {}) {
// DEBUG 로그는 개발 환경에서만 출력
if (process.env.NODE_ENV === 'development') {
this.log(LogLevel.DEBUG, message, context);
}
}
/**
* HTTP 요청 로그
*/
http(method, url, statusCode, duration, user = 'anonymous') {
const level = statusCode >= 400 ? LogLevel.ERROR : LogLevel.INFO;
const message = `${method} ${url} - ${statusCode} (${duration}ms)`;
const context = {
method,
url,
statusCode,
duration,
user
};
this.log(level, message, context);
}
/**
* 데이터베이스 쿼리 로그
*/
query(sql, params = [], duration = 0) {
if (process.env.NODE_ENV === 'development') {
this.debug('DB Query', {
sql,
params,
duration: `${duration}ms`
});
}
}
}
// 싱글톤 인스턴스 생성 및 내보내기
const logger = new Logger();
module.exports = logger;

View File

@@ -0,0 +1,173 @@
/**
* Password Validator - 비밀번호 정책 검증
*
* 강력한 비밀번호 정책:
* - 최소 12자 이상
* - 대문자 포함
* - 소문자 포함
* - 숫자 포함
* - 특수문자 포함
*
* @author TK-FB-Project
* @since 2026-02-04
*/
/**
* 비밀번호 강도 검증
*
* @param {string} password - 검증할 비밀번호
* @param {Object} options - 옵션 (기본값 사용 권장)
* @returns {Object} { valid: boolean, errors: string[], strength: string }
*/
const validatePassword = (password, options = {}) => {
const config = {
minLength: options.minLength || 12,
requireUppercase: options.requireUppercase !== false,
requireLowercase: options.requireLowercase !== false,
requireNumbers: options.requireNumbers !== false,
requireSpecialChars: options.requireSpecialChars !== false,
maxLength: options.maxLength || 128
};
const errors = [];
let strength = 0;
// 필수 검증
if (!password || typeof password !== 'string') {
return {
valid: false,
errors: ['비밀번호를 입력해주세요.'],
strength: 'invalid'
};
}
// 길이 검증
if (password.length < config.minLength) {
errors.push(`비밀번호는 최소 ${config.minLength}자 이상이어야 합니다.`);
} else {
strength += 1;
}
if (password.length > config.maxLength) {
errors.push(`비밀번호는 ${config.maxLength}자를 초과할 수 없습니다.`);
}
// 대문자 검증
if (config.requireUppercase && !/[A-Z]/.test(password)) {
errors.push('대문자를 1개 이상 포함해야 합니다.');
} else if (/[A-Z]/.test(password)) {
strength += 1;
}
// 소문자 검증
if (config.requireLowercase && !/[a-z]/.test(password)) {
errors.push('소문자를 1개 이상 포함해야 합니다.');
} else if (/[a-z]/.test(password)) {
strength += 1;
}
// 숫자 검증
if (config.requireNumbers && !/\d/.test(password)) {
errors.push('숫자를 1개 이상 포함해야 합니다.');
} else if (/\d/.test(password)) {
strength += 1;
}
// 특수문자 검증
const specialChars = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/;
if (config.requireSpecialChars && !specialChars.test(password)) {
errors.push('특수문자를 1개 이상 포함해야 합니다. (!@#$%^&*()_+-=[]{};\':"|,.<>/?)');
} else if (specialChars.test(password)) {
strength += 1;
}
// 공백 검증
if (/\s/.test(password)) {
errors.push('비밀번호에 공백을 포함할 수 없습니다.');
}
// 연속된 문자 검증 (선택적)
if (/(.)\1{2,}/.test(password)) {
errors.push('동일한 문자를 3회 이상 연속 사용할 수 없습니다.');
}
// 강도 계산
let strengthLabel;
if (strength <= 2) {
strengthLabel = 'weak';
} else if (strength <= 3) {
strengthLabel = 'medium';
} else if (strength <= 4) {
strengthLabel = 'strong';
} else {
strengthLabel = 'very_strong';
}
return {
valid: errors.length === 0,
errors,
strength: strengthLabel,
score: strength
};
};
/**
* 간단한 비밀번호 검증 (기존 호환용)
* 모든 조건을 만족하면 true, 아니면 false
*
* @param {string} password - 검증할 비밀번호
* @returns {boolean} 유효 여부
*/
const isValidPassword = (password) => {
return validatePassword(password).valid;
};
/**
* 비밀번호 검증 결과를 한국어 메시지로 반환
*
* @param {string} password - 검증할 비밀번호
* @returns {string|null} 오류 메시지 (유효하면 null)
*/
const getPasswordError = (password) => {
const result = validatePassword(password);
if (result.valid) {
return null;
}
return result.errors.join(' ');
};
/**
* Express 미들웨어: 요청 body의 password 또는 newPassword 필드 검증
*
* @param {string} fieldName - 검증할 필드명 (기본: 'password')
* @returns {Function} Express 미들웨어
*/
const validatePasswordMiddleware = (fieldName = 'password') => {
return (req, res, next) => {
const password = req.body[fieldName] || req.body.newPassword;
if (!password) {
return next(); // 비밀번호 필드가 없으면 다음 미들웨어로
}
const result = validatePassword(password);
if (!result.valid) {
return res.status(400).json({
success: false,
error: '비밀번호가 보안 요구사항을 충족하지 않습니다.',
details: result.errors,
code: 'WEAK_PASSWORD'
});
}
next();
};
};
module.exports = {
validatePassword,
isValidPassword,
getPasswordError,
validatePasswordMiddleware
};

View File

@@ -0,0 +1,433 @@
// 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.worker_id = dwr.worker_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.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
};

View File

@@ -0,0 +1,188 @@
// utils/responseFormatter.js - 통합 응답 포맷터
/**
* 성공 응답 포맷터
*/
const successResponse = (data = null, message = '요청이 성공적으로 처리되었습니다.', meta = null) => {
const response = {
success: true,
message,
timestamp: new Date().toISOString()
};
if (data !== null) {
response.data = data;
}
if (meta) {
response.meta = meta;
}
return response;
};
/**
* 페이지네이션 응답 포맷터
*/
const paginatedResponse = (data, totalCount, page = 1, limit = 10, message = '데이터 조회 성공') => {
const totalPages = Math.ceil(totalCount / limit);
return successResponse(data, message, {
pagination: {
currentPage: parseInt(page),
totalPages,
totalCount: parseInt(totalCount),
limit: parseInt(limit),
hasNextPage: page < totalPages,
hasPrevPage: page > 1
}
});
};
/**
* 리스트 응답 포맷터
*/
const listResponse = (items, message = '목록 조회 성공') => {
return successResponse(items, message, {
count: items.length
});
};
/**
* 생성 응답 포맷터
*/
const createdResponse = (data, message = '데이터가 성공적으로 생성되었습니다.') => {
return successResponse(data, message);
};
/**
* 업데이트 응답 포맷터
*/
const updatedResponse = (data = null, message = '데이터가 성공적으로 업데이트되었습니다.') => {
return successResponse(data, message);
};
/**
* 삭제 응답 포맷터
*/
const deletedResponse = (message = '데이터가 성공적으로 삭제되었습니다.') => {
return successResponse(null, message);
};
/**
* 통계 응답 포맷터
*/
const statsResponse = (stats, period = null, message = '통계 조회 성공') => {
const meta = {};
if (period) {
meta.period = period;
}
meta.generatedAt = new Date().toISOString();
return successResponse(stats, message, meta);
};
/**
* 인증 응답 포맷터
*/
const authResponse = (user, token, redirectUrl = null, message = '로그인 성공') => {
const data = {
user,
token
};
if (redirectUrl) {
data.redirectUrl = redirectUrl;
}
return successResponse(data, message);
};
/**
* 파일 업로드 응답 포맷터
*/
const uploadResponse = (fileInfo, message = '파일 업로드 성공') => {
return successResponse({
filename: fileInfo.filename,
originalName: fileInfo.originalname,
size: fileInfo.size,
mimetype: fileInfo.mimetype,
path: fileInfo.path,
uploadedAt: new Date().toISOString()
}, message);
};
/**
* 헬스체크 응답 포맷터
*/
const healthResponse = (status = 'healthy', services = {}) => {
return successResponse({
status,
services,
uptime: process.uptime(),
memory: process.memoryUsage(),
version: process.version
}, `서버 상태: ${status}`);
};
/**
* Express 응답 확장 미들웨어
*/
const responseMiddleware = (req, res, next) => {
// 성공 응답 헬퍼들을 res 객체에 추가
res.success = (data, message, meta) => {
return res.json(successResponse(data, message, meta));
};
res.paginated = (data, totalCount, page, limit, message) => {
return res.json(paginatedResponse(data, totalCount, page, limit, message));
};
res.list = (items, message) => {
return res.json(listResponse(items, message));
};
res.created = (data, message) => {
return res.status(201).json(createdResponse(data, message));
};
res.updated = (data, message) => {
return res.json(updatedResponse(data, message));
};
res.deleted = (message) => {
return res.json(deletedResponse(message));
};
res.stats = (stats, period, message) => {
return res.json(statsResponse(stats, period, message));
};
res.auth = (user, token, redirectUrl, message) => {
return res.json(authResponse(user, token, redirectUrl, message));
};
res.upload = (fileInfo, message) => {
return res.json(uploadResponse(fileInfo, message));
};
res.health = (status, services) => {
return res.json(healthResponse(status, services));
};
next();
};
module.exports = {
successResponse,
paginatedResponse,
listResponse,
createdResponse,
updatedResponse,
deletedResponse,
statsResponse,
authResponse,
uploadResponse,
healthResponse,
responseMiddleware
};

View File

@@ -0,0 +1,304 @@
// utils/validator.js - 유효성 검사 유틸리티
const { handleValidationError } = require('./errorHandler');
/**
* 필수 필드 검사
*/
const required = (value, fieldName) => {
if (value === undefined || value === null || value === '') {
handleValidationError(fieldName, value, 'required');
}
return true;
};
/**
* 문자열 길이 검사
*/
const stringLength = (value, fieldName, min = 0, max = Infinity) => {
if (typeof value !== 'string') {
handleValidationError(fieldName, value, 'string type');
}
if (value.length < min || value.length > max) {
handleValidationError(fieldName, value, `length between ${min} and ${max}`);
}
return true;
};
/**
* 숫자 범위 검사
*/
const numberRange = (value, fieldName, min = -Infinity, max = Infinity) => {
const num = parseFloat(value);
if (isNaN(num)) {
handleValidationError(fieldName, value, 'number type');
}
if (num < min || num > max) {
handleValidationError(fieldName, value, `number between ${min} and ${max}`);
}
return true;
};
/**
* 정수 검사
*/
const integer = (value, fieldName) => {
const num = parseInt(value);
if (isNaN(num) || num.toString() !== value.toString()) {
handleValidationError(fieldName, value, 'integer');
}
return true;
};
/**
* 이메일 형식 검사
*/
const email = (value, fieldName) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
handleValidationError(fieldName, value, 'valid email format');
}
return true;
};
/**
* 날짜 형식 검사 (YYYY-MM-DD)
*/
const dateFormat = (value, fieldName) => {
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(value)) {
handleValidationError(fieldName, value, 'YYYY-MM-DD format');
}
const date = new Date(value);
if (isNaN(date.getTime())) {
handleValidationError(fieldName, value, 'valid date');
}
return true;
};
/**
* 시간 형식 검사 (HH:MM)
*/
const timeFormat = (value, fieldName) => {
const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/;
if (!timeRegex.test(value)) {
handleValidationError(fieldName, value, 'HH:MM format');
}
return true;
};
/**
* 열거형 값 검사
*/
const enumValue = (value, fieldName, allowedValues) => {
if (!allowedValues.includes(value)) {
handleValidationError(fieldName, value, `one of: ${allowedValues.join(', ')}`);
}
return true;
};
/**
* 배열 검사
*/
const arrayType = (value, fieldName, minLength = 0, maxLength = Infinity) => {
if (!Array.isArray(value)) {
handleValidationError(fieldName, value, 'array type');
}
if (value.length < minLength || value.length > maxLength) {
handleValidationError(fieldName, value, `array length between ${minLength} and ${maxLength}`);
}
return true;
};
/**
* 객체 검사
*/
const objectType = (value, fieldName) => {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
handleValidationError(fieldName, value, 'object type');
}
return true;
};
/**
* 비밀번호 강도 검사
*/
const passwordStrength = (value, fieldName) => {
if (typeof value !== 'string') {
handleValidationError(fieldName, value, 'string type');
}
const minLength = 8;
const hasUpperCase = /[A-Z]/.test(value);
const hasLowerCase = /[a-z]/.test(value);
const hasNumbers = /\d/.test(value);
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(value);
if (value.length < minLength) {
handleValidationError(fieldName, value, `minimum ${minLength} characters`);
}
const strengthChecks = [hasUpperCase, hasLowerCase, hasNumbers, hasSpecialChar];
const passedChecks = strengthChecks.filter(Boolean).length;
if (passedChecks < 3) {
handleValidationError(fieldName, value, 'at least 3 of: uppercase, lowercase, numbers, special characters');
}
return true;
};
/**
* 스키마 기반 유효성 검사
*/
const validateSchema = (data, schema) => {
const errors = [];
for (const [field, rules] of Object.entries(schema)) {
const value = data[field];
try {
// 필수 필드 검사
if (rules.required) {
required(value, field);
}
// 값이 없고 필수가 아니면 다른 검사 스킵
if ((value === undefined || value === null || value === '') && !rules.required) {
continue;
}
// 타입별 검사
if (rules.type === 'string' && rules.minLength !== undefined && rules.maxLength !== undefined) {
stringLength(value, field, rules.minLength, rules.maxLength);
}
if (rules.type === 'number' && rules.min !== undefined && rules.max !== undefined) {
numberRange(value, field, rules.min, rules.max);
}
if (rules.type === 'integer') {
integer(value, field);
}
if (rules.type === 'email') {
email(value, field);
}
if (rules.type === 'date') {
dateFormat(value, field);
}
if (rules.type === 'time') {
timeFormat(value, field);
}
if (rules.type === 'array') {
arrayType(value, field, rules.minLength, rules.maxLength);
}
if (rules.type === 'object') {
objectType(value, field);
}
if (rules.enum) {
enumValue(value, field, rules.enum);
}
if (rules.password) {
passwordStrength(value, field);
}
// 커스텀 검증 함수
if (rules.custom && typeof rules.custom === 'function') {
rules.custom(value, field);
}
} catch (error) {
errors.push({
field,
value,
message: error.message
});
}
}
if (errors.length > 0) {
const errorMessage = errors.map(e => `${e.field}: ${e.message}`).join(', ');
handleValidationError('validation', 'multiple fields', errorMessage);
}
return true;
};
/**
* 일반적인 스키마 정의들
*/
const schemas = {
// 사용자 생성
createUser: {
username: { required: true, type: 'string', minLength: 3, maxLength: 50 },
password: { required: true, password: true },
name: { required: true, type: 'string', minLength: 2, maxLength: 100 },
access_level: { required: true, enum: ['user', 'admin', 'system'] },
worker_id: { type: 'integer' }
},
// 사용자 업데이트
updateUser: {
name: { type: 'string', minLength: 2, maxLength: 100 },
access_level: { enum: ['user', 'admin', 'system'] },
worker_id: { type: 'integer' }
},
// 비밀번호 변경
changePassword: {
currentPassword: { required: true, type: 'string' },
newPassword: { required: true, password: true }
},
// 일일 작업 보고서 생성 (배열 형태)
createDailyWorkReport: {
report_date: { required: true, type: 'date' },
worker_id: { required: true, type: 'integer' },
work_entries: { required: true, type: 'array' },
created_by: { type: 'integer' }
},
// 프로젝트 생성
createProject: {
project_name: { required: true, type: 'string', minLength: 2, maxLength: 200 },
description: { type: 'string', maxLength: 1000 },
start_date: { type: 'date' },
end_date: { type: 'date' }
},
// 작업자 생성
createWorker: {
worker_name: { required: true, type: 'string', minLength: 2, maxLength: 100 },
position: { type: 'string', maxLength: 100 },
department: { type: 'string', maxLength: 100 },
phone: { type: 'string', maxLength: 20 },
email: { type: 'email' }
}
};
module.exports = {
// 개별 검증 함수들
required,
stringLength,
numberRange,
integer,
email,
dateFormat,
timeFormat,
enumValue,
arrayType,
objectType,
passwordStrength,
// 스키마 검증
validateSchema,
schemas
};