Handle Portal registry initialization and timeout classification
All checks were successful
Deploy Gateway / deploy (push) Successful in 2m40s

This commit is contained in:
2026-09-17 21:52:00 +09:00
parent 87be952fd0
commit 1be6bf7278
12 changed files with 431 additions and 11 deletions

View File

@@ -0,0 +1,173 @@
# shl_mcp 소스 분석 보고서
- 대상: `C:\axhub\shl_mcp`에 있는 현재 로컬 소스(커밋하지 않은 수정분 포함)
- 범위: `src/main/java` 전체 49개 파일, 설정(`application*.yml`, `logback-spring.xml`), 배포(`Dockerfile`, Helm, `.gitea`), 일부 테스트
- 기준일: 2026-09-17
---
## 0. 총평
구조와 설계 수준은 높은 편입니다. 판단 근거를 담은 설계 결정 기록(ADR), 계약 테스트, 외부에 보여줄 에러 정리(SafeError), single-flight 갱신, last-good 스냅샷 유지, 요청 전체 제한시간(deadline), 재시도를 도구 annotation 기준으로 제한한 점이 모두 잘 되어 있어요.
다만 **운영에 올리기 전에 반드시 고쳐야 할 항목**이 몇 가지 있습니다. 로그, 비밀값, 배포 설정 불일치, 타임아웃 분류 문제입니다. 또 코드는 Portal 기반 다중 route 방식으로 발전했는데, **문서와 Helm 설정은 아직 "배포 하나에 Tool Service 하나"(ADR-0007) 방식에 머물러 있습니다.** 이 차이를 먼저 정리해야 합니다.
| 등급 | 개수 | 요약 |
|---|---|---|
| 🔴 치명 (운영 전 필수) | 5 | 운영 로그에 DEBUG 본문(개인정보) 기록, API Key 기본값, 배포 설정 불일치, 타임아웃 오분류, 도구 endpoint를 임의 host로 지정 가능 |
| 🟠 높음 | 7 | 요청 처리 중 동기 갱신으로 지연 발생, 가상 스레드 pinning, 응답 크기 무제한, 이름 하나 중복 시 route 전체 장애, 도구 서버 에러 내용 유실 등 |
| 🟡 보통 | 9 | 프로토콜 협상, ping 미지원, 사용하지 않는 이벤트, 상태 키 충돌 가능성 등 |
| 🟢 추가 개발 | 8 | 메트릭, 트레이싱, 서킷브레이커, 동시성 제한, 감사 로그 등 |
---
## 1. 🔴 치명: 운영 전 반드시 수정
### 1-1. 운영에서도 DEBUG 로그로 요청·응답 본문이 그대로 기록됨 (개인정보 및 성능)
- `logback-spring.xml`에서 `<root level="DEBUG">`**profile 구분 없이** 적용됩니다.
- 그래서 prod에서도 아래 로그가 모두 출력됩니다.
- `McpExchangeFilter`: `TEMP_MCP_HTTP_REQUEST_BODY` (Agent가 보낸 요청 본문 전체 = 도구 인자)
- `McpController`: `TEMP_MCP_HTTP_RESPONSE_BODY` (응답 전체 = 도구 결과)
- `HttpToolClient`: `TEMP_TOOL_HTTP_REQUEST/RESPONSE_BODY`
- `ToolRegistryService`: 스냅샷 전체, `PortalToolRegistryClient`: Portal registry 전체
- Spring, Lettuce, JDK HttpClient 같은 라이브러리의 DEBUG 로그까지 포함
- 보험 업무 도구의 인자와 결과에는 고객정보가 들어갈 가능성이 높아서 **개인정보 로그 유출 위험**이 있습니다.
- **성능 문제도 있습니다.** `prettyJson(...)`, `toJson(response)``log.debug(...)`의 *인자*로 넘어가기 때문에, 로그 레벨과 상관없이 **매 요청마다 JSON을 파싱하고 pretty-print**합니다. 요청 본문은 한 요청에 3번까지 파싱됩니다(extractMethod, prettyJson, 컨트롤러).
- **조치**
1. root는 `INFO`로 두고, `<springProfile name="local,dev">`에서만 `io.shinhanlife` 패키지를 DEBUG로 켭니다.
2. `TEMP_` 로그는 `if (log.isDebugEnabled())`로 감싸거나 제거합니다.
3. 본문 로그가 꼭 필요하면 크기 제한과 필드 마스킹(주민번호, 전화번호 등)을 추가합니다.
### 1-2. Tool Server API Key가 기본값 `tool-server-key`로 운영에 나감
- `application.yml`: `api-key: ${TOOL_SERVER_API_KEY:tool-server-key}`
- `McpProperties.ToolClient.defaults()`에도 같은 값이 하드코딩되어 있습니다.
- Helm `deployment.yaml`에는 `TOOL_SERVER_API_KEY`를 넣는 부분(Secret 참조)이 **없습니다.** 그래서 운영 Pod가 이 약한 기본값으로 호출합니다.
- **조치**
- non-local profile에서는 기본값을 없애고, 값이 없으면 기동이 실패하게 합니다(`@NotBlank`, 기본값 제거).
- Helm에서 `valueFrom.secretKeyRef`로 주입합니다.
### 1-3. 코드 방향(Portal)과 배포 설정(Helm/ADR-0007)이 서로 다름
- **코드**: `/mcp/{routeKey}` path로 route를 나누고, Portal registry에서 routeKey별 Tool Service 목록을 받는 **다중 route, Portal 방식**이 주력입니다(`PortalToolRegistryClient`가 가장 큰 클래스이고 최근 수정도 집중되어 있음).
- **Helm**: `discovery.enabled: true` + bundle 1개, Portal 비활성화, `MCP_PORTAL_*``MCP_REDIS_ENABLED` 주입 없음. 즉 ADR-0007 방식입니다.
- Redis host는 주입하지만 `mcp.redis.enabled` 기본값이 false여서 Redis가 실제로는 쓰이지 않습니다.
- **CI**: `.gitea/workflows/deploy.yaml``main`에 push하면 `/home/ubuntu/apps/prd-dap-gateway/deploy.sh`를 실행합니다. Helm/OpenShift 배포와는 **별개의 배포 경로**이고, **테스트 없이** 곧바로 운영에 반영됩니다.
- `Dockerfile``build -x test`로 테스트를 건너뜁니다.
- `application.yml``spring.profiles.active: local` 때문에 환경변수가 빠지면 **운영에서 local profile로 뜰 수 있습니다.**
- `mcp.identity` 기본값에 오타가 있습니다: `ax-hu-mcp``ax-hub-mcp`
- **조치**
- 목표 아키텍처를 하나로 정하고(Portal 방식이면 ADR을 새로 작성), Helm, ConfigMap, 문서를 맞춥니다.
- CI에 `./gradlew check`를 필수 단계로 추가하고, 운영 배포는 태그나 수동 승인 뒤에만 실행되게 합니다.
- `spring.profiles.active` 기본값을 제거합니다.
### 1-4. 도구 호출 타임아웃이 "TIMEOUT"이 아니라 "NETWORK"로 분류될 가능성이 높음
- `HttpToolClient.hasTimeoutCause()`는 원인 체인에서 `SocketTimeoutException`만 찾습니다.
- 그런데 실제로 사용하는 `JdkClientHttpRequestFactory`(JDK HttpClient)는 타임아웃이 나면 **`java.net.http.HttpTimeoutException` / `HttpConnectTimeoutException`**을 던집니다. 이 둘은 `SocketTimeoutException`의 하위 클래스가 아닙니다.
- 결과적으로 도구 타임아웃이 `TOOL_UNAVAILABLE`(NETWORK)로 보고되고, 모니터링과 Agent 응답에서 원인을 잘못 판단하게 됩니다.
- `HttpToolClientTest`에 타임아웃 케이스가 없어서 이 문제가 드러나지 않았습니다.
- **조치**: `instanceof java.net.http.HttpTimeoutException`도 함께 검사합니다. MockWebServer로 응답을 지연시키는 테스트를 추가합니다.
### 1-5. Manifest에 절대 URL을 적으면 어느 host로든 도구를 호출함 (API Key와 사용자 헤더 유출 경로)
- `ToolBundleDiscovery.resolveManifestEndpoint()`는 manifest의 `endpoint`가 **절대 URL이면 base host 검증 없이 그대로 사용**합니다. 테스트 `acceptsAbsoluteToolEndpointDeclaredByTheManifest`로 확인한 의도된 동작입니다.
- 반면 `application.yml` 주석에는 *"nothing a Tool Service returns can change where MCP sends the call"*이라고 되어 있어서 **설계 의도와 구현이 반대**입니다.
- Manifest 한 건만 잘못되거나 변조되어도 `X-Tool-Server-API-Key`, `X-Praf-No`, `X-User-Ip` 같은 헤더와 인자가 임의 host로 전송됩니다.
- **조치**: 절대 URL은 base와 scheme, host, port가 같을 때만 허용합니다. 다른 host가 필요하면 Portal에 등록한 allowlist에 있을 때만 허용합니다.
---
## 2. 🟠 높음
### 2-1. TTL이 만료되면 사용자 요청을 처리하는 도중에 동기로 갱신함 → 주기적인 지연
- `ToolRegistryService.listTools()` / `findEnabledTool()``refreshIfStale()``refresh()`로 이어지면서, **tools/list와 tools/call 요청 처리 중에** Portal 조회와 manifest 조회를 동기로 실행합니다.
- 추가로 `McpExchangeFilter.validateKnownRoute()`에서도 Portal 갱신을 동기로 실행합니다.
- 5분(TTL)마다 첫 요청이 connect 1초 + read 3초 × Tool Service 수만큼 느려질 수 있습니다.
- Tool Service가 죽어 있고 메모리 스냅샷도 없으면 TTL마다 사용자가 타임아웃을 그대로 겪습니다.
- **조치**: stale-while-revalidate 방식으로 바꿉니다. 만료된 스냅샷은 즉시 반환하고, 갱신은 가상 스레드에서 비동기로 한 번만 실행합니다. 연속 실패 시 backoff도 적용합니다.
### 2-2. 가상 스레드 + `synchronized` 안의 네트워크 I/O → carrier 스레드 pinning
- `spring.threads.virtual.enabled: true`이고 런타임은 Java 21입니다. JDK 24 미만에서는 synchronized 안에서 블로킹하면 가상 스레드가 carrier 스레드에 고정됩니다(pinning).
- `PortalToolRegistryClient.fetchRoutingManifests()``synchronized (refreshLock)` 안에서 **routing manifest를 HTTP로 순차 조회**합니다. 이 코드는 initialize 요청마다 실행됩니다.
- 동시 요청이 몰리면 carrier 스레드(CPU 코어 수만큼)가 모두 막혀서 **서버 전체가 멈춘 것처럼 보일 수 있습니다.**
- **조치**: `ReentrantLock`이나 CompletableFuture 기반 single-flight로 바꿉니다(`ToolRegistryService`에서 이미 쓰는 패턴). 또는 JDK 25 LTS로 올립니다.
### 2-3. 도구 응답과 Portal 응답 크기에 제한이 없음
- manifest는 `maxManifestBytes`로 제한하지만, `HttpToolClient``toEntity(String.class)`**응답 전체를 메모리에 올립니다.**
- Portal registry도 `body(JsonNode.class)`로 제한 없이 읽습니다.
- 대용량 응답이 오면 OOM이 나거나, LLM 컨텍스트가 넘쳐서 Agent 품질이 떨어집니다.
- **조치**: `exchange()` + `readNBytes(max+1)`로 도구 응답 최대 크기를 설정합니다(예: 1MB). 넘으면 `isError` 결과를 반환하거나 잘라서 반환합니다.
### 2-4. 도구 이름 하나만 중복되거나 개수가 초과돼도 route 전체가 불능이 됨
- `PortalToolRegistryClient.merge()`에서 서비스 간 이름 중복이나 `maxToolsTotal` 초과가 있으면 route 전체를 `TOOL_REGISTRY_UNAVAILABLE`로 처리합니다.
- Tool Service 하나의 배포 실수가 같은 route의 **모든 도구를 막습니다.**
- 메모리 스냅샷이 있으면 그 스냅샷으로 버티지만, 신규 Pod은 readiness를 통과하지 못합니다.
- **조치**: 중복된 도구만 제외하고 경고 로그와 메트릭을 남깁니다. 실패 격리 단위를 route가 아니라 Tool Service로 낮춥니다.
### 2-5. 도구 서버 에러 본문이 모두 버려짐 → LLM이 스스로 수정할 수 없음
- `HttpToolClient`는 4xx/5xx 응답의 본문을 읽지 않습니다. `ToolsCallHandler.failureResult()`는 고정 문구("Tool execution failed.")만 반환합니다.
- 인자 검증 실패(`TOOL_ARGUMENT_ERROR`)도 `"'custNo' is required"` 같은 상세 내용이 빠진 채 반환됩니다.
- MCP 스펙이 입력 검증 에러를 `isError` 결과로 돌려주라고 하는 이유가 **모델이 인자를 고쳐서 다시 호출하게 하려는 것**인데, 지금은 그 효과가 없습니다.
- **조치**
- 인자 오류는 필드 단위 상세 내용을 포함합니다(스키마 정보라서 민감하지 않음).
- 도구 서버 4xx 본문 중 합의한 필드(예: `error.message`)만 제한된 길이로 전달합니다.
### 2-6. 요청을 받을 때 Portal 장애가 "등록되지 않은 route" 에러로 보임
- 기동 시 Portal에 실패하고 Redis fallback도 없으면 `bundlesByRoute`가 비어 있습니다.
- `refreshSourceRegistry()``finally`가 시도 시각을 기록하기 때문에, 이후 TTL(300초) 동안 모든 요청이 `"route key is not registered"`(INVALID_REQUEST)로 거부됩니다.
- 실제 원인은 레지스트리 장애인데 클라이언트 오류로 보여서 **장애 원인 파악을 방해합니다.**
- **조치**: 레지스트리를 아직 불러오지 못한 상태면 `TOOL_REGISTRY_UNAVAILABLE`로 응답합니다. 실패 시에는 짧은 backoff(예: 5~10초)로 다시 시도합니다.
### 2-7. `ToolBundleDiscovery`의 상태 키가 `bundle.id`(serviceKey)만 사용함
- `states` map의 키가 serviceKey뿐입니다. 같은 serviceKey를 **서로 다른 route에서 다른 serviceDomain으로** 쓰면 last-good 스냅샷이 섞입니다.
- 이 경우 한 route의 장애 fallback이 **다른 도메인의 endpoint를 가진 도구 목록**을 반환할 수 있습니다.
- **조치**: 키를 `routeKey + serviceKey + serviceDomain`으로 바꿉니다.
---
## 3. 🟡 보통
1. **프로토콜 버전 협상**: `InitializeHandler`는 클라이언트가 요청한 `protocolVersion`을 보지 않고 항상 preferredVersion을 반환합니다. 지금은 지원 버전이 하나라 문제가 없지만, 버전을 추가하는 순간 스펙 위반이 됩니다. 요청 버전을 지원하면 그 버전을 반환하도록 바꿉니다.
2. **`ping` 메서드 미지원**: MCP의 `ping` 요청에 METHOD_NOT_FOUND를 반환합니다. 빈 결과(`{}`)를 반환하는 handler를 추가합니다(5줄 정도).
3. **`ToolListChangedEvent`를 받는 곳이 없음**: 이벤트를 발행하지만 listener도 SSE도 없어서 사용되지 않는 코드입니다. capabilities도 `listChanged:false`입니다. 쓸 계획이 없으면 제거합니다.
4. **`Mcp-Session-Id`**: initialize마다 UUID를 발급하지만 검증하거나 저장하지 않습니다. stateless 방침(ADR-0001)이라면 발급 자체를 생략하거나, 발급하는 이유를 문서에 명시합니다.
5. **Origin 헤더 검증 없음**: Streamable HTTP 스펙은 Origin 검증을 요구합니다(DNS rebinding 방지). 내부망이고 NetworkPolicy가 있어서 위험은 낮지만 간단한 allowlist를 추가하는 것을 권장합니다.
6. **도구 이름 규칙**: `[A-Za-z0-9_./-]{1,64}``/``..`을 허용하고, `exactEndpoint=false`일 때는 이름이 endpoint path에 그대로 붙습니다(`ToolRoutingService`). 이름은 manifest가 정하므로 신뢰 경계 안이지만 `..`은 막는 게 좋습니다. 최신 스펙 권장 문자셋은 `[A-Za-z0-9_.-]`, 최대 128자입니다.
7. **HTTP/2 업그레이드**: JDK HttpClient는 기본값이 HTTP/2라서 `http://` 호출 시 h2c Upgrade 헤더를 보냅니다. 오래된 WAS나 프록시에서 POST 본문 처리 문제가 날 수 있으니 `.version(HttpClient.Version.HTTP_1_1)`로 고정하는 것을 권장합니다.
8. **요청마다 RestClient 새로 생성**: `HttpToolClient.clientFor()`가 호출마다 factory와 RestClient를 만듭니다. 지금 부하에서는 문제가 없지만, timeout별로 캐시하거나 요청 단위 timeout으로 바꾸면 좋습니다.
9. **`claimStaleRefreshSlot` 경쟁 조건**: `ToolExecutionService`에서 get과 put이 원자적이지 않습니다. `ToolRegistryService`처럼 `compute`를 쓰면 됩니다. 영향은 작습니다.
10. **`CachedBodyHttpServletRequest`**: 잘못된 charset이 오면 `Charset.forName`이 예외를 던집니다. 기본값을 UTF-8로 처리합니다.
11. **`searchTime` meta**: 값에 재시도와 backoff 시간까지 포함되고 이름도 의미와 맞지 않습니다. `durationMillis``attempts`로 나누는 것을 권장합니다.
12. **Portal 방식의 bundle 상태 조회 불가**: `ToolBundleStatusEndpoint``mcp.bundles`(정적 설정)만 보여줘서 Portal 방식에서는 항상 비어 있습니다. route별 서비스 상태를 조회할 수 있게 확장합니다.
---
## 4. 🟢 추가 개발 권장
| 우선순위 | 항목 | 내용 |
|---|---|---|
| 1 | **메트릭** | `micrometer-registry-prometheus`를 추가합니다. 도구별 호출 수, 지연(p95/p99), 에러 유형, 재시도 수, 레지스트리 갱신 성공/실패, 스냅샷 경과 시간을 수집합니다. 지금은 health와 info만 노출됩니다. |
| 2 | **분산 트레이싱** | Micrometer Tracing + OTel을 붙입니다. `X-Guid`를 trace와 연결해서 Agent Builder → MCP → Tool Service 전 구간을 추적합니다. |
| 3 | **서킷브레이커 / 동시성 제한** | Tool Service별로 Resilience4j CircuitBreaker와 Bulkhead를 적용합니다. 느린 서비스 하나가 가상 스레드와 커넥션을 모두 차지하지 못하게 합니다. |
| 4 | **감사 로그** | tools/call마다 누가(agentId, prafNo), 무엇을(toolName, version), 결과와 소요 시간을 본문 없이 구조화 로그로 따로 남깁니다. 금융권 감사 대응용입니다. |
| 5 | **Rate limit** | route나 agentId 단위로 호출량을 제한합니다(LLM 반복 호출 폭주 방지). |
| 6 | **structuredContent / outputSchema** | 결과를 텍스트뿐 아니라 `structuredContent`로도 반환하고, manifest의 `outputSchema`를 tools/list에 노출합니다. |
| 7 | **JSON 로그 인코더** | 지금 key=value 텍스트 형식을 logstash-logback-encoder 같은 JSON 형식으로 바꿔 수집과 검색을 쉽게 합니다. |
| 8 | **통합 테스트** | Testcontainers(Redis) + MockWebServer(Portal, Tool)로 Portal 장애, Redis fallback, TTL 갱신, 타임아웃 시나리오를 end-to-end로 검증합니다. |
---
## 5. 권장 진행 순서
1. **즉시 (반나절)**: 1-1 로그 레벨과 TEMP 로그, 1-2 API Key, 1-3 profile 기본값과 identity 오타, 1-4 타임아웃 분류 + 테스트
2. **이번 스프린트**: 1-5 endpoint host 검증, 2-2 pinning, 2-3 응답 크기 제한, 2-5 에러 상세 전달, CI 테스트 게이트
3. **다음 스프린트**: 2-1 비동기 갱신, 2-4 실패 격리, 2-6, 2-7, 아키텍처(Portal vs ADR-0007) 결정 후 Helm과 문서 정리
4. **이후**: 메트릭, 트레이싱, 서킷브레이커, 감사 로그
---
## 부록: 잘 된 점 (유지 권장)
- SafeError / PublicErrorMapper로 내부 예외 메시지가 외부로 새지 않게 막은 구조
- 요청 전체 제한시간(270초, Agent Builder 300초보다 먼저 종료)과 남은 시간 기준 timeout 계산
- `idempotentHint`/`readOnlyHint` 기반 재시도 허용과 404/410 수신 시 즉시 레지스트리 갱신
- single-flight 갱신, last-good 스냅샷, Redis를 캐시로만 쓰는 원칙
- graceful shutdown 40초 < terminationGracePeriod 45초
- 패키지 경계, 코드 스타일, Helm 배포 계약을 테스트로 강제하는 방식

