6 Commits

Author SHA1 Message Date
6127ffb6ce 서식 계약 테스트를 되살린다
c9a6bd2가 지운 셋 중 마지막이다. 이름과 달리 IntelliJ 코드 스타일을
판정하지 않는다. 원본 Javadoc이 선을 긋고 있듯 들여쓰기 폭과 줄바꿈
위치는 .idea/codeStyles/Project.xml이 소유하고 이 테스트는 건드리지
않는다. ideaFormatCheck와도 별개다.

검사하는 것은 도구 없이 판정할 수 있는 일곱 가지다. CRLF·탭·줄 끝
공백·파일 끝 개행·미사용 import·import 순서, 그리고 소스 수집이 실제로
되는지 확인하는 자기 방어다.

Spotless를 대체하려고 만든 것이라는 점이 지금 중요하다. 그 플러그인은
빌드를 읽는 시점에 외부 저장소를 받아야 해서 폐쇄망에서는 검사 하나
때문에 빌드가 시작되지 못한다. 이 테스트는 외부 의존이 없다.

현재 코드로 일곱 건 모두 통과한다. .java 85개에 CRLF가 없고,
core.autocrlf가 true여도 .gitattributes의 `*.java text eol=lf`가 이기므로
새로 clone해도 결과가 같다. 기존 코드를 고칠 필요가 없다.

미사용 import와 줄 끝 공백과 탭을 심어 각각 해당 검사가 실패하는 것을
확인했고 확인 후 되돌렸다. 202개 테스트 전부 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:51:34 +09:00
973c650bed 문서 클래스 표 계약 테스트를 되살린다
architecture.md의 클래스 책임 표는 코드 구조를 문서에 복제한 것이라
rename이나 package 이동이 있으면 조용히 낡는다. 이 테스트는 그 이유로
만들어졌고, 삭제된 원본 Javadoc에 "패키지 재구성 한 번에 네 개의
이름이 죽은 적이 있다"고 적혀 있다.

c9a6bd2의 dap에서 dat으로의 개명에서 같은 일이 다시 일어났다. 테스트가
그 커밋에서 함께 삭제되어 드리프트를 잡지 못했고, dc6f602에서 사람이
손으로 대조해 아홉 건을 고쳐야 했다.

패키지 문자열만 dat으로 바꿔 복원한다. 표의 클래스 행 서른 개를 모두
읽어 src/main/java에 해당 소스가 있는지 확인한다. 패키지 경계 표의
일곱 행은 첫 칸이 소문자 패키지명이라 정규식이 의도대로 건너뛰므로
사각지대는 없다.

존재하지 않는 클래스 행을 표에 심어 실패를 내는 것까지 확인했고 확인 후
되돌렸다. 195개 테스트 전부 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:43:33 +09:00
dc6f60219b architecture.md를 현재 코드에 맞춘다
문서가 3de052a 시점 상태로 복구되면서 그 뒤 main의 코드 커밋 일곱 개가
반영되지 않았다. 계약 테스트가 보는 범위 밖이라 194건이 통과해도
드러나지 않는다. 코드와 대조해 확인한 아홉 건을 고친다.

사실이 틀린 것:

- 갱신 모델. ToolRegistryRefreshScheduler에는 @Scheduled가 없고
  ApplicationReadyEvent 하나만 있다. 갱신은 주기 실행이 아니라 요청
  시점 TTL 만료로 일어난다. 설정 키도 refresh-interval-seconds가 아니라
  refresh-ttl-seconds다.
- 1:1 배포 전제. ADR-0007은 Superseded이고 코드는 route당 N개 Tool
  Service를 병합한다. ADR-0013 기준으로 다시 쓴다.
- route table이 없다는 서술. McpController가 /mcp/{routeKey}를 받고
  PortalToolRegistryClient가 bundlesByRoute를, ToolRegistryService가
  route별 snapshot을 들고 있다. route 매핑은 애플리케이션 안에 있다.
- 실행 흐름 1번의 publicPath. route key는 URI에서만 결정된다.

빠진 것:

- 클래스 표에 McpRouteKeyValidator, PortalToolRegistryClient,
  RedisPortalRegistryCache, AgentRoutingHintsProperties,
  LocalFixtureProperties를 추가한다. 앞의 셋은 Portal 모드의 핵심
  경로인데 표에 없었다.
- Agent routing hint 절을 새로 쓴다. 9905d52가 들여온 기능이 문서에
  전혀 없었다.
- Tool 호출 retry 절을 새로 쓴다. 6526e73의 retrySafeByAnnotation이
  destructiveHint를 항상 금지하고 annotations 미선언 Tool은 재시도하지
  않는다는 사실이 없었다.
- 스키마 정책 두 클래스를 표에 넣고, 검사 지점이 ToolMetadata 생성자
  하나라는 것을 실행 흐름 9번에 적는다.

인용한 식별자 열여섯 개가 코드에 실재하는지, 상대링크가 깨지지 않는지
확인했다. retry 기본값도 application.yml과 대조했다. 194개 테스트 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:42:01 +09:00
6746c59ab3 패키지 경계 계약 테스트를 되살린다
c9a6bd2가 dap에서 dat으로 옮기면서 docs 패키지의 계약 테스트 셋을
삭제했다. 같은 커밋이 다른 테스트는 모두 이전했으므로 의도적 판단으로
보이지만, architecture.md는 여전히 "이 규칙은
PackageBoundaryContractTest가 강제한다"고 적고 있어 문서가 없는 장치를
가리키는 상태였다.

규칙 자체는 지금도 유효하고 위반도 없다. jakarta.servlet을 쓰는 네
파일이 모두 transport/http 안에 있고, transport에서 execute나 registry를
import하는 파일도 없다. McpRouteKeyValidator는 registry 지식이 필요한데도
transport/http에 인터페이스를 두고 구현을 주입받는 방식으로 경계를
지킨다.

패키지 문자열만 dat으로 바꿔 그대로 복원한다. 소스 파일을 읽기만 하고
application context를 띄우지 않으므로 의존이 늘지 않는다.

