"""사용자별 `KoreaInvestmentAPI` 인스턴스 풀 (설계 문서 §5.5). - 자격증명 복호화는 호출 측(Registry/credentials API, B그룹)이 수행해 평문(app_key/app_secret/account_no)을 넘겨준다 — 이 모듈은 DB 세션을 열지 않는다. - 캐시 키는 `user_id` 하나뿐이다(1단계는 사용자당 자격증명 1세트 전제, §3.1). 자격증명이 바뀌면 호출 측이 반드시 `invalidate(user_id)`를 먼저 호출해야 한다 (여기서는 캐시가 있으면 그대로 재사용 — 자격증명 변경 감지를 하지 않음). """ import asyncio import logging from typing import Dict from app.services.korea_investment_api import KoreaInvestmentAPI from app.services import korea_investment_api as _kis_module logger = logging.getLogger(__name__) class KISClientPool: """사용자별 `KoreaInvestmentAPI` 인스턴스 캐시 (asyncio.Lock으로 동시 생성 방지).""" def __init__(self) -> None: self._instances: Dict[int, KoreaInvestmentAPI] = {} self._lock = asyncio.Lock() async def get_or_create( self, user_id: int, app_key: str, app_secret: str, account_no: str, mode: str, ) -> KoreaInvestmentAPI: """캐시에 있으면 반환, 없으면 `KoreaInvestmentAPI.for_user(...)`로 생성 후 캐시.""" existing = self._instances.get(user_id) if existing is not None: return existing async with self._lock: existing = self._instances.get(user_id) if existing is not None: return existing instance = KoreaInvestmentAPI.for_user( user_id=user_id, app_key=app_key, app_secret=app_secret, account_no=account_no, mode=mode, ) self._instances[user_id] = instance return instance async def invalidate(self, user_id: int) -> None: """캐시된 인스턴스를 정리(httpx 클라이언트 close)하고 제거한다. 자격증명 교체/삭제 시 credentials API(B그룹)가 호출해야 한다. """ async with self._lock: instance = self._instances.pop(user_id, None) if instance is not None: try: await instance.close() except Exception as e: logger.warning("⚠️ KISClientPool.invalidate: user_id=%s close 실패: %s", user_id, e) def get_legacy(self) -> KoreaInvestmentAPI: """전역 legacy 싱글톤 반환 (`MULTI_TENANT_ENABLED=False` 또는 legacy owner 경로용).""" return _kis_module.korea_investment_api def active_user_ids(self) -> list: """현재 캐시된(생성된) 사용자 id 목록.""" return list(self._instances.keys()) # 모듈 싱글톤 kis_client_pool = KISClientPool()