IMG_Worker/loggerModule.py

394 lines
14 KiB
Python

"""
서버/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 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)
# 로그 설정
self.logger = logging.getLogger(logger_name)
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)
self._add_file_handler(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):
"""파일 크기 기반 로테이팅 + 수명 관리"""
from logging.handlers import RotatingFileHandler
# 확장자가 .log가 아니면 .log로 변경
if not log_file.endswith('.log'):
base_name, _ = os.path.splitext(log_file)
log_file = base_name + '.log'
# 크기 기반 로테이션: 10MB, 최대 50개(정리 스레드가 3일/일5개로 실제 보존 제한)
file_handler = RotatingFileHandler(
log_file,
maxBytes=10 * 1024 * 1024,
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
)