Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
build/
!gradle/wrapper/gradle-wrapper.jar
*.log
logs/
.env
.idea/
*.iml
Expand Down
37 changes: 31 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ AI coding agents are great at reasoning but blind to live market data. `toss-inv
- ✅ **Market data (read-only)** — `getPrices` (quotes, up to 200 symbols), `getOrderbook`, `getTrades`, `getCandles` (1m/1d), `getStocks` (instrument info). Parameters verified against the official OpenAPI spec.
- 🔒 OAuth2 client-credentials with automatic token caching & refresh
- 🔑 Secrets via environment variables only (never committed)
- 🗺️ **Roadmap**: caching + request-coalescing (done) → HTTP (Streamable) transport (done) → load testing for concurrency (see [Roadmap](#roadmap))
- 📊 **Observability** — Micrometer metrics (cache offload, single-flight, Caffeine stats) at `/actuator/prometheus` (HTTP profile); load-test harness in [`loadtest/`](loadtest/)
- 🗺️ **Roadmap**: caching + coalescing (done) → HTTP transport (done) → virtual-thread pinning fix + load testing & observability (done) → order tools behind safety gates (see [Roadmap](#roadmap))

## Quickstart

Expand Down Expand Up @@ -110,7 +111,8 @@ AI Agent (Claude Code / Cursor / …)
| 1 ✅ | Read-only market-data tools: prices, orderbook, trades, candles, stocks — **done** |
| 2 ✅ | Two-tier cache (Caffeine L1 + Redis L2) + per-node single-flight coalescing in front of the rate-limited upstream — **done** |
| 2.5 ✅ | HTTP (Streamable) transport (WebMVC) alongside stdio — **done** |
| 3 | Load testing (k6) + observability (Micrometer / Prometheus / Grafana) with published throughput & latency numbers |
| 3a ✅ | Remove virtual-thread carrier pinning at the cache loader and token refresh, proven with JFR pin-count tests — **done** |
| 3b ✅ | Load testing (k6) + observability (Micrometer / Prometheus / Grafana); measured cache-offload & single-flight ratios — **done** (see [Observability & load testing](#observability--load-testing)) |
| 4 | Account & order tools behind explicit opt-in safety gates (dry-run → confirm) |

## Caching
Expand All @@ -119,10 +121,11 @@ Read-only market-data calls pass through a cache so bursts of identical
requests collapse to at most one upstream call, and slow-changing data is not
re-fetched from the rate-limited upstream on every request:

- **L1 — Caffeine (in-process):** `get(key, loader)` is atomic per key, so
concurrent identical requests on a node are single-flighted to one load.
Each entry expires at its per-type TTL, so a single node caches correctly
**without Redis**.
- **L1 — Caffeine `AsyncCache` (in-process):** concurrent identical requests
on a node share one in-flight future, so they are single-flighted to one
load. Loading runs off the map's monitor on a virtual thread, so blocking
upstream I/O never pins a JDK 21 carrier. Each entry expires at its per-type
TTL, so a single node caches correctly **without Redis**.
- **Per-type TTLs:** quotes/orderbook 2s, trades 3s, intraday candles 10s,
daily candles 1h, stock info 6h.
- **L2 — Redis (opt-in, shared):** the same entries in a shared cache, so
Expand All @@ -145,6 +148,28 @@ implemented; the shared L2 narrows (but does not eliminate) the concurrent-miss
window when running multiple instances. The Redis L2 path is covered by
`RedisL2CacheIT` (Testcontainers), which requires Docker to run.

## Observability & load testing

The HTTP profile exposes Micrometer metrics at `/actuator/prometheus`, including
domain counters that make cache behavior legible:

- `marketdata_upstream_calls_total` — actual upstream calls (fewer than requests = offload)
- `marketdata_l2_hits_total` — shared-cache hits
- Caffeine L1 stats (`cache_gets_total{result="hit"|"miss"}`, size, evictions)

[`loadtest/`](loadtest/) has k6 scripts, a Prometheus + Grafana stack, and a
`loadtest` profile with a fixed-latency **stub** upstream, so the cache /
coalescing / virtual-thread path can be driven without real credentials.

**Measured** (WSL2 dev box; cache + 40 ms stub upstream — these are *ratios*, not
absolute latency claims; full honesty caveats in [loadtest/README](loadtest/README.md)):

- 200 concurrent cold-key requests → **1** upstream call (single-flight)
- 157,476 requests on one hot key → **1** upstream call, L1 hit ratio ≈ 99.998%

These offload and coalescing properties are guarded deterministically in CI by
`LoadOffloadIT` — no k6 or Docker required.

## Contributing

Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Good first issues are labeled [`good first issue`](https://github.com/java-jaydev/toss-invest-mcp/labels/good%20first%20issue).
Expand Down
5 changes: 4 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,13 @@ dependencies {
// 전송 선택은 Spring 프로파일(stdio / http)로 가른다.
implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
implementation 'org.springframework.boot:spring-boot-starter-json'
// L1 near-cache (single-flight via Caffeine's atomic get(key, loader))
// L1 near-cache (single-flight via Caffeine AsyncCache; loader runs off the monitor)
implementation 'com.github.ben-manes.caffeine:caffeine'
// L2 shared cache
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// 관측성: Micrometer 계측 + /actuator/prometheus 노출 (버전은 Boot BOM 관리)
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
Expand Down
99 changes: 99 additions & 0 deletions loadtest/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# 부하테스트 & 관측성

이 서버의 **캐시 오프로드 · 요청병합(single-flight) · 가상스레드 처리량**을 관측 가능하게
만들고 부하로 확인하기 위한 자산이다.

## 먼저: 정직성 (무엇을 재고, 무엇을 안 재는가)

실제 토스 Open API 를 부하로 두들길 수 없다(레이트리밋·약관·자격증명). 그래서 `loadtest`
프로파일은 **고정 지연 스텁 upstream**(`StubTossApiClient`)으로 상단을 대체한다. 따라서:

- ❌ **절대 지연·처리량을 "토스 서비스 성능"으로 제시하지 않는다.** 스텁 지연은 임의값이고,
숫자는 실행 머신 사양에 의존한다.
- ❌ MCP JSON-RPC 프레이밍 오버헤드는 측정 범위가 아니다(부하는 얇은 HTTP shim 경유).
- ✅ **방어 가능한 결론은 하드웨어 독립적인 "비율"이다:**
- **캐시 오프로드**: 요청 수 대비 upstream 실제 호출 수(`marketdata_upstream_calls_total`).
- **single-flight**: 동일 키 동시요청이 upstream 1회로 병합됨.
- **가상스레드**: 요청이 플랫폼 스레드가 아니라 가상스레드에서 처리됨(`VirtualThreadProbeIT` 로 증명).

공개 그래프·수치에는 항상 **"캐시 + 고정지연 스텁 업스트림, <머신> 기준"** 을 캡션한다.

## CI 로 재현되는 결정론적 증거 (k6/Docker 불필요)

수치를 지어낼 필요가 없다. `LoadOffloadIT` 가 HTTP 로 부하를 주고 지표 델타를 단언한다:

- 동일 키 **200 동시요청 → upstream 정확히 1회** (single-flight + 캐시)
- **500 반복요청 → upstream 정확히 1회** (오프로드)

이건 매 CI 실행에서 검증된다. 아래 k6/Grafana 는 "규모를 키워 눈으로 보는" 재현 도구다.

## 실측 결과 (예시 실행)

> **환경(캡션 필수):** WSL2, 4 vCPU / 11GiB, JDK 21, k6 v0.50.0, **캐시 + 고정지연
> 40ms 스텁 업스트림**. 절대 처리량·지연은 이 머신·스텁 기준이며 토스 실제 성능이 아니다.
> 방어 가능한 결론은 아래의 **오프로드 비율**이다.

| 시나리오 | 요청 수 | upstream 실제 호출 | 관측 |
|---|---:|---:|---|
| single-flight (콜드 키 200 동시) | 200 | **1** | 200 동시요청이 upstream 1회로 병합 |
| cache-offload (핫키, ~45s 램프 100 VU) | **157,476** (~3,500 req/s) | **1** | 6h TTL 핫키 → 전체 부하가 upstream 1회 |

- cache-offload 실행의 L1 히트율 ≈ **99.998%** (`cache_gets_total`: hit 157,674 / miss 3).
- http_req_duration p95 ≈ 55ms(위 스텁·머신 기준). **지연 절대값은 강조하지 않는다** — 핵심은
"요청 대비 upstream 호출 수"다.

이 숫자는 아래 절차로 재현할 수 있으며, 병합·오프로드 성질은 `LoadOffloadIT` 가 CI 에서
매번 결정론적으로 담보한다.

## 로컬 실행 (k6)

1. 앱을 `http,loadtest` 프로파일로 띄운다(실제 자격증명 불필요):

```bash
./gradlew bootRun --args='--spring.profiles.active=http,loadtest'
# 스텁 지연 조정: LOADTEST_UPSTREAM_LATENCY_MS=40
```

2. 부하를 준다:

```bash
k6 run loadtest/k6/cache-offload.js # 핫키 반복 → 오프로드
k6 run -e KEY=COLD1 loadtest/k6/single-flight.js # 콜드 버스트 → 병합
```

3. 오프로드를 확인한다 — k6 총 요청수와 서버 카운터를 비교:

```bash
curl -s localhost:8080/actuator/prometheus | grep -E 'marketdata_upstream_calls_total|cache_gets_total'
```

예: k6 가 수만 요청을 보냈는데 `marketdata_upstream_calls_total` 은 한 자릿수 →
오프로드가 그 비율만큼 일어났다는 뜻(single-flight 스크립트는 델타가 1 이어야 한다).

## 관측성 스택 (Prometheus + Grafana)

> ⚠️ 이 저장소의 개발 환경(WSL)에는 Docker 데몬이 없어 **이 스택은 여기서 실행하지 않았다.**
> Docker 가 있는 머신에서 재현한다.

```bash
cd loadtest/observability
docker compose up -d # Prometheus :9090, Grafana :3000 (익명 Admin)
# 앱은 컴포즈 밖에서 http,loadtest 로 띄운다(위 참고).
# Grafana > Dashboards > Import > grafana-dashboard.json, Prometheus 데이터소스 선택.
```

대시보드 패널: upstream calls/s, L1 히트율, `/loadtest` 요청률, 요청 지연 p95
(히스토그램 버킷은 `http` 프로파일에서 발행하도록 설정됨).

## 구성요소

| 파일 | 역할 |
|---|---|
| `k6/cache-offload.js` | 핫키 반복 부하(오프로드) |
| `k6/single-flight.js` | 콜드 버스트(요청병합) |
| `observability/docker-compose.yml` | Prometheus + Grafana |
| `observability/prometheus.yml` | `/actuator/prometheus` 스크레이프 설정 |
| `observability/grafana-dashboard.json` | 대시보드(임포트용) |

스텁 upstream·HTTP shim 은 `loadtest` 프로파일에서만 활성화되며 프로덕션엔 존재하지 않는다
(`src/main/java/dev/jaydev/tossmcp/loadtest/`).
36 changes: 36 additions & 0 deletions loadtest/k6/cache-offload.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import http from 'k6/http';
import { check } from 'k6';

// 캐시 오프로드 부하: 단일 핫키를 여러 VU 가 반복 조회한다.
// stocks 는 6h TTL 이라 부하 동안 재적재가 없어, 순수 오프로드/병합을 본다.
//
// 정직성: 이 수치는 "캐시 + 고정지연 스텁 업스트림" 기준이다. 토스 실제 지연이 아니고,
// 절대 처리량은 실행 머신 사양에 의존한다. 방어 가능한 결론은 "요청 대비 upstream 호출이
// 극소"라는 오프로드 비율이다 — 실행 후 /actuator/prometheus 의
// marketdata_upstream_calls_total 을 k6 의 총 요청수와 비교해 확인한다.
//
// 실행: k6 run loadtest/k6/cache-offload.js (BASE_URL 로 대상 지정 가능)

const BASE = __ENV.BASE_URL || 'http://localhost:8080';

export const options = {
scenarios: {
hot_key: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '10s', target: 100 },
{ duration: '30s', target: 100 },
{ duration: '5s', target: 0 },
],
},
},
thresholds: {
http_req_failed: ['rate<0.01'],
},
};

export default function () {
const res = http.get(`${BASE}/loadtest/stocks?symbol=HOT`);
check(res, { 'status is 200': (r) => r.status === 200 });
}
31 changes: 31 additions & 0 deletions loadtest/k6/single-flight.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import http from 'k6/http';
import { check } from 'k6';

// single-flight 부하: 다수 VU 가 콜드 스타트 순간 같은 키를 동시에 친다.
// 서버를 새로 띄운 뒤(또는 새 KEY 로) 실행하고, 직후 /actuator/prometheus 의
// marketdata_upstream_calls_total 델타가 1 인지 확인한다 — N 동시요청이 upstream
// 1회로 병합됨을 뜻한다.
//
// 실행: k6 run -e KEY=COLD1 loadtest/k6/single-flight.js

const BASE = __ENV.BASE_URL || 'http://localhost:8080';
const KEY = __ENV.KEY || 'COLD';

export const options = {
scenarios: {
burst: {
executor: 'shared-iterations',
vus: 200,
iterations: 200,
maxDuration: '30s',
},
},
thresholds: {
http_req_failed: ['rate<0.01'],
},
};

export default function () {
const res = http.get(`${BASE}/loadtest/stocks?symbol=${KEY}`);
check(res, { 'status is 200': (r) => r.status === 200 });
}
24 changes: 24 additions & 0 deletions loadtest/observability/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# 관측성 스택(Prometheus + Grafana).
# 이 저장소의 개발 환경(WSL, Docker 없음)에서는 실행하지 않았다. Docker 가 있는
# 머신에서 `docker compose up` 으로 재현한다. 앱은 컴포즈 밖에서 http,loadtest
# 프로파일로 띄운다(README 참고).
services:
prometheus:
image: prom/prometheus:latest
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
ports:
- "9090:9090"
extra_hosts:
- "host.docker.internal:host-gateway"

grafana:
image: grafana/grafana:latest
depends_on:
- prometheus
environment:
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
- GF_SECURITY_ALLOW_EMBEDDING=true
ports:
- "3000:3000"
69 changes: 69 additions & 0 deletions loadtest/observability/grafana-dashboard.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
{
"__comment": "toss-invest-mcp 캐시/오프로드 대시보드. Grafana > Dashboards > Import 로 불러오고, Prometheus 데이터소스를 고른다. 이 환경(Docker 없음)에서는 미실행 — Docker 머신에서 재현.",
"title": "toss-invest-mcp — cache & offload",
"schemaVersion": 39,
"editable": true,
"templating": {
"list": [
{
"name": "datasource",
"type": "datasource",
"query": "prometheus",
"current": {},
"hide": 0
}
]
},
"panels": [
{
"type": "timeseries",
"title": "Upstream calls / sec (스텁, 낮을수록 오프로드 큼)",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"targets": [
{
"expr": "sum(rate(marketdata_upstream_calls_total[1m]))",
"legendFormat": "upstream calls/s"
}
]
},
{
"type": "stat",
"title": "L1 cache hit ratio",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": { "defaults": { "unit": "percentunit", "min": 0, "max": 1 } },
"targets": [
{
"expr": "sum(rate(cache_gets_total{result=\"hit\"}[1m])) / clamp_min(sum(rate(cache_gets_total[1m])), 1)",
"legendFormat": "hit ratio"
}
]
},
{
"type": "timeseries",
"title": "HTTP requests / sec (shim)",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"targets": [
{
"expr": "sum(rate(http_server_requests_seconds_count{uri=~\"/loadtest.*\"}[1m]))",
"legendFormat": "requests/s"
}
]
},
{
"type": "timeseries",
"title": "HTTP request latency p95 (스텁 업스트림 기준)",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": { "defaults": { "unit": "s" } },
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri=~\"/loadtest.*\"}[1m])) by (le))",
"legendFormat": "p95"
}
]
}
]
}
8 changes: 8 additions & 0 deletions loadtest/observability/prometheus.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
scrape_configs:
- job_name: toss-invest-mcp
metrics_path: /actuator/prometheus
scrape_interval: 5s
static_configs:
# 컴포즈 밖(호스트)에서 http,loadtest 프로파일로 띄운 앱을 긁는다.
# 리눅스에서 host.docker.internal 이 안 되면 호스트 IP 로 바꾼다.
- targets: ['host.docker.internal:8080']
Loading
Loading