# common.py import os import yaml import logging import time import glob from functools import wraps from typing import Any, Callable # 로거 설정 def setup_logging(name: str, level: str = 'INFO') -> logging.Logger: """ 로거 설정 (일관된 포맷 적용) Args: name: 로거 이름 level: 로그 레벨 (INFO, DEBUG, WARNING, ERROR) Returns: Logger 인스턴스 """ logger = logging.getLogger(name) if not logger.handlers: handler = logging.StreamHandler() formatter = logging.Formatter( '[%(asctime)s] %(name)s - %(levelname)s: %(message)s', datefmt='%Y-%m-%d %H:%M:%S' ) handler.setFormatter(formatter) logger.addHandler(handler) logger.setLevel(getattr(logging, level.upper(), logging.INFO)) return logger def get_logger(name: str) -> logging.Logger: """기존 호환성 유지""" return setup_logging(name) def load_config(config_path: str = None) -> dict: """ 환경변수에서 설정 로드 (config.yaml 대체) Args: config_path: 하위 호환성을 위해 유지 (사용 안 함) Returns: 설정 딕셔너리 (환경변수 기반) """ config = { 'database': { 'host': os.getenv('DB_HOST', 'localhost'), 'user': os.getenv('DB_USER', 'firstgarden'), 'password': os.getenv('DB_PASSWORD', 'Fg9576861!'), 'name': os.getenv('DB_NAME', 'firstgarden') }, 'table_prefix': os.getenv('TABLE_PREFIX', 'fg_manager_static_'), 'DATA_API': { 'serviceKey': os.getenv('DATA_API_SERVICE_KEY', ''), 'startDt': os.getenv('DATA_API_START_DATE', '20170101'), 'endDt': os.getenv('DATA_API_END_DATE', '20250701'), 'air': { 'station_name': os.getenv('AIR_STATION_NAMES', '운정').split(',') }, 'weather': { 'stnIds': [int(x) for x in os.getenv('WEATHER_STN_IDS', '99').split(',')] } }, 'ga4': { 'token': os.getenv('GA4_API_TOKEN', ''), 'property_id': int(os.getenv('GA4_PROPERTY_ID', '384052726')), 'service_account_file': os.getenv('GA4_SERVICE_ACCOUNT_FILE', './conf/service-account-credentials.json'), 'startDt': os.getenv('GA4_START_DATE', '20170101'), 'endDt': os.getenv('GA4_END_DATE', '20990731'), 'max_rows_per_request': int(os.getenv('GA4_MAX_ROWS_PER_REQUEST', '10000')) }, 'POS': { 'VISITOR_CA': os.getenv('VISITOR_CATEGORIES', '입장료,티켓,기업제휴').split(',') }, 'FORECAST_WEIGHT': { 'visitor_forecast_multiplier': float(os.getenv('FORECAST_VISITOR_MULTIPLIER', '0.5')), 'minTa': float(os.getenv('FORECAST_WEIGHT_MIN_TEMP', '1.0')), 'maxTa': float(os.getenv('FORECAST_WEIGHT_MAX_TEMP', '1.0')), 'sumRn': float(os.getenv('FORECAST_WEIGHT_PRECIPITATION', '10.0')), 'avgRhm': float(os.getenv('FORECAST_WEIGHT_HUMIDITY', '1.0')), 'pm25': float(os.getenv('FORECAST_WEIGHT_PM25', '1.0')), 'is_holiday': int(os.getenv('FORECAST_WEIGHT_HOLIDAY', '20')) }, 'max_workers': int(os.getenv('MAX_WORKERS', '4')), 'debug': os.getenv('DEBUG', 'false').lower() == 'true', 'force_update': os.getenv('FORCE_UPDATE', 'false').lower() == 'true', 'upsolution': { 'id': os.getenv('UPSOLUTION_ID', 'firstgarden'), 'code': os.getenv('UPSOLUTION_CODE', '1112'), 'pw': os.getenv('UPSOLUTION_PW', '9999') } } return config def retry_on_exception(max_retries: int = 3, delay: float = 1.0, backoff: float = 2.0): """ 지정된 횟수만큼 재시도하는 데코레이터 Args: max_retries: 최대 재시도 횟수 delay: 재시도 간격 (초) backoff: 재시도마다 지연 시간 배수 """ def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(*args, **kwargs) -> Any: logger = logging.getLogger(func.__module__) last_exception = None for attempt in range(max_retries): try: return func(*args, **kwargs) except Exception as e: last_exception = e if attempt < max_retries - 1: wait_time = delay * (backoff ** attempt) logger.warning( f"{func.__name__} 재시도 {attempt + 1}/{max_retries} " f"({wait_time:.1f}초 대기): {e}" ) time.sleep(wait_time) else: logger.error( f"{func.__name__} 모든 재시도 실패: {e}" ) raise last_exception return wrapper return decorator def wait_download_complete(download_dir: str, ext: str, timeout: int = 60) -> str: """ 파일 다운로드 완료 대기 Args: download_dir: 다운로드 디렉토리 ext: 파일 확장자 (예: 'xlsx', 'csv') timeout: 대기 시간 (초) Returns: 다운로드된 파일 경로 Raises: TimeoutError: 지정 시간 내 파일이 나타나지 않을 때 """ logger = logging.getLogger(__name__) ext = ext.lstrip('.') for i in range(timeout): files = glob.glob(os.path.join(download_dir, f"*.{ext}")) if files: logger.info(f"다운로드 완료: {files[0]}") return files[0] if i > 0 and i % 10 == 0: logger.debug(f"다운로드 대기 중... ({i}초 경과)") time.sleep(1) raise TimeoutError( f"파일 다운로드 대기 시간 초과 ({timeout}초): {download_dir}/*.{ext}" )