테스트 강화 및 코드 품질 개선

This commit is contained in:
2025-12-17 00:01:46 +09:00
parent 37a150bd0d
commit 00c57ddd32
51 changed files with 10670 additions and 217 deletions

View File

@@ -1,13 +1,88 @@
import os
import signal
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from concurrent.futures import ThreadPoolExecutor, TimeoutError, as_completed
from typing import Any
from .common import logger
from .config import RuntimeConfig
from .constants import THREADPOOL_MAX_WORKERS_CAP
from .notifications import send_telegram_with_retry
from .signals import process_symbol
# ============================================================================
# MEDIUM-004: Graceful Shutdown 지원
# ============================================================================
_shutdown_requested = False
_shutdown_lock = threading.Lock()
def _signal_handler(signum, frame):
"""
SIGTERM/SIGINT 신호 수신 시 graceful shutdown 시작
Args:
signum: 신호 번호 (SIGTERM=15, SIGINT=2)
frame: 현재 스택 프레임
"""
global _shutdown_requested
with _shutdown_lock:
if not _shutdown_requested:
_shutdown_requested = True
logger.warning(
"[Graceful Shutdown] 종료 신호 수신 (signal=%d). 진행 중인 작업 완료 후 종료합니다...", signum
)
def request_shutdown():
"""프로그래밍 방식으로 shutdown 요청 (테스트용)"""
global _shutdown_requested
with _shutdown_lock:
_shutdown_requested = True
logger.info("[Graceful Shutdown] 프로그래밍 방식 종료 요청")
def is_shutdown_requested() -> bool:
"""Shutdown 요청 상태 확인"""
with _shutdown_lock:
return _shutdown_requested
# Signal handler 등록 (프로그램 시작 시 자동 등록)
try:
signal.signal(signal.SIGTERM, _signal_handler)
signal.signal(signal.SIGINT, _signal_handler)
logger.debug("[Graceful Shutdown] Signal handler 등록 완료 (SIGTERM, SIGINT)")
except (ValueError, OSError) as e:
# Windows에서 SIGTERM이 없거나, 메인 스레드가 아닌 경우 무시
logger.debug("[Graceful Shutdown] Signal handler 등록 실패 (무시): %s", e)
def _get_optimal_thread_count(max_threads: int | None) -> int:
"""CPU 코어 수 기반으로 최적 스레드 수 계산.
I/O bound 작업이므로 CPU 코어 수 * 2를 기본값으로 사용합니다.
사용자가 명시적으로 값을 설정한 경우 해당 값을 사용합니다.
Args:
max_threads: 사용자 지정 스레드 수 (None이면 자동 계산)
Returns:
최적 스레드 수 (최대 8개로 제한)
"""
cap = int(os.getenv("THREADPOOL_MAX_WORKERS_CAP", THREADPOOL_MAX_WORKERS_CAP))
if max_threads is not None and max_threads > 0:
return min(max_threads, cap)
# I/O bound 작업이므로 CPU 코어 수 * 2
cpu_count = os.cpu_count() or 4
optimal = cpu_count * 2
# 최대 8개로 제한 (너무 많은 스레드는 오히려 성능 저하)
return min(optimal, cap)
def _process_result_and_notify(
symbol: str, res: dict[str, Any], cfg: RuntimeConfig, alerts: list[dict[str, str]]
@@ -110,22 +185,44 @@ def run_sequential(symbols: list[str], cfg: RuntimeConfig, aggregate_enabled: bo
def run_with_threads(symbols: list[str], cfg: RuntimeConfig, aggregate_enabled: bool = False):
"""
병렬 처리로 여러 심볼 분석 (MEDIUM-004: Graceful Shutdown 지원)
Args:
symbols: 처리할 심볼 리스트
cfg: RuntimeConfig 객체
aggregate_enabled: 집계 알림 활성화 여부
Returns:
매수 신호 발생 횟수
"""
global _shutdown_requested
max_workers = _get_optimal_thread_count(cfg.max_threads)
cpu_cores = os.cpu_count() or 4
logger.info(
"병렬 처리 시작 (심볼 수=%d, 스레드 수=%d, 심볼 간 지연=%.2f초)",
"병렬 처리 시작 (심볼 수=%d, 스레드 수=%d [CPU 코어: %d], 심볼 간 지연=%.2f초)",
len(symbols),
cfg.max_threads or 0,
max_workers,
cpu_cores,
cfg.symbol_delay or 0.0,
)
alerts = []
buy_signal_count = 0
max_workers = cfg.max_threads or 4
# Throttle control
last_request_time = [0.0]
throttle_lock = threading.Lock()
def worker(symbol: str):
"""워커 함수 (조기 종료 지원)"""
# 종료 요청 확인
if is_shutdown_requested():
logger.info("[%s] 종료 요청으로 스킵", symbol)
return symbol, None
try:
with throttle_lock:
elapsed = time.time() - last_request_time[0]
@@ -141,20 +238,64 @@ def run_with_threads(symbols: list[str], cfg: RuntimeConfig, aggregate_enabled:
return symbol, None
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_symbol = {executor.submit(worker, sym): sym for sym in symbols}
future_to_symbol = {}
# Collect results as they complete
# 심볼 제출 (조기 종료 지원)
for sym in symbols:
if is_shutdown_requested():
logger.warning(
"[Graceful Shutdown] 종료 요청으로 나머지 심볼 제출 중단 (%d/%d 제출 완료)",
len(future_to_symbol),
len(symbols),
)
break
future = executor.submit(worker, sym)
future_to_symbol[future] = sym
# 결과 수집 (타임아웃 적용)
results = {}
for future in as_completed(future_to_symbol):
sym = future_to_symbol[future]
try:
symbol, res = future.result()
results[symbol] = res
except Exception as e:
logger.exception("[%s] Future 결과 조회 오류: %s", sym, e)
timeout_seconds = 90 # 전체 작업 타임아웃 90초
individual_timeout = 15 # 개별 결과 조회 타임아웃 15초
# Process results in original order to maintain consistent log/alert order if desired,
# or just process as is. Here we process in original symbol order.
try:
for future in as_completed(future_to_symbol, timeout=timeout_seconds):
# 종료 요청 시 즉시 중단
if is_shutdown_requested():
logger.warning(
"[Graceful Shutdown] 종료 요청으로 결과 수집 중단 (%d/%d 수집 완료)",
len(results),
len(future_to_symbol),
)
break
sym = future_to_symbol[future]
try:
symbol, res = future.result(timeout=individual_timeout)
results[symbol] = res
except TimeoutError:
logger.warning("[%s] 결과 조회 타임아웃 (%d초 초과), 건너뜀", sym, individual_timeout)
except Exception as e:
logger.exception("[%s] Future 결과 조회 오류: %s", sym, e)
except TimeoutError:
logger.error(
"[경고] 전체 작업 타임아웃 (%d초 초과). 진행 중인 작업 강제 종료 중... (%d/%d 완료)",
timeout_seconds,
len(results),
len(future_to_symbol),
)
# Graceful shutdown 완료 체크
if is_shutdown_requested():
logger.warning(
"[Graceful Shutdown] 병렬 처리 조기 종료 완료 (처리 심볼: %d/%d, 매수 신호: %d)",
len(results),
len(symbols),
buy_signal_count,
)
return buy_signal_count
# 결과 처리 (원래 순서대로)
for sym in symbols:
res = results.get(sym)
if res:
@@ -166,5 +307,5 @@ def run_with_threads(symbols: list[str], cfg: RuntimeConfig, aggregate_enabled:
_notify_no_signals(alerts, cfg)
logger.info("병렬 처리 완료")
logger.info("병렬 처리 완료 (처리 심볼: %d, 매수 신호: %d)", len(results), buy_signal_count)
return buy_signal_count