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

18 KiB
Raw Permalink Blame History

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.yamlmain에 push하면 /home/ubuntu/apps/prd-dap-gateway/deploy.sh를 실행합니다. Helm/OpenShift 배포와는 별개의 배포 경로이고, 테스트 없이 곧바로 운영에 반영됩니다.
    • Dockerfilebuild -x test로 테스트를 건너뜁니다.
  • application.ymlspring.profiles.active: local 때문에 환경변수가 빠지면 운영에서 local profile로 뜰 수 있습니다.
  • mcp.identity 기본값에 오타가 있습니다: ax-hu-mcpax-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로 제한하지만, HttpToolClienttoEntity(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 시간까지 포함되고 이름도 의미와 맞지 않습니다. durationMillisattempts로 나누는 것을 권장합니다.
  12. Portal 방식의 bundle 상태 조회 불가: ToolBundleStatusEndpointmcp.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 배포 계약을 테스트로 강제하는 방식