# 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 작성 가능 (이미 명세 충분).
