""" 서버/CLI 친화 Logger 모듈 - 파일/콘솔 분리 로깅과 자동 정리 권장 로그 레벨: - 개발: logging.DEBUG - 운영: logging.INFO 또는 logging.WARNING 사용 예시: ```python # 기본 사용 logger = Logger( log_file="logs/app.log", file_log_level=logging.DEBUG ) logger.info("서버 시작") # FastAPI에서 사용 (예) # from fastapi import FastAPI # app = FastAPI() # log = Logger(log_file="logs/api.log") # @app.get("/") # def root(): # log.info("요청 수신") # return {"ok": True} ``` """ import re import logging from logging.handlers import TimedRotatingFileHandler import os import time import threading from datetime import datetime, timedelta from pathlib import Path 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 WindowsSafeRotatingFileHandler(logging.Handler): """ Windows 호환 로테이팅 파일 핸들러 기존 RotatingFileHandler의 문제점: - Windows에서 파일이 열려있으면 rename 실패 → 롤링 실패 → 로그 유실 해결책: - 파일 크기 초과 시 기존 파일을 닫고 새 파일명으로 직접 생성 - 타임스탬프 기반 파일명으로 충돌 방지 """ def __init__(self, filename, maxBytes=10*1024*1024, backupCount=50, encoding='utf-8'): super().__init__() self.baseFilename = os.path.abspath(filename) self.maxBytes = maxBytes self.backupCount = backupCount self.encoding = encoding self.stream = None self._lock = threading.Lock() self._open_file() def _open_file(self): """파일 스트림 열기""" try: # 디렉토리 생성 os.makedirs(os.path.dirname(self.baseFilename), exist_ok=True) self.stream = open(self.baseFilename, 'a', encoding=self.encoding) except Exception as e: # 파일 열기 실패 시 stderr로 출력 import sys print(f"[Logger] 파일 열기 실패: {self.baseFilename}, 오류: {e}", file=sys.stderr) self.stream = None def _close_file(self): """파일 스트림 닫기""" if self.stream: try: self.stream.flush() self.stream.close() except Exception: pass self.stream = None def _should_rollover(self): """롤오버가 필요한지 확인""" if self.stream is None: return False try: # 현재 파일 크기 확인 self.stream.seek(0, 2) # 파일 끝으로 이동 size = self.stream.tell() return size >= self.maxBytes except Exception: return False def _do_rollover(self): """롤오버 실행 - Windows 안전 방식""" self._close_file() try: # 타임스탬프 기반 백업 파일명 생성 timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') base, ext = os.path.splitext(self.baseFilename) backup_name = f"{base}_{timestamp}{ext}" # 기존 파일을 백업 파일로 이름 변경 if os.path.exists(self.baseFilename): try: os.rename(self.baseFilename, backup_name) except OSError: # rename 실패 시 복사 후 삭제 시도 try: import shutil shutil.copy2(self.baseFilename, backup_name) # 원본 파일 내용 비우기 (삭제 대신) with open(self.baseFilename, 'w', encoding=self.encoding) as f: pass except Exception: pass # 오래된 백업 파일 정리 self._cleanup_old_backups() except Exception as e: import sys print(f"[Logger] 롤오버 실패: {e}", file=sys.stderr) # 새 파일 열기 self._open_file() def _cleanup_old_backups(self): """오래된 백업 파일 정리""" try: base_dir = os.path.dirname(self.baseFilename) base_name = os.path.basename(self.baseFilename) name_without_ext, ext = os.path.splitext(base_name) # 백업 파일 패턴: {name}_{timestamp}.log backup_files = [] for f in os.listdir(base_dir): if f.startswith(name_without_ext + '_') and f.endswith(ext): full_path = os.path.join(base_dir, f) try: mtime = os.path.getmtime(full_path) backup_files.append((full_path, mtime)) except Exception: pass # 최신순 정렬 후 backupCount 초과분 삭제 backup_files.sort(key=lambda x: x[1], reverse=True) for file_path, _ in backup_files[self.backupCount:]: try: os.remove(file_path) except Exception: pass except Exception: pass def emit(self, record): """로그 레코드 출력""" with self._lock: try: # 롤오버 체크 if self._should_rollover(): self._do_rollover() # 스트림이 없으면 다시 열기 시도 if self.stream is None: self._open_file() if self.stream: msg = self.format(record) self.stream.write(msg + '\n') self.stream.flush() except Exception: self.handleError(record) def close(self): """핸들러 종료""" with self._lock: self._close_file() super().close() class Logger: 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 초기화 (서버/CLI용) :param gui_logger: 선택적 콜백 함수(메시지 수신용) :param log_file: 로그 파일 이름 :param logger_name: 로거 이름 :param file_log_level: 파일 로거의 로그 레벨 :param gui_log_level: 콜백 호출 로그 레벨(기본 INFO) :param max_days: 로그 파일 보관 일수 (기본 3일) :param cleanup_interval: 정리 작업 간격(초, 기본 1시간) """ 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) # 멀티프로세스 환경에서의 로그 파일명 충돌 방지를 위해 PID 추가 # 윈도우에서는 실행 중인 파일의 이름 변경(롤링) 시 PermissionError가 발생할 수 있으므로 # 프로세스별로 별도의 로그 파일을 사용하도록 함. pid = os.getpid() # 원본 로그 파일명에서 확장자 분리 if log_file.endswith('.log'): base_name = log_file[:-4] ext = '.log' else: base_name = log_file ext = '.log' # PID가 포함된 실제 로그 파일명 생성 (예: app_1234.log) # 단, 메인 프로세스라고 판단되거나 특정 조건에서는 원본 이름을 유지할 수도 있으나 # 교착 상태 해결을 위해 무조건 PID를 붙이는 것이 안전함. self.actual_log_file = self.log_dir / f"{Path(base_name).stem}_{pid}{ext}" # cleanup을 위한 기본 이름 패턴 설정 (PID 부분 제외하고 매칭) # self.log_base_name은 원본 파일의 stem을 유지하여 모든 PID 로그를 관리 대상으로 함 self.log_base_name = Path(log_file).stem # 로그 설정 self.logger = logging.getLogger(f"{logger_name}_{pid}") # 로거 이름도 유니크하게 self.logger.setLevel(file_log_level) # 상위 로거로의 전파 방지(중복 출력 방지) self.logger.propagate = False # 기존 핸들러 제거 (중복 방지) 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" ) # 콜백 전용 간결한 포맷 (시간:분:초 + 메시지만) # 필요 시 log()에서 Formatter를 생성하여 사용 # 핸들러 추가 self._add_console_handler(file_log_level) # 실제 파일명(PID 포함)으로 핸들러 생성 self._add_file_handler(str(self.actual_log_file), file_log_level) # 자동 정리 스레드 시작 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): """파일 크기 기반 로테이팅 + 수명 관리 (Windows 호환)""" # 확장자가 .log가 아니면 .log로 변경 if not log_file.endswith('.log'): base_name, _ = os.path.splitext(log_file) log_file = base_name + '.log' # Windows 호환 커스텀 핸들러 사용 # RotatingFileHandler는 Windows에서 파일 잠금 문제로 롤링 실패 가능 file_handler = WindowsSafeRotatingFileHandler( log_file, maxBytes=10 * 1024 * 1024, # 10MB backupCount=50, encoding="utf-8", ) 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): """오래된 로그(> max_days) 및 날짜별 최대 10개 초과분 정리""" cutoff_date = datetime.now() - timedelta(days=self.max_days) log_pattern = f"{self.log_base_name}*.log*" # .log, .log.1 등 모두 포함 files = [] for log_file in self.log_dir.glob(log_pattern): try: if log_file.name == f"{self.log_base_name}.log": continue file_mtime = datetime.fromtimestamp(log_file.stat().st_mtime) files.append((log_file, file_mtime)) except Exception: continue # 1) 보존 기간 초과 파일 삭제 for log_file, mtime in files: if mtime < cutoff_date: try: log_file.unlink() except Exception: pass # 2) 날짜별 최대 5개 유지 (최신순 보존) from collections import defaultdict by_day = defaultdict(list) for log_file, mtime in files: day_key = mtime.strftime('%Y-%m-%d') by_day[day_key].append((log_file, mtime)) for day_key, items in by_day.items(): # 최신순으로 정렬 items.sort(key=lambda x: x[1], reverse=True) for (log_file, _mtime) in items[5:]: try: if log_file.exists(): log_file.unlink() except Exception: pass 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) # 선택적 콜백으로 전달 (간결 포맷 적용) if self.gui_logger and level >= self.gui_log_level: gui_formatter = logging.Formatter( fmt="[%(asctime)s] %(message)s", datefmt="%H:%M:%S" ) formatted_message = gui_formatter.format(record) try: self.gui_logger(formatted_message) except Exception: # 콜백 오류는 로깅 실패로 간주하지 않음 pass def set_gui_logger(self, gui_logger, gui_log_level=None): """ 로그 콜백 함수를 설정합니다. 선택적으로 콜백 로그 레벨도 변경할 수 있습니다. """ self.gui_logger = gui_logger if gui_log_level is not None: self.gui_log_level = gui_log_level def set_gui_log_level(self, level): """콜백 로그 레벨을 동적으로 변경합니다""" self.gui_log_level = level level_name = get_level_name(level) self.logger.info(f"콜백 로그 레벨이 {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 )