검사가 실제로 동작하는지 두 규칙 각각에 위반 파일을 심어 확인했고
둘 다 실패를 냈다. 확인 후 삭제했다. 194개 테스트 전부 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:38:53 +09:00
e8ed554351 ADR-0013에서 잘못 지운 route-key 항목을 되살린다
4e4c341에서 mcp.portal.route-key가 main에 없다고 보고 "남은 판단"의
해당 항목을 삭제했다. 근거로 삼은 grep이 YAML 키만 봤고
McpProperties.Portal의 record 컴포넌트를 놓친 오판이었다.

routeKey 컴포넌트는 McpProperties.java:266에 그대로 있고, .portal()
호출 다섯 곳 중 routeKey()를 읽는 곳은 없다. 원래 항목이 맞았으므로
확인한 근거를 덧붙여 되살린다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:36:44 +09:00
5d8d004f93 에이전트 도구의 로컬 상태 파일을 gitignore에 추가한다
.ua/와 skills-lock.json은 에이전트 도구가 작업 중 로컬에 만드는
파일이다. 개발자마다 내용이 달라지고 저장소 산출물이 아니므로
공유하지 않는다. 지금은 untracked로 남아 git status를 계속 채운다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:26:06 +09:00
6 changed files with 443 additions and 6 deletions

4
.gitignore vendored
View File

@@ -21,6 +21,10 @@ AGENTS.md
# 결정은 docs/decisions/의 ADR에, 규칙은 계약 테스트에 남긴다(AGENTS.md 4절).
docs/superpowers/
# 에이전트 도구가 로컬에 만드는 상태 파일. 개발자마다 달라지므로 공유하지 않는다.
.ua/
skills-lock.json
# Local configuration and secrets
.env
.env.*

View File

