diff --git a/.gitignore b/.gitignore index bbacfb1..34a3946 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ build/ !gradle/wrapper/gradle-wrapper.jar *.log +logs/ .env .idea/ *.iml diff --git a/README.md b/README.md index 5cbddc5..23250a5 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 @@ -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). diff --git a/build.gradle b/build.gradle index 5ae7604..982437a 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/loadtest/README.md b/loadtest/README.md new file mode 100644 index 0000000..1d83da5 --- /dev/null +++ b/loadtest/README.md @@ -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/`). diff --git a/loadtest/k6/cache-offload.js b/loadtest/k6/cache-offload.js new file mode 100644 index 0000000..9052b4f --- /dev/null +++ b/loadtest/k6/cache-offload.js @@ -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 }); +} diff --git a/loadtest/k6/single-flight.js b/loadtest/k6/single-flight.js new file mode 100644 index 0000000..0e46e43 --- /dev/null +++ b/loadtest/k6/single-flight.js @@ -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 }); +} diff --git a/loadtest/observability/docker-compose.yml b/loadtest/observability/docker-compose.yml new file mode 100644 index 0000000..13d12d6 --- /dev/null +++ b/loadtest/observability/docker-compose.yml @@ -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" diff --git a/loadtest/observability/grafana-dashboard.json b/loadtest/observability/grafana-dashboard.json new file mode 100644 index 0000000..826aac5 --- /dev/null +++ b/loadtest/observability/grafana-dashboard.json @@ -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" + } + ] + } + ] +} diff --git a/loadtest/observability/prometheus.yml b/loadtest/observability/prometheus.yml new file mode 100644 index 0000000..d1c8042 --- /dev/null +++ b/loadtest/observability/prometheus.yml @@ -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'] diff --git a/src/main/java/dev/jaydev/tossmcp/cache/MarketDataCache.java b/src/main/java/dev/jaydev/tossmcp/cache/MarketDataCache.java index 6adbbca..d980d44 100644 --- a/src/main/java/dev/jaydev/tossmcp/cache/MarketDataCache.java +++ b/src/main/java/dev/jaydev/tossmcp/cache/MarketDataCache.java @@ -4,6 +4,10 @@ import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.Expiry; import com.github.benmanes.caffeine.cache.Ticker; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.binder.cache.CaffeineCacheMetrics; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -30,21 +34,42 @@ public class MarketDataCache { private final AsyncCache l1; private final L2Cache l2; + private final Counter upstreamCalls; + private final Counter l2Hits; @Autowired + public MarketDataCache(L2Cache l2, MeterRegistry registry) { + this(l2, Ticker.systemTicker(), registry); + } + + // ํ…Œ์ŠคํŠธ ํŽธ์˜: ์ง€ํ‘œ๋ฅผ ๋ฒ„๋ฆฌ๋Š” ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ๋กœ ๋งŒ๋“ ๋‹ค(๊ณ„์ธก ๊ฒ€์ฆ์ด ๋ชฉ์ ์ด ์•„๋‹Œ ํ…Œ์ŠคํŠธ์šฉ). public MarketDataCache(L2Cache l2) { - this(l2, Ticker.systemTicker()); + this(l2, Ticker.systemTicker(), new SimpleMeterRegistry()); } + // ํ…Œ์ŠคํŠธ ํŽธ์˜: ์ˆ˜๋™ ticker + ๋ฒ„๋ ค์ง€๋Š” ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ(TTL ํ…Œ์ŠคํŠธ์šฉ). MarketDataCache(L2Cache l2, Ticker ticker) { + this(l2, ticker, new SimpleMeterRegistry()); + } + + MarketDataCache(L2Cache l2, Ticker ticker, MeterRegistry registry) { this.l2 = l2; + this.upstreamCalls = Counter.builder("marketdata.upstream.calls") + .description("upstream(ํ† ์Šค API) ์‹ค์ œ ํ˜ธ์ถœ ํšŸ์ˆ˜ โ€” ์บ์‹œยท๋ณ‘ํ•ฉ์œผ๋กœ ์š”์ฒญ์ˆ˜๋ณด๋‹ค ์ ๋‹ค") + .register(registry); + this.l2Hits = Counter.builder("marketdata.l2.hits") + .description("L2(๊ณต์œ  ์บ์‹œ)์—์„œ ๊ฐ’์„ ๊ฐ€์ ธ์˜จ ํšŸ์ˆ˜") + .register(registry); this.l1 = Caffeine.newBuilder() .maximumSize(L1_MAX_SIZE) .ticker(ticker) // ๋กœ๋”ฉ์„ ๊ฐ€์ƒ์Šค๋ ˆ๋“œ์—์„œ ๋Œ๋ ค loader ์˜ ๋ธ”๋กœํ‚น I/O ๊ฐ€ ์บ๋ฆฌ์–ด๋ฅผ ํ•€ํ•˜์ง€ ์•Š๊ฒŒ ํ•œ๋‹ค. .executor(Executors.newVirtualThreadPerTaskExecutor()) .expireAfter(new TtlExpiry()) + .recordStats() .buildAsync(); + // Caffeine ๋‚ด๋ถ€ ํ†ต๊ณ„(ํžˆํŠธ/๋ฏธ์Šค/์ ์žฌ/์ถ•์ถœ)๋ฅผ Micrometer ๋กœ ๋…ธ์ถœ. + CaffeineCacheMetrics.monitor(registry, l1.synchronous(), "marketdata.l1"); } /** L1(ํƒ€์ž…๋ณ„ TTL, single-flight) โ†’ L2(๊ณต์œ ) โ†’ upstream ์ˆœ์œผ๋กœ ํ•ด์„. */ @@ -64,8 +89,10 @@ public String get(String key, Duration ttl, Supplier upstream) { private String l2GetOrLoad(String key, Duration ttl, Supplier upstream) { Optional hit = l2.get(key); if (hit.isPresent()) { + l2Hits.increment(); return hit.get(); } + upstreamCalls.increment(); String value = upstream.get(); if (value != null) { l2.put(key, value, ttl); diff --git a/src/main/java/dev/jaydev/tossmcp/loadtest/LoadTestController.java b/src/main/java/dev/jaydev/tossmcp/loadtest/LoadTestController.java new file mode 100644 index 0000000..bb0d2ee --- /dev/null +++ b/src/main/java/dev/jaydev/tossmcp/loadtest/LoadTestController.java @@ -0,0 +1,37 @@ +package dev.jaydev.tossmcp.loadtest; + +import dev.jaydev.tossmcp.service.MarketDataService; +import org.springframework.context.annotation.Profile; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * loadtest ํ”„๋กœํŒŒ์ผ ์ „์šฉ HTTP shim. k6ยท๋ถ€ํ•˜ IT ๊ฐ€ MCP JSON-RPC ํ•ธ๋“œ์…ฐ์ดํฌ ์—†์ด + * ์„œ๋น„์Šคยท์บ์‹œยท๊ฐ€์ƒ์Šค๋ ˆ๋“œ ๊ฒฝ๋กœ๋ฅผ ๊ทธ๋Œ€๋กœ ๋‘๋“ค๊ธธ ์ˆ˜ ์žˆ๊ฒŒ ํ•œ๋‹ค. ํ”„๋กœ๋•์…˜์—” ์กด์žฌํ•˜์ง€ ์•Š๋Š”๋‹ค. + * + *

์ธก์ • ๋Œ€์ƒ์€ ์บ์‹œ ์˜คํ”„๋กœ๋“œยท์š”์ฒญ๋ณ‘ํ•ฉ(single-flight)ยท๊ฐ€์ƒ์Šค๋ ˆ๋“œ ์ฒ˜๋ฆฌ๋Ÿ‰์ด๋ฉฐ, + * MCP ํ”„๋ ˆ์ด๋ฐ ์˜ค๋ฒ„ํ—ค๋“œ๋Š” ์ธก์ • ๋ฒ”์œ„๊ฐ€ ์•„๋‹ˆ๋‹ค(๋ฌธ์„œ์— ๋ช…์‹œ). + */ +@RestController +@Profile("loadtest") +public class LoadTestController { + + private final MarketDataService marketData; + + public LoadTestController(MarketDataService marketData) { + this.marketData = marketData; + } + + /** ์งง์€ TTL(2s) ์‹œ์„ธ โ€” ์ง€์† ๋ถ€ํ•˜์—์„œ ์ฃผ๊ธฐ์  ์žฌ์ ์žฌ๊ฐ€ ์„ž์ธ ํ˜„์‹ค์  ์‹œ๋‚˜๋ฆฌ์˜ค. */ + @GetMapping("/loadtest/prices") + public String prices(@RequestParam String symbol) { + return marketData.prices(symbol); + } + + /** ๊ธด TTL(6h) ์ข…๋ชฉ์ •๋ณด โ€” ๋ถ€ํ•˜ ๋™์•ˆ ์žฌ์ ์žฌ ์—†์ด ์ˆœ์ˆ˜ ์บ์‹œ ์˜คํ”„๋กœ๋“œ/๋ณ‘ํ•ฉ์„ ๋ณธ๋‹ค. */ + @GetMapping("/loadtest/stocks") + public String stocks(@RequestParam String symbol) { + return marketData.stocks(symbol); + } +} diff --git a/src/main/java/dev/jaydev/tossmcp/loadtest/StubTossApiClient.java b/src/main/java/dev/jaydev/tossmcp/loadtest/StubTossApiClient.java new file mode 100644 index 0000000..eb4e166 --- /dev/null +++ b/src/main/java/dev/jaydev/tossmcp/loadtest/StubTossApiClient.java @@ -0,0 +1,67 @@ +package dev.jaydev.tossmcp.loadtest; + +import dev.jaydev.tossmcp.auth.TossAuthService; +import dev.jaydev.tossmcp.client.TossApiClient; +import dev.jaydev.tossmcp.config.TossProperties; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +/** + * loadtest ํ”„๋กœํŒŒ์ผ ์ „์šฉ ์Šคํ… upstream. ์‹ค์ œ ํ† ์Šค API ๋ฅผ ๋ถ€ํ•˜๋กœ ๋‘๋“ค๊ธธ ์ˆ˜ ์—†์œผ๋ฏ€๋กœ, + * ๊ณ ์ • ์ง€์—ฐ ๋’ค ์บ”๋“œ(canned) JSON ์„ ๋Œ๋ ค์ค€๋‹ค. + * + *

์ •์ง์„ฑ: ๋ถ€ํ•˜ํ…Œ์ŠคํŠธ๊ฐ€ ์žฌ๋Š” ๊ฒƒ์€ "์ด ์„œ๋ฒ„์˜ ์บ์‹œยท์š”์ฒญ๋ณ‘ํ•ฉยท๊ฐ€์ƒ์Šค๋ ˆ๋“œ ์ฒ˜๋ฆฌ๋Ÿ‰"์ด๋ฉฐ + * ํ† ์Šค์˜ ์‹ค์ œ ์‘๋‹ต ์ง€์—ฐ์ด ์•„๋‹ˆ๋‹ค. ๊ณต๊ฐœ ์ˆ˜์น˜์—๋Š” ๋ฐ˜๋“œ์‹œ "์บ์‹œ + ๊ณ ์ •์ง€์—ฐ ์Šคํ… ์—…์ŠคํŠธ๋ฆผ" ์„ + * ์บก์…˜ํ•œ๋‹ค. ์ ˆ๋Œ€ ์ง€์—ฐ/์ฒ˜๋ฆฌ๋Ÿ‰์„ ์‹ค์ œ ์„œ๋น„์Šค ์„ฑ๋Šฅ์ฒ˜๋Ÿผ ์ œ์‹œํ•˜์ง€ ์•Š๋Š”๋‹ค. + */ +@Component +@Profile("loadtest") +@Primary +public class StubTossApiClient extends TossApiClient { + + private final long latencyMillis; + + public StubTossApiClient(TossProperties props, TossAuthService auth, + @Value("${loadtest.upstream.latency-ms:40}") long latencyMillis) { + super(props, auth); + this.latencyMillis = latencyMillis; + } + + @Override + public String getPrices(String symbols) { + return canned("prices", symbols); + } + + @Override + public String getOrderbook(String symbol) { + return canned("orderbook", symbol); + } + + @Override + public String getTrades(String symbol, Integer count) { + return canned("trades", symbol + ":" + count); + } + + @Override + public String getCandles(String symbol, String interval, Integer count, String before, Boolean adjusted) { + return canned("candles", symbol + ":" + interval); + } + + @Override + public String getStocks(String symbols) { + return canned("stocks", symbols); + } + + private String canned(String kind, String key) { + if (latencyMillis > 0) { + try { + Thread.sleep(latencyMillis); // upstream I/O ์ง€์—ฐ์„ ํ‰๋‚ด๋‚ธ๋‹ค(๊ฐ€์ƒ์Šค๋ ˆ๋“œ์—์„œ ์–ธ๋งˆ์šดํŠธ๋จ) + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + return "{\"stub\":\"" + kind + "\",\"key\":\"" + key + "\",\"latencyMs\":" + latencyMillis + "}"; + } +} diff --git a/src/main/resources/application-http.yml b/src/main/resources/application-http.yml index 7aa89ed..79b57eb 100644 --- a/src/main/resources/application-http.yml +++ b/src/main/resources/application-http.yml @@ -22,6 +22,23 @@ spring: server: port: ${PORT:8080} +# ๊ด€์ธก์„ฑ: Prometheus ์Šคํฌ๋ ˆ์ดํ”„ ์—”๋“œํฌ์ธํŠธ๋ฅผ ๋…ธ์ถœํ•œ๋‹ค. ์ด ํ”„๋กœํŒŒ์ผ(http)์—๋งŒ +# ์›น์„œ๋ฒ„๊ฐ€ ์žˆ์œผ๋ฏ€๋กœ, ๊ณ„์ธก ๋…ธ์ถœ๋„ ์—ฌ๊ธฐ์„œ๋งŒ ์˜๋ฏธ๊ฐ€ ์žˆ๋‹ค(stdio ๋Š” ์›น์„œ๋ฒ„ ์—†์Œ). +management: + endpoints: + web: + exposure: + include: health, prometheus + endpoint: + health: + probes: + enabled: true + metrics: + distribution: + # HTTP ์š”์ฒญ ์ง€์—ฐ์˜ ํžˆ์Šคํ† ๊ทธ๋žจ ๋ฒ„ํ‚ท์„ ๋ฐœํ–‰ํ•ด Prometheus ์—์„œ p95/p99 ๋ฅผ ๊ณ„์‚ฐํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•œ๋‹ค. + percentiles-histogram: + http.server.requests: true + # HTTP ๋ชจ๋“œ์—์„œ๋Š” stdout ์ด ํ”„๋กœํ† ์ฝœ ์ฑ„๋„์ด ์•„๋‹ˆ๋ฏ€๋กœ ์ฝ˜์†” ๋กœ๊ทธ๋ฅผ ๋˜์‚ด๋ฆฐ๋‹ค. logging: threshold: diff --git a/src/main/resources/application-loadtest.yml b/src/main/resources/application-loadtest.yml new file mode 100644 index 0000000..02f3ff7 --- /dev/null +++ b/src/main/resources/application-loadtest.yml @@ -0,0 +1,12 @@ +# loadtest ํ”„๋กœํŒŒ์ผ: http ์™€ ํ•จ๊ป˜ ํ™œ์„ฑํ™”ํ•œ๋‹ค (์˜ˆ: --spring.profiles.active=http,loadtest). +# ์‹ค์ œ ํ† ์Šค ์ž๊ฒฉ์ฆ๋ช… ์—†์ด ๋ถ€ํŒ…๋˜๋„๋ก ๋”๋ฏธ ๊ฐ’์„ ์ฑ„์šฐ๊ณ , ์Šคํ… upstream ์˜ ๊ณ ์ • ์ง€์—ฐ์„ ๋‘”๋‹ค. +toss: + base-url: http://stub.local + client-id: stub + client-secret: stub + account: stub + +loadtest: + upstream: + # ์Šคํ… upstream ์ด ์‘๋‹ต ์ „ ๋Œ€๊ธฐํ•˜๋Š” ๊ณ ์ • ์ง€์—ฐ(ms). ํ† ์Šค ์‹ค์ œ ์ง€์—ฐ์ด ์•„๋‹ˆ๋‹ค. + latency-ms: ${LOADTEST_UPSTREAM_LATENCY_MS:40} diff --git a/src/test/java/dev/jaydev/tossmcp/cache/CacheWiringTest.java b/src/test/java/dev/jaydev/tossmcp/cache/CacheWiringTest.java index 4e01dd4..a4ce918 100644 --- a/src/test/java/dev/jaydev/tossmcp/cache/CacheWiringTest.java +++ b/src/test/java/dev/jaydev/tossmcp/cache/CacheWiringTest.java @@ -4,6 +4,7 @@ import dev.jaydev.tossmcp.config.TossProperties; import dev.jaydev.tossmcp.service.MarketDataService; import dev.jaydev.tossmcp.tools.MarketDataTools; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -15,6 +16,7 @@ class CacheWiringTest { private final ApplicationContextRunner runner = new ApplicationContextRunner() .withBean(TossProperties.class, () -> new TossProperties("https://example.test", "id", "secret", "acct")) + .withBean(SimpleMeterRegistry.class) .withUserConfiguration( dev.jaydev.tossmcp.auth.TossAuthService.class, TossApiClient.class, diff --git a/src/test/java/dev/jaydev/tossmcp/cache/MarketDataCacheMetricsTest.java b/src/test/java/dev/jaydev/tossmcp/cache/MarketDataCacheMetricsTest.java new file mode 100644 index 0000000..5cac430 --- /dev/null +++ b/src/test/java/dev/jaydev/tossmcp/cache/MarketDataCacheMetricsTest.java @@ -0,0 +1,82 @@ +package dev.jaydev.tossmcp.cache; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * ๊ณ„์ธก์ด ์‹ค์ œ ์บ์‹œ ๋™์ž‘์„ ๋ฐ˜์˜ํ•˜๋Š”์ง€ ๊ฒ€์ฆํ•œ๋‹ค. ํ•ต์‹ฌ ์ง€ํ‘œ๋Š” + * marketdata.upstream.calls โ€” ์บ์‹œยท๋ณ‘ํ•ฉ์œผ๋กœ ์š”์ฒญ ์ˆ˜๋ณด๋‹ค ์ ์–ด์•ผ ํ•˜๋ฉฐ, ์ด๊ฒƒ์ด + * "์บ์‹œ ์˜คํ”„๋กœ๋“œ"๋ฅผ ์ •์งํ•˜๊ฒŒ ๋ณด์—ฌ์ฃผ๋Š” (ํ•˜๋“œ์›จ์–ด ๋…๋ฆฝ์ ์ธ) ์ˆ˜์น˜๋‹ค. + */ +class MarketDataCacheMetricsTest { + + private final Duration ttl = Duration.ofSeconds(60); + + @Test + void repeatedKeyCallsUpstreamOnceAndCountsIt() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + MarketDataCache cache = new MarketDataCache(new NoOpL2Cache(), registry); + AtomicInteger loads = new AtomicInteger(); + Supplier loader = () -> { + loads.incrementAndGet(); + return "V"; + }; + + cache.get("k", ttl, loader); + cache.get("k", ttl, loader); // L1 ํžˆํŠธ โ†’ upstream ํ˜ธ์ถœ ์•ˆ ํ•จ + + assertThat(registry.get("marketdata.upstream.calls").counter().count()) + .as("๋‘ ์š”์ฒญ ์ค‘ upstream ์€ 1ํšŒ๋งŒ ํ˜ธ์ถœ๋ผ์•ผ ํ•œ๋‹ค") + .isEqualTo(1.0); + assertThat(loads.get()).isEqualTo(1); + } + + @Test + void l2HitAvoidsUpstreamAndIsCounted() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + L2Cache alwaysHit = new L2Cache() { + @Override + public Optional get(String key) { + return Optional.of("FROM_L2"); + } + + @Override + public void put(String key, String value, Duration ttl) { + } + }; + MarketDataCache cache = new MarketDataCache(alwaysHit, registry); + + String value = cache.get("k", ttl, () -> { + throw new AssertionError("L2 ํžˆํŠธ ์‹œ upstream ์„ ํ˜ธ์ถœํ•˜๋ฉด ์•ˆ ๋œ๋‹ค"); + }); + + assertThat(value).isEqualTo("FROM_L2"); + assertThat(registry.get("marketdata.l2.hits").counter().count()).isEqualTo(1.0); + assertThat(registry.get("marketdata.upstream.calls").counter().count()).isZero(); + } + + @Test + void caffeineHitMissMetersAreRegistered() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + MarketDataCache cache = new MarketDataCache(new NoOpL2Cache(), registry); + + cache.get("k", ttl, () -> "V"); // miss โ†’ load + cache.get("k", ttl, () -> "V"); // hit + + // Caffeine ํ†ต๊ณ„๊ฐ€ Micrometer ๋กœ ๋ฐ”์ธ๋”ฉ๋๋Š”์ง€(๊ด€์ธก์„ฑ ๋…ธ์ถœ ์—ฌ๋ถ€) ํ™•์ธ. + // CaffeineCacheMetrics ๋Š” cache.* ๋ฏธํ„ฐ๋ฅผ FunctionCounter/Gauge ๋กœ ๋“ฑ๋กํ•˜๋ฏ€๋กœ + // ํƒ€์ž…์— ์˜์กดํ•˜์ง€ ์•Š๊ณ  ์ด๋ฆ„ ์ ‘๋‘์‚ฌ๋กœ ์กด์žฌ๋ฅผ ํ™•์ธํ•œ๋‹ค. + assertThat(registry.getMeters().stream() + .map(m -> m.getId().getName()) + .filter(name -> name.startsWith("cache."))) + .as("CaffeineCacheMetrics ๊ฐ€ cache.* ๋ฏธํ„ฐ๋ฅผ ๋“ฑ๋กํ•ด์•ผ ํ•œ๋‹ค") + .isNotEmpty(); + } +} diff --git a/src/test/java/dev/jaydev/tossmcp/loadtest/LoadOffloadIT.java b/src/test/java/dev/jaydev/tossmcp/loadtest/LoadOffloadIT.java new file mode 100644 index 0000000..73b9d9e --- /dev/null +++ b/src/test/java/dev/jaydev/tossmcp/loadtest/LoadOffloadIT.java @@ -0,0 +1,78 @@ +package dev.jaydev.tossmcp.loadtest; + +import io.micrometer.core.instrument.MeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.actuate.observability.AutoConfigureObservability; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * ๋ถ€ํ•˜ ํ•˜๋„ค์Šค๊ฐ€ ์‹ค์ œ๋กœ ์บ์‹œ ์˜คํ”„๋กœ๋“œยทsingle-flight ๋ฅผ ๋ณด์ด๋Š”์ง€ HTTP ๋กœ ๊ฒ€์ฆํ•œ๋‹ค. + * ํ•ต์‹ฌ ์ฆ๊ฑฐ๋Š” marketdata.upstream.calls ์นด์šดํ„ฐ์˜ ๋ธํƒ€ โ€” ํ•˜๋“œ์›จ์–ด ๋…๋ฆฝ์ ์ด๊ณ  CI ์—์„œ + * ๊ฒฐ์ •๋ก ์ ์œผ๋กœ ์žฌํ˜„๋œ๋‹ค(k6/Grafana ์—†์ด๋„). ์ ˆ๋Œ€ ์ง€์—ฐ/์ฒ˜๋ฆฌ๋Ÿ‰์ด ์•„๋‹ˆ๋ผ "์š”์ฒญ ๋Œ€๋น„ + * upstream ํ˜ธ์ถœ ์ˆ˜"๋ผ๋Š” ์„ฑ์งˆ์„ ๋‹จ์–ธํ•œ๋‹ค. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureObservability +@ActiveProfiles({"http", "loadtest"}) +class LoadOffloadIT { + + private static double upstreamCalls(MeterRegistry registry) { + return registry.get("marketdata.upstream.calls").counter().count(); + } + + @Test + void concurrentBurstOnOneKeyCollapsesToSingleUpstreamCall( + @Autowired TestRestTemplate rest, @Autowired MeterRegistry registry) throws Exception { + String url = "/loadtest/stocks?symbol=BURST"; // 6h TTL โ†’ ๋ถ€ํ•˜ ๋™์•ˆ ์žฌ์ ์žฌ ์—†์Œ + double before = upstreamCalls(registry); + + int n = 200; + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(n); + try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) { + for (int i = 0; i < n; i++) { + pool.submit(() -> { + try { + start.await(); + rest.getForObject(url, String.class); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + }); + } + start.countDown(); + done.await(); + } + + // ์ง„ํ–‰ ์ค‘ future ๋ฅผ ๊ณต์œ (๋ณ‘ํ•ฉ)ํ–ˆ๋“  ์ด๋ฏธ ์บ์‹œ๋œ ๊ฐ’์„ ์ฝ์—ˆ๋“ , upstream ์€ ์ •ํ™•ํžˆ 1ํšŒ. + assertThat(upstreamCalls(registry) - before) + .as("๋™์ผ ํ‚ค %d ๋™์‹œ์š”์ฒญ์€ single-flight+์บ์‹œ๋กœ upstream 1ํšŒ์—ฌ์•ผ ํ•œ๋‹ค", n) + .isEqualTo(1.0); + } + + @Test + void repeatedRequestsHitCacheSoUpstreamCalledOnce( + @Autowired TestRestTemplate rest, @Autowired MeterRegistry registry) { + String url = "/loadtest/stocks?symbol=REPEAT"; + double before = upstreamCalls(registry); + + for (int i = 0; i < 500; i++) { + rest.getForObject(url, String.class); + } + + assertThat(upstreamCalls(registry) - before) + .as("500 ๋ฐ˜๋ณต์š”์ฒญ์€ ์บ์‹œ๋กœ upstream 1ํšŒ์—ฌ์•ผ ํ•œ๋‹ค") + .isEqualTo(1.0); + } +} diff --git a/src/test/java/dev/jaydev/tossmcp/observability/PrometheusEndpointIT.java b/src/test/java/dev/jaydev/tossmcp/observability/PrometheusEndpointIT.java new file mode 100644 index 0000000..3d95947 --- /dev/null +++ b/src/test/java/dev/jaydev/tossmcp/observability/PrometheusEndpointIT.java @@ -0,0 +1,37 @@ +package dev.jaydev.tossmcp.observability; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.actuate.observability.AutoConfigureObservability; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * http ํ”„๋กœํŒŒ์ผ์—์„œ Prometheus ์Šคํฌ๋ ˆ์ดํ”„ ์—”๋“œํฌ์ธํŠธ๊ฐ€ ์‹ค์ œ๋กœ ๋œจ๊ณ , ์šฐ๋ฆฌ๊ฐ€ ๋“ฑ๋กํ•œ + * ์ปค์Šคํ…€ ์ง€ํ‘œ(marketdata.*)๋ฅผ ๋…ธ์ถœํ•˜๋Š”์ง€ ๊ฒ€์ฆํ•œ๋‹ค. ์„ค์ •๋งŒ ํ™•์ธํ•˜๋Š” ๊ฒŒ ์•„๋‹ˆ๋ผ + * ์ปจํ…์ŠคํŠธ๋ฅผ ๋ถ€ํŒ…ํ•ด HTTP ๋กœ ๊ธ์–ด๋ณธ๋‹ค. stdio ํ”„๋กœํŒŒ์ผ์—” ์›น์„œ๋ฒ„๊ฐ€ ์—†์œผ๋ฏ€๋กœ ์ด ๋…ธ์ถœ์€ + * http ์—๋งŒ ์กด์žฌํ•œ๋‹ค. + * + *

{@code @AutoConfigureObservability} ๋Š” @SpringBootTest ๊ฐ€ ํ…Œ์ŠคํŠธ์—์„œ ๊ธฐ๋ณธ์œผ๋กœ + * ๋„๋Š” ์ง€ํ‘œ export ๋ฅผ ๋˜์‚ด๋ฆฐ๋‹ค(์‹ค์ œ ์•ฑ์—์„œ๋Š” ๊ธฐ๋ณธ ํ™œ์„ฑ์ด๋ผ ํ•„์š” ์—†๋‹ค). + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureObservability +@ActiveProfiles("http") +class PrometheusEndpointIT { + + @Test + void prometheusEndpointExposesCustomMarketDataMeters(@Autowired TestRestTemplate rest) { + ResponseEntity response = rest.getForEntity("/actuator/prometheus", String.class); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()) + .as("์ปค์Šคํ…€ ์บ์‹œ ์˜คํ”„๋กœ๋“œ ์ง€ํ‘œ๊ฐ€ ์Šคํฌ๋ ˆ์ดํ”„ ์ถœ๋ ฅ์— ์žˆ์–ด์•ผ ํ•œ๋‹ค") + .contains("marketdata_upstream_calls") + .contains("marketdata_l2_hits"); + } +}