# 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: """ conf/config.yaml 파일을 UTF-8로 읽어 파이썬 dict로 반환 환경변수가 있으면 우선 적용 Args: config_path: 설정 파일 경로 (없으면 기본값 사용) Returns: 설정 딕셔너리 Raises: FileNotFoundError: 설정 파일을 찾을 수 없을 때 yaml.YAMLError: YAML 파싱 실패 시 """ if config_path is None: config_path = os.path.join(os.path.dirname(__file__), '..', 'conf', 'config.yaml') try: with open(config_path, encoding='utf-8') as f: config = yaml.safe_load(f) if config is None: raise ValueError(f"설정 파일이 비어있음: {config_path}") # 환경변수로 설정 덮어쓰기 _apply_env_overrides(config) return config except FileNotFoundError: raise FileNotFoundError(f"설정 파일을 찾을 수 없음: {config_path}") except yaml.YAMLError as e: raise yaml.YAMLError(f"YAML 파싱 오류: {e}") def _apply_env_overrides(config: dict) -> None: """환경변수로 설정값 덮어쓰기""" # 데이터베이스 설정 if os.getenv('DB_HOST'): config.setdefault('database', {}) config['database']['host'] = os.getenv('DB_HOST', config['database'].get('host')) config['database']['user'] = os.getenv('DB_USER', config['database'].get('user')) config['database']['password'] = os.getenv('DB_PASSWORD', config['database'].get('password')) config['database']['name'] = os.getenv('DB_NAME', config['database'].get('name')) # 테이블 접두사 if os.getenv('TABLE_PREFIX'): config['table_prefix'] = os.getenv('TABLE_PREFIX') # API 설정 if os.getenv('DATA_API_SERVICE_KEY'): config.setdefault('DATA_API', {}) config['DATA_API']['serviceKey'] = os.getenv('DATA_API_SERVICE_KEY') if os.getenv('DATA_API_START_DATE'): config.setdefault('DATA_API', {}) config['DATA_API']['startDt'] = os.getenv('DATA_API_START_DATE') if os.getenv('DATA_API_END_DATE'): config.setdefault('DATA_API', {}) config['DATA_API']['endDt'] = os.getenv('DATA_API_END_DATE') # GA4 설정 if os.getenv('GA4_API_TOKEN'): config.setdefault('ga4', {}) config['ga4']['token'] = os.getenv('GA4_API_TOKEN') if os.getenv('GA4_PROPERTY_ID'): config.setdefault('ga4', {}) config['ga4']['property_id'] = int(os.getenv('GA4_PROPERTY_ID')) if os.getenv('GA4_SERVICE_ACCOUNT_FILE'): config.setdefault('ga4', {}) config['ga4']['service_account_file'] = os.getenv('GA4_SERVICE_ACCOUNT_FILE') # UPSolution 설정 if os.getenv('UPSOLUTION_ID'): config.setdefault('upsolution', {}) config['upsolution']['id'] = os.getenv('UPSOLUTION_ID') config['upsolution']['code'] = os.getenv('UPSOLUTION_CODE') config['upsolution']['pw'] = os.getenv('UPSOLUTION_PW') # 시스템 설정 if os.getenv('MAX_WORKERS'): config['max_workers'] = int(os.getenv('MAX_WORKERS')) if os.getenv('DEBUG'): config['debug'] = os.getenv('DEBUG', 'false').lower() == 'true' if os.getenv('FORCE_UPDATE'): config['force_update'] = os.getenv('FORCE_UPDATE', 'false').lower() == 'true' 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}" )