@@ -21,7 +21,7 @@ MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. T
## 전체 실행 흐름
1. Agent Builder가 배포별 공개 URL `https://{host}{publicPath}` 호출한다. OpenShift Route는 host와 path로 MCP Service만 선택하고 원래 path를 컨테이너에 전달한다([ADR-0009](decisions/ADR-0009-container-handles-public-mcp-path.md)). 요청 body·header·Tool 이름은 선택에 관여하지 않는다.
1. Agent Builder가 route별 공개 URL `https://{host}/mcp/{routeKey}` 호출한다. OpenShift Route는 host와 path로 MCP Service만 선택하고 원래 path를 컨테이너에 전달한다([ADR-0009](decisions/ADR-0009-container-handles-public-mcp-path.md)). route key는 이 URI에서만 결정하며 설정 기본값으로 보정하지 않는다([ADR-0013](decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md)). Portal 모드에서 route가 없는 `/mcp` 호출은 `route key is required`로 거부한다. 요청 body·header·Tool 이름은 route 선택에 관여하지 않는다.
2. 컨테이너의 `mcp.endpoint-path` 전용 `McpExchangeFilter`가 header를 검증/추출하고 요청 전체 deadline을 포함한 `McpRequestContext`를 만든다. 호출자 헤더 다섯 개(`guid`, `x-request-id`, `mcp-session-id`, `employee-no`, `virtual-employee-no`)는 모두 선택값이며, 응답과 downstream Tool 호출에 그대로 전파한다. `guid`는 요청 하나의 end-to-end 상관 값, `x-request-id`는 개별 HTTP 요청 식별자다.
3. filter는 크기가 제한된 repeatable request body에서 `method`만 관찰용으로 읽고 `mcp_http_request_received` 로그를 남긴다. body, header 값, credential은 로그에 저장하지 않는다.
4. `McpProtocolVersionValidator``initialize`를 제외한 요청의 `MCP-Protocol-Version`을 supported versions와 대조한다. 누락·불일치는 Controller 진입 전 HTTP 400으로 종료한다.
@@ -30,7 +30,7 @@ MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. T
6. `McpMethodHandlerRegistry`가 method를 명시적 handler에 연결한다.
7. `tools/list``ToolRegistryService`의 in-memory snapshot에서 실행 metadata를 얻는다. 요청 경로는 Redis를 호출하지 않으므로 Redis 장애·지연이 응답에 영향을 주지 않으며, snapshot이 비어 있는 기동 직후에만 Tool catalog provider를 한 번 조회한다. 이후 `ToolsListHandler`가 MCP SDK의 `Tool``ListToolsResult`로 변환한다. local 기본 구성은 Tool Service 매니페스트를 먼저 조회하고, 최초 실패 시 bundle별 local manifest sample을 cold-start fallback으로 사용한다. 운영은 이 배포가 보는 Tool Service 매니페스트의 사용 가능한 성공본만 원천으로 사용한다.
8. `tools/call``ToolsCallHandler`가 표준 MCP의 `params.name`과 object인 `params.arguments`를 검증하고 추출한다.
9. `ToolExecutionService`가 표준 Tool name으로 metadata를 확정하고 argument schema를 검증한다. `ToolRoutingService`는 snapshot에 저장된 정확한 Tool endpoint와 metadata timeout으로 HTTP 요청을 만든다. Agent Builder가 보낸 `arguments` 객체는 JSON raw body로 전달하며 MCP가 Tool을 대체 선택하지 않는다.
9. `ToolExecutionService`가 표준 Tool name으로 metadata를 확정하고 argument schema를 검증한다. `inputSchema` 자체의 안전성은 실행 시점이 아니라 `ToolMetadata` 생성 시점에 이미 검사됐다. 매니페스트 파싱·local 파일 로딩·Redis snapshot 역직렬화가 모두 같은 생성자를 지나므로 검사 지점은 하나다. `ToolRoutingService`는 snapshot에 저장된 정확한 Tool endpoint와 metadata timeout으로 HTTP 요청을 만든다. Agent Builder가 보낸 `arguments` 객체는 JSON raw body로 전달하며 MCP가 Tool을 대체 선택하지 않는다.
10. `arguments`의 어떤 field도 outbound URL 선택에 사용하지 않는다. Portal registry는 Tool Server의 `serviceDomain``manifestPath`만 제공하고, Tool별 실행 endpoint는 Tool Server manifest의 top-level `endpoint` 또는 `_meta.endpoint`에서 가져온다. manifest endpoint가 절대 HTTP(S) URL이면 Tool Server가 제공한 실행 주소 원천으로 허용하고, 상대 경로이면 Portal registry의 `serviceDomain` 뒤에 붙인다.
11. `HttpToolClient`가 JDK 공유 HTTP client의 connection pool을 사용해 correlation 헤더와 함께 POST를 실행한다. arguments는 JSON body로 전달하며 Tool read timeout은 metadata timeout과 요청 전체 deadline의 남은 시간 이하로 제한한다. Authorization 전달은 설정으로 통제한다.
12. Tool 응답은 요청 payload와 분리해 `response.data`만 사용한다. plain text는 그대로, JSON object/array는 compact JSON string으로 MCP SDK `CallToolResult`/`TextContent``result.content[0].text`에 넣고 outer JSON serializer가 escaping을 처리한다. 호출 소요 시간(ms)은 `result.content[0]._meta.searchTime`으로 반환하고, 정상 결과에도 `isError: false`를 명시한다. Tool 실행·timeout·권한 오류는 JSON-RPC error가 아니라 `isError: true` result로 변환한다. JSON-RPC envelope/params/method 및 서버 구성 오류는 최상위 JSON-RPC `error`로 반환한다.
@@ -44,6 +44,7 @@ MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. T
|---|---|---|
| `McpController` | `transport/http` | `mcp.endpoint-path`의 단일 공개 endpoint, parser/handler 연결, notification 202와 initialize UUID header 선택 |
| `McpRequestContextFactory` | `transport/http` | 호출자 헤더 5종 추출. correlation 값 형식 검증, 사원 식별자는 해석하지 않고 주입 위험 문자만 차단 |
| `McpRouteKeyValidator` | `transport/http` | `/mcp/{routeKey}`의 route가 현재 서버가 아는 route인지 확인하는 transport 전용 port. memory snapshot만 읽고 registry를 직접 참조하지 않아 패키지 경계를 유지 |
| `McpRequestContextHolder` | `context` | 요청 수명 ThreadLocal 저장; 세션 저장소가 아님 |
| `JsonRpcRequestParser` | `jsonrpc` | JSON-RPC envelope shape 검증과 내부 request 정규화 |
| `McpMethodHandlerRegistry` | `method` | `Handler` 전략과 method dispatch를 한 경계에서 관리 |
@@ -52,8 +53,12 @@ MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. T
| `LocalFileToolRegistryClient` | `registry` | 매니페스트 조회를 끈 local profile에서 legacy JSON/manifest fixture를 읽어 테스트 Tool 목록을 제공 |
| `ToolBundleDiscovery` | `registry` | 구현상 N개 Tool Service 매니페스트를 병렬 조회·검증하고 bundle별 last-good 상태를 유지. 최초 원격 조회 실패 시에만 설정된 local manifest fallback을 사용하며, 운영 배포는 1개 Bundle만 사용 |
| `ToolBundleRegistryClient` | `registry` | 구현상 모든 bundle의 사용 가능한 성공본을 중복·총량 검증 후 하나의 snapshot으로 병합. 운영 배포에서는 단일 Bundle 결과를 채택 |
| `PortalToolRegistryClient` | `registry` | Portal registry를 route → Tool Service 목록(`bundlesByRoute`)으로 유지하고 route별 manifest를 조회. 한 route의 실패는 `fetchRouteToolsSafely()`로 격리해 다른 route 수집을 막지 않음 |
| `RedisPortalRegistryCache` | `registry` | Portal Registry API 장애 시 endpoint registry JSON을 읽는 선택적 Redis fallback. route별 Tool snapshot key와 분리 |
| `ToolSchemaReferencePolicy` | `registry` | `inputSchema`가 문서 밖을 가리키는 `$ref`·`$dynamicRef`와 미지원 dialect를 거부([ADR-0011](decisions/ADR-0011-tool-input-schema-stays-in-document.md)) |
| `ToolSchemaPatternPolicy` | `registry` | `pattern` 정규식의 길이·무한 수량자·중첩 반복을 제한하고 `maxLength` 동반을 요구. `patternProperties`는 거부([ADR-0012](decisions/ADR-0012-tool-input-schema-pattern-budget.md)) |
| `RedisToolRegistryCache` | `registry` | best-effort Redis snapshot, 실제 read/write 실패를 cache miss로 격리 |
| `ToolRegistryRefreshScheduler` | `registry` | 기동 preload와 주기 refresh; 실패 애플리케이션 생존 |
| `ToolRegistryRefreshScheduler` | `registry` | `ApplicationReadyEvent`에서 warm start와 원천 preload를 한 번 실행. 주기 실행은 하지 않으며 이름과 달리 scheduler가 아니다. 실패해도 애플리케이션 생존 |
| `ToolArgumentValidator` | `execute` | 기존 required/type 오류 계약을 보존하고 MCP SDK JSON Schema 2020-12 검증 적용 |
| `ToolExecutionService` | `execute` | 이름 기반 metadata 해석, argument validation, 단일 Tool 실행, HTTP 경계 로그와 오류 mapping |
| `ToolRoutingService` | `execute` | 단일 POST endpoint와 timeout 확정, 기본 URI 검증 |
@@ -65,6 +70,8 @@ MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. T
| `McpProtocolVersionValidator` | `transport/http` | `initialize` 이후 HTTP `MCP-Protocol-Version`의 지원 여부 검증; 서버 상태를 저장하지 않음 |
| `TraceLogger` | `observability` | context의 guid/requestId를 직접 포함하는 최소 key=value 경계 로그. 사원 식별자는 기록하지 않음 |
| `McpExceptionHandler` | `transport/http` | JSON parse, JSON-RPC, 예상 밖 오류의 표준 response 변환 |
| `AgentRoutingHintsProperties` | `config` | initialize 응답 `_meta`에 넣을 Tool Server routing manifest 조회 경로 설정(기본 `/tool-service-manifest`) |
| `LocalFixtureProperties` | `config` | 실제 Tool Server가 없는 local·dev 환경의 임시 manifest·응답 파일 위치. 기본 비활성이며 실서버 확보 후 제거 대상 |
Spring Boot 3.5가 관리하는 Jackson 2 databind 모델과 annotation API는 `com.fasterxml.jackson.*` namespace를 사용한다. Registry 응답의 unknown field 무시는 회귀 테스트로 검증한다.
@@ -145,6 +152,18 @@ Tool 호출 직전마다 `remainingMillis()`로 남은 예산을 계산해 read
중복 실행 방지는 Tool Service의 책임이다. retry에서 같은 `guid`를 재사용해 멱등성 키로 삼을지는
[미합의 항목](extension-points.md)이며, 합의 전에는 MCP가 이를 보장한다고 가정하지 않는다.
## Tool 호출 retry
MCP는 Tool 호출 실패를 제한적으로 재시도한다. 세 조건이 모두 참일 때만 재시도하며, 하나라도 거짓이면 단발 호출이다(`ToolRoutingService:64`).
1. `mcp.tool-client.retry.enabled`가 참이고 `max-attempts`가 2 이상이다(기본 `true`, `2`).
2. HTTP status가 `retry-on-http-status` 목록에 있다(기본 408, 429).
3. Tool이 annotations로 안전하다고 선언했다.
3번은 `ToolMetadata.retrySafeByAnnotation()`이 공개 정의의 annotations로 판단한다. `destructiveHint`가 참이면 **항상 금지**하고, 그렇지 않은 경우에만 `readOnlyHint` 또는 `idempotentHint`가 참이면 허용한다. annotations가 없으면 허용하지 않는다. 즉 **선언하지 않은 Tool은 재시도하지 않는다.**
재시도는 같은 요청 deadline 안에서 일어나므로 `remainingMillis()` 예산을 넘지 못한다. 멱등성 자체는 Tool Service의 책임이며, MCP는 Tool이 선언한 annotations를 신뢰할 뿐 검증하지 않는다([ADR-0004](decisions/ADR-0004-execution-guardrails.md)).
## Protocol version 협상과 검증
- 서버는 `mcp.protocol.supported-versions``mcp.protocol.preferred-version`으로 지원 버전을 명시적으로 관리한다. preferred version은 반드시 supported versions에 포함되어야 한다.
@@ -159,6 +178,12 @@ Tool 호출 직전마다 `remainingMillis()`로 남은 예산을 계산해 read
- Agent Builder는 MCP 2025-11-25 lifecycle에 따라 `notifications/initialized`를 보낸다. 서버는 이를 저장하거나 이후 요청의 readiness gate로 사용하지 않는다.
- `InitializedNotificationHandler`는 id 없는 notification을 HTTP 202으로 수용한다. 이는 Tool 실행 준비 상태를 메모리에 세우는 동작이 아니므로 replica 간 affinity가 필요 없다.
## Agent routing hint
`mcp.agent-routing-hints.enabled`가 켜져 있고 요청에 route가 있으면, `initialize` 응답의 `_meta``toolServers` 배열을 실어 보낸다. `InitializeHandler`가 해당 route의 Tool Server에서 `mcp.agent-routing-hints.manifest-path`(기본 `/tool-service-manifest`)를 조회해 받은 JSON을 **변환 없이 그대로** 감싼다.
기능이 꺼져 있거나 route가 없으면 `_meta`를 붙이지 않고 표준 `initialize` 응답만 반환한다. 이 값은 Agent Builder에 주는 힌트이며 MCP의 Tool 실행 경로는 이를 읽지 않는다.
## Tool metadata 갱신 장애 시나리오
요청 경로는 memory만 읽으므로 Redis 상태가 등장하지 않는다.
@@ -198,7 +223,7 @@ Redis는 요청 경로의 의존성이 아닌 선택적인 warm-start cache다.
로컬 검증에서는 `mcp.portal.registry-url``file:./config/local-toolserver-info-sample-v1.json` 같은 Spring resource location으로 지정할 수 있다. 이 경우 MCP는 기동 preload와 주기 endpoint refresh에서 Portal HTTP API를 호출하지 않고 프로젝트 안의 registry JSON을 읽는다. 파일에서 확보한 endpoint 목록 이후의 Tool Server `tool-manifest` 주기 조회, route별 in-memory snapshot 갱신, Redis fallback 규칙은 Portal API를 사용할 때와 동일하다.
Portal Registry를 사용하는 구성에서는 포털을 route별 Tool Server 목록의 원천으로만 사용한다. MCP는 기동 preload 때 포털 registry API를 먼저 호출해 `serviceDomain``manifestPath`를 확보한 뒤 Tool Server `tool-manifest`를 조회한다. 이후에는 `mcp.registry.refresh-interval-seconds` 주기로 저장된 Tool Server 목록에 대해 manifest만 다시 조회하고, `mcp.portal.refresh-interval-seconds` 주기로 포털 registry만 별도로 갱신한다. 포털 `registryRevision`은 포털 응답 JSON 변경 로그와 Tool Server 목록 변경 진단에 사용하며, Tool Server 내부 tool/schema/revision/endpoint 변경 감지는 MCP의 manifest 주기 조회 결과를 route별 in-memory snapshot에 다시 병합하면서 처리한다. 요청 경로의 `tools/list``tools/call`은 계속 in-memory snapshot만 읽는다. Portal API 조회가 실패하면 이미 확보한 in-memory Tool Server snapshot을 유지하며, cold start처럼 memory가 비어 있을 때만 `mcp.redis.portal-registry-key`의 Redis registry JSON을 fallback으로 읽는다. 이 Portal registry fallback은 route 목록과 Tool Server 목록 확보용이고, route별 Tool snapshot Redis key는 이미 알고 있는 route의 마지막 Tool 목록 fallback에만 사용한다. Redis fallback도 실패하면 Tool Server 원천을 확보하지 못한 것으로 처리하고 다음 주기에서 재시도한다.
Portal Registry를 사용하는 구성에서는 포털을 route별 Tool Server 목록의 원천으로만 사용한다. MCP는 기동 preload 때 포털 registry API를 먼저 호출해 `serviceDomain``manifestPath`를 확보한 뒤 Tool Server `tool-manifest`를 조회한다. 이후 갱신은 주기 실행이 아니라 **요청 시점 TTL 만료**로 일어난다. 요청이 들어오면 `ToolRegistryService``mcp.portal.refresh-ttl-seconds`가 지났을 때만 포털 registry를, `mcp.registry.refresh-ttl-seconds`가 지났을 때만 해당 route의 manifest를 다시 조회한다. 아직 snapshot이 없는 route는 TTL과 무관하게 조회하며, 포털 endpoint 목록 변경이 그 route의 마지막 manifest 조회보다 나중이면 TTL이 남아 있어도 manifest를 다시 읽는다. 요청이 없으면 갱신도 일어나지 않는다. 포털 `registryRevision`은 포털 응답 JSON 변경 로그와 Tool Server 목록 변경 진단에 사용하며, Tool Server 내부 tool/schema/revision/endpoint 변경 감지는 MCP의 manifest 주기 조회 결과를 route별 in-memory snapshot에 다시 병합하면서 처리한다. 요청 경로의 `tools/list``tools/call`은 계속 in-memory snapshot만 읽는다. Portal API 조회가 실패하면 이미 확보한 in-memory Tool Server snapshot을 유지하며, cold start처럼 memory가 비어 있을 때만 `mcp.redis.portal-registry-key`의 Redis registry JSON을 fallback으로 읽는다. 이 Portal registry fallback은 route 목록과 Tool Server 목록 확보용이고, route별 Tool snapshot Redis key는 이미 알고 있는 route의 마지막 Tool 목록 fallback에만 사용한다. Redis fallback도 실패하면 Tool Server 원천을 확보하지 못한 것으로 처리하고 다음 주기에서 재시도한다.
노출 대상 Tool은 그 파일이 정의한다. 목록을 이 문서에 옮겨 적지 않는다. 파일의 공개 필드는 그대로 보존하고 `_meta``endpoint` 실행 정보만 제거해 `tools/list`에 내보낸다. fallback도 원격 매니페스트와 같이 top-level `endpoint` 또는 `_meta.endpoint`를 내부 실행 endpoint로 사용한다.
@@ -206,9 +231,15 @@ Portal Registry를 사용하는 구성에서는 포털을 route별 Tool Server
운영 profile에서는 `ToolBundleDiscovery``ToolBundleRegistryClient`만 metadata 원천으로 활성화한다. MCP 배포별 `mcp.bundles`가 Tool Service의 매니페스트와 실행 주소를 선언한다. 운영 Helm 설정에는 fallback 파일을 넣지 않는다. Tool Service는 표준 `name`을 소유하고, MCP는 자기 Bundle 안에서 형식·설정된 `namePrefix`·중복을 검증하되 이름을 재작성하지 않는다. 서로 다른 MCP 배포 간 이름의 전역 유일성은 Tool Service·플랫폼의 변경 절차가 보장한다. Redis는 선택적인 공유 last-good cache일 뿐 Tool 목록의 원천이 아니다.
**`mcp.bundles`는 N개를 지원하지만 운영 배포에서는 항상 한 항목이다.** MCP 배포 하나가 Tool Service 하나만 보기로 했기 때문이다([ADR-0007](decisions/ADR-0007-one-mcp-per-tool-service.md)). 대상을 늘리는 방법은 이 목록을 늘리는 것이 아니라 MCP 배포를 하나 더 만드는 것이다. 그래야 등급이 다른 Tool Service의 조회 실패가 서로의 카탈로그 갱신을 막지 않는다. 다중 bundle 병합 코드는 유지하되 Helm Chart가 1개로 잠그고 `HelmDeploymentContractTest`가 그 사실을 검사한다.
**MCP 배포 하나가 N개 route를 서비스하고, route 하나에 N개 Tool Service가 붙는다**([ADR-0013](decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md)). 카탈로그 병합 단위는 route다. 이전의 1:1 전제([ADR-0007](decisions/ADR-0007-one-mcp-per-tool-service.md), `Superseded`)는 매핑이 배포 시점에 확정된다는 가정 위에 있었으나, 매핑의 원천이 Portal로 옮겨지면서 성립하지 않는다.
각 배포는 같은 환경 host의 고유 `publicPath`를 가진 OpenShift Route로 노출된다([ADR-0009](decisions/ADR-0009-container-handles-public-mcp-path.md)). Route는 path로 Service만 선택하고 컨테이너가 같은 값을 `mcp.endpoint-path`로 직접 처리한다. Java 애플리케이션에는 route table이나 다중 Registry를 추가하지 않는다. Deployment·snapshot·readiness·connection pool은 path별로 분리되고, 공유되는 장애 지점은 OpenShift ingress와 DNS다.
등급별 물리 격리는 이 구조에서 얻지 못한다. 무엇이 남는지는 ADR-0013의 격리 표가 정본이며, 요약하면 route별 snapshot 보관과 route 간 갱신은 격리되지만 프로세스 자원·배포·재기동은 전 route가 공유한다.
`mcp.bundles` 기반 1:1 구성은 코드에 남아 있어 local 검증과 1:1 배포에서 유효하고 `HelmDeploymentContractTest`가 그 계약을 검사한다. 내부망 운영 대상인지는 ADR-0013이 정하지 않는다.
Route는 path로 Service만 선택하고 컨테이너가 `mcp.endpoint-path`와 그 아래 `{routeKey}`를 직접 처리한다([ADR-0009](decisions/ADR-0009-container-handles-public-mcp-path.md)의 결정 4는 ADR-0013이 대체한다). `McpController``${mcp.endpoint-path}``${mcp.endpoint-path}/{routeKey}` 두 패턴을 받는다.
route 매핑은 애플리케이션 안에 있다. `PortalToolRegistryClient`가 Portal registry를 route → Tool Service 목록으로 유지하고, `ToolRegistryService`가 route별 snapshot을 들고, `McpRouteKeyValidator`가 등록되지 않은 route를 controller 진입 전에 거부한다. snapshot과 Redis key는 route별로 분리되지만 Deployment·readiness·connection pool은 전 route가 공유하며, 공유되는 장애 지점은 프로세스 자체와 OpenShift ingress·DNS다.
운영 상태는 외부 ingress가 아니라 management port(기본 9090)의 `GET /actuator/toolBundles`로 확인한다.

