Files
dap-was-dapms/Claude outputs/shl_mcp-code-review-20260917.md
janghw 1be6bf7278
All checks were successful
Deploy Gateway / deploy (push) Successful in 2m40s
Handle Portal registry initialization and timeout classification
2026-09-17 21:52:00 +09:00

174 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 배포 계약을 테스트로 강제하는 방식