feat(kis): 토큰 디스크 캐시 + restart.bat env 재로드

토큰 발급 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 영속)
This commit is contained in:
claude-owner
2026-05-23 01:22:32 +09:00
parent 78388d347e
commit 928c2160f9
2 changed files with 91 additions and 8 deletions

View File

@@ -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

View File

@@ -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