View File

@@ -74,6 +74,7 @@ ADR-0007이 지키려던 것은 가용성 등급별 격리였다. 이 구조에
## 남은 판단
- `McpProperties.Portal``routeKey` 컴포넌트가 선언돼 있으나 어떤 코드도 읽지 않는다. `application.yml``route-key` 키도 없고, `.portal()` 호출 다섯 곳 중 `routeKey()`를 읽는 곳이 없다. 결정 2에 따라 route는 URI에서만 오므로 이 컴포넌트는 제거 대상이다. 설정으로 기본 route를 보정하면 잘못된 단일 진입점 호출이 조용히 성공한다.
- `deploy/helm/`의 배포별 topology와 `HelmDeploymentContractTest``mcp.bundles` 기반 1:1 구성의 계약이다. 코드에 그 경로가 남아 있어 local 검증과 1:1 배포에서는 유효하지만, 내부망 운영 대상인지 여부는 이 ADR이 정하지 않는다.
- readiness를 route 단위로 세분화할지는 운영 관측 이후에 다시 본다.

View File

@@ -0,0 +1,74 @@
package io.shinhanlife.dat.biz.mcp.docs;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
/**
* {@code docs/architecture.md}의 클래스 책임 표가 실제 소스와 어긋나지 않는지 확인하는 문서 계약 테스트입니다. 이 표는 코드 구조를 문서에 복제한 것이라 class를 rename하거나 package를 옮기면 조용히 낡습니다. 실제로 패키지 재구성 한 번에 네
* 개의 이름이 죽은 적이 있어, 사람의 주의력 대신 테스트로 고정합니다. 소스를 읽기만 하며 애플리케이션 context를 띄우지 않습니다.
*/
class ArchitectureDocumentContractTest {
private static final Path ARCHITECTURE = Path.of("docs", "architecture.md");
private static final Path MAIN_PACKAGE =
Path.of("src", "main", "java", "io", "shinhanlife", "dat", "biz", "mcp");
/**
* 표의 첫 두 칸에 백틱으로 감싼 타입 이름과 패키지 경로가 있는 행만 뽑는다.
*/
private static final Pattern TABLE_ROW =
Pattern.compile("^\\| `([A-Z][A-Za-z0-9]*)` \\| `([a-z0-9/]+)` \\|");
/**
* 클래스 표에 적힌 모든 타입이 {@code src/main/java}에 실제로 존재하는지 확인합니다. 존재하지 않는 이름이 있으면 rename 후 문서를 갱신하지 않은 것이므로, 어떤 이름인지 함께 알려 줍니다.
*/
@Test
void everyDocumentedClassPathStillExists() throws IOException {
List<DocumentedType> documented = documentedTypes();
// 표 자체가 사라지면 이 테스트가 조용히 통과해 버리므로 최소 개수를 함께 고정한다.
assertThat(documented)
.withFailMessage("architecture.md의 클래스 책임 표를 찾지 못했습니다. 표 형식이 바뀌었는지 확인하세요.")
.hasSizeGreaterThan(10);
List<DocumentedType> missing = documented.stream().filter(type -> !sourceExists(type)).toList();
assertThat(missing)
.withFailMessage(
"architecture.md에 적힌 package와 class 경로에 소스가 없는 타입: %s%n"
+ "class를 rename하거나 package를 옮겼다면 문서의 표도 같은 변경에서 고쳐야 합니다.",
missing)
.isEmpty();
}
/**
* 클래스 책임 표에서 타입 이름과 패키지 경로를 순서대로 모읍니다.
*/
private List<DocumentedType> documentedTypes() throws IOException {
try (Stream<String> lines = Files.lines(ARCHITECTURE)) {
return lines.map(TABLE_ROW::matcher)
.filter(Matcher::find)
.map(matcher -> new DocumentedType(matcher.group(1), matcher.group(2)))
.distinct()
.toList();
}
}
/**
* 문서에 적힌 패키지와 타입 이름이 가리키는 main 소스 파일이 정확히 존재하는지 확인합니다.
*/
private boolean sourceExists(DocumentedType type) {
return Files.isRegularFile(MAIN_PACKAGE.resolve(type.packagePath()).resolve(type.name() + ".java"));
}
private record DocumentedType(String name, String packagePath) {
}
}

