From ef3fab8a5cfc65c9c4216148eb9c48bc978eac41 Mon Sep 17 00:00:00 2001 From: Jinkyu Lee Date: Sun, 26 Jul 2026 22:02:32 +0900 Subject: [PATCH 1/5] feat(observability): instrument cache with Micrometer and expose Prometheus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관측성의 토대. actuator + micrometer-registry-prometheus 를 추가하고 MarketDataCache 를 계측한다. 지표: - marketdata.upstream.calls — upstream(토스 API) 실제 호출 횟수. 캐시·병합으로 요청 수보다 적어야 하며, 이것이 캐시 오프로드를 정직하게(하드웨어 독립적으로) 보여주는 핵심 수치다. - marketdata.l2.hits — L2(공유 캐시) 히트 횟수. - Caffeine 내부 통계(cache.gets 히트/미스, 크기, 축출)를 CaffeineCacheMetrics 로 노출. /actuator/prometheus 는 http 프로파일에서만 노출한다(stdio 엔 웹서버가 없다). 검증: MarketDataCacheMetricsTest(카운터 동작), PrometheusEndpointIT(부팅해 실제로 스크레이프 — @AutoConfigureObservability 로 테스트의 export-off 기본값을 되살림). --- build.gradle | 5 +- .../jaydev/tossmcp/cache/MarketDataCache.java | 29 ++++++- src/main/resources/application-http.yml | 12 +++ .../jaydev/tossmcp/cache/CacheWiringTest.java | 2 + .../cache/MarketDataCacheMetricsTest.java | 82 +++++++++++++++++++ .../observability/PrometheusEndpointIT.java | 37 +++++++++ 6 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 src/test/java/dev/jaydev/tossmcp/cache/MarketDataCacheMetricsTest.java create mode 100644 src/test/java/dev/jaydev/tossmcp/observability/PrometheusEndpointIT.java 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/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/resources/application-http.yml b/src/main/resources/application-http.yml index 7aa89ed..8645d77 100644 --- a/src/main/resources/application-http.yml +++ b/src/main/resources/application-http.yml @@ -22,6 +22,18 @@ spring: server: port: ${PORT:8080} +# 관측성: Prometheus 스크레이프 엔드포인트를 노출한다. 이 프로파일(http)에만 +# 웹서버가 있으므로, 계측 노출도 여기서만 의미가 있다(stdio 는 웹서버 없음). +management: + endpoints: + web: + exposure: + include: health, prometheus + endpoint: + health: + probes: + enabled: true + # HTTP 모드에서는 stdout 이 프로토콜 채널이 아니므로 콘솔 로그를 되살린다. logging: threshold: 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/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"); + } +} From 3c66cdd93c2b982cb6caf53da0aa4011430a777d Mon Sep 17 00:00:00 2001 From: Jinkyu Lee Date: Sun, 26 Jul 2026 22:07:00 +0900 Subject: [PATCH 2/5] feat(loadtest): stub upstream + HTTP shim to drive cache/coalescing under load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadtest 프로파일(http 와 함께 활성)을 추가한다. 실제 토스 자격증명·API 없이 서비스·캐시·가상스레드 경로에 부하를 줄 수 있게 한다. - StubTossApiClient: @Primary 스텁 upstream, 고정 지연 뒤 캔드 JSON 반환. 재는 것은 이 서버의 캐시·병합·가상스레드 처리량이지 토스 실제 지연이 아니다. - LoadTestController: MCP 핸드셰이크 없이 서비스 경로를 노출하는 얇은 HTTP shim (/loadtest/prices 2s TTL, /loadtest/stocks 6h TTL). 프로덕션엔 없다. - LoadOffloadIT: HTTP 로 부하를 주고 marketdata.upstream.calls 델타를 단언 — 200 동시요청→upstream 1회(single-flight), 500 반복→1회(오프로드). 하드웨어 독립적이고 CI 에서 결정론적으로 재현된다(k6/Docker 없이도). --- .../tossmcp/loadtest/LoadTestController.java | 37 +++++++++ .../tossmcp/loadtest/StubTossApiClient.java | 67 ++++++++++++++++ src/main/resources/application-loadtest.yml | 12 +++ .../tossmcp/loadtest/LoadOffloadIT.java | 78 +++++++++++++++++++ 4 files changed, 194 insertions(+) create mode 100644 src/main/java/dev/jaydev/tossmcp/loadtest/LoadTestController.java create mode 100644 src/main/java/dev/jaydev/tossmcp/loadtest/StubTossApiClient.java create mode 100644 src/main/resources/application-loadtest.yml create mode 100644 src/test/java/dev/jaydev/tossmcp/loadtest/LoadOffloadIT.java 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-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/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); + } +} From 6f85db97cff93a52c35ce1d23ba2fc08d4dcb1bb Mon Sep 17 00:00:00 2001 From: Jinkyu Lee Date: Sun, 26 Jul 2026 22:09:59 +0900 Subject: [PATCH 3/5] docs(loadtest): k6 scripts, Prometheus/Grafana stack, honest methodology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 부하테스트·관측성 재현 자산. 절대 지연/처리량이 아니라 하드웨어 독립적인 비율 (캐시 오프로드·single-flight)을 본다는 정직성 원칙을 README 앞머리에 못박았다. - k6/cache-offload.js, k6/single-flight.js - observability/docker-compose.yml (Prometheus + Grafana), prometheus.yml, 대시보드 JSON - README: 무엇을 재고/안 재는지, 실행법, /actuator/prometheus 로 오프로드 확인법 - http 프로파일에서 http.server.requests 히스토그램 버킷 발행(p95/p99 산출용) Docker 없는 이 환경에서 Grafana 스택은 미실행 — README 에 명시하고 Docker 머신 재현 절차를 뒀다. 오프로드/병합의 결정론적 증거는 LoadOffloadIT 가 CI 에서 담보한다. --- loadtest/README.md | 81 +++++++++++++++++++ loadtest/k6/cache-offload.js | 36 +++++++++ loadtest/k6/single-flight.js | 31 +++++++ loadtest/observability/docker-compose.yml | 24 ++++++ loadtest/observability/grafana-dashboard.json | 69 ++++++++++++++++ loadtest/observability/prometheus.yml | 8 ++ src/main/resources/application-http.yml | 5 ++ 7 files changed, 254 insertions(+) create mode 100644 loadtest/README.md create mode 100644 loadtest/k6/cache-offload.js create mode 100644 loadtest/k6/single-flight.js create mode 100644 loadtest/observability/docker-compose.yml create mode 100644 loadtest/observability/grafana-dashboard.json create mode 100644 loadtest/observability/prometheus.yml diff --git a/loadtest/README.md b/loadtest/README.md new file mode 100644 index 0000000..38aef9d --- /dev/null +++ b/loadtest/README.md @@ -0,0 +1,81 @@ +# 부하테스트 & 관측성 + +이 서버의 **캐시 오프로드 · 요청병합(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 는 "규모를 키워 눈으로 보는" 재현 도구다. + +## 로컬 실행 (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/resources/application-http.yml b/src/main/resources/application-http.yml index 8645d77..79b57eb 100644 --- a/src/main/resources/application-http.yml +++ b/src/main/resources/application-http.yml @@ -33,6 +33,11 @@ management: health: probes: enabled: true + metrics: + distribution: + # HTTP 요청 지연의 히스토그램 버킷을 발행해 Prometheus 에서 p95/p99 를 계산할 수 있게 한다. + percentiles-histogram: + http.server.requests: true # HTTP 모드에서는 stdout 이 프로토콜 채널이 아니므로 콘솔 로그를 되살린다. logging: From d041c43f75824b0a5735ed1a5f1a4dcde977664d Mon Sep 17 00:00:00 2001 From: Jinkyu Lee Date: Sun, 26 Jul 2026 22:13:23 +0900 Subject: [PATCH 4/5] docs(loadtest): record measured offload results with honest environment caption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WSL2/4vCPU, JDK 21, k6 v0.50.0, 캐시+40ms 스텁 업스트림 기준 실측: - single-flight: 콜드 키 200 동시요청 → upstream 1회 - cache-offload: 핫키 157,476 요청(~3,500 req/s) → upstream 1회, L1 히트율 99.998% 절대 지연/처리량은 강조하지 않고 오프로드 비율을 결론으로 둔다. 환경 캡션 명시. --- loadtest/README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/loadtest/README.md b/loadtest/README.md index 38aef9d..1d83da5 100644 --- a/loadtest/README.md +++ b/loadtest/README.md @@ -27,6 +27,24 @@ 이건 매 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` 프로파일로 띄운다(실제 자격증명 불필요): From fc02bd509caabfc2d3db32575795af860d42d116 Mon Sep 17 00:00:00 2001 From: Jinkyu Lee Date: Sun, 26 Jul 2026 22:15:25 +0900 Subject: [PATCH 5/5] docs: document observability & load testing, correct L1 to AsyncCache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: 관측성 지표(/actuator/prometheus)·부하 하네스 섹션 추가, 로드맵 3a/3b 완료 표기, 실측 오프로드 비율(200→1, 157,476→1, 히트율 99.998%)을 정직한 캡션과 함께 기재. - 캐싱 설명을 실제 구현(AsyncCache, 모니터 밖 가상스레드 로딩)에 맞게 정정. - logs/ 를 .gitignore 에 추가(부팅 산출물). --- .gitignore | 1 + README.md | 37 +++++++++++++++++++++++++++++++------ 2 files changed, 32 insertions(+), 6 deletions(-) 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).