View File

@@ -23,6 +23,7 @@ import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.LongSupplier; import java.util.function.LongSupplier;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -50,6 +51,7 @@ import org.springframework.web.client.RestClient;
* 2026.09.16 j.h.w routing hint 요청 시점 TTL snapshot과 last-good fallback 추가 * 2026.09.16 j.h.w routing hint 요청 시점 TTL snapshot과 last-good fallback 추가
* 2026.09.17 j.h.w Portal enabled 및 routeRevision 기반 변경 감지 추가 * 2026.09.17 j.h.w Portal enabled 및 routeRevision 기반 변경 감지 추가
* 2026.09.17 j.h.w 활성 route와 Tool Service 가용성 분리 및 잘못된 endpoint last-good 방어 * 2026.09.17 j.h.w 활성 route와 Tool Service 가용성 분리 및 잘못된 endpoint last-good 방어
* 2026.09.17 j.h.w routing hint 갱신 잠금을 virtual thread 친화적인 방식으로 변경
* *
* </pre> * </pre>
*/ */
@@ -72,7 +74,7 @@ public class PortalToolRegistryClient implements ToolRegistryClient {
new java.util.concurrent.ConcurrentHashMap<>(); new java.util.concurrent.ConcurrentHashMap<>();
private final ConcurrentMap<String, RoutingHintSnapshot> routingHintSnapshotsByRoute = private final ConcurrentMap<String, RoutingHintSnapshot> routingHintSnapshotsByRoute =
new ConcurrentHashMap<>(); new ConcurrentHashMap<>();
private final ConcurrentMap<String, Object> routingHintRefreshLocksByRoute = private final ConcurrentMap<String, ReentrantLock> routingHintRefreshLocksByRoute =
new ConcurrentHashMap<>(); new ConcurrentHashMap<>();
private volatile Map<String, String> routeRevisionsByRoute = Map.of(); private volatile Map<String, String> routeRevisionsByRoute = Map.of();
private volatile Set<String> changedSourceRouteKeys = Set.of(); private volatile Set<String> changedSourceRouteKeys = Set.of();
@@ -226,7 +228,17 @@ public class PortalToolRegistryClient implements ToolRegistryClient {
} }
/** /**
* 현재 route의 routing hint snapshot을 반환합니다. snapshot이 없거나 TTL이 만료되었거나 Portal의 bundle 구성이 바뀐 경우에만 Tool Server API를 다시 호출하며, 일부 호출이 실패하면 해당 bundle의 last-good 값을 유지합니다. * Portal registry를 API, Redis fallback, 로컬 resource 중 어느 경로로도 아직 한 번도 적재하지 못했는지 확인합니다. 한 번 적재한 뒤에는 이후 조회가 실패해도 last-good snapshot을 유지하므로 false를 반환합니다.
*
* @return 최초 적재 대기 여부를 반환합니다.
*/
@Override
public boolean isSourceRegistryAwaitingInitialLoad() {
return !portalRegistryLoaded;
}
/**
* 현재 route의 routing hint snapshot을 반환합니다. snapshot이 없거나 TTL이 만료되었거나 Portal의 bundle 구성이 바뀐 경우에만 Tool Server API를 다시 호출하며, route별 {@link ReentrantLock}으로 동시 갱신을 한 번으로 모읍니다. 일부 호출이 실패하면 해당 bundle의 last-good 값을 유지합니다.
* *
* @param routeKey 처리 대상 route key입니다. * @param routeKey 처리 대상 route key입니다.
* @param manifestPath Tool Server에 붙일 routing manifest API 경로입니다. * @param manifestPath Tool Server에 붙일 routing manifest API 경로입니다.
@@ -246,15 +258,18 @@ public class PortalToolRegistryClient implements ToolRegistryClient {
if (isUsableRoutingHintSnapshot(snapshot, bundles, normalizedManifestPath)) { if (isUsableRoutingHintSnapshot(snapshot, bundles, normalizedManifestPath)) {
return routingManifests(snapshot, bundles); return routingManifests(snapshot, bundles);
} }
Object refreshLock = routingHintRefreshLocksByRoute.computeIfAbsent( ReentrantLock refreshLock = routingHintRefreshLocksByRoute.computeIfAbsent(
normalizedRouteKey, ignored -> new Object()); normalizedRouteKey, ignored -> new ReentrantLock());
synchronized (refreshLock) { refreshLock.lock();
try {
snapshot = routingHintSnapshotsByRoute.get(normalizedRouteKey); snapshot = routingHintSnapshotsByRoute.get(normalizedRouteKey);
if (isUsableRoutingHintSnapshot(snapshot, bundles, normalizedManifestPath)) { if (isUsableRoutingHintSnapshot(snapshot, bundles, normalizedManifestPath)) {
return routingManifests(snapshot, bundles); return routingManifests(snapshot, bundles);
} }
return refreshRoutingHintSnapshot( return refreshRoutingHintSnapshot(
normalizedRouteKey, bundles, normalizedManifestPath, snapshot); normalizedRouteKey, bundles, normalizedManifestPath, snapshot);
} finally {
refreshLock.unlock();
} }
} }

View File

@@ -79,6 +79,16 @@ public interface ToolRegistryClient {
return true; return true;
} }
/**
* Registry 원천을 아직 한 번도 적재하지 못한 상태인지 확인합니다. 기본 구현은 원천 적재 개념이 없는 정적 Registry 구현과의 호환을 위해 false를 반환하며,
* Portal 기반 구현은 최초 적재 성공 전까지 true를 반환해 호출자가 짧은 간격으로 재시도하고 route 오류 대신 Registry 장애로 응답하게 합니다.
*
* @return 최초 적재 대기 여부를 반환합니다.
*/
default boolean isSourceRegistryAwaitingInitialLoad() {
return false;
}
/** /**
* Agent가 MCP 등록·선택 최적화에 사용할 route별 Tool Server routing manifest 원문을 읽습니다. 지원하지 않는 구현은 빈 목록을 반환하며, 호출자는 initialize 자체를 실패시키지 않고 `_meta`를 생략할 수 있습니다. * Agent가 MCP 등록·선택 최적화에 사용할 route별 Tool Server routing manifest 원문을 읽습니다. 지원하지 않는 구현은 빈 목록을 반환하며, 호출자는 initialize 자체를 실패시키지 않고 `_meta`를 생략할 수 있습니다.
* *

View File

@@ -49,6 +49,9 @@ public class ToolRegistryService implements McpRouteKeyValidator {
private static final Logger log = LoggerFactory.getLogger(ToolRegistryService.class); private static final Logger log = LoggerFactory.getLogger(ToolRegistryService.class);
private static final long TOOL_MISS_REFRESH_COOLDOWN_MILLIS = 5_000L; private static final long TOOL_MISS_REFRESH_COOLDOWN_MILLIS = 5_000L;
// Portal registry를 한 번도 적재하지 못한 동안에는 TTL 대신 이 간격으로 다시 조회한다.
// 최초 적재 전에는 last-good snapshot이 없어 모든 route 요청이 실패하므로 TTL(기본 300초)만큼 기다리지 않는다.
private static final long PORTAL_INITIAL_LOAD_RETRY_MILLIS = 10_000L;
private final ToolRegistryClient registryClient; private final ToolRegistryClient registryClient;
private final Optional<RedisToolRegistryCache> redisCache; private final Optional<RedisToolRegistryCache> redisCache;
@@ -415,6 +418,19 @@ public class ToolRegistryService implements McpRouteKeyValidator {
return registryClient.isKnownRoute(normalizeRouteKey(routeKey)); return registryClient.isKnownRoute(normalizeRouteKey(routeKey));
} }
/**
* Portal 모드에서 Portal registry를 아직 한 번도 적재하지 못한 상태인지 확인합니다. Portal 모드가 아니면 route 원천 적재 개념이 없으므로 false를 반환합니다.
*
* @return 최초 적재 대기 여부를 반환합니다.
*/
@Override
public boolean isSourceRegistryAwaitingInitialLoad() {
if (properties.portal() == null || !properties.portal().enabled()) {
return false;
}
return registryClient.isSourceRegistryAwaitingInitialLoad();
}
/** /**
* Tool 원천을 한 번 조회하고 성공한 활성 snapshot만 memory와 Redis에 반영합니다. 원천 조회가 실패하면 기존 memory를 유지하고, memory가 비어 있을 때만 Redis last-good snapshot을 fallback으로 채택합니다. * Tool 원천을 한 번 조회하고 성공한 활성 snapshot만 memory와 Redis에 반영합니다. 원천 조회가 실패하면 기존 memory를 유지하고, memory가 비어 있을 때만 Redis last-good snapshot을 fallback으로 채택합니다.
* *
@@ -585,7 +601,8 @@ public class ToolRegistryService implements McpRouteKeyValidator {
} }
/** /**
* Portal registry 조회가 필요한 시점인지 TTL 설정과 마지막 시도 시각으로 판단합니다. Portal 모드가 아니면 항상 false를 반환합니다. * Portal registry 조회가 필요한 시점인지 마지막 시도 시각으로 판단합니다. 최초 적재에 성공하기 전에는 10초 간격, 적재한 뒤에는 TTL 설정 간격을 사용합니다.
* Portal 모드가 아니면 항상 false를 반환합니다.
* *
* @return Portal registry refresh 필요 여부를 반환합니다. * @return Portal registry refresh 필요 여부를 반환합니다.
*/ */
@@ -593,7 +610,10 @@ public class ToolRegistryService implements McpRouteKeyValidator {
if (properties.portal() == null || !properties.portal().enabled()) { if (properties.portal() == null || !properties.portal().enabled()) {
return false; return false;
} }
return isExpired(portalRefreshAttemptMillis, properties.portal().refreshTtlSeconds()); long intervalMillis = registryClient.isSourceRegistryAwaitingInitialLoad()
? PORTAL_INITIAL_LOAD_RETRY_MILLIS
: properties.portal().refreshTtlSeconds() * 1_000L;
return isExpiredMillis(portalRefreshAttemptMillis, intervalMillis);
} }
/** /**
@@ -650,11 +670,22 @@ public class ToolRegistryService implements McpRouteKeyValidator {
* @return TTL 만료 여부를 반환합니다. * @return TTL 만료 여부를 반환합니다.
*/ */
private boolean isExpired(long lastAttemptMillis, long ttlSeconds) { private boolean isExpired(long lastAttemptMillis, long ttlSeconds) {
return isExpiredMillis(lastAttemptMillis, ttlSeconds * 1_000L);
}
/**
* 마지막 시도 시각과 밀리초 간격을 비교해 만료 여부를 계산합니다. 한 번도 시도하지 않았으면 만료로 봅니다.
*
* @param lastAttemptMillis 마지막 refresh 시도 시각입니다.
* @param intervalMillis 다음 시도까지의 간격(밀리초)입니다.
* @return 만료 여부를 반환합니다.
*/
private boolean isExpiredMillis(long lastAttemptMillis, long intervalMillis) {
if (lastAttemptMillis == Long.MIN_VALUE) { if (lastAttemptMillis == Long.MIN_VALUE) {
return true; return true;
} }
long elapsedMillis = currentTimeMillis.getAsLong() - lastAttemptMillis; long elapsedMillis = currentTimeMillis.getAsLong() - lastAttemptMillis;
return elapsedMillis >= ttlSeconds * 1_000L; return elapsedMillis >= intervalMillis;
} }
/** /**

View File

@@ -12,6 +12,7 @@ import io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory;
import java.net.InetAddress; import java.net.InetAddress;
import java.net.SocketTimeoutException; import java.net.SocketTimeoutException;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpTimeoutException;
import java.time.Duration; import java.time.Duration;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.ZoneOffset; import java.time.ZoneOffset;
@@ -45,6 +46,7 @@ import org.springframework.web.client.RestClientException;
* 2026.08.06 j.h.w 최초생성 * 2026.08.06 j.h.w 최초생성
* 2026.09.08 j.h.w 임시 local fixture 활성 시 실제 HTTP 호출 비활성화 * 2026.09.08 j.h.w 임시 local fixture 활성 시 실제 HTTP 호출 비활성화
* 2026.09.17 j.h.w Tool 호출자 IP와 host name을 기동 시 한 번만 확정 * 2026.09.17 j.h.w Tool 호출자 IP와 host name을 기동 시 한 번만 확정
* 2026.09.17 j.h.w JDK HttpClient timeout 예외 분류 보완
* *
* </pre> * </pre>
*/ */
@@ -346,7 +348,7 @@ public class HttpToolClient implements ToolClient {
} }
/** /**
* 예외 cause chain 전체를 따라가며 실제 socket timeout이 포함되어 있는지 확인합니다. * 예외 cause chain 전체를 따라가며 socket 또는 JDK HttpClient timeout이 포함되어 있는지 확인합니다. 연결 timeout을 포함한 {@link HttpTimeoutException}을 일반 네트워크 장애로 잘못 분류하지 않도록 합니다.
* *
* @param throwable 처리 중 발생한 예외 정보입니다. * @param throwable 처리 중 발생한 예외 정보입니다.
* @return 조건 충족 여부를 반환합니다. * @return 조건 충족 여부를 반환합니다.
@@ -354,7 +356,8 @@ public class HttpToolClient implements ToolClient {
private boolean hasTimeoutCause(Throwable throwable) { private boolean hasTimeoutCause(Throwable throwable) {
Throwable current = throwable; Throwable current = throwable;
while (current != null) { while (current != null) {
if (current instanceof SocketTimeoutException) { if (current instanceof SocketTimeoutException
|| current instanceof HttpTimeoutException) {
return true; return true;
} }
current = current.getCause(); current = current.getCause();

View File

@@ -247,6 +247,7 @@ public class McpExchangeFilter implements Filter {
/** /**
* Portal 모드에서 요청 route가 현재 endpoint registry snapshot에 등록되어 있는지 확인합니다. 하드코딩된 route 목록을 쓰지 않고 메모리 상태만 보며, 미등록 route는 controller와 Tool 실행 계층에 닿기 전에 invalid request로 거부합니다. * Portal 모드에서 요청 route가 현재 endpoint registry snapshot에 등록되어 있는지 확인합니다. 하드코딩된 route 목록을 쓰지 않고 메모리 상태만 보며, 미등록 route는 controller와 Tool 실행 계층에 닿기 전에 invalid request로 거부합니다.
* Portal registry를 아직 한 번도 적재하지 못했거나 refresh가 예외로 끝나면 route 등록 여부를 판단할 수 없으므로 Registry 장애로 응답합니다.
* *
* @param context 현재 MCP 요청 context입니다. * @param context 현재 MCP 요청 context입니다.
* @param request 처리할 요청 정보입니다. * @param request 처리할 요청 정보입니다.
@@ -255,7 +256,19 @@ public class McpExchangeFilter implements Filter {
if (properties.portal() == null || !properties.portal().enabled() || isFixedEndpointRequest(request)) { if (properties.portal() == null || !properties.portal().enabled() || isFixedEndpointRequest(request)) {
return; return;
} }
routeKeyValidator.refreshSourceRegistryIfStale(); try {
routeKeyValidator.refreshSourceRegistryIfStale();
} catch (JsonRpcException exception) {
throw exception;
} catch (RuntimeException exception) {
// Portal 조회 실패(연결 오류 등)가 JSON-RPC 형식이 아닌 기본 500 응답으로 새지 않게 한다.
throw new JsonRpcException(
JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, "Portal registry refresh failed", exception);
}
if (routeKeyValidator.isSourceRegistryAwaitingInitialLoad()) {
throw new JsonRpcException(
JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, "Portal registry has not been loaded yet");
}
if (!routeKeyValidator.isKnownRoute(context.routeKey())) { if (!routeKeyValidator.isKnownRoute(context.routeKey())) {
throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "route key is not registered"); throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "route key is not registered");
} }

View File

@@ -30,4 +30,13 @@ public interface McpRouteKeyValidator {
* @return 조건 충족 여부를 반환합니다. * @return 조건 충족 여부를 반환합니다.
*/ */
boolean isKnownRoute(String routeKey); boolean isKnownRoute(String routeKey);
/**
* route 원천을 아직 한 번도 적재하지 못해 route 등록 여부를 판단할 수 없는 상태인지 확인합니다. 이 상태의 요청은 미등록 route가 아니라 Registry 장애로 응답해야 합니다.
*
* @return 최초 적재 대기 여부를 반환합니다.
*/
default boolean isSourceRegistryAwaitingInitialLoad() {
return false;
}
} }