View File

@@ -0,0 +1,215 @@
package io.shinhanlife.dat.biz.mcp.docs;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
/**
* Java 소스의 기계적 서식 규칙을 빌드에서 강제하는 계약 테스트입니다. 이전에는 Spotless Gradle 플러그인이 같은 검사를 했지만, 그 플러그인은 빌드를 읽는 시점에 외부 저장소에서 내려받아야 해서 폐쇄망에서는 검사 하나 때문에 빌드 전체가 시작되지 못합니다. 규칙을
* 여기로 옮겨 외부 의존성 없이 같은 것을 지킵니다.
*
* <p>여기서 보는 것은 <b>도구 없이도 판정할 수 있는 규칙</b>뿐입니다. 들여쓰기 폭과 줄바꿈 위치는 IntelliJ 코드 스타일({@code .idea/codeStyles/Project.xml})이 소유하며 이 테스트가 판정하지 않습니다. 소스를 읽기만 하며
* 애플리케이션 context를 띄우지 않습니다.
*/
class CodeStyleContractTest {
private static final List<Path> SOURCE_ROOTS =
List.of(Path.of("src", "main", "java"), Path.of("src", "test", "java"));
/**
* {@code import a.b.C;}와 {@code import static a.b.C.d;}에서 마지막 이름만 뽑는다.
*/
private static final Pattern IMPORT = Pattern.compile("^import (?:static )?[\\w.]*?(\\w+);");
/**
* 모든 Java 소스가 LF 줄바꿈만 쓰는지 확인합니다. CRLF가 섞이면 Linux 컨테이너에서 문제가 되고, 한 번 섞인 파일은 이후 모든 변경의 diff가 파일 전체로 부풀어 실제 변경을 가립니다.
*/
@Test
void everySourceUsesUnixLineEndings() throws IOException {
List<String> broken = violations(source -> source.raw().contains("\r\n"));
assertThat(broken).withFailMessage("CRLF 줄바꿈이 있는 파일: %s", broken).isEmpty();
}
/**
* 들여쓰기에 탭을 쓰지 않는지 확인합니다. 탭과 공백이 섞이면 보는 도구마다 정렬이 달라집니다.
*/
@Test
void noSourceContainsTabCharacters() throws IOException {
List<String> broken = violations(source -> source.raw().contains("\t"));
assertThat(broken).withFailMessage("탭 문자가 있는 파일: %s", broken).isEmpty();
}
/**
* 줄 끝에 눈에 보이지 않는 공백이 남아 있지 않은지 확인합니다. 화면에 드러나지 않아 사람이 리뷰로 잡을 수 없고, 의미 없는 diff만 만듭니다.
*/
@Test
void noLineEndsWithWhitespace() throws IOException {
List<String> broken =
violations(
source ->
source.lines().stream()
.anyMatch(line -> !line.equals(line.stripTrailing())));
assertThat(broken).withFailMessage("줄 끝에 공백이 있는 파일: %s", broken).isEmpty();
}
/**
* 파일이 개행 하나로 끝나는지 확인합니다. 개행이 없으면 마지막 줄을 고칠 때 diff가 두 줄로 보이고, 여러 개면 의미 없는 빈 줄이 쌓입니다.
*/
@Test
void everySourceEndsWithExactlyOneNewline() throws IOException {
List<String> broken =
violations(source -> !source.raw().endsWith("\n") || source.raw().endsWith("\n\n"));
assertThat(broken).withFailMessage("파일 끝 개행이 정확히 하나가 아닌 파일: %s", broken).isEmpty();
}
/**
* 쓰지 않는 {@code import}가 남아 있지 않은지 확인합니다. 클래스를 옮기거나 지운 뒤 정리하지 않으면 남으며, 실제로는 없는 의존 관계가 있는 것처럼 보이게 합니다.
*
* <p>판정은 그 이름이 import 문 바깥 어디에든 나타나는지로 합니다. Javadoc의 {@code @link}도 사용으로 봅니다. 실제로 쓰는 import를 지우라고 하는 오탐이 없어야 하기 때문입니다.
*/
@Test
void noSourceKeepsAnUnusedImport() throws IOException {
List<String> unused = new ArrayList<>();
for (JavaSource source : sources()) {
String body =
String.join(
"\n",
source.lines().stream().filter(line -> !line.startsWith("import ")).toList());
for (String line : source.lines()) {
Matcher matcher = IMPORT.matcher(line);
if (matcher.find() && !containsWord(body, matcher.group(1))) {
unused.add(source.path() + " -> " + matcher.group(1));
}
}
}
assertThat(unused).withFailMessage("사용하지 않는 import: %s", unused).isEmpty();
}
/**
* {@code import}가 static 먼저, 그다음 알파벳 순으로 놓였는지 확인합니다. 순서가 제각각이면 같은 import를 두 사람이 다른 자리에 넣어 실제 변경과 무관한 diff가 생깁니다.
*
* <p>비교는 <b>세미콜론을 뗀 경로</b>로 합니다. {@code A;}와 {@code A.B;}를 문자열 그대로 비교하면 {@code ';'}(0x3B)가 {@code '.'}(0x2E)보다 커서 중첩 타입이 바깥 타입보다 앞서야 한다고 잘못
* 판정합니다.
*
* <p>그룹 사이 빈 줄은 검사하지 않습니다. 저장소 전체를 세어 보면 빈 줄을 넣은 경계와 넣지 않은 경계가 섞여 있어 지킬 관례가 존재하지 않습니다. 없는 규칙을 만들어 기존 파일을 무더기로 고치는 것보다, 실재하는 규칙만
* 잠그는 편이 낫습니다.
*/
@Test
void importsAreOrderedStaticFirstThenAlphabetically() throws IOException {
List<String> broken = new ArrayList<>();
for (JavaSource source : sources()) {
List<String> statics = new ArrayList<>();
List<String> regular = new ArrayList<>();
for (String line : source.lines()) {
if (line.startsWith("import static ")) {
statics.add(line.substring("import static ".length()).replace(";", ""));
} else if (line.startsWith("import ")) {
regular.add(line.substring("import ".length()).replace(";", ""));
}
}
if (!isSorted(statics) || !isSorted(regular)) {
broken.add(source.path());
}
if (!source.staticImportsComeFirst()) {
broken.add(source.path() + " (static import가 일반 import 뒤에 있음)");
}
}
assertThat(broken).withFailMessage("import 순서가 어긋난 파일: %s", broken).isEmpty();
}
/**
* 검사 대상 소스가 실제로 수집되는지 확인합니다. 경로가 바뀌어 목록이 비면 위 검사들이 모두 조용히 통과하므로 최소 개수를 함께 고정합니다.
*/
@Test
void theSourceSetIsActuallyScanned() throws IOException {
assertThat(sources())
.withFailMessage("Java 소스를 찾지 못했습니다. SOURCE_ROOTS 경로가 바뀌었는지 확인하세요.")
.hasSizeGreaterThan(50);
}
/**
* 규칙을 어긴 파일 경로를 모읍니다. 어떤 파일인지 알려주지 않으면 고칠 수가 없습니다.
*/
private List<String> violations(Predicate<JavaSource> broken) throws IOException {
return sources().stream().filter(broken).map(JavaSource::path).toList();
}
/**
* 목록이 오름차순인지 확인합니다. 정렬본과 비교하면 어긋난 위치를 따로 추적하지 않아도 됩니다.
*/
private boolean isSorted(List<String> values) {
return values.equals(values.stream().sorted().toList());
}
/**
* 이름이 식별자 경계에 맞게 등장하는지 확인합니다. {@code List}를 찾을 때 {@code ArrayList}가 걸리지 않아야 합니다.
*/
private boolean containsWord(String text, String word) {
return Pattern.compile("\\b" + Pattern.quote(word) + "\\b").matcher(text).find();
}
/**
* main과 test의 모든 Java 소스를 읽어 옵니다.
*/
private List<JavaSource> sources() throws IOException {
List<JavaSource> sources = new ArrayList<>();
for (Path root : SOURCE_ROOTS) {
try (Stream<Path> paths = Files.walk(root)) {
for (Path path : paths.filter(path -> path.toString().endsWith(".java")).toList()) {
sources.add(
new JavaSource(
path.toString().replace('\\', '/'),
new String(Files.readAllBytes(path), StandardCharsets.UTF_8)));
}
}
}
return sources;
}
/**
* 검사 대상 소스 하나의 경로와 원본 내용입니다. 줄바꿈 검사 때문에 줄 단위가 아니라 원본 문자열을 그대로 들고 있어야 합니다.
*/
private record JavaSource(String path, String raw) {
/**
* 줄 단위 검사를 위해 개행으로만 나눕니다. CR이 남아 있으면 줄 끝 공백 검사에서도 함께 드러납니다.
*/
List<String> lines() {
return List.of(raw.split("\n", -1));
}
/**
* 마지막 static import가 첫 일반 import보다 앞에 있는지 확인합니다. 둘 중 한쪽이 없으면 판정할 것이 없으므로 참입니다.
*/
boolean staticImportsComeFirst() {
List<String> lines = lines();
int lastStatic = -1;
int firstRegular = Integer.MAX_VALUE;
for (int index = 0; index < lines.size(); index++) {
String line = lines.get(index);
if (line.startsWith("import static ")) {
lastStatic = index;
} else if (line.startsWith("import ") && firstRegular == Integer.MAX_VALUE) {
firstRegular = index;
}
}
return lastStatic < firstRegular;
}
}
}

