From 928c2160f951f49c985090ea37ca42991654ef7f Mon Sep 17 00:00:00 2001 From: claude-owner Date: Sat, 23 May 2026 01:22:32 +0900 Subject: [PATCH] =?UTF-8?q?feat(kis):=20=ED=86=A0=ED=81=B0=20=EB=94=94?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=20=EC=BA=90=EC=8B=9C=20+=20restart.bat=20env?= =?UTF-8?q?=20=EC=9E=AC=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 토큰 발급 1분/1일 제한 (EGW00133 등 403) 회피: - _load_disk_cache / _save_disk_cache 로 /app/.cache/kis_token.json 영속화 · ./backend:/app 바인드 마운트로 호스트 backend/.cache/ 에 저장 · backend/.cache/ 는 .gitignore 에 이미 포함됨 (secret 비커밋) · app_key prefix 캐시 무효화 키 (.env 갱신 시 자동 폐기) · atomic write (tmp → rename) + 0600 권한 - get_token: 메모리 → 디스크 → 신규 발급 순으로 fallback - 컨테이너 재기동해도 24시간 유효 토큰 재사용 → 발급 폭주 방지 restart.bat: - restart → up -d --force-recreate --no-deps backend web · restart 는 env_file 재로드 안 함 (.env 의 KIS_APP_KEY 변경이 무시됨) · up -d 는 새 인스턴스 생성하며 env_file 다시 읽음 · --no-deps 로 db 는 절대 건드리지 않음 (postgres_data 영속) --- backend/app/fetch/kis.py | 81 +++++++++++++++++++++++++++++++++++++++- restart.bat | 18 ++++++--- 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/backend/app/fetch/kis.py b/backend/app/fetch/kis.py index c7f6322..64e3b20 100644 --- a/backend/app/fetch/kis.py +++ b/backend/app/fetch/kis.py @@ -13,11 +13,14 @@ status='skipped_missing_key' 처리. """ from __future__ import annotations +import json import logging +import os import threading import time from dataclasses import dataclass from datetime import date, datetime +from pathlib import Path from typing import Any import httpx @@ -30,6 +33,16 @@ logger = logging.getLogger(__name__) KIS_BASE = "https://openapi.koreainvestment.com:9443" USER_AGENT = "stock_chart_site/0.1 (+personal)" +# 토큰 디스크 캐시 경로. 기본값은 컨테이너 안 /app/.cache/kis_token.json — docker-compose +# 의 `./backend:/app` 바인드 마운트 덕에 호스트 `./backend/.cache/` 에 영속된다. +# `backend/.cache/` 는 .gitignore 에 들어있어 secrets 가 커밋되지 않는다. +# +# 왜 디스크 캐시가 필요한가: +# KIS 는 access_token 발급을 1분 1회, 하루 N회로 강하게 제한한다. 메모리만 쓰면 +# `restart.bat` / `build.bat` / 컨테이너 재기동 때마다 새 발급 → 403 (EGW00133 등) 빈발. +# 토큰 자체는 24시간 유효하므로, 컨테이너 인스턴스가 바뀌어도 같은 토큰을 재사용한다. +_TOKEN_CACHE_PATH = Path(os.environ.get("KIS_TOKEN_CACHE_PATH", "/app/.cache/kis_token.json")) + class SkippedMissingKey(RuntimeError): """KIS 키 미설정 시 발생. 호출 측에서 skipped 로 매핑.""" @@ -49,6 +62,54 @@ def _has_keys() -> bool: return bool(settings.kis_app_key and settings.kis_app_secret) +def _current_key_prefix() -> str: + # app_key 가 바뀌었는데 옛 키로 받은 토큰을 그대로 쓰면 401. 캐시 무효화 키로 사용. + return (settings.kis_app_key or "")[:8] + + +def _load_disk_cache() -> _Token | None: + try: + with _TOKEN_CACHE_PATH.open() as f: + data = json.load(f) + if data.get("key_prefix") != _current_key_prefix(): + # .env 에서 app_key 가 바뀌었을 가능성 → 캐시 폐기 + return None + tok = _Token(value=str(data["value"]), expires_at=float(data["expires_at"])) + if tok.expires_at <= time.time(): + return None + return tok + except FileNotFoundError: + return None + except (OSError, ValueError, KeyError, TypeError) as exc: + logger.warning("kis token disk-cache read failed (%s): %s", _TOKEN_CACHE_PATH, exc) + return None + + +def _save_disk_cache(tok: _Token) -> None: + try: + _TOKEN_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) + tmp = _TOKEN_CACHE_PATH.with_suffix(".json.tmp") + # atomic write: 부분 쓰기 중 컨테이너가 죽어도 다음 시작 시 깨진 파일 안 읽음 + with tmp.open("w") as f: + json.dump( + { + "value": tok.value, + "expires_at": tok.expires_at, + "key_prefix": _current_key_prefix(), + }, + f, + ) + os.replace(tmp, _TOKEN_CACHE_PATH) + # 토큰 파일은 키 동등의 secret. 0600 권한. + try: + os.chmod(_TOKEN_CACHE_PATH, 0o600) + except OSError: + pass + except OSError as exc: + # 캐시 쓰기 실패는 치명적이지 않음 — 메모리 캐시로만 동작 가능. 경고만. + logger.warning("kis token disk-cache write failed (%s): %s", _TOKEN_CACHE_PATH, exc) + + @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), @@ -75,13 +136,29 @@ def _issue_token() -> _Token: def get_token() -> str: - """캐시된 토큰 반환. 만료 60초 전부터 재발급. 키 없으면 SkippedMissingKey.""" + """캐시된 토큰 반환. 메모리 → 디스크 → 신규 발급 순. 키 없으면 SkippedMissingKey. + + 디스크 캐시는 컨테이너 재기동 시 토큰 재발급 1분 제한 (EGW00133) 회피용. + """ global _token_cache with _token_lock: if _token_cache and _token_cache.expires_at > time.time(): return _token_cache.value + disk = _load_disk_cache() + if disk is not None: + _token_cache = disk + logger.info( + "kis token loaded from disk, expires_at=%s", + datetime.fromtimestamp(disk.expires_at), + ) + return disk.value _token_cache = _issue_token() - logger.info("kis token issued, expires_at=%s", datetime.fromtimestamp(_token_cache.expires_at)) + _save_disk_cache(_token_cache) + logger.info( + "kis token issued (and cached to %s), expires_at=%s", + _TOKEN_CACHE_PATH, + datetime.fromtimestamp(_token_cache.expires_at), + ) return _token_cache.value diff --git a/restart.bat b/restart.bat index 05ee741..23fe169 100644 --- a/restart.bat +++ b/restart.bat @@ -58,13 +58,19 @@ if "%RUN_COUNT%"=="0" ( exit /b 1 ) -REM 4) backend + web 만 재시작 — db 는 건드리지 않음. -REM docker compose restart 는 depends_on.condition: service_healthy 를 지키지 않으므로 -REM db 까지 같이 재시작하면 backend lifespan 부팅 시드가 db 준비 전에 실행될 수 있다. -REM db 는 상태 (postgres_data 볼륨) 가 영속이라 재시작할 이유도 없다. +REM 4) backend + web 만 재기동 — db 는 건드리지 않음 (--no-deps). +REM +REM 왜 `restart` 가 아니라 `up -d --force-recreate` 인가: +REM - `docker compose restart` 는 기존 컨테이너를 stop/start 만 한다. 그래서 +REM `.env` 변경 (예: KIS_APP_KEY 갱신) 이 반영되지 않는다. env_file 은 +REM 컨테이너 "생성" 시점에만 읽힌다. +REM - `up -d --force-recreate` 는 새 컨테이너 인스턴스를 만들어서 env_file 을 +REM 다시 읽는다. 이게 사용자가 .env 만 고치고 restart.bat 돌렸을 때 직관에 맞는다. +REM - `--no-deps` 로 db 는 절대 건드리지 않음. db 는 postgres_data 볼륨이 영속이라 +REM 재기동할 이유 없고, depends_on.condition: service_healthy 와 무관하게 안전. echo. -echo === docker compose restart backend web === -docker compose %COMPOSE_FILES% restart backend web +echo === docker compose up -d --force-recreate --no-deps backend web === +docker compose %COMPOSE_FILES% up -d --force-recreate --no-deps backend web if errorlevel 1 ( echo [ERROR] restart 실패. pause