View File

@@ -19,6 +19,11 @@ import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.function.LongSupplier; import java.util.function.LongSupplier;
import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockResponse;
@@ -433,6 +438,33 @@ class PortalToolRegistryClientTest {
assertThat(toolServer.getRequestCount()).isEqualTo(1); assertThat(toolServer.getRequestCount()).isEqualTo(1);
} }
@Test
void refreshesRoutingHintOnlyOnceForConcurrentRequestsOnTheSameRoute() throws Exception {
portal.enqueue(portalRegistry("portal-1"));
toolServer.enqueue(jsonResponse(routingManifest("external-tool-server", "revision-1"))
.setHeadersDelay(300, TimeUnit.MILLISECONDS));
PortalToolRegistryClient client = client();
client.refreshSourceRegistry();
CountDownLatch start = new CountDownLatch(1);
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<List<JsonNode>> first = executor.submit(() -> {
start.await();
return client.fetchRoutingManifests("external", "/tool-service-manifest");
});
Future<List<JsonNode>> second = executor.submit(() -> {
start.await();
return client.fetchRoutingManifests("external", "/tool-service-manifest");
});
start.countDown();
assertThat(first.get(5, TimeUnit.SECONDS)).hasSize(1);
assertThat(second.get(5, TimeUnit.SECONDS)).hasSize(1);
}
assertThat(toolServer.getRequestCount()).isEqualTo(1);
}
@Test @Test
void ignoresFailedRoutingManifestWithoutFailingInitializeHintCollection() { void ignoresFailedRoutingManifestWithoutFailingInitializeHintCollection() {
portal.enqueue(portalRegistry("portal-1")); portal.enqueue(portalRegistry("portal-1"));

View File

@@ -441,6 +441,53 @@ class ToolRegistryServiceTest {
.isEqualTo("notifications/tools/list_changed"); .isEqualTo("notifications/tools/list_changed");
} }
@Test
void retriesPortalRegistryEveryTenSecondsUntilTheFirstLoadSucceeds() {
ToolRegistryClient client = mock(ToolRegistryClient.class);
AtomicLong now = new AtomicLong(0);
when(client.isSourceRegistryAwaitingInitialLoad()).thenReturn(true);
when(client.refreshSourceRegistry()).thenThrow(new IllegalStateException("portal down"));
ToolRegistryService service = serviceWithClock(client, propertiesWithTtl(true, 300, 300), now);
assertThatThrownBy(service::refreshSourceRegistryIfStale).isInstanceOf(IllegalStateException.class);
now.set(9_999);
service.refreshSourceRegistryIfStale();
verify(client, times(1)).refreshSourceRegistry();
now.set(10_000);
assertThatThrownBy(service::refreshSourceRegistryIfStale).isInstanceOf(IllegalStateException.class);
verify(client, times(2)).refreshSourceRegistry();
assertThat(service.isSourceRegistryAwaitingInitialLoad()).isTrue();
}
@Test
void usesPortalTtlOnceThePortalRegistryHasBeenLoaded() {
ToolRegistryClient client = mock(ToolRegistryClient.class);
AtomicLong now = new AtomicLong(0);
when(client.isSourceRegistryAwaitingInitialLoad()).thenReturn(false);
when(client.refreshSourceRegistry()).thenReturn(false);
ToolRegistryService service = serviceWithClock(client, propertiesWithTtl(true, 300, 300), now);
service.refreshSourceRegistryIfStale();
now.set(10_000);
service.refreshSourceRegistryIfStale();
verify(client, times(1)).refreshSourceRegistry();
now.set(300_000);
service.refreshSourceRegistryIfStale();
verify(client, times(2)).refreshSourceRegistry();
}
@Test
void doesNotReportInitialLoadWaitOutsidePortalMode() {
ToolRegistryClient client = mock(ToolRegistryClient.class);
when(client.isSourceRegistryAwaitingInitialLoad()).thenReturn(true);
ToolRegistryService service =
serviceWithClock(client, propertiesWithTtl(false, 300, 300), new AtomicLong(0));
assertThat(service.isSourceRegistryAwaitingInitialLoad()).isFalse();
}
private ToolRegistryService serviceWithClock( private ToolRegistryService serviceWithClock(
ToolRegistryClient client, McpProperties properties, AtomicLong now) { ToolRegistryClient client, McpProperties properties, AtomicLong now) {
return new ToolRegistryService( return new ToolRegistryService(

View File

@@ -211,4 +211,27 @@ class HttpToolClientTest {
assertThat(exception.httpStatusCode()).hasValue(410); assertThat(exception.httpStatusCode()).hasValue(410);
}); });
} }
@Test
void classifiesJdkHttpClientResponseTimeoutAsTimeout() throws Exception {
server.enqueue(new MockResponse()
.setHeader("Content-Type", "application/json")
.setBody("{\"status\":\"late\"}")
.setHeadersDelay(500, TimeUnit.MILLISECONDS));
HttpToolClient client =
new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient());
ToolRequest request =
new ToolRequest(
"customer.search",
"1.0.0",
server.url("/api/v1/search").toString(),
OBJECT_MAPPER.readTree("{\"customerNo\":\"1234567890\"}"),
100);
assertThatThrownBy(() -> client.execute(request, context()))
.isInstanceOfSatisfying(
ToolClientException.class,
exception -> assertThat(exception.kind())
.isEqualTo(ToolClientException.Kind.TIMEOUT));
}
} }

