Files
web-page-backend/docs/superpowers/plans/2026-05-18-track-a-cache-hardening.md
gahusb 11d86450c3 docs(plan): Track A cache hardening (SP-A1 + SP-A2)
web-ai stock_client TTL 증가 (60/300/60 → 180/600/300) + NAS stock
TTLCache 도입 (cachetools, webai_cache 모듈, 3 endpoint 적용).
2-layer cache로 V2 재시작 시점부터 NAS 인바운드 호출 70% 감소 예상.

8개 task, TDD 적용 (회귀 테스트 3건 + cache 단위 테스트 6건).
~40분 작업.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:30:43 +09:00

657 lines
24 KiB
Markdown

# Track A — NAS↔Windows API 부하 캐시 강화 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** web-ai → NAS stock 호출량을 분당 12회 → 분당 3~4회로 축소하여, V2 재시작 시점부터 즉시 NAS CPU 부담 70% 감소.
**Architecture:** 2-layer cache. (1) web-ai client side: 3개 endpoint TTL 60/300/60 → 180/600/300으로 증가. (2) NAS stock server side: 동일 endpoint에 in-memory TTLCache 추가하여 web-ai 캐시 miss 시에도 KIS·LLM 재호출 차단. 두 layer가 cumulative하게 작동.
**Tech Stack:** Python 3.12 / FastAPI / pytest / `cachetools.TTLCache`. **two repos**: `web-ai` (signal_v2/) + `web-backend` (stock/).
**Spec:** `web-backend/docs/superpowers/specs/2026-05-18-nas-windows-distributed-architecture-design.md` §4 SP-A1·A2, §10 상세
---
## File Structure
### SP-A1 — web-ai 캐시 TTL (Modify)
| 파일 | 변경 | 책임 |
|------|------|------|
| `web-ai/signal_v2/stock_client.py:13-17` | `_TTL` dict 3개 값 변경 | endpoint별 client-side cache TTL |
| `web-ai/signal_v2/tests/test_stock_client_ttl.py` (Create) | TTL 값 회귀 테스트 | 미래 변경 시 의도하지 않은 회귀 방지 |
### SP-A2 — NAS stock TTLCache (Modify + Create)
| 파일 | 변경 | 책임 |
|------|------|------|
| `web-backend/stock/requirements.txt` | `cachetools>=5.3` 추가 | 의존성 |
| `web-backend/stock/app/webai_cache.py` (Create) | 3개 TTLCache + helper 함수 | server-side cache 중앙화 |
| `web-backend/stock/app/main.py:419-422` | `get_webai_portfolio()` cache 적용 | NAS portfolio 캐시 |
| `web-backend/stock/app/main.py:467-470` | `get_webai_news_sentiment(date)` cache 적용 | date별 캐시 |
| `web-backend/stock/app/screener/router.py:173` | `post_run()` cache 적용 (mode=preview만) | screener preview 캐시 |
| `web-backend/stock/app/test_webai_cache.py` (Create) | cache 동작 + TTL + key 분기 | 캐시 hit/miss 검증 |
---
## Task 1: web-ai SP-A1 — `_TTL` dict 회귀 테스트 작성
**Files:**
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/signal_v2/tests/test_stock_client_ttl.py`
- [ ] **Step 1: 실패하는 테스트 작성**
```python
# tests/test_stock_client_ttl.py
"""SP-A1 회귀 — _TTL이 NAS 부담 완화를 위한 값으로 설정되어 있어야 함."""
from signal_v2.stock_client import _TTL
def test_portfolio_ttl_is_180s():
"""portfolio TTL은 180초 이상 (3분 폴링에서 1회 fetch가 3 폴링 커버)."""
assert _TTL["portfolio"] >= 180.0
def test_news_sentiment_ttl_is_600s():
"""news-sentiment TTL은 600초 이상 (10분, 뉴스 sentiment는 자주 안 바뀜)."""
assert _TTL["news-sentiment"] >= 600.0
def test_screener_preview_ttl_is_300s():
"""screener-preview TTL은 300초 이상 (5분, Top-20은 분 단위로 거의 안 바뀜)."""
assert _TTL["screener-preview"] >= 300.0
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai && python -m pytest signal_v2/tests/test_stock_client_ttl.py -v`
Expected: FAIL — 현재 _TTL 값은 60/300/60. portfolio·screener-preview 모두 < 180/300.
- [ ] **Step 3: `_TTL` 값 변경**
`C:/Users/jaeoh/Desktop/workspace/web-ai/signal_v2/stock_client.py` line 13-17:
변경 전:
```python
_TTL = {
"portfolio": 60.0,
"news-sentiment": 300.0,
"screener-preview": 60.0,
}
```
변경 후:
```python
# Cache TTL by endpoint (seconds).
# 2026-05-18 — NAS 인바운드 호출 부담 완화 (Plan-A SP-A1).
_TTL = {
"portfolio": 180.0, # 3분 (1분 폴링 시 3 폴링당 1회 실제 fetch)
"news-sentiment": 600.0, # 10분 (뉴스 sentiment는 자주 안 바뀜)
"screener-preview": 300.0, # 5분 (Top-20은 분 단위로 거의 안 바뀜)
}
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai && python -m pytest signal_v2/tests/test_stock_client_ttl.py -v`
Expected: PASS — 3개 모두 통과.
- [ ] **Step 5: 전체 회귀 확인 (기존 56 tests + 신규 3 tests)**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai && python -m pytest signal_v2/tests/ -v 2>&1 | tail -5`
Expected: 59 tests 모두 PASS (기존 56 + 신규 3).
- [ ] **Step 6: 커밋**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add signal_v2/stock_client.py signal_v2/tests/test_stock_client_ttl.py
git commit -m "$(cat <<'EOF'
perf(signal_v2): raise stock_client TTL for NAS load relief (SP-A1)
portfolio 60s → 180s (3분 폴링 → 3회당 1회 fetch)
news-sent 300s → 600s (sentiment는 자주 안 바뀜)
screener 60s → 300s (Top-20 분 단위 변화 미미)
V2 재시작 시점부터 NAS stock에 대한 인바운드 호출이
분당 12 → 분당 3~4 로 감소 예상. 캐시 hit ratio 0~50% → 66~80%.
회귀 테스트 3건 추가로 미래 의도치 않은 TTL 변경 차단.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 2: NAS SP-A2 — `cachetools` 의존성 추가
**Files:**
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/requirements.txt`
- [ ] **Step 1: 현재 requirements.txt 확인**
Run: `cat C:/Users/jaeoh/Desktop/workspace/web-backend/stock/requirements.txt`
파일 끝 확인 — 마지막 줄 newline 여부 확인 (sed/append 안전).
- [ ] **Step 2: cachetools 추가**
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/requirements.txt` 끝에 한 줄 추가:
```
cachetools>=5.3
```
(파일 마지막에 newline 없으면 newline 먼저, 그 다음 cachetools 줄.)
- [ ] **Step 3: 로컬 import 가능 여부 확인 (선택, NAS rebuild가 정본)**
Run (Windows 로컬에서 docker 외부 검증용, 선택):
```bash
python -c "import cachetools; print(cachetools.__version__)" 2>&1
```
로컬 미설치라면 skip — NAS deployer가 rebuild 시 install. 이 plan은 코드 정합성만 보장.
- [ ] **Step 4: 커밋 (단독 커밋, deps만)**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add stock/requirements.txt
git commit -m "$(cat <<'EOF'
chore(stock): add cachetools for server-side TTLCache (SP-A2 prep)
다음 커밋에서 /api/webai/portfolio·news-sentiment·screener/run에
in-memory TTLCache 적용 예정.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: NAS SP-A2 — `webai_cache.py` 모듈 + 단위 테스트
**Files:**
- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/webai_cache.py`
- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_webai_cache.py`
- [ ] **Step 1: 실패하는 테스트 작성**
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_webai_cache.py`:
```python
"""SP-A2 — webai_cache module의 cache hit/miss + key 분기 검증."""
import time
import pytest
from app.webai_cache import (
PORTFOLIO_CACHE, NEWS_CACHE, SCREENER_CACHE,
cache_get_portfolio, cache_set_portfolio,
cache_get_news, cache_set_news,
cache_get_screener, cache_set_screener,
_screener_key,
)
def _clear_all():
PORTFOLIO_CACHE.clear()
NEWS_CACHE.clear()
SCREENER_CACHE.clear()
def test_portfolio_cache_miss_then_hit():
_clear_all()
assert cache_get_portfolio() is None
cache_set_portfolio({"holdings": [], "cash": 0})
assert cache_get_portfolio() == {"holdings": [], "cash": 0}
def test_news_cache_key_by_date():
"""date가 다르면 별도 캐시 슬롯."""
_clear_all()
cache_set_news("2026-05-18", {"count": 5})
cache_set_news("2026-05-17", {"count": 3})
assert cache_get_news("2026-05-18") == {"count": 5}
assert cache_get_news("2026-05-17") == {"count": 3}
assert cache_get_news("2026-05-16") is None # not cached
def test_news_cache_latest_key_normalized():
"""date=None은 'latest' 키로 정규화되어 동일 슬롯."""
_clear_all()
cache_set_news(None, {"count": 9})
assert cache_get_news(None) == {"count": 9}
def test_screener_key_includes_mode_and_top_n():
"""screener key는 mode + top_n + weights hash로 분기."""
k_preview = _screener_key("preview", 20, None)
k_preview_w = _screener_key("preview", 20, {"news": 0.3})
k_auto = _screener_key("auto", 20, None)
assert k_preview != k_preview_w
assert k_preview != k_auto
def test_screener_cache_roundtrip():
_clear_all()
payload = {"asof": "2026-05-18", "survivors_count": 17}
cache_set_screener("preview", 20, None, payload)
assert cache_get_screener("preview", 20, None) == payload
assert cache_get_screener("preview", 20, {"news": 0.3}) is None
def test_ttl_expiry_portfolio():
"""짧은 ttl로 만료 확인 — 직접 시간 조작 대신 TTLCache 내부 동작 신뢰."""
from cachetools import TTLCache
short = TTLCache(maxsize=1, ttl=0.1) # 0.1초
short["result"] = "x"
assert short.get("result") == "x"
time.sleep(0.2)
assert short.get("result") is None
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest app/test_webai_cache.py -v`
Expected: FAIL — `app.webai_cache` 모듈 존재 안 함.
- [ ] **Step 3: `webai_cache.py` 작성**
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/webai_cache.py`:
```python
"""SP-A2 — NAS stock의 /api/webai/* 엔드포인트 in-memory TTLCache.
web-ai 측 캐시(stock_client._TTL)가 miss됐을 때도 NAS에서 같은 데이터를
KIS·LLM 재호출 없이 즉시 반환하기 위한 2-layer 캐시의 server 측.
V1+V2가 동시 호출해도 NAS는 1회만 계산.
TTL 정책 (spec §10 SP-A2):
- portfolio: 120s (web-ai TTL 180s 보다 짧게 — 변경 감지 가능)
- news: 600s (sentiment는 일 단위)
- screener: 180s
"""
from __future__ import annotations
import hashlib
import json
from typing import Any, Optional
from cachetools import TTLCache
PORTFOLIO_CACHE: TTLCache = TTLCache(maxsize=1, ttl=120.0)
NEWS_CACHE: TTLCache = TTLCache(maxsize=10, ttl=600.0)
SCREENER_CACHE: TTLCache = TTLCache(maxsize=10, ttl=180.0)
# ----- portfolio -----
def cache_get_portfolio() -> Optional[Any]:
return PORTFOLIO_CACHE.get("result")
def cache_set_portfolio(value: Any) -> None:
PORTFOLIO_CACHE["result"] = value
# ----- news-sentiment -----
def _news_key(date: Optional[str]) -> str:
return date if date else "latest"
def cache_get_news(date: Optional[str]) -> Optional[Any]:
return NEWS_CACHE.get(_news_key(date))
def cache_set_news(date: Optional[str], value: Any) -> None:
NEWS_CACHE[_news_key(date)] = value
# ----- screener -----
def _screener_key(mode: str, top_n: int, weights: Optional[dict]) -> str:
"""mode + top_n + weights canonical hash. weights 객체 동등성을 키로."""
if weights is None:
w_repr = "none"
else:
# canonical: sorted keys → md5 hex (긴 weights도 짧은 키로)
canon = json.dumps(weights, sort_keys=True, ensure_ascii=False)
w_repr = hashlib.md5(canon.encode("utf-8")).hexdigest()[:12]
return f"{mode}:{top_n}:{w_repr}"
def cache_get_screener(mode: str, top_n: int, weights: Optional[dict]) -> Optional[Any]:
return SCREENER_CACHE.get(_screener_key(mode, top_n, weights))
def cache_set_screener(mode: str, top_n: int, weights: Optional[dict], value: Any) -> None:
SCREENER_CACHE[_screener_key(mode, top_n, weights)] = value
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest app/test_webai_cache.py -v`
Expected: PASS — 6개 모두 통과.
- [ ] **Step 5: 커밋**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add stock/app/webai_cache.py stock/app/test_webai_cache.py
git commit -m "$(cat <<'EOF'
feat(stock): webai_cache module (TTLCache for SP-A2)
3개의 TTLCache (portfolio 120s · news 600s · screener 180s) +
헬퍼 함수. screener key는 mode + top_n + weights canonical hash로
분기. 다음 커밋에서 /api/webai/portfolio·news-sentiment·screener/run
3 endpoint에 적용.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: NAS SP-A2 — `/api/webai/portfolio` 캐시 적용
**Files:**
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py:419-422`
- [ ] **Step 1: 현재 endpoint 코드 확인**
`web-backend/stock/app/main.py` 419-422 line은 spec §10 SP-A2와 일치:
```python
@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)])
def get_webai_portfolio():
"""web-ai 전용 portfolio (인증 필수, pnl_pct 비율 필드 추가)."""
return _augment_portfolio_with_pnl_pct(get_portfolio())
```
- [ ] **Step 2: 캐시 적용으로 교체**
`web-backend/stock/app/main.py` 419-422 line을 다음으로 교체:
```python
@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)])
def get_webai_portfolio():
"""web-ai 전용 portfolio (인증 필수, pnl_pct 비율 필드 추가).
SP-A2 server-side TTLCache 적용. V1+V2 동시 호출도 NAS에서 1회 계산.
"""
cached = webai_cache.cache_get_portfolio()
if cached is not None:
return cached
result = _augment_portfolio_with_pnl_pct(get_portfolio())
webai_cache.cache_set_portfolio(result)
return result
```
- [ ] **Step 3: import 추가 (파일 상단)**
`web-backend/stock/app/main.py` 파일 상단 import 블록 (다른 `from .xxx import` 들과 같은 위치)에 추가:
```python
from . import webai_cache
```
- [ ] **Step 4: 빠른 import sanity 체크**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -c "from app import main; print('OK')"` 2>&1 | tail -3
(`cachetools` 미설치 환경에선 ImportError 가능 → 그 경우 `pip install cachetools` 후 재시도. 실제 검증은 NAS rebuild 후.)
Expected: `OK` 또는 cachetools 누락 메시지 (의도된 상태).
---
## Task 5: NAS SP-A2 — `/api/webai/news-sentiment` 캐시 적용
**Files:**
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py:467-470`
- [ ] **Step 1: 캐시 적용**
`web-backend/stock/app/main.py` 467-470 line을 다음으로 교체:
```python
@app.get("/api/webai/news-sentiment", dependencies=[Depends(verify_webai_key)])
def get_webai_news_sentiment(date: str | None = None):
"""web-ai 전용 news sentiment 일별 dump.
SP-A2 server-side TTLCache 적용. date 파라미터별로 별도 슬롯.
"""
cached = webai_cache.cache_get_news(date)
if cached is not None:
return cached
result = _fetch_news_sentiment_dump(date)
webai_cache.cache_set_news(date, result)
return result
```
- [ ] **Step 2: import sanity 체크**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -c "from app import main; print('OK')" 2>&1 | tail -3`
Expected: `OK`
---
## Task 6: NAS SP-A2 — `/api/stock/screener/run` 캐시 적용 (preview 모드만)
**Files:**
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/screener/router.py:173-...`
- [ ] **Step 1: 현재 함수 확인 (참고)**
`web-backend/stock/app/screener/router.py:173` 시작 `def post_run(body: schemas.RunRequest):` — 함수 본체는 mode 분기 후 _conn() + KIS 호출 등. 단, `mode == "auto"` 는 휴장일/실 운영 트리거이므로 캐시하지 않음 (매 호출이 다른 의미). `mode == "preview"` 는 frontend·web-ai 폴링용 → 캐시 적용.
- [ ] **Step 2: 함수 진입부에 cache 분기 추가**
`web-backend/stock/app/screener/router.py:173` `@router.post("/run", ...)``def post_run(...)` 본체 **첫 줄들에** 다음 캐시 분기 추가:
변경 전 (line 173-179 근처):
```python
@router.post("/run", response_model=schemas.RunResponse)
def post_run(body: schemas.RunRequest):
from .registry import NODE_REGISTRY as _NR, GATE_REGISTRY as _GR
started_at = dt.datetime.utcnow().isoformat()
with _conn() as c:
asof = _resolve_asof(body.asof, c)
```
변경 후:
```python
@router.post("/run", response_model=schemas.RunResponse)
def post_run(body: schemas.RunRequest):
from .registry import NODE_REGISTRY as _NR, GATE_REGISTRY as _GR
# SP-A2 — preview 모드는 web-ai/frontend 폴링이라 캐시 적용.
# auto 모드는 실제 운영 트리거(휴장일 게이트 등)라 캐시 미적용.
if body.mode == "preview":
cached = webai_cache.cache_get_screener(body.mode, body.top_n, body.weights)
if cached is not None:
return cached
started_at = dt.datetime.utcnow().isoformat()
with _conn() as c:
asof = _resolve_asof(body.asof, c)
```
- [ ] **Step 3: 함수 끝 부분 — preview 결과를 캐시에 저장**
`post_run`의 반환부 직전에 (preview 모드일 때만) 캐시 저장. `post_run` 함수는 결과를 `schemas.RunResponse(...)` 로 만들어 return하는 구조일 것. 정확한 return 위치 확인 후, return 직전에:
`web-backend/stock/app/screener/router.py` `post_run` 함수의 마지막 return 직전에:
```python
# SP-A2 — preview 모드 결과 캐시 저장.
if body.mode == "preview":
webai_cache.cache_set_screener(body.mode, body.top_n, body.weights, response)
return response
```
(`response` 라는 변수가 없으면, 기존 return 표현식을 `response = ...` 로 binding 후 위 코드 추가.)
> **주의:** post_run의 정확한 return 라인을 먼저 확인. `grep -n "return " app/screener/router.py | head` 로 위치 파악 후 적용.
- [ ] **Step 4: import 추가 (router.py 상단)**
`web-backend/stock/app/screener/router.py` 상단 import 블록에 추가:
```python
from .. import webai_cache
```
- [ ] **Step 5: 빠른 import sanity 체크**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -c "from app.screener import router; print('OK')" 2>&1 | tail -3`
Expected: `OK`
---
## Task 7: 통합 검증 — 기존 테스트 회귀 + SP-A2 신규 테스트
**Files:** (조회만)
- [ ] **Step 1: stock 전체 pytest 실행**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest -v 2>&1 | tail -30`
Expected: 기존 모든 테스트 + SP-A2 신규 6 tests 모두 PASS. **0 failed**.
- [ ] **Step 2: 회귀 발견 시 처리**
회귀가 발견되면:
- import 누락 → `from . import webai_cache` 또는 `from .. import webai_cache` 위치 재확인
- screener test가 cache hit으로 fail → test가 `_clear_all()` 또는 cache fixture 통해 격리되어 있는지 확인. 필요 시 conftest에 `autouse=True` cache reset fixture 추가:
```python
# conftest.py에 추가 (선택)
import pytest
from app import webai_cache
@pytest.fixture(autouse=True)
def _reset_webai_cache():
webai_cache.PORTFOLIO_CACHE.clear()
webai_cache.NEWS_CACHE.clear()
webai_cache.SCREENER_CACHE.clear()
yield
```
- [ ] **Step 3: 커밋 (SP-A2 endpoint 통합 + 회귀 확인)**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add stock/app/main.py stock/app/screener/router.py
# (필요 시) git add stock/app/conftest.py
git commit -m "$(cat <<'EOF'
feat(stock): apply webai_cache to portfolio/news/screener-preview (SP-A2)
3 endpoint cache 적용 — /api/webai/portfolio, /api/webai/news-sentiment,
/api/stock/screener/run (preview 모드만, auto는 캐시 미적용).
V1+V2 동시 호출도 NAS에서 1회 계산. web-ai 측 SP-A1 캐시와 2-layer로
작동하여 NAS 인바운드 부담 70% 감소 예상.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 8: 양쪽 push + NAS deploy 트리거
**Files:** 없음 (git 작업)
- [ ] **Step 1: web-ai push**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git push origin main
```
Expected: success. 인증 prompt 뜨면 자격증명 입력. 1회 실패 시 1회 재시도 (캐시 패턴).
> **참고:** web-ai는 NAS deployer가 별도 webhook 없음 (Windows 머신 코드). push는 백업/이력 동기화 목적. 실제 적용은 V2 재시작 시점.
- [ ] **Step 2: web-backend push (NAS deployer 트리거)**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git push origin main
```
Expected: success. NAS deployer가 webhook 수신 → `git pull``docker compose build stock --no-cache` (cachetools 신규 설치) → `docker compose up -d stock`. 통상 2~3분 소요.
- [ ] **Step 3: NAS stock 컨테이너 헬스 확인**
```bash
curl -s -o /dev/null -w "HTTP %{http_code}\n" https://gahusb.synology.me/api/stock/news -m 10
```
Expected: `HTTP 200`. (NAS deploy 완료 후 통상 30초 ~ 2분 대기 필요.)
- [ ] **Step 4: webai 캐시 효과 확인 (선택)**
연속 2회 호출 시 두 번째가 즉시 응답하는지 (cached):
```bash
# 인증키 필요. .env의 WEBAI_API_KEY 사용 또는 NAS에서 직접 호출.
# Windows 로컬에서:
# 첫 호출
time curl -s -H "X-WebAI-Key: $WEBAI_API_KEY" https://gahusb.synology.me/api/webai/portfolio -o /dev/null
# 즉시 두번째 (캐시 hit 기대, 첫 호출 < 1s + DB / 두번째 < 100ms)
time curl -s -H "X-WebAI-Key: $WEBAI_API_KEY" https://gahusb.synology.me/api/webai/portfolio -o /dev/null
```
Expected: 두 번째 호출이 첫 번째보다 명확히 빠름 (DB·계산 skip).
---
## Self-Review
### Spec 커버리지
| Spec 요구사항 | 구현 Task |
|---------------|-----------|
| §4 SP-A1: web-ai 캐시 TTL 증가 (180/600/300) | Task 1 |
| §4 SP-A2: NAS stock TTLCache | Task 2~7 |
| §10 SP-A2: 3 endpoint (portfolio/news/screener) 적용 | Task 4 (portfolio), Task 5 (news), Task 6 (screener preview) |
| §10 SP-A2: cachetools 의존성 | Task 2 |
| §8: X-WebAI-Key 인증 (기존 verify_webai_key 유지) | 기존 dependency 그대로, 변경 없음 |
| §6: server cache 별개 (Redis 캐시 옵션) | in-memory TTLCache 선택 (Redis는 SP-1 이후 도입 검토) |
§4의 SP-A2는 `/api/webai/portfolio`, `/api/webai/news-sentiment`, `/api/stock/screener/run` 3건만 명시. 추가 endpoint 캐시는 out of scope (별도 plan에서).
### Placeholder 스캔
- TBD/TODO/"implement later" 패턴 없음 ✓
- 모든 code step에 완전 코드 포함 ✓
- Task 6에 한 가지 conditional ("`post_run`의 정확한 return 라인을 먼저 확인") — 이건 plan 실행 시 grep 명령으로 즉시 해결 가능한 단순 lookup이라 placeholder가 아님. 그러나 안전성 위해 helper note 그대로 유지.
### Type consistency
- `webai_cache.cache_get_portfolio()` / `cache_set_portfolio(value)` — Task 3에서 정의, Task 4에서 사용. 시그니처 일치 ✓
- `cache_get_news(date)` — Task 3·5 일치 ✓
- `cache_get_screener(mode, top_n, weights)` / `cache_set_screener(mode, top_n, weights, value)` — Task 3·6 일치 ✓
- 변수명 `cached`, `result`, `payload` — 각 함수 안에서만 사용, 충돌 없음 ✓
### 위험·주의
- **NAS deployer rebuild**: `requirements.txt` 변경은 docker image rebuild 필요. deployer가 변경 감지 시 rebuild 트리거. 만약 deployer가 변경 미감지(예: requirements.txt만 변경 시 rebuild 안 함)라면 NAS에서 `docker compose build stock --no-cache && docker compose up -d stock` 수동 실행 필요.
- **Cache stale**: TTL이 충분히 짧아 stale 문제 미미. portfolio 120s = web-ai 폴링 주기(1분) 2배. 변경 감지에 최대 2분 지연.
- **Cache miss thunder herd**: V1+V2가 정확히 동시에 캐시 miss 시 KIS 동시 호출 가능. 현재 V1/V2 둘 다 정지 상태라 risk 0. 향후 재시작 시 KIS rate limit 모니터링 필요 (별도 plan 항목).
---
## 완료 후 다음 단계
Plan-A 완료 후 spec §14 "차후 plan 작성 순서 권장"대로:
1. **Plan-B-Base** — SP-1 (Redis) + SP-2 (WSL2)
2. **Plan-B-Insta** — SP-3 + SP-4
3. **Plan-B-Music** — SP-5 + SP-6
4. **Plan-B-Video** — SP-7 + SP-8
5. **Plan-B-Infra** — SP-9 + SP-10
각각은 별도 brainstorm 없이 spec §10에서 직접 plan 작성 가능 (이미 명세 충분).