Files
dap-was-dapms/docs/decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md
koseokmin 5c069a0ee7 Portal registry ADR의 번호를 0013으로 옮긴다
main이 6078852의 endpoint 소유권 반전을 ADR-0010으로 기록하면서 이
브랜치의 ADR-0010과 번호가 겹쳤다. 두 문서는 다른 결정이므로 나중에
문서를 합칠 때 한쪽을 옮겨야 한다.

이 브랜치가 0012까지 쓰고 있어 0011·0012를 건드리지 않는 첫 번호인
0013을 쓴다. 파일명과 제목, 대체 관계를 가리키는 ADR-0007·ADR-0009,
결정 목록, architecture 문서, Portal 계약 문서, Helm 설명, 그리고
application.yml 주석의 참조를 함께 바꾼다. 결정 내용은 바뀌지 않는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:23:29 +09:00

9.7 KiB

ADR-0013 Tool Server endpoint 목록과 route 매핑의 원천은 Portal이 소유한다

배경

ADR-0007은 MCP 배포 하나가 Tool Service 하나만 보게 하고 mcp.bundles를 배포 설정에 선언했다. 그 전제는 어떤 Tool Service를 볼지가 배포 시점에 확정된다는 것이었다.

내부망 운영은 그 전제를 따르지 않기로 했다. route와 Tool Service의 매핑은 Portal이 관리하고, MCP는 기동할 때 Portal API에서 route 정보·Tool Service endpoint·매핑 관계를 받아 온다. 매핑이 바뀌어도 MCP를 다시 배포하지 않아야 한다.

결정

  1. Tool Server endpoint 목록과 route↔Tool Service 매핑의 원천은 Portal이다. MCP는 기동 preload와 주기 refresh에서 Portal registry API를 조회한다. mcp.bundles는 비운다.
  2. MCP 배포 하나가 N개 route를 서비스한다. route key는 /mcp/{routeKey} URI에서 결정하며 설정 기본값으로 보정하지 않는다.
  3. route 하나에 N개 Tool Service가 붙을 수 있다. 카탈로그 병합 단위는 route다.
  4. Portal은 주소만 소유한다. Tool 목록·schema·timeout은 Tool Service 매니페스트가 소유한다.
  5. 요청 경로(tools/list, tools/call)는 in-memory snapshot만 읽는다. Portal은 요청 경로에 없다.
  6. 요청·응답 모양과 실패 처리는 Portal-MCP 계약 v0.1이 정본이다.
  7. deploy/helm/의 배포별 topology는 내부망 운영에 사용하지 않는다.

근거

매핑이 동적이면 배포 축과 매핑 축을 겹칠 수 없다

ADR-0007은 매핑을 배포 정의에 넣었다. Portal이 매핑을 소유하는 순간 매핑 변경이 곧 배포 변경이 되어 Portal을 원천으로 둔 의미가 사라진다. 원천이 Portal이면 배포는 매핑에 대해 중립이어야 하고, 그래서 한 배포가 N route를 서비스한다.

이 결정은 새 코드를 요구하지 않는다

구현은 이미 이 구조다.

  • PortalToolRegistryClient가 registry 응답을 bundlesByRoute(route → Tool Service 목록)로 만든다. route당 N개를 이미 지원한다
  • McpRequestContextFactory/mcp/{route}에서 route key를 뽑는다
  • ToolRegistryServicesnapshotsByRoute로 route별 snapshot을 유지한다

확정하는 것은 코드가 아니라 어느 경로를 운영으로 삼을지다. 지금까지 이 경로에는 근거 문서가 없었다.

ADR-0007의 격리 논거는 층위별로 다르게 남는다

격리는 약해진다. 숨기지 않고 적는다.

층위 격리 근거
route 간 snapshot·Redis key·refresh 유지 snapshotsByRoute와 route별 Redis key로 분리
route 안 N개 Tool Service의 조회 유지 bundle마다 last-good을 따로 보관하므로 한쪽 실패가 다른 쪽 조회를 멈추지 않는다
route 안 카탈로그 교체 없음 한 번도 성공하지 못한 Tool Service가 있으면 그 route 전체 교체를 거부한다
프로세스 자원(connection pool, thread, heap) 없음 전 route가 공유한다
배포·재기동·프로세스 장애 없음 전 route가 동시에 영향을 받는다

ADR-0007이 지키려던 가용성 등급별 물리 분리는 이 구조에서 성립하지 않는다. 등급 요구가 다시 생기면 이 ADR을 재검토한다(전제 2).

전제

아래가 깨지면 이 결정을 재검토한다.

  1. route↔Tool Service 매핑의 관리 주체는 Portal이며, 매핑 변경이 MCP 재배포 없이 반영되어야 한다.
  2. 가용성 등급별 물리 분리 요구가 없다.
  3. 전 route의 Tool 총량과 매니페스트 조회 부하를 한 프로세스가 감당한다.
  4. Portal은 신뢰 경계 안에 있고 공개 네트워크에 노출되지 않는다(계약 §8).

영향