View File

@@ -0,0 +1,112 @@
package io.shinhanlife.dat.biz.mcp.docs;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
/**
* 패키지 경계를 코드로 고정하는 계약 테스트입니다. MCP는 stdio 등 다른 transport를 가질 수 있는 프로토콜이므로, inbound Servlet 지식이 전송 경계 밖으로 새면 전송 방식이 응용 계층에 굳어져 나중에 떼어낼 수 없게 됩니다. 실제로 재구성 전에는 서블릿
* 타입이 세 패키지에 흩어져 있었고, 문서만으로는 다시 새는 것을 막지 못합니다. 소스 파일을 읽기만 하며 애플리케이션 context를 띄우지 않습니다.
*/
class PackageBoundaryContractTest {
private static final Path MAIN_SOURCES = Path.of("src", "main", "java");
/**
* 전송 경계 안쪽. 이 아래에서만 서블릿 API를 다룰 수 있다.
*/
private static final String TRANSPORT_PACKAGE = "io/shinhanlife/dat/biz/mcp/transport/";
/**
* 서블릿 API를 import하는 production 파일이 {@code transport} 패키지 안에만 있는지 확인합니다. 밖에서 발견되면 어떤 파일인지 함께 알려 주고, 옮기거나 서블릿 타입을 걷어내도록 유도합니다.
*/
@Test
void servletApiStaysInsideTheTransportPackage() throws IOException {
List<Path> leaks = sourcesImporting("jakarta.servlet").stream()
.filter(path -> !normalize(path).contains(TRANSPORT_PACKAGE))
.toList();
assertThat(leaks)
.withFailMessage(
"jakarta.servlet은 transport 패키지 안에서만 사용한다. 경계 밖에서 발견된 파일: %s%n"
+ "HTTP 전용 코드라면 transport/http로 옮기고, 아니라면 서블릿 타입을 파라미터에서 제거하세요.",
leaks)
.isEmpty();
}
/**
* 전송 경계 안쪽 코드가 Tool 실행·Registry 내부로 직접 들어가지 않는지 확인합니다. transport는 요청을 받아 method handler에 넘기는 데까지가 책임이며, 실행 상세는 그 뒤 계층이 소유합니다.
*/
@Test
void transportDoesNotReachIntoExecutionOrRegistry() throws IOException {
List<Path> violations = sourcesImportingAny(List.of(
"io.shinhanlife.dat.biz.mcp.execute.",
"io.shinhanlife.dat.biz.mcp.registry."))
.stream()
.filter(path -> normalize(path).contains(TRANSPORT_PACKAGE))
.toList();
assertThat(violations)
.withFailMessage(
"transport는 execute 또는 registry 계층을 직접 호출하지 않는다. method handler를 거쳐야 한다: %s",
violations)
.isEmpty();
}
/**
* main 소스에서 주어진 import 접두사 중 하나를 사용하는 파일을 모읍니다.
*/
private List<Path> sourcesImportingAny(List<String> importPrefixes) throws IOException {
try (Stream<Path> paths = Files.walk(MAIN_SOURCES)) {
return paths.filter(path -> path.toString().endsWith(".java"))
.filter(path -> declaresAnyImport(path, importPrefixes))
.toList();
}
}
/**
* main 소스에서 주어진 import 접두사를 사용하는 파일을 모읍니다.
*/
private List<Path> sourcesImporting(String importPrefix) throws IOException {
try (Stream<Path> paths = Files.walk(MAIN_SOURCES)) {
return paths.filter(path -> path.toString().endsWith(".java"))
.filter(path -> declaresImport(path, importPrefix))
.toList();
}
}
/**
* 파일이 해당 import 선언을 포함하는지 확인합니다. 주석이나 문자열이 아니라 import 줄만 봅니다.
*/
private boolean declaresImport(Path path, String importPrefix) {
try (Stream<String> lines = Files.lines(path)) {
return lines.anyMatch(line -> line.startsWith("import " + importPrefix));
} catch (IOException exception) {
throw new IllegalStateException("소스를 읽을 수 없습니다: " + path, exception);
}
}
/**
* 파일이 주어진 접두사 중 하나에 해당하는 import 선언을 포함하는지 확인합니다.
*/
private boolean declaresAnyImport(Path path, List<String> importPrefixes) {
try (Stream<String> lines = Files.lines(path)) {
return lines.anyMatch(line -> importPrefixes.stream()
.anyMatch(importPrefix -> line.startsWith("import " + importPrefix)));
} catch (IOException exception) {
throw new IllegalStateException("소스를 읽을 수 없습니다: " + path, exception);
}
}
/**
* OS별 경로 구분자를 슬래시로 통일해 패키지 비교가 Windows에서도 동작하게 합니다.
*/
private String normalize(Path path) {
return path.toString().replace('\\', '/');
}
}