""" 개선된 Logger 모듈 - GUI와 파일 로그 분리, 자동 정리 기능 GUI 로그 레벨 권장 설정: - 일반 사용자: logging.INFO (정보성 메시지만) - 개발자: logging.DEBUG (모든 메시지) - 운영 환경: logging.WARNING (경고 이상만) 사용 예시: ```python # 기본 사용 logger = Logger( gui_logger=your_gui_function, log_file="logs/app.log", gui_log_level=logging.INFO # GUI에는 INFO 이상만 ) # 동적 레벨 변경 logger.set_gui_log_level(logging.WARNING) # GUI에는 경고만 logger.set_file_log_level(logging.DEBUG) # 파일에는 모든 로그 ``` """ import logging from logging.handlers import TimedRotatingFileHandler import os import glob import time import threading from datetime import datetime, timedelta from pathlib import Path from PySide6.QtCore import QObject, Signal import traceback # 로그 레벨 이름 매핑 (안전하고 호환성 좋은 방법) def get_level_name(level): """로그 레벨을 이름으로 변환 (호환성 보장)""" level_names = { logging.NOTSET: "NOTSET", logging.DEBUG: "DEBUG", logging.INFO: "INFO", logging.WARNING: "WARNING", logging.ERROR: "ERROR", logging.CRITICAL: "CRITICAL" } return level_names.get(level, f"Level {level}") class Logger(QObject): log_signal = Signal(str) # GUI로 로그 메시지를 전달할 시그널 def __init__(self, gui_logger=None, log_file="Edit_PartTimer_log.log", logger_name="Edit_PartTimer_log", file_log_level=logging.DEBUG, gui_log_level=logging.INFO, max_days=3, cleanup_interval=3600): """ 개선된 Logger 초기화 :param gui_logger: GUI에 로그를 출력할 콜백 함수 :param log_file: 로그 파일 이름 :param logger_name: 로거 이름 :param file_log_level: 파일 로거의 로그 레벨 :param gui_log_level: GUI 로거의 로그 레벨 :param max_days: 로그 파일 보관 일수 (기본 3일) :param cleanup_interval: 정리 작업 간격(초, 기본 1시간) """ super().__init__() self.gui_logger = gui_logger self.file_log_level = file_log_level self.gui_log_level = gui_log_level self.max_days = max_days self.cleanup_interval = cleanup_interval self.log_dir = Path(log_file).parent self.log_base_name = Path(log_file).stem # 로그 디렉토리 생성 self.log_dir.mkdir(parents=True, exist_ok=True) # 로그 설정 self.logger = logging.getLogger(logger_name) self.logger.setLevel(file_log_level) # 기존 핸들러 제거 (중복 방지) for handler in self.logger.handlers[:]: self.logger.removeHandler(handler) # 포맷 설정 self.simple_format = "[%(asctime)s] [%(levelname)s] %(message)s" self.detailed_format = ( "[%(asctime)s] [%(threadName)s] [%(levelname)s] " "[%(filename)s:%(funcName)s:%(lineno)d] %(message)s" ) # GUI 전용 간결한 포맷 (시간:분:초 + 메시지만) self.gui_format = "[%(asctime)s] %(message)s" # 핸들러 추가 self._add_console_handler(file_log_level) self._add_file_handler(log_file, file_log_level) # GUI Logger 연결 if self.gui_logger: self.log_signal.connect(self.gui_logger) # 자동 정리 스레드 시작 self._start_cleanup_thread() def _add_console_handler(self, level): """콘솔 핸들러 추가""" console_handler = logging.StreamHandler() console_handler.setLevel(level) formatter = logging.Formatter( self.detailed_format if level <= logging.DEBUG else self.simple_format ) console_handler.setFormatter(formatter) self.logger.addHandler(console_handler) def _add_file_handler(self, log_file, level): """개선된 시간 기반 파일 핸들러 추가""" # 확장자가 .log가 아니면 .log로 변경 if not log_file.endswith('.log'): base_name, _ = os.path.splitext(log_file) log_file = base_name + '.log' # 시간 기반 로테이팅 핸들러 사용 (매일 자정에 로테이션) file_handler = TimedRotatingFileHandler( log_file, when='midnight', # 매일 자정에 로테이션 interval=1, # 1일 간격 backupCount=self.max_days, # 보관할 파일 수 encoding="utf-8", utc=False ) # 로테이션된 파일명 형식 설정 (YYYY-MM-DD) file_handler.suffix = "%Y-%m-%d.log" file_handler.extMatch = r"^\d{4}-\d{2}-\d{2}\.log$" file_handler.setLevel(level) formatter = logging.Formatter( self.detailed_format if level <= logging.DEBUG else self.simple_format ) file_handler.setFormatter(formatter) self.logger.addHandler(file_handler) # 핸들러 참조 저장 (정리 작업용) self.file_handler = file_handler def _start_cleanup_thread(self): """자동 정리 스레드 시작""" def cleanup_worker(): while True: try: self._cleanup_old_logs() time.sleep(self.cleanup_interval) except Exception as e: # 정리 작업 실패 시에도 계속 동작 pass cleanup_thread = threading.Thread(target=cleanup_worker, daemon=True) cleanup_thread.start() def _cleanup_old_logs(self): """오래된 로그 파일 정리""" try: cutoff_date = datetime.now() - timedelta(days=self.max_days) log_pattern = f"{self.log_base_name}*.log" for log_file in self.log_dir.glob(log_pattern): try: # 파일 수정 시간 확인 file_mtime = datetime.fromtimestamp(log_file.stat().st_mtime) # 현재 활성 로그 파일은 삭제하지 않음 if log_file.name == f"{self.log_base_name}.log": continue # 오래된 파일 삭제 if file_mtime < cutoff_date: log_file.unlink() self.logger.info(f"오래된 로그 파일 삭제: {log_file.name}") except Exception as e: self.logger.warning(f"로그 파일 정리 중 오류: {log_file.name} - {e}") except Exception as e: self.logger.error(f"로그 정리 작업 실패: {e}") def log(self, message, level=logging.INFO, exc_info=False): """로그 메시지 기록""" if exc_info: message = f"{message}\n{traceback.format_exc()}" # 호출 위치 정보를 동적으로 추출 caller_frame = logging.currentframe().f_back record = self.logger.makeRecord( self.logger.name, level, caller_frame.f_code.co_filename, caller_frame.f_lineno, message, None, None, caller_frame.f_code.co_name ) # 파일 로거에 메시지 전달 if level >= self.file_log_level: self.logger.handle(record) # GUI 로그로 전달 (포맷 적용) if self.gui_logger and level >= self.gui_log_level: # GUI용 간결한 포맷터 (시간:분:초만 표시) gui_formatter = logging.Formatter( fmt="[%(asctime)s] %(message)s", datefmt="%H:%M:%S" # 시:분:초만 표시 ) formatted_message = gui_formatter.format(record) colored_message = self.format_gui_message(formatted_message, level) self.log_signal.emit(colored_message) def format_gui_message(self, message, level): """GUI 로그 메시지의 HTML 색상 지정""" color_map = { logging.DEBUG: "#808080", # 회색 logging.INFO: "#000000", # 검정 logging.WARNING: "#FF8C00", # 주황 logging.ERROR: "#DC143C", # 빨강 logging.CRITICAL: "#8B008B", # 보라 } color = color_map.get(level, "#000000") return f'{message}' def set_gui_logger(self, gui_logger, gui_log_level=None): """ GUI 로그 콜백 함수를 설정하고, log_signal에 연결합니다. 선택적으로 GUI 로그 레벨도 변경할 수 있습니다. """ self.gui_logger = gui_logger self.log_signal.connect(gui_logger) if gui_log_level is not None: self.gui_log_level = gui_log_level def set_gui_log_level(self, level): """GUI 로그 레벨을 동적으로 변경합니다""" self.gui_log_level = level level_name = get_level_name(level) self.logger.info(f"GUI 로그 레벨이 {level_name}로 변경되었습니다") def set_file_log_level(self, level): """파일 로그 레벨을 동적으로 변경합니다""" self.file_log_level = level self.logger.setLevel(level) for handler in self.logger.handlers: if isinstance(handler, (logging.FileHandler, TimedRotatingFileHandler)): handler.setLevel(level) level_name = get_level_name(level) self.logger.info(f"파일 로그 레벨이 {level_name}로 변경되었습니다") def get_log_levels(self): """현재 로그 레벨 정보 반환""" return { "file_level": get_level_name(self.file_log_level), "gui_level": get_level_name(self.gui_log_level), "logger_level": get_level_name(self.logger.level) } def get_log_info(self): """로그 파일 정보 반환""" try: log_files = list(self.log_dir.glob(f"{self.log_base_name}*.log")) total_size = sum(f.stat().st_size for f in log_files) return { "log_dir": str(self.log_dir), "total_files": len(log_files), "total_size_mb": total_size / (1024 * 1024), "max_days": self.max_days, "files": [f.name for f in log_files] } except Exception: return {"error": "로그 정보 조회 실패"} def force_cleanup(self): """수동 정리 실행""" self._cleanup_old_logs() # 파이썬 표준 로깅 인터페이스 지원 def debug(self, message, *args, **kwargs): """DEBUG 레벨 로그""" if args: message = message % args self.log(message, level=logging.DEBUG, exc_info=kwargs.get('exc_info', False)) def info(self, message, *args, **kwargs): """INFO 레벨 로그""" if args: message = message % args self.log(message, level=logging.INFO, exc_info=kwargs.get('exc_info', False)) def warning(self, message, *args, **kwargs): """WARNING 레벨 로그""" if args: message = message % args self.log(message, level=logging.WARNING, exc_info=kwargs.get('exc_info', False)) def error(self, message, *args, **kwargs): """ERROR 레벨 로그""" if args: message = message % args self.log(message, level=logging.ERROR, exc_info=kwargs.get('exc_info', False)) def critical(self, message, *args, **kwargs): """CRITICAL 레벨 로그""" if args: message = message % args self.log(message, level=logging.CRITICAL, exc_info=kwargs.get('exc_info', False)) def exception(self, message, *args, **kwargs): """ERROR 레벨로 예외 정보와 함께 로그""" if args: message = message % args self.log(message, level=logging.ERROR, exc_info=True) # 전역 기본 로거 인스턴스 _default_logger = None def get_default_logger(): """기본 로거 인스턴스 반환""" global _default_logger if _default_logger is None: _default_logger = Logger(log_file="logs/default.log") return _default_logger def set_default_logger(logger): """기본 로거 설정""" global _default_logger _default_logger = logger # 편의 함수들 - 실제 구현 def debug(msg, *args, **kwargs): """편의 함수 - DEBUG 레벨 로그""" get_default_logger().debug(msg, *args, **kwargs) def info(msg, *args, **kwargs): """편의 함수 - INFO 레벨 로그""" get_default_logger().info(msg, *args, **kwargs) def warning(msg, *args, **kwargs): """편의 함수 - WARNING 레벨 로그""" get_default_logger().warning(msg, *args, **kwargs) def error(msg, *args, **kwargs): """편의 함수 - ERROR 레벨 로그""" get_default_logger().error(msg, *args, **kwargs) def critical(msg, *args, **kwargs): """편의 함수 - CRITICAL 레벨 로그""" get_default_logger().critical(msg, *args, **kwargs) def exception(msg, *args, **kwargs): """편의 함수 - 예외 정보와 함께 로그""" get_default_logger().exception(msg, *args, **kwargs) # 구조화된 로깅을 위한 추가 클래스 class StructuredLogger(Logger): """JSON 형태의 구조화된 로깅을 지원하는 로거""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def log_structured(self, event, level=logging.INFO, **context): """구조화된 로그 기록""" import json log_data = { "timestamp": datetime.now().isoformat(), "event": event, "level": get_level_name(level), **context } message = json.dumps(log_data, ensure_ascii=False, separators=(',', ':')) self.log(message, level) def log_performance(self, operation, duration, **context): """성능 로그 기록""" self.log_structured( "performance", level=logging.INFO, operation=operation, duration_ms=round(duration * 1000, 2), **context ) def log_error_with_context(self, error, **context): """에러와 컨텍스트를 함께 로그""" self.log_structured( "error", level=logging.ERROR, error_type=type(error).__name__, error_message=str(error), **context )