refactor: API 서버 구조 개선 및 표준화

- 통합 에러 처리 시스템 구축:
  * utils/errorHandler.js: ApiError 클래스 및 에러 미들웨어
  * 데이터베이스, 유효성 검사, 권한 에러 표준화
  * 비동기 함수 래퍼 (asyncHandler) 추가

- 응답 포맷터 시스템 구축:
  * utils/responseFormatter.js: 일관된 API 응답 형식
  * 성공, 페이지네이션, 인증, 파일업로드 등 전용 포맷터
  * Express 응답 확장 미들웨어

- 유효성 검사 시스템 구축:
  * utils/validator.js: 스키마 기반 유효성 검사
  * 필수 필드, 타입, 길이, 형식 검사 함수들
  * 일반적인 스키마 정의 (사용자, 프로젝트, 작업보고서 등)

- 코드 정리 및 표준화:
  * 삭제된 테이블 참조 제거 (work_report_audit_log 등)
  * 대문자 테이블명을 소문자로 통일 (Users -> users)
  * authController.js에 새로운 유틸리티 적용 예시

- 미들웨어 통합:
  * index.js에 에러 핸들러 및 응답 포맷터 적용
  * 헬스체크 엔드포인트 개선
This commit is contained in:
Hyungi Ahn
2025-11-03 10:42:29 +09:00
parent 31e941cfbd
commit 4716434d65
8 changed files with 720 additions and 105 deletions

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,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,307 @@
// 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' },
project_id: { required: true, type: 'integer' },
work_type_id: { required: true, type: 'integer' },
work_hours: { required: true, type: 'number', min: 0.1, max: 24 },
work_status_id: { type: 'integer' },
error_type_id: { 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
};