From b2461502e752b606c21d4f19dc04acf665de5e5a Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Thu, 11 Dec 2025 10:28:51 +0900 Subject: [PATCH] =?UTF-8?q?refactor(phase2-1):=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=EB=A1=9C=EA=B9=85=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 추가된 파일 - utils/errors.js: 표준화된 커스텀 에러 클래스 - AppError, ValidationError, AuthenticationError - ForbiddenError, NotFoundError, ConflictError - DatabaseError, ExternalApiError, TimeoutError - utils/logger.js: 통합 로깅 유틸리티 - 로그 레벨별 관리 (ERROR, WARN, INFO, DEBUG) - 콘솔 및 파일 로깅 지원 - HTTP 요청/DB 쿼리 전용 로거 ## 개선된 파일 - middlewares/errorHandler.js: 에러 핸들러 개선 - 새로운 AppError 클래스 사용 - logger 통합 - asyncHandler 및 notFoundHandler 추가 ## 다음 단계 - config 파일들 생성 (cors, security) - activityLogger 미들웨어 생성 - userController 인라인 코드 분리 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- api.hyungi.net/middlewares/errorHandler.js | 106 +++++++++-- api.hyungi.net/utils/errors.js | 186 +++++++++++++++++++ api.hyungi.net/utils/logger.js | 197 +++++++++++++++++++++ 3 files changed, 479 insertions(+), 10 deletions(-) create mode 100644 api.hyungi.net/utils/errors.js create mode 100644 api.hyungi.net/utils/logger.js diff --git a/api.hyungi.net/middlewares/errorHandler.js b/api.hyungi.net/middlewares/errorHandler.js index 8d20808..646926d 100644 --- a/api.hyungi.net/middlewares/errorHandler.js +++ b/api.hyungi.net/middlewares/errorHandler.js @@ -1,16 +1,102 @@ -// middlewares/errorHandler.js -exports.errorHandler = (err, req, res, next) => { - console.error('Error:', err); - +/** + * 에러 핸들러 미들웨어 + * + * 애플리케이션 전역 에러를 처리하는 Express 미들웨어 + * + * @author TK-FB-Project + * @since 2025-12-11 + */ + +const { AppError } = require('../utils/errors'); +const logger = require('../utils/logger'); + +/** + * 에러 응답 포맷터 + */ +const formatErrorResponse = (error, req) => { + const response = { + success: false, + error: { + message: error.message || '알 수 없는 오류가 발생했습니다', + code: error.code || 'INTERNAL_ERROR' + }, + timestamp: new Date().toISOString() + }; + + // 검증 에러의 경우 상세 정보 포함 + if (error.details) { + response.error.details = error.details; + } + + // 개발 환경에서만 스택 트레이스 포함 if (process.env.NODE_ENV === 'development') { - res.status(500).json({ - error: '서버 오류가 발생했습니다.', - details: err.message, - stack: err.stack + response.error.stack = error.stack; + response.request = { + method: req.method, + url: req.originalUrl, + ip: req.ip, + user: req.user?.username || 'anonymous' + }; + } + + return response; +}; + +/** + * 에러 핸들러 미들웨어 + */ +const errorHandler = (error, req, res, next) => { + // AppError가 아닌 경우 변환 + if (!(error instanceof AppError)) { + error = new AppError( + error.message || '서버 내부 오류가 발생했습니다', + 500, + 'INTERNAL_ERROR' + ); + } + + // 로깅 + if (error.statusCode >= 500) { + logger.error(error.message, { + code: error.code, + stack: error.stack, + url: req.originalUrl, + method: req.method, + user: req.user?.username || 'anonymous' }); } else { - res.status(500).json({ - error: '서버 오류가 발생했습니다.' + logger.warn(error.message, { + code: error.code, + url: req.originalUrl, + method: req.method, + user: req.user?.username || 'anonymous' }); } + + // 응답 + const response = formatErrorResponse(error, req); + res.status(error.statusCode).json(response); +}; + +/** + * 비동기 함수 래퍼 (에러 자동 처리) + */ +const asyncHandler = (fn) => { + return (req, res, next) => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +}; + +/** + * 404 Not Found 핸들러 + */ +const notFoundHandler = (req, res, next) => { + const { NotFoundError } = require('../utils/errors'); + next(new NotFoundError(`경로를 찾을 수 없습니다: ${req.originalUrl}`)); +}; + +module.exports = { + errorHandler, + asyncHandler, + notFoundHandler }; \ No newline at end of file diff --git a/api.hyungi.net/utils/errors.js b/api.hyungi.net/utils/errors.js new file mode 100644 index 0000000..490908d --- /dev/null +++ b/api.hyungi.net/utils/errors.js @@ -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 +}; diff --git a/api.hyungi.net/utils/logger.js b/api.hyungi.net/utils/logger.js new file mode 100644 index 0000000..4109a96 --- /dev/null +++ b/api.hyungi.net/utils/logger.js @@ -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;