실패 전파 범위를 route 단위로 잠갔다. 계약 v0.2 §1의 "aggregate는 전부 아니면 전무"는 카탈로그 하나를 온전히 유지하기 위한 규칙이다. 1:1 구조에서는 카탈로그 하나가 곧 route 하나였으므로 범위가 같았다. route가 N개가 되면서 같은 코드가 "전 route 전부 아니면 전무"로 확대됐고, 이는 의도된 것이 아니었다. 이 결정과 함께 다음을 적용한다.

  1. PortalToolRegistryClient.fetchAllTools()는 route마다 예외를 격리하고 실패한 route만 결과에서 제외한다.
  2. ToolRegistryClient.knownRoutes()가 원천이 선언한 route 집합을 제공하고, ToolRegistryService.refreshKnownRoutes()제거 판단을 이 집합으로만 한다. 조회 결과를 기준으로 지우면 이번 주기에 실패한 route의 정상 snapshot까지 사라져 AGENTS.md 2절의 "어떤 실패도 목록을 비우지 않는다"를 깨뜨린다.

그 결과 Tool Service 하나가 죽어도 다른 route는 적재·갱신되고, 실패한 route는 마지막 성공본을 유지한다.

readiness는 route 하나만 준비돼도 UP이다. readiness는 Pod 전체의 트래픽 게이트여서 route별 상태를 표현할 수 없다. 모든 route를 요구하면 Tool Service 하나의 장애가 정상 route까지 트래픽에서 제외해 위 격리를 되돌리는 셈이 된다. 대신 ToolCatalogHealthIndicatorreadyRoutesroutesWithoutSnapshot을 detail로 노출해 관제가 부분 상태를 감지하도록 한다.

ToolRegistryService.warmStartFromSharedCache()는 route ""의 Redis key만 읽으므로 route가 이름을 갖는 이 구성에서는 동작하지 않는다. 기동 직후 빈 목록 구간을 줄이는 warm start가 없다.

그 밖에:

  • route 없는 /mcp 호출은 route key is required로 거부된다. Agent Builder에는 route별 URL만 등록한다.
  • Tool 이름 유일성은 route 안에서만 검사한다. 서로 다른 route에 같은 이름이 있어도 거부하지 않는다.
  • mcp.discovery.max-tools-total은 전역이 아니라 route 단위 상한으로 동작한다.
  • Portal 조회 실패는 목록을 비우지 않는다. memory를 유지하고, cold start일 때만 Redis fallback을 읽는다.
  • deploy/helm/, HelmDeploymentContractTest, values.yamldeployments는 이 결정과 맞지 않는다. 상태 표시나 제거를 판단해야 한다.
  • ADR-0002의 Tool 노출 상한 50개는 Agent 기준 합계이므로 바뀌지 않는다.
  • ADR-0009의 "공개 path를 rewrite하지 않고 컨테이너가 직접 처리한다"는 유지된다. 다만 고정 publicPath 대신 /mcp + 동적 route로 처리한다.

후속 조치

이 ADR과 함께 정리한 항목이다. 남은 판단이 있는 것만 적는다.

  1. warm start를 route별로 확장했다. warmStartFromSharedCache()가 원천이 선언한 route마다 Redis last-good을 읽는다. 읽을 key를 알려면 route 목록이 먼저 있어야 하므로 기동 preload 순서를 registry 조회 → warm start → manifest 조회로 바꿨다.
  2. mcp.portal.route-key를 제거했다. 어떤 코드도 읽지 않았고, route key는 요청 URI에서만 결정된다. 설정으로 기본 route를 보정하면 잘못된 단일 진입점 호출이 조용히 성공한다.
  3. Helm chart는 유지하되 적용 범위를 명시했다. mcp.bundles 구성이 코드에 그대로 남아 있고 local 검증과 1:1 배포 환경에서 유효하므로 삭제하지 않는다. 내부망 운영 대상이 아니라는 사실을 deploy/README.mdvalues.yaml 머리말에 적었다. HelmDeploymentContractTest는 그 구성의 계약으로 계속 유효하다.
  4. ToolRegistryService.java의 한글 Javadoc 손상을 복구했다. 이중 인코딩으로 33줄이 깨져 있었고 무손실 복원이 불가능해 코드 동작에 맞춰 다시 썼다. awaitRefresh의 Javadoc이 replaceSnapshot 위에 겹쳐 있던 고아 블록도 제거했다. 이 결정과 무관한 기존 결함이었다.

남은 판단:

  • readiness를 route 단위로 세분화할 필요가 생기는지는 운영 관측 이후에 다시 본다. 현재는 최소 1개 route로 UP을 판정하고 routesWithoutSnapshot을 detail로 노출한다(위 영향 절).

채택하지 않은 대안

ADR-0007을 유지하고 배포마다 Portal의 자기 route만 조회한다. 격리는 지키지만 route 추가가 배포 추가가 된다. Portal이 route 목록의 원천인데 배포 topology가 그 목록을 따라가야 하므로 순환이 생긴다.

mcp.bundles에 매핑을 하드코딩한다. 매핑 변경마다 재배포가 필요해 전제 1과 충돌한다. 또한 비Portal 경로의 ToolBundleRegistryClient.fetchTools(routeKey)routeKey를 읽지 않으므로 route마다 다른 카탈로그를 만들 수 없다. 모든 route가 같은 목록을 오류 없이 반환해 라우팅이 검증되지 않은 채 통과한다.

route별로 프로세스를 나누고 각자 Portal을 조회한다. 자원 격리는 얻지만 Portal이 route 목록을 소유하는 이상 배포 수를 Portal이 정하게 된다. 운영 중 route 추가가 배포 파이프라인을 건드린다.