View File

@@ -18,6 +18,7 @@ import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -490,6 +491,69 @@ class McpExchangeFilterTest {
.doesNotContain("route key is not registered"); .doesNotContain("route key is not registered");
} }
@Test
void answersRegistryUnavailableInsteadOfUnknownRouteBeforeThePortalRegistryIsLoaded() throws Exception {
McpRouteKeyValidator routeKeyValidator = mock(McpRouteKeyValidator.class);
when(routeKeyValidator.isSourceRegistryAwaitingInitialLoad()).thenReturn(true);
when(routeKeyValidator.isKnownRoute(anyString())).thenReturn(false);
MockHttpServletResponse response = sendToolsListThroughPortalFilter(routeKeyValidator);
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getContentAsString())
.contains("\"code\":-32003", "TOOL_REGISTRY_UNAVAILABLE")
.doesNotContain("INVALID_REQUEST");
}
@Test
void answersRegistryUnavailableAsJsonRpcWhenPortalRefreshThrows() throws Exception {
McpRouteKeyValidator routeKeyValidator = mock(McpRouteKeyValidator.class);
doThrow(new IllegalStateException("portal down")).when(routeKeyValidator).refreshSourceRegistryIfStale();
MockHttpServletResponse response = sendToolsListThroughPortalFilter(routeKeyValidator);
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getContentAsString())
.contains("\"code\":-32003", "TOOL_REGISTRY_UNAVAILABLE")
.doesNotContain("portal down");
}
private MockHttpServletResponse sendToolsListThroughPortalFilter(McpRouteKeyValidator routeKeyValidator)
throws Exception {
McpProperties base = properties(false, false);
McpProperties portalEnabled =
new McpProperties(
base.identity(),
base.endpointPath(),
base.server(),
base.registry(),
base.toolClient(),
base.redis(),
base.trace(),
base.protocol(),
base.discovery(),
new McpProperties.Portal(true, "", "http://portal.test/api/registry", 15),
base.bundles());
McpExchangeFilter filter = filter(portalEnabled, routeKeyValidator);
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp/external");
addStandardHeaders(request);
request.addHeader("MCP-Protocol-Version", "2025-11-25");
request.setContent(
"""
{"jsonrpc":"2.0","id":"call-1","method":"tools/list","params":{}}
"""
.getBytes(StandardCharsets.UTF_8));
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(
request,
response,
(ignoredRequest, ignoredResponse) -> {
throw new AssertionError("Controller chain must not be called while the registry is unavailable");
});
return response;
}
@Test @Test
void rejectsInitializeForRouteKeyThatIsNotRegisteredInPortalSnapshot() throws Exception { void rejectsInitializeForRouteKeyThatIsNotRegisteredInPortalSnapshot() throws Exception {
McpProperties base = properties(false, false); McpProperties base = properties(false, false);