# clean-architecture-backend-template — 상세 분석 (통합 정본) > **성격**: 정책 문서가 아니라 **읽기 기록의 통합본**이다. 62개 등록 leaf를 20편의 bounded analysis로 > 나눠 읽고, 그 결과를 한 문서로 합쳤다. 새로운 사실을 여기서 처음 만들지 않는다 — 모든 판정에 > 모듈 문서의 절 번호를 붙였고, 그쪽이 원본이다. > > SSOT는 `src/config/architecture/modules.json`, 각 모듈의 `CLAUDE.md`, `docs/**/support-matrix.md`다. > 이 문서와 그것들이 어긋나면 그쪽이 맞다. | | | |---|---| | 리비전 | `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` | | 최초 기준선 | `a24ece9cf797f7ea647e33bf846b115208ed1ba5` (모듈 01~19) | | 등록 leaf | 62 — COMPLETE 61 · EXCLUDED 1 | | 추적 파일 | 6,747 | | main Java | 4,614 파일 / 320,318 LOC | | 모듈 문서 | `analysis/**` 66편 (최상위 23 · messaging 25 · grpc 18) — 합계 43,700줄 | | 증거 | `evidence/raw` 370 · `evidence/meta` 14 · 끊긴 참조 **0** | | finding 총계 | **462** — P1 30 · P2 147 · P3 285 (델타 조정값, §14.2) | | 사이클 2 통독 추가 | **92** — P1 2 · P2 25 · P3 65 (23개 리프, §14.2) | | 분석 사이클 | **2** — 18개 리프 재검증(번복 1건) + 23개 리프 통독(1회 재작업, §14.2) | | 교차 스코프 | `analysis/99-cross-scope.md` COMPLETE | | 앵커 | SRC 173행 · EVD 81행 | | 소스 트리 변경 | 없음 | --- ## 0. 이 문서를 읽는 법 이 저장소는 **Clean Architecture 템플릿**인 동시에 **두 개의 벤더드 플랫폼**(messaging 25 leaf, gRPC 18 leaf)을 품고 있다. 그래서 leaf 수(62)가 계층 수보다 훨씬 많고, 읽는 순서가 중요하다. 읽는 순서를 하나만 고른다면 이렇다. 1. **§1 숫자 지도** — 무엇이 얼마나 있고 무엇이 출하되는가. 2. **§2 경계** — 무엇이 그 구조를 강제하는가. 여기가 이 저장소의 실제 설계다. 3. **§7 반복된 네 가지 형태** — 62개 leaf를 읽고 나서 남는 것. 개별 결함보다 이쪽이 값이 크다. 4. **§8 확인된 문제** — 고칠 것. 깊이가 필요하면 각 절이 가리키는 모듈 문서로 간다. 예를 들어 `analysis/05`(persistence-jpa)는 4,674줄이고 이 문서 §4.1은 그 요약이다. **표기.** `(05 §23)`은 `analysis/05-adapter-outbound-persistence-jpa.md`의 §23을 뜻한다. --- ## 1. Project map — 숫자로 먼저 ### 1.1 빌드와 레지스트리 Java 21 · Spring Boot 4.0.8 · Gradle 9.0.0 멀티모듈. `src/settings.gradle`은 16줄이고, 실제 검증은 included build `build-logic`의 `ca.architecture-registry` settings plugin이 한다. **`src/config/architecture/modules.json`이 SSOT다.** leaf의 존재, 경로, 허용 project dependency, runtime membership을 전부 여기서 읽는다. 등록되지 않은 디렉터리는 빌드에 포함되지 않는다 — **fail-closed**. 이 성질이 이 저장소의 형태를 결정했다. `docs/jpa/repository-adaptation.md`가 그 결정을 기록한다: > 원래 설계는 JPA 플랫폼을 **18개 Stable Gradle 모듈 + 7개 Experimental 모듈**로 모델링한다. > 그런데 이 저장소는 fail-closed 레지스트리를 쓴다. Gradle 프로젝트를 25개 더 만드는 건 > `AGENTS.md`의 HARD-STOP #5 위반이다. 그래서 **설계상의 모듈이 이 leaf 안의 패키지 경계가 됐다.** 같은 압력이 messaging과 gRPC에서는 반대로 풀렸다 — 그쪽은 실제로 leaf 25개와 18개를 등록했다. 결과적으로 이 저장소에는 **두 가지 크기의 leaf**가 공존한다: 패키지가 모듈 역할을 하는 거대 leaf(`persistence-jpa` 350 main / 27,744 LOC)와, 파일 몇 개짜리 leaf(`messaging-schema-json` 1 main). ### 1.2 가족별 분모와 출하 여부 | 가족 | leaf | 출하 | 추적 파일 | main Java | 담당 문서 | |---|---|---|---|---|---| | core/composition | 5 | 5 | 1,859 | 1,295 | `01`·`02`·`03`·`18` | | adapter/outbound | 10 | 10 | 2,515 | 1,713 | `04`–`13` | | adapter/inbound | 4 | 2 | 1,443 | 982 | `14`–`17` | | messaging 플랫폼 | 25 | 18 | 549 | 364 | `19` | | gRPC 플랫폼 | 18 | **0** | 381 | 260 | `20` | | **합계** | **62** | **35** | **6,747** | **4,614** | | 런타임 조합은 둘 — `app-bootstrap`(33 leaf), `sample-portfolio`(8 leaf, 사용자 지시로 분석 제외). ### 1.3 leaf별 규모 (main Java 기준 상위) | leaf | main | LOC | 스테레오타입 | 비율 | |---|---|---|---|---| | `application-core` | 885 | 35,772 | **0** | 0.0% | | `adapter/inbound/graphql` | 408 | 26,303 | 3 | 0.7% | | `adapter/inbound/web` | 397 | 27,473 | 42 | 10.6% | | `adapter/outbound/persistence-mongo` | 351 | 22,924 | 4 | 1.1% | | `adapter/outbound/persistence-jpa` | 350 | 27,744 | 28 | 8.0% | | `adapter/outbound/cache-redis` | 314 | 32,082 | 1 | 0.3% | | `adapter/outbound/httpclient` | 260 | 15,004 | **0** | 0.0% | | `sample-portfolio` | 201 | 9,387 | 50 | 24.9% | | `grpc` (12 leaf 합) | 194 | 14,778 | 1 | 0.5% | | `adapter/outbound/notification` | 171 | 14,695 | 5 | 2.9% | | `adapter/inbound/websocket` | 169 | 12,784 | 5 | 3.0% | | `app-bootstrap` | 149 | 12,380 | 48 | 32.2% | | `adapter/outbound/objectstorage` | 147 | 14,336 | 5 | 3.4% | | `messaging-core-api` | 85 | 3,948 | **0** | 0.0% | | `adapter/outbound/fileserver` | 78 | 12,707 | 2 | 2.6% | | `grpc-advanced` (6 leaf 합) | 66 | 3,948 | **0** | 0.0% | | `shared-contract` | 53 | 2,628 | **0** | 0.0% | | `grpc-core-api` | 32 | 1,897 | **0** | 0.0% | | `domain-core` | 7 | 104 | **0** | 0.0% | | **전체** | **4,614** | **320,318** | **206** | **4.5%** | ### 1.4 이 표에서 읽어야 할 것 **(1) 프레임워크 표면이 4.5%다.** main Java 4,614개 중 Spring 스테레오타입(`@Component`·`@Service`· `@Repository`·`@Configuration`·`@AutoConfiguration`·`@Controller`·`@RestController`· `@(Rest)ControllerAdvice`)을 가진 것은 **206개**이고, 그중 98개가 두 합성 루트에 있다. 나머지 4,408개(95.5%)는 프레임워크 애노테이션이 없다. 선언된 아키텍처 그대로다. `application-core` 885개, `httpclient` 260개, `messaging-core-api` 85개, `grpc-core-api` 32개가 전부 0이다. **그리고 이 사실이 §7의 지배적 결함 형태를 설명한다** — 코드의 절대다수가 프레임워크와 무관하게 옳게 작성돼 있고, 그것을 런타임에 연결하는 일은 4.5%의 좁은 표면에서만 일어나며, **결함은 거의 전부 그 표면에서 발생한다.** **(2) 자동설정 루트가 저장소 전체에 13개뿐이다.** ``` adapter/inbound/graphql GraphQlRootAutoConfiguration adapter/inbound/web WebMvcPlatformAutoConfiguration WebFluxPlatformAutoConfiguration adapter/outbound/cache-redis RedisSdkAutoConfiguration adapter/outbound/messaging MessagingBridgeRootAutoConfiguration adapter/outbound/persistence-mongo MongoRootAutoConfiguration app-bootstrap FileserverPlatformAutoConfiguration HttpClientPlatformAutoConfiguration PersistenceJpaRootAutoConfiguration DisabledMessagingSentinelAutoConfiguration NotificationRootAutoConfiguration AdapterActivationAutoConfiguration messaging-spring-boot-starter MessagingPlatformRootAutoConfiguration grpc-spring-boot-starter GrpcPlatformAutoConfiguration ``` 8개 `.imports` 파일 / 13개 클래스. 이 목록이 "무엇이 조립될 수 있는가"의 전체 집합이다. **(3) build-only가 27 leaf / 729 파일 / main 479개다.** ``` adapter-inbound-websocket 253 파일 / main 169 ← 단일 최대 build-only leaf grpc + grpc-advanced 18개 381 파일 / main 260 ← 가족 전체가 build-only messaging 7개 77 파일 / main 42 adapter-inbound-grpc 18 파일 / main 8 ``` 이 구분이 이 문서 전체의 심각도 축이다 — **런타임에 오르지 않는 leaf의 미조립은 오늘의 사고가 아니라 채택 시점의 부채다**(`17` §26.6 · `19` §1.1 · `20` §1.1). --- ## 2. Architectural boundaries — 무엇이 경계를 강제하는가 이 저장소의 핵심은 "규칙을 문서에 적었다"가 아니라 **"규칙을 어기면 빌드나 부팅이 실패한다"**로 바꾼 장치들이다. 실제로 도는 것만 정리한다. ### 2.1 강제 장치 목록 | 장치 | 시점 | 무엇을 | |---|---|---| | `modules.json` `allowed_dependencies` | build | leaf 간 project edge | | `verifyCleanArchitectureDependencies` | build | 실제 `api/implementation/compileOnly/runtimeOnly` 집합 ↔ 레지스트리 allowlist | | `verifyRuntimeModuleMembership` | build | 어떤 leaf가 어떤 런타임 조합에 들어가는가 | | `verifyPublicPathSnapshot` · `verifyDependencyLocks` | build | 공개 경로·의존 잠금 | | `CleanArchitectureTest` (ArchUnit) | test | production classes 전체에 규칙 14종 + **위반/허용 픽스처 76개** | | `JpaModuleBoundaryTest` | test | JPA leaf의 패키지 카탈로그 **정확한 동등성** + 사이클 | | `ConditionalTransportCompositionContractTest` | 별도 레인 | build-only 전송이 어떤 런타임에도 오르지 않음 | | `MessagingPublicSurfaceContractTest` | app-bootstrap 레인 | vendor 타입 노출 ↔ `api` 선언 일치 | | `verifyDocumentedLeafCount` | build | 문서의 leaf 수 진술 ↔ 레지스트리 | | `verifyJpaSqlConstructionSafety` | build | 벤더 소스의 SQL 문자열 연결 | | 각 leaf의 startup validator | 부팅 | §5.3 | ### 2.2 `CleanArchitectureTest`의 규칙 14종 `app-bootstrap`이 소유하고 production classes 전체를 대상으로 돈다. 위반/허용 **합성 픽스처 76개**가 각 규칙의 양방향을 고정한다 — 규칙이 통과만 하는 것으로는 그 모양을 알 수 없기 때문이다 (`18` SRC-159). `testkit/arch`의 `JpaArchitectureRules`가 그중 6종을 담당한다. | 규칙 | 막는 것 | |---|---| | `noEntityFromWeb()` | 컨트롤러가 엔티티 반환 → 트랜잭션 밖 lazy association 직렬화 | | `entitiesFollowPortableMappingRules()` | `final` 엔티티(프록시 불가 → 모든 lazy 참조가 eager), no-arg 생성자 | | `entitiesStayOutOfWebPackages()` | web 패키지 안의 엔티티는 노출될 엔티티 | | `domainDoesNotDependOnHibernate()` | 도메인이 ORM에 의존 | | `tenantScopedRepositoriesDoNotInheritBroadCrud()` | tenant-scoped 엔티티 리포지토리가 CRUD 상속 | | `noGenericRepository()` | 플랫폼이 `CrudRepository` 재구현 | `tenantScopedRepositoriesDoNotInheritBroadCrud`의 설명이 이 팩의 성격을 보여준다: > **이름은 중요한 속성이 아니다.** `tenantId`를 가진 엔티티에 대해 `JpaRepository`를 확장하는 > `OrderRepository`는 `findById(UUID)`, `findAll()`, `deleteById(UUID)`를 노출한다 — 전부 > tenant-blind, 전부 상속, 그리고 **리뷰어가 볼 만한 어디에도 적혀 있지 않다.** ### 2.3 검증된 경계 — 실제로 성립하는 것 **`domain-core`** (`01`). 허용 의존 0개. main 7파일 / 104 LOC. 이 모듈이 소유하는 것은 재사용 가능한 도메인 "내용"이 아니라 **도메인 모델링 계약**이다 — `ResourceId`, `IdFactory`, 그리고 `@ValueObject` / `@AggregateRoot` / `@DomainEvent` 세 개의 stereotype marker. 두 런타임 조합 모두의 membership에 들어가지만, 그건 런타임 closure 포함 계약이지 Spring bean을 갖는다는 뜻이 아니다. **`shared-contract`** (`02`). production dependency block이 비어 있고 Java stdlib만 쓴다. 이 모듈의 아키텍처적 의미는 "공통 유틸리티"가 아니라 **서로 다른 outer module이 한쪽 adapter의 타입에 의존하지 않고 합의할 수 있는 중립 계약 지점**이다. 그 방향성의 직접 증거: GraphQL persisted-operation registry가 자기 저장소 인터페이스를 선언하지 않고 `OperationalRecordStorePort`(중립 CAS port)에 의존한다. inbound adapter가 선언한 인터페이스를 outbound가 구현하는 역방향 dependency를 피한다. **`grpc-core-api`** (`20` §2.1). 이 저장소에서 본 가장 강한 형태의 자기 제약이다. ```groovy apply plugin: 'java-library' dependencies { } ``` `dependencies {}`가 **비어 있다.** `io.grpc`가 컴파일 클래스패스에 아예 없으므로 "framework-free"가 문서가 아니라 빌드로 강제된다. main 32파일 / 1,897 LOC가 Java stdlib만으로 선다. 소스에서 `io.grpc`가 3번 등장하는데 전부 **javadoc 산문**이고, 그 내용이 왜 타입을 쓰지 않는지를 설명한다: > The canonical gRPC status codes, **mirrored so that `grpc-core-api` stays free of io.grpc**. ... > a failure context that names `io.grpc.Status` would put the transport inside the contract that > exists to describe what the transport did. **`messaging-core-api`** (`19` §2.1). 25개 leaf main 전체에서 예외 타입을 문자열로 판별하는 코드 (`getClass().getName().contains` / `getSimpleName().contains` / `getMessage().contains`)가 **0건**. 이 규칙은 앞선 모듈들에서 반복해서 깨진 것이다(`11` httpclient, `13` notification). **Stable → advanced 금지** (`20` §2.2). 레지스트리 0위반 · 소스 0참조. `grpc-spring-boot-starter`의 `allowed_dependencies`는 Stable 10개 leaf뿐이고, `src/grpc` 전체에서 `dev.caskeleton.grpc.advanced` 참조가 0건이다. `grpc-advanced`의 CLAUDE.md가 별도 디렉터리·별도 Gradle prefix를 쓴 이유를 명시한다 — "그 불변 조건을 registry의 `allowed_dependencies`만으로 **기계 검증할 수 있게** 하기 위해서." *(단, 이 규칙의 runtime 절반은 실행되지 않는다 — §8.2 참조.)* **`adapter-inbound-grpc` ↛ gRPC 가족** (`20` §2.3). `allowed_dependencies`가 `[domain-core, application-core, shared-contract]`다. **이 가족을 볼 수 없다.** 이것이 messaging과의 결정적 차이다. messaging은 `messaging-spring-boot-starter`가 `app-bootstrap` 의존으로 들어가면서 18개 leaf가 출하 아티팩트에 실렸고, 그 결과 MSG-015(서로 모르는 두 스택)가 실재 문제가 됐다. gRPC 가족은 아직 그 선을 넘지 않았고, **넘지 않았다는 사실을 문서가 정확히 말한다.** ### 2.4 경계가 열려 있는 지점 **MSG-015 — 서로 모르는 Kafka 스택 둘이 한 아티팩트에** (`19` §6.5, 가족 문서가 P0 미해결로 표시). | | app-bootstrap seam | messaging platform | |---|---|---| | 설정 클래스 | `bootstrap/messaging/KafkaSenderConfig` | `autoconfigure/KafkaMessagingAutoConfiguration` | | producer bean | `kafkaSeamProducer` : `Producer` | `messagingKafkaProducer` : `Producer` | | 조건 | `@ConditionalOnProperty("app.messaging.broker", havingValue="kafka")` | root의 `app.messaging.enabled=true` → 선택 → `broker=kafka` | | 의존 방향 | `adapter-outbound-messaging`은 platform에 의존하지 않음 | platform은 adapter를 모름 | bean 이름 충돌은 해소됐고, `KafkaSenderConfig`의 javadoc이 그 과정을 적는다: > Sharing the method name `messagingKafkaProducer` made the context refuse to start with a > `BeanDefinitionOverrideException`, and a type-scoped `@ConditionalOnMissingBean` would have been > worse: whichever configuration lost the race would leave its own stack without a producer while > the other stack's, with incompatible serializers, sat in its place. 판단은 옳다. 그러나 **결과적으로 이 결함의 유일한 가시적 증상이 제거됐다.** 지금은 부팅이 성공하고 두 스택이 조용히 공존한다. 그리고 **정정할 부분이 하나 있다.** 가족 문서는 안전의 근거를 "지금 안전한 이유는 설계가 아니라 기본값이다 — `app.messaging.enabled=false`"에 둔다. 그런데 seam 스택은 `enabled`를 보지 않고 `broker`만 본다. 따라서 `enabled=false`는 **두 스택 중 하나만 막는다.** **리액티브 web 절반** (`14` §40.1). `adapter-inbound-web`의 리액티브 29개 파일은 어떤 출하 배포에서도 활성화될 수 없다. **`messaging-rabbit`** (`19` §6.2). 20 main / 2,443 LOC가 출하되지만 선택할 수 없다 — `MessagingProviderSelection.BROKERS_WITHOUT_A_TRANSPORT`에 등록돼 있고 `RabbitChannelPublisher` 구현이 저장소에 없다. 코드는 fail-closed로 정직하고, 운영 문서만 그 사실을 말하지 않는다. --- ## 3. Representative execution paths ### 3.1 HTTP 요청 — 출하 경로 `adapter-inbound-web` → `application-core` port → outbound adapter. **오류 응답은 `Envelope` 계약이 낸다.** `GlobalExceptionHandler`(22 handler + 6 override) + `EnvelopeBodyAdvice` + `ErrorResponseFactory` + `ClientSafeErrorMessages`가 컴포넌트 스캔으로 등록된다. **RFC 9457 `problem+json` 계약 23개 파일은 등록되지 않는다** (`14` §8.1). 합성 루트가 다섯 web 패키지를 컴포넌트 스캔에서 제외하고, 그 이유를 `CaSkeletonApplication`의 javadoc이 적는다: > The web platform's error, budget and operation packages are here for a third reason. Their > advices and controllers need beans that only exist when the corresponding platform > auto-configuration is active, and a component scan finds them regardless — so an all-off or > partially configured deployment failed to start on an unsatisfied dependency rather than simply > not installing the control. **Ownership by auto-configuration is what ties a control's presence > to its dependency's.** 진단도 조치의 방향도 옳다. 자동설정도 존재하고 `.imports`에 등록돼 있으며 출하 컨텍스트에 실제로 import된다(`app-bootstrap/build.gradle:98`이 이 leaf를 물고, 저장소의 유일한 `AutoConfigurationImportFilter`는 JPA 전용이다). 그런데 그 자동설정이 등록하는 `@Bean` **13개 (MVC)·10개(WebFlux)가 전부 협력자**다: ``` MVC : InMemoryWebOperationCatalog · WebBudgetCatalog · ProblemCatalog · WebProblemFactory · WebValidationExceptionMapper · WebValidationIssueMapper · WebWireTypeManifest · WebUriPolicy · WebMethodPolicy · webStrictObjectMapper · WebMvcRequestIdFilter · WebMvcEvidenceFilter · webMvcRequestContextConfigurer ``` 스캔에서 제외된 다섯 패키지의 **컴포넌트 여섯 개**는 어느 쪽도 소유하지 않는다: | 컴포넌트 | 패키지 | 애노테이션 | main 참조 | |---|---|---|---| | `WebMvcProblemExceptionHandler` | `mvc.error` | `@RestControllerAdvice @Order` | **0** | | `WebFluxProblemExceptionHandler` | `webflux.error` | `@RestControllerAdvice @Order` | **0** | | `WebMvcBudgetExceptionHandler` | `mvc.budget` | `@RestControllerAdvice @Order` | **0** | | `WebMvcBudgetFilter` | `mvc.budget` | (없음) | **0** | | `OperationHttpController` | `mvc.operation` | `@RestController` | **0** | | `ReactiveOperationHttpController` | `webflux.operation` | `@RestController` | **0** | 즉 `ProblemCatalog`와 `WebProblemFactory`는 빈으로 존재하고, **그것을 사용하는 `@RestControllerAdvice`가 존재하지 않는다.** javadoc이 말한 "Ownership by auto-configuration"에서 **dependency 쪽만 소유되고 control 쪽은 소유되지 않았다.** **실패 시나리오.** 팀이 `ProblemCode.VALIDATION_FAILED`로 분기하는 클라이언트 SDK를 작성한다. `WebProblemFactoryTest`(122줄)·`ProblemCatalogTest`(99줄)·`WebValidationExceptionMapperTest`(125줄)가 전부 통과하고, `/v3/api-docs`에도 problem 스키마가 기여되지 않아 계약 불일치를 볼 방법이 없다. 배포된 API는 422 대신 `Envelope`의 400을 내고 `code` 필드는 `ProblemCode`가 아니라 `OperationalError` 어휘다. **SDK의 모든 분기가 빗나간다.** **같은 경로의 다른 미조립:** - **용량 보호 계층 41 main 파일**이 자기 테스트 픽스처 안에서만 실행된다 (`14` §16.1). - **멱등 실행 계층·durable-operation 표면**이 픽스처에서만 조립된다 (`14` §20.1). - 요청 식별자를 클라이언트가 고를 수 없다는 정책이, **뒤에 도는 다른 배선 필터에 의해 뒤집힌다** (`14` §32.1). - forwarded 헤더 신뢰 판정이 **Nginx 설정에만** 있고 그것을 위해 쓴 Java 정책 **421 LOC**은 배선되지 않는다 (`14` §32.2). - 배선된 캐시 필터의 `no-store`가 배선된 조건부 읽기 경로를 무력화하고, 둘을 조정하려고 만든 패키지는 참조 0이다 (`14` §24.1). ### 3.2 트랜잭션 — `application-core` 포트에서 PostgreSQL local timeout까지 이 저장소에서 가장 밀도 높은 경로다 (`03` §4, `05` §3). **포트가 둘이고 같은 빈이 둘 다 구현한다.** ``` application-core: TransactionPort inWrite / inRootWrite / inRead / inNew PolicyTransactionPort → extends TransactionPort + inTransaction(TransactionRequest, Supplier) : TransactionResult adapter: SpringTransactionPort implements PolicyTransactionPort ├─ 레거시 4모드 → 미리 만들어 둔 TransactionTemplate 3개 └─ inTransaction → SpringPolicyTransactionPort (package-private 위임체) ``` 템플릿을 **생성 시점에 미리 만들어 두는** 이유: `TransactionTemplate`은 thread-safe지만 mutable이라, 호출마다 propagation/readOnly를 바꿔 쓰면 같은 빈을 공유하는 동시 요청 사이에 race window가 생긴다. `inRootWrite`는 `REQUIRES_NEW`로 suspend해서 "root인 척"하지 **않는다.** 그러면 호출자 트랜잭션과 독립 커밋되는 silent 의미 변경이 생기므로, 대신 fail-fast한다: ```java public T inRootWrite(Supplier action) { if (TransactionSynchronizationManager.isActualTransactionActive()) { throw new NestedRootTransactionRejectedException(); } return executeLegacy(writeTemplate, action); } ``` **정책 기반 실행기의 반환 타입이 결과 대수를 만든다.** | 변형 | 의미 | |---|---| | `Committed(value, operationId)` | 물리 커밋 확인됨 | | `Participating(value)` | 바깥 트랜잭션에 참여 — **커밋을 주장하지 않는다** | | `DeterminateRollback(failure)` | 롤백 확정 | | `Indeterminate(operationId, lastObservedPhase, reconciliationReference)` | 결과 불명 — **replay 권한을 주지 않는다** | | `CommittedWithPostCommitFailure(value, operationId, operationalFailure)` | 커밋됐지만 afterCommit 훅이 실패 | 핵심 실행 루틴: ```java tracker.observe(TransactionPhase.COMMIT_REQUESTED); try { transactionManager.commit(status); } catch (RuntimeException commitFailure) { if (sentinel.commitAcknowledged()) { return CommittedWithPostCommitFailure(...); // afterCommit이 이미 왔다 } if (sentinel.rolledBack() || commitFailure instanceof UnexpectedRollbackException || TransactionRetryClassifier.isReplayCandidate(commitFailure)) { return DeterminateRollback(translate(commitFailure)); // 롤백 확정 } return Indeterminate(operationId, tracker.lastObserved(), empty()); // 모른다 } ``` **데드라인이 3단으로 좁혀진다.** ``` 획득 전: required = connectionTimeout + beginBudget + minimumActionWindow + completionMargin remaining < required → TransactionAdmissionException springTimeoutSeconds < 1 → TransactionAdmissionException (Spring은 초 단위) begin 이후: statementWindow = min(callRemaining - completionMargin, springRemaining) - transactionMargin statement = min(settings.statementTimeout, statementWindow) lock = min(settings.lockTimeout, statement - lockMargin) idle = min(settings.idleGuardTimeout, callRemaining - completionMargin) 셋 중 하나라도 1ms 미만 → TransactionAdmissionException ``` 그리고 DB에 밀어 넣는다: ```java "select set_config('statement_timeout', ?, true)" "select set_config('lock_timeout', ?, true)" "select set_config('idle_in_transaction_session_timeout', ?, true)" ``` 세 번째 인자 `true`가 **transaction-local**이다. `SET statement_timeout = ?`가 아닌 이유는 두 가지 — `SET`은 파라미터 바인딩 전에 파싱되어 syntax error가 나고, `set_config` 함수 호출은 **값이 statement text에서 빠진다.** ### 3.3 메시지 발행 — messaging 플랫폼 `DefaultMessagePublisher`가 유일한 publish 경로이고, 단계 순서가 고정돼 있다 (`19` §3.1): ``` resolve → authorize → encode → admit → lease → send → normalize ``` 각 단계 위치의 근거가 javadoc에 있다. - **destination·access가 encode보다 먼저** — 인가되지 않은 publish가 payload를 직렬화하면 claim-check나 로그가 그 바이트를 들고 있게 된다. - **encode가 admit보다 먼저** — admission 한도가 바이트 기준이고, 바이트 수는 인코딩 전에 알 수 없다. - **runtime lease가 send 직전 마지막** — 이미 in-flight 카운트에 잡힌 메시지 밑에서 rotation이 transport를 갈아끼우지 못하게. 그리고 획득한 permit·lease는 **성공·실패·예외·취소 모든 경로에서 정확히 한 번** 반환된다 — "실패 경로에서 새는 permit은 실패 한 번에 하나씩 줄어들다 아무것도 받지 않게 되는 limiter다." **증거와 결론이 분리돼 있다** (`19` §3.2). `PublishEvidence`가 compact constructor에서 표현 불가능한 조합을 거부한다: ```java if (!brokerAccepted && confirmationLevel != ConfirmationLevel.NONE) throw new IllegalArgumentException("confirmation level requires broker acceptance: " + ...); if (transmission == TransmissionEvidence.NOT_TRANSMITTED && brokerAccepted) throw new IllegalArgumentException("untransmitted message cannot be broker accepted"); ``` | 지점 | completion | 근거 | |---|---|---| | access 거부 / encode 실패 | `REJECTED` + `notTransmitted()` | "아무것도 이 프로세스를 떠나지 않았으므로 결과는 확정적이다. ambiguous로 보고하면 어떤 브로커도 보지 못한 메시지에 대해 caller를 reconciliation으로 보낸다." | | 준비 중 데드라인 소진 | `REJECTED` | 아직 전송 전 | | transport 단계 실패/데드라인 | `AMBIGUOUS` + `retryable=true` | "요청이 wire 위에 있었으므로 브로커가 들고 있을 수 있다." | `sanitized(Throwable)`는 **타입만** 남기고 메시지를 버린다 — "드라이버 메시지는 routing key, payload 조각, connection string을 담을 수 있고 `FailureDescriptor`는 로깅·export되도록 설계됐다." **오늘 실제로 배포 가능한 브로커는 Kafka 하나다** (§2.4). ### 3.4 gRPC — 채택 시점 경로 `ca-skeleton.grpc.platform.enabled=true`(fail-closed, `matchIfMissing=false`)가 `GrpcPlatformAutoConfiguration`을 켜고 `@Bean` 9개를 만든다 — 전부 프로파일·정책이다: ``` GrpcExecutorProfile · GrpcServerProfile · GrpcAdmissionController · GrpcServiceHealthRegistry · GrpcReflectionPolicy · GrpcAdminExposurePolicy · GrpcDrainPolicy · GrpcContextBinder · GrpcErrorMapper ``` **서버도, 인터셉터 체인도, 서비스 어댑터 등록도 만들지 않는다** (`20` §3.4). 그 담당 타입들이 전부 main 참조 0이다 — `GrpcServerInterceptorChain`, `ProtovalidateGrpcInterceptor`, `GrpcIdempotencyInterceptor`, `GrpcServiceAdapter`, 스트리밍 기계 4종, `GrpcTypedStubFactory`. `GrpcServerInterceptorChain`의 javadoc이 자기 존재 이유를 적는다: > That reversal is the reason this class exists rather than a list literal at the call site. > `ServerInterceptors.intercept` wraps each interceptor around the previous one, so the last one > passed is the outermost at runtime — the opposite of how the order reads. **Every codebase that > builds this list by hand gets it backwards at least once, and the symptom is an exception > boundary that catches nothing.** 18 leaf 전부 `runtime_memberships: []`이므로 이 경로는 아직 어떤 배포에도 없다. ### 3.5 알림 발송 — 논리적 수락과 provider 불확실성 `application-core`가 **논리적 수락**(요청을 받아 저장)과 **provider 결과**(실제 발송)를 분리한다 (`03` §12). 어댑터(`13`)가 8개 provider를 갖고, 그중 공유 계약(`ProviderAdapterContract`)을 상속하는 것은 3개뿐이며 강제 장치가 없다 (`13` §29.2). 이 경로에서 확인된 P2 중 무거운 것: - **`SINGLE` 전용 가드가 먼저 던져** 다중 타깃 검증 전체(순환 탐지 포함)가 도달 불가이고, 그것을 검증한다는 테스트가 `hasMessageContaining("strategy")`로 **다른 가드에 걸려** 통과한다 (`13` §9). - "Every reveal is auditable"을 선언한 `AccessContext`의 세 필드를 읽는 코드가 저장소에 **0개**. 감사 싱크는 존재하고 다른 경로는 사용 중이다 (`13` §17.1). - **클라이언트 제공 Web Push 엔드포인트가 SSRF 가드를 지나지 않는다.** 약한 검사의 private 사본을 쓴다 (`13` §25.1). - "상한을 두고 읽는다"는 본문 핸들러가 `ofByteArray()`로 **전부 읽은 뒤** 자른다 (`13` §25.2). - FCM만 "커밋 후 응답 손실 = ambiguous" 규칙 밖이고, 배치라 한 번의 손실이 배치 크기만큼 영향을 준다 (`13` §29.1). --- ## 4. Data and state ### 4.1 관계형 — `persistence-jpa` (605 파일 / main 350 / 27,744 LOC) 이 leaf는 어댑터가 아니라 **플랫폼**이다. 원래 설계의 25개 모듈이 fail-closed 레지스트리 때문에 이 leaf 안의 패키지 경계가 됐다(§1.1). top-level child 패키지 22개가 각각 원래는 별도 라이브러리다. **계약층 `api`가 프레임워크를 모른다.** 여기 있는 결정이 아래층 전부를 규정한다. 이름을 타입으로 만든다 — `PersistenceOperationName`, `QueryName`, `ConstraintCode`, `FetchPlanName`, `BulkOperationName`, `WorkQueueName`, `JsonPathName`, `TenantId`가 전부 같은 패턴이다: ```java public record PersistenceOperationName(String value) { private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9.-]{2,95}"); } ``` 목적은 하나다 — 이 값은 metric tag / trace / retry policy의 키가 되므로 **엔티티 id, tenant id, SQL 조각, 요청 스코프 값을 담을 수 없다.** 카디널리티 상한이 관례가 아니라 타입 차원에서 보장된다. "메트릭 태그에 id 넣지 마세요"를 코드 리뷰 규칙이 아니라 **정규식을 통과 못 하면 생성자가 던진다**로 만들었다. **핵심 불변식이 하나 있다.** ```java // JpaFailureContext if (completionUnknown && retryable) { throw new IllegalArgumentException("completion unknown failures are never retryable"); } ``` 이게 이 플랫폼 전체가 존재하는 이유다. *커밋됐을지도 모르는 작업을 자동 재실행하는 것*이 할 수 있는 최악의 일이고, 그래서 **타입 시스템이 그 상태를 표현하는 것 자체를 거부한다.** 이중 방어도 있다 — `TransactionCompletionUnknownException` 생성자가 `forceCompletionUnknown(context)`로 컨텍스트를 다시 만들어, 정책 버그가 retryable한 completion-unknown을 만들 방법이 없다. `RetryProfile`도 `COMPLETION_UNKNOWN`을 화이트리스트에 넣는 것을 생성자에서 거부한다. **`57P01` 발견.** completion-unknown 판정 규칙은 좁다 — 커밋 단계에서 발생했고 **동시에** 드라이버가 어느 쪽인지 말해주지 못했을 때만이다. 그 규칙에 `57P0x`가 들어온 경위가 기록돼 있다: > **"커밋 모호성은 SQLSTATE class 08뿐"은 틀렸다.** 커밋이 in-flight인 백엔드에 > `pg_terminate_backend`를 하면 connection-class가 아니라 `57P01`(admin_shutdown)이 보고되고 — > 그리고 그게 도착할 때 **커밋 레코드는 이미 WAL에 있을 수 있다.** 컨테이너 레인이 그걸 > 보여줬고, `CommitAmbiguityContractTest`가 SQLSTATE를 직접 assert해서 규칙이 다시 조용히 > 좁아지지 못하게 한다. 넓히지 않는 이유도 적혀 있다 — 커밋 단계의 모든 커넥션 에러를 completion-unknown으로 표시하면 평범한 풀 고갈과 서버 재시작이 reconciliation 큐로 밀려들고, **운영자는 그 큐를 읽지 않고 비우는 습관을 배운다.** **Flyway가 스키마를 소유하고, 그것이 강제 가능하다.** 런타임 롤에서 DDL 권한을 뺐기 때문이다: ```sql select has_schema_privilege(current_user, current_schema(), 'CREATE') as create_on_schema, has_database_privilege(current_user, current_database(), 'CREATE') as create_on_database ``` > 질문은 **설정이 아니라 서버가** 답한다. 롤의 유효 권한은 직접 grant, 상속된 롤 멤버십, > `PUBLIC` grant, 스키마 소유권에서 나오고, 어떤 배포 매니페스트를 읽어도 그 조합을 신뢰성 있게 > 재구성할 수 없다. `has_schema_privilege`는 할 수 있다. **마이그레이션 스트림이 8개로 갈라져 있다.** ``` db/migration/postgresql/ flyway_schema_history (legacy/adoption) db/migration/jpa/core/ flyway_jpa_core_history db/migration/jpa/idempotency/ flyway_jpa_idempotency_history db/migration/jpa/outbox-storage/ flyway_jpa_outbox_storage_history db/migration/jpa/outbox-polling/ flyway_jpa_outbox_polling_history db/migration/jpa/inbox/ flyway_jpa_inbox_history db/migration/jpa/fileserver/ (capability 스트림) db/migration/jpa/notification-platform/ flyway_jpa_notification_history db/experimental-rls/ Stable location이 절대 적용하지 않음 ``` 이유가 `NotificationSchemaStream` javadoc에 명확하다: > 뻔한 해법 — 디렉터리를 primary location 목록에 추가 — 이 틀린 이유: **두 트리가 다 V1부터 > 번호를 매기고**, 공유 history 테이블은 `V1__notification_platform_core`와 `V1__initial_schema`를 > 같은 버전으로 만든다. Flyway는 두 번째를 거부하거나, 더 나쁘게는 resolution 순서에 따라 하나를 > 기록하고 하나를 건너뛴다. **`capability_schema_registry`가 그 접착제다.** 각 capability 스트림의 V1이 (1) 선행조건 검사 (`DO $$ ... RAISE EXCEPTION`), (2) 테이블 생성, (3) **자기를 `INSTALLED_INACTIVE`로 등록**을 한다. 스키마가 적용된 것과 capability가 사용 승인된 것이 분리되고, 어댑터가 런타임에 `ACTIVE`인지 확인한다. **owner-safe 상태 기계 네 개**(idempotency / outbox-storage / outbox-polling / inbox)가 공통 패턴을 공유한다: 1. capability guard — 레지스트리에서 `ACTIVE` + 정확한 epoch/revision 2. same-resource primary write transaction guard — `hasResource(dataSource)` 3. **row lock 먼저, `clock_timestamp()` 그 다음** — 행이 바뀔 수 없게 된 뒤에 DB 시계를 읽는다 4. **owner CAS 튜플을 SQL where 절에 전부 반복** — scope, token, attempt, claim operation, state revision. update count가 곧 답이다 5. transition digest — 재시도가 같은 전이인지 구별 6. Spring stereotype 없음 — 두 합성 루트가 `dev.caskeleton.adapter`를 스캔하므로 (2)번이 생긴 경위가 좋다: > store가 스레드에 활성 read-write 트랜잭션이 있는지 확인했는데, 그건 **어떤 data source에서든** > 참이다. 차이는 data source가 둘인 애플리케이션에서 드러난다: **다른** 쪽의 트랜잭션 안에서 > 발행된 mutation이 옛 검사를 통과하고, 이 store의 커넥션에서 트랜잭션 밖으로 돌고, > **원자적이어야 했던 작업과 독립적으로 커밋됐다.** **확정된 P1급 상태 결함** (`05`): `inspect()`와 `claim()`이 만료된 COMPLETED row를 동시에 다르게 해석(§59.1) · baseline outbox가 stale relay worker를 fence하지 못해 terminal state를 되돌릴 수 있음(§70) · durable operation의 lease 만료 후 stale owner 완료 가능(§71) · persistent byte quota가 admission에서 집행되지 않음(§78) · schema activation이 V2/V4를 current로 오인(§79·§88) · provider 호출 뒤 recipient projection write가 lease fencing 우회(§89) · RLS verifier가 "반드시 보호돼야 하는 table"의 부재를 성공으로 인정(§97) · database-per-tenant 전역 커넥션 예산이 새 pool 크기를 계산하지 않아 ceiling 초과(§98). ### 4.2 문서형 — `persistence-mongo` (497 파일 / main 351 / 22,924 LOC) **subsystem 전체가 배선돼 있지 않은데 그것을 켜는 flag는 startup 검사를 수행한다** (`06` §49). **출하 default 조합이 첫 write에서 예외를 던진다** (`06` §23). 세 사실이 겹친다: 1. `mongoTypeMetadataRegistry()`가 **비어 있는** registry를 기본 bean으로 등록한다 — "An empty registry so a deployment with no long-lived collection still starts." 2. `MongoTypeMetadataConfigurer`가 `PolicyAwareMongoTypeMapper`를 **모든** `MappingMongoConverter`에 무조건 설치한다. 3. `PolicyAwareMongoTypeMapper.writeType(...)`이 등록되지 않은 타입에 `IllegalStateException`을 던진다. 즉 module을 켜기만 하고 type metadata를 등록하지 않은 배포는 **시작은 하고 첫 write에서 실패한다.** 그리고 같은 컴포넌트가 같은 질문에 세 가지로 답한다: | 물음 | 답 | |---|---| | 미등록 타입의 정책은? | `CLASS_METADATA_ALLOWED` (registry javadoc: "unregistered types keep Spring Data's default") | | 미등록 타입으로 type-restricted **query**를 만들면? | Java class name을 `_class` predicate에 씀 | | 미등록 타입을 **write**하면? | 예외 | 읽기 경로와 쓰기 경로가 같은 정책 질문에 정반대로 답하고, 그중 어느 쪽도 registry가 스스로 문서화한 기본값과 일치하지 않는다. **high-water mark가 재전달된 이벤트를 삼킨다** (`06` §67). `MongoChangeStreamPipeline`의 mark는 "투영이 완료된 위치"가 아니라 "**본 적 있는 위치**"다(이벤트를 받자마자 전진). resume 시 같은 pipeline 인스턴스를 다시 쓰므로 mark가 남는다. 실행 probe C: worker 하나, 평범한 failover. E(clusterTime 5.1)의 투영이 시작됐다가 취소되고, resume 후 재전달된 E는 **pipeline이 삼킨다**(mark가 이미 5.1). 그 다음 F가 투영되고 checkpoint가 token-6으로 저장되면서 저장 위치가 E를 지나친다. ``` PROBE-C projector started=2 completed=1 PROBE-C checkpoints saved=[token-6] PROBE-C highWaterMark=6.1 PROBE-C state=RUNNING runbook= ``` **E는 영구히 사라졌고, 구독은 `RUNNING`에 runbook은 비어 있고, caller의 `Flux`는 정상 완료한다.** 왜 테스트가 못 잡았나가 특히 중요하다 — 세 테스트가 각각 절반씩 본다. 하나는 이벤트를 하나만 돌리고, 하나는 checkpoint store가 `NoOpCheckpoints`라 상호작용이 안 보이고, 하나는 첫 stream을 이벤트 전달 없이 실패시켜 mark가 설정되지 않는다. **"본 적 있지만 완료되지 않은 위치"라는 제3의 상태가 어느 테스트에도 없다.** 그 밖에: 프로파일의 TLS·타임아웃·풀·Stable API가 driver에 도달하지 않음(§75) · sharding admin gateway의 네 작업 중 셋은 어떤 입력으로도 완료 불가(§85) · index diff가 실제로 비교하는 것은 두 필드뿐(§57) · Flamingock lease로는 어떤 migration도 실행할 수 없는데 javadoc은 다르게 적음(§59). ### 4.3 messaging 신뢰성 저장소 (`19` §7) **outbox/inbox 체인 전체가 만족되지 않는 `@ConditionalOnBean` 뒤에 있다.** ```java @ConditionalOnBean({OutboxRepository.class, OutboxEnvelopeFactory.class}) public OutboxRelay outboxRelay(...) @ConditionalOnBean(OutboxRelay.class) public OutboxRelayWorker outboxRelayWorker(...) @ConditionalOnBean(OutboxRelayWorker.class) public MessagingOutboxRelayLifecycle outboxRelayLifecycle(...) @ConditionalOnBean(InboxRepository.class) public InboxCleanupJob inboxCleanupJob(...) ``` 사슬의 뿌리인 `OutboxRepository`/`InboxRepository`의 유일한 구현(`JdbcOutboxRepository`, `JdbcInboxRepository`)을 **어떤 자동설정도 만들지 않는다.** 19 main 파일 / 2,818 LOC가 전부 조용히 비어 있다. **실패가 특히 조용하다.** Spring은 조건부 bean이 조건을 만족하지 못하는 것을 오류로 보고하지 않는다. 즉 **"outbox가 꺼져 있음"과 "outbox가 조립될 수 없음"이 런타임에서 구별되지 않는다.** 같은 starter가 `MessageCodecRegistry`에는 `@ConditionalOnMissingBean` 기본 구현을 제공했다는 점이 이것을 결함으로 만든다. **마이그레이션 스트림을 적용하는 곳이 없고, 적용하려는 순간 버전이 충돌한다** (`19` §7.2). 합성 루트의 Flyway 기본 위치는 `PostgreSqlPersistenceConfig:115`의 `classpath:db/migration/postgresql`이고, `db/migration/messaging`을 이름으로 부르는 main 코드가 저장소 전체에 **0건**이다. 그리고 두 leaf가 같은 리소스 디렉터리에 각자 번호를 매긴다: ``` messaging-inbox-jdbc-postgresql V2__messaging_inbox.sql (CREATE TABLE) messaging-outbox-jdbc-postgresql V1__messaging_outbox.sql V2__messaging_outbox_lease_fencing.sql (ALTER ×4) V3__messaging_admin_operation_journal.sql V4__messaging_outbox_canonical_metadata.sql ``` **`V2`가 둘이다.** 그 위치를 Flyway에 주는 순간 duplicate version으로 부팅이 실패한다. 각 leaf의 IT는 자기 jar 리소스만 보므로 재현하지 못한다. 원 구현 계획서는 분리된 위치 (`db/migration/messaging-outbox`, `messaging-inbox`)를 지정했었다. **설계 자체는 정확하다.** `V2__messaging_outbox_lease_fencing.sql`의 헤더: > V1 recorded only `lease_expires_at`, so a claim said *when* it would end and nothing about *who* > held it. ... relay A claims the row and calls the broker / the lease expires; relay B reclaims it, > publishes, and records PUBLISHED / relay A finally times out and records AMBIGUOUS over the top. > **Making the lease longer than the publish timeout lowers the odds; it does not turn a GC pause, > a scheduler stall** ... inbox는 `(message_id, consumer_id)` 복합 키로 소비자별 dedup을 트랜잭션 안에서 보장한다. ### 4.4 fileserver / objectstorage / cache-redis **`fileserver`** (`08`, 119 파일). 6개 테이블. 가장 좋은 제약이 이것이다: ```sql CONSTRAINT ck_fs_file_ready_is_complete CHECK ( state <> 'READY' OR (content_key IS NOT NULL AND actual_size IS NOT NULL AND sha256 IS NOT NULL AND strong_etag IS NOT NULL AND published_at IS NOT NULL)) ``` **READY가 유일하게 공개 읽기 가능한 상태이므로, 완전하고 검증된 identity를 반드시 들고 있어야 한다** — 를 DB CHECK으로 강제한다. V2/V3/V4가 각각 실제 사고의 수정이다(staging 객체 주소지정 누락 / fenced cleanup lease 부재 / upload terminal state 부재). 발견: README가 "노출된 setting도 bean도 없다"고 적은 능력들에 **production bean 8개**가 있음 (§4·§39) · scriptable 콘텐츠 탐지가 접두사 **시작**에만 고정돼 BOM·NUL·주석으로 우회됨(§40). **`objectstorage`** (`09`, 206 파일, `sample-portfolio`에만 출하). 발견: 직접 multipart의 마지막 part는 grant를 받을 수 없음(§38) · 서명된 grant의 endpoint 검증이 upload 경로에만 있음(§39) · APPLY를 켜는 설정은 있고 승인을 검증하는 bean은 없음(§49) · production 판정이 두 개의 리터럴 프로파일 이름에 걸림(§5). **`cache-redis`** (`10`, 390 파일 / main 314 / 32,082 LOC). 발견: startup probe가 production에서 한 번도 실행되지 않음(§6) · SDK가 선언한 두 진입점에 구현이 없음(§15) · "build gate"라 불리는 catalog drift 검사가 **어디에서도 실행되지 않음**(§47) · 의미 어댑터 다섯이 `CommandPolicyGuard`를 지나지 않음(§64) · NOSCRIPT 복구가 다섯 벌로 구현돼 있고 넷은 스크립트 레지스트리를 지나지 않음(§56). --- ## 5. Failure and operational behavior ### 5.1 실패 분류 — 세 개의 계층 이 저장소에는 실패 어휘가 세 층으로 있고, 각각 다른 질문에 답한다. **(1) `shared-contract`의 `Category` 10값** — 웹 표면이 응답 코드를 정하는 어휘. VALIDATION, AUTH, AUTHZ, NOT_FOUND, CONFLICT, RATE_LIMIT, TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL. `PERSISTENCE` 카테고리는 **없다**. `OperationalErrorTest`가 category × retryable 의미를 강하게 검증한다 — deterministic VALIDATION/AUTHZ/NOT_FOUND는 `retryable=false`, transient INTERNAL은 기본 `true`이되 deploy-time/ configuration/terminal 상태는 명시적 예외로 `false`. `AUTH_KID_UNKNOWN`은 key rotation 중 JWKS refresh 가능성 때문에 **AUTH 중 유일한 retryable case**로 pin돼 있다. **(2) `persistence-jpa`의 `FailureCategory` 16값** — 플랫폼 내부의 안정 예외 계층. `api.error` 23개 파일이고, **메시지를 서브클래스가 만들지 않는다** — 루트가 bounded 조각과 고정 라벨로 조립한다. ```java private static String describe(FailureCategory category, JpaFailureContext context) { // 등록된 operation name, 검증된 SQLSTATE, bounded identifier, enum, int, boolean 뿐. // row 데이터에서 유래할 수 있는 것이 하나도 없다. } ``` **(3) messaging의 3축** — `FailureCategory` + `PublishCompletion`(CONFIRMED/REJECTED/AMBIGUOUS) + `TransmissionEvidence`. §3.3. **웹 표면 SQLState 매트릭스**(`failure` 패키지)가 (2)와 (1)을 잇는다. merge 규칙이 강경하다 — 서로 다른 contributor가 같은 exact SQLState를 등록하면 **코드가 같아도 startup을 실패시킨다.** last-writer-wins merge는 매핑 소유권 drift를 숨기기 때문이다. 미지의 SQLState는 `Optional.empty()`이고 **translator는 `DB_*` 코드를 절대 지어내지 않는다** — 원본 예외를 web catch-all까지 전파시켜 detail 누설 없는 generic `INTERNAL`로 답한다. ### 5.2 관측 — 태그를 유한하게, 그리고 그 대가 `JpaMetricTags`는 **다섯 개** 태그뿐이다: `persistence.unit`, `persistence.operation`, `persistence.query`, `outcome`, `failure.category`. 없는 것이 요점이다 — 엔티티 id 없음, tenant id 없음, SQL 파라미터 없음, 예외 메시지 없음. 검증이 registry가 아니라 **생성자**에서 일어나고, sanitize가 아니라 **reject**다 — sanitize하면 caller가 unbounded 값을 계속 넘기고 절대 눈치채지 못한다. `shared-contract`의 `ForbiddenMetricTags`가 같은 규칙을 프로젝트 전체에 건다. `request_id`가 baggage에는 허용되지만 metric label에는 금지되는 비대칭이 테스트에서 의도적으로 pin돼 있다. `jpa.transaction.completion.unknown`이 **자기 카운터를 갖는** 이유: > 사람이 봐야 하는 유일한 결과다: 다른 모든 실패는 **확실히 일어나지 않은** 트랜잭션이고, 이건 > **일어났을 수도 있는** 트랜잭션이다. 일반 실패율에 묻는 게 눈치채지 못하게 되는 방식이다. **그런데 messaging의 출하 publish 경로는 관측을 하나도 기록하지 않는다** (`19` §5.1). 자동설정이 6인자 생성자를 골라 `NO_OBSERVATION`을 주입하고, `MessagingMetrics`·`MessagingTracer`· `MessagingAuditSink`·`DefaultMessagingObservationConvention`이 전부 main 참조 0이며, 등록되는 것은 협력자 `MessagingRedactor`·`CardinalityGuard` 둘뿐이다. `DefaultMessagePublisher`의 해당 필드 javadoc이 그 상태를 정확히 예언한다: > `MessagingObservation` existed as a bean and no publish path called it, so the platform's own > metrics described nothing. It is a constructor argument rather than an optional decorator because > **an unobserved publish path is how "the dashboards were empty during the incident" happens.** 수정은 runtime-core에서 이뤄졌고, **조립이 그 수정을 되돌렸다.** 그리고 §3.3의 "예외 메시지를 버린다"와 합쳐지면 **진단 가능한 흔적이 남지 않는다.** ### 5.3 시작 검증기 — 법칙과 그 예외 모듈 18에서 확립한 법칙: **시작 검증기가 도는지 여부는 그 능력에 `.imports` 자동설정 루트가 있는지와 정확히 일치한다.** `app-bootstrap`의 runtime 검증기 12종은 전부 배선돼 있다(`RuntimeSafetyConfig` `@Bean` 7 · `SecretSourceConfig` · `MigrationStartupConfig`). `StartupFailures`(75줄)가 구조화 실패 로그의 단일 발생원이며 종료 코드까지 규정한다(STARTUP_VALIDATION_FAILED=78 · MigrationFailed=70). main 157 파일에 **고아 0** — 참조 0인 파일은 전부 `@Configuration` 루트 · logback 컴포넌트 · `spring.factories` 항목으로 설명된다. JPA 쪽 startup 가드: | 가드 | 하는 일 | |---|---| | `JpaDangerousConfigurationGuard` | `open-in-view=true`(local 편의 프로필 외), 스키마 변경 `ddl-auto` 거부 | | `JpaDataSourceProfileValidator` | resolved `DataSource`를 열어서 product/version 확인 | | `PostgreSqlVersionPolicy` | PostgreSQL 16/17/18만 | | `PersistenceVendorProdSafetyValidator` | prod에서 H2 거부 | | `HikariPoolConstraintValidator` | 풀 제약 | | `NotificationSchemaActivation` / `FileserverSchemaActivation` | capability 스트림 승격 확인 | | `PostgreSqlRuntimeRoleVerifier` | 런타임 롤이 DDL 못 함 (§4.1) | messaging 쪽은 **6개 시작 검증기**를 실제로 돌린다 (`19` §9.1) — `MessagingPrefixMigrationValidator` (죽은 세 네임스페이스) · `MessagingConfigurationKeyValidator`(적법 키를 settings record에서 **파생**) · `MessagingCredentialRequirementValidator`(production 한정) · `MessagingAdminDurabilityValidator` · Kafka/Rabbit `StartupProfileValidation` 2종. `MessagingConfigurationKeyValidator`의 근거가 특히 좋다: > A misspelt prefix is loud — the whole section is missing and someone notices. A misspelt key > inside a section that does bind is the opposite: the entry appears, the platform starts, and > **the one setting the operator came to change is the only one that did not take.** > `consumer.prefech: 64` is a throughput change that never happened. **법칙의 예외가 둘이다** — 루트가 있는데 검증기를 부르지 않는 경우: - `adapter-inbound-web`의 `WebPlatformStartupValidator` (`14` §44.2) - `grpc-spring-boot-starter`의 `GrpcPlatformStartupValidator` (`20` §3.1) 후자는 실행되지 않는 규칙이 **13개**다(transport·security 4 / executor 2 / methods 4 / channels 2 / advanced isolation 1). 클래스 javadoc이 13개를 고른 기준을 적는다: > Every rule here is a mistake **whose runtime symptom is either silence or a misattributed > failure**: a unary method with no deadline hangs until the client's, an unbounded executor turns > overload into unbounded latency, **trust-all in production reports TLS while providing none**, > reflection in production publishes the schema, and a keyed method without a ledger accepts > idempotency keys it cannot honour. **None of them fails a smoke test.** ### 5.4 admin plane — 가장 잘 조립된 게이트 messaging의 admin plane이 이 저장소에서 가장 잘 만들어진 조립이다 (`19` §8.1). ```java @ConditionalOnProperty(prefix = "app.messaging.admin", name = "enabled", havingValue = "true") ``` `matchIfMissing` 없음 — 기본 꺼짐. 그리고 세 가지가 정확하다: 1. **`DestructiveOperationGuard(false)`** — "an application runtime never holds an admin credential, so the guard refuses the operations that would need one." 2. **`DestructiveMessagingAdmin`은 의도적으로 bean이 아니다** — javadoc이 명시한다. **부재를 문서화한 것**이 이 저장소에서 드물다. 3. **비내구 journal + 시작 검증기 쌍**: ```java throw new MessagingConfigurationException("ADMIN_JOURNAL_NOT_DURABLE", "the destructive-operation journal in use (" + journal.getClass().getSimpleName() + ") is not durable, and profiles " + active + " include a production profile; supply an " + "AdminOperationJournal bean backed by the shared database (JdbcAdminOperationJournal) so " + "one approval cannot be executed twice across replicas or across a restart"); ``` 메시지가 **무엇을 공급해야 하는지 클래스 이름으로** 말한다. **그런데 가드만 켜고 서비스는 켜지 않는다** (`19` §8.2). `DefaultMessagingAdminService`· `HmacApprovalVerifier`·`TopologyValidationRuntime`이 main 참조 0이다. 부재 4건 중 하나만 문서화됐다. **실패 시나리오.** 사고 대응 중 redrive를 실행하려고 `app.messaging.admin.enabled=true`로 켠다. 부팅은 성공하고 가드·journal·durability 검증기가 올라온다. 그런데 `MessagingAdminService` bean이 없으므로 호출할 대상이 없다. `MessagingAdminDurabilityValidator`의 javadoc이 경계한 시점 — "the gap only shows up during the incident the operation was run to resolve, which is **the worst possible moment to discover it**" — 과 정확히 같다. ### 5.5 gRPC 구현 층의 원자성 (`20` §7) `grpc-policy`·`grpc-server`의 동시성·경계를 읽고 6건을 확인했다. 여섯 중 **넷이 같은 형태**다 — `Atomic*` 타입을 쓰면서 원자적 연산을 하지 않는다. **`GrpcAdmissionController.tryAdmit()`** — 조립되는 9개 bean 중 하나다. ```java int running = inFlight.get(); if (running < maxConcurrentCalls) { inFlight.incrementAndGet(); // 검사와 증가 사이가 열려 있다 ``` 경계에 있는 N개 스레드가 모두 같은 `running`을 읽고 모두 통과한다. 클래스 javadoc이 "under load"를 두 번 강조하는데, **부하 아래에서 지키라고 만든 경계가 부하 아래에서만 샌다.** `release()`도 `get() > 0` 후 감소라 카운터가 −1이 될 수 있고, 그러면 경계가 영구적으로 느슨해진다. `promoteFromQueue()`는 `inFlight`를 경계와 대조하지도 않는다. **`GrpcCredentialRotationManager`** — `AtomicReference`를 `get()`→`set()` 홀더로만 쓴다(CAS 0건, `synchronized` 0건). 동시 회전 시 한 세대가 `draining`에 오르지 못한 채 사라진다. javadoc이 그 경합을 이미 알고 있다 — "the usual reason for one is two rotators racing" — 면서 닫는 연산을 쓰지 않았다. **그리고 이것은 messaging이 이미 고친 결함이다.** `CredentialRotationContractTest`: > `resolve` was get → fetch → put → clear **with no synchronization**. Two callers rotating the > same credential both read the same old runtime and both fetched a replacement: **one replacement > was dropped from the map without ever being cleared — a secret left in memory that nothing owns**. **`GrpcSerializedStreamWriter`의 `DROP_OLDEST`** — 동기화는 정확한데(9곳) 바이트 회계가 틀렸다. ```java queuedBytes = Math.max(0L, queuedBytes - nextBytes); // nextBytes = 들어오는 메시지 크기 ``` 버려지는 것은 `dropped`인데 빼는 값은 새로 들어오는 메시지의 크기다. `GrpcStreamEnvelope`가 크기를 담지 않으므로 **알 방법이 애초에 없다.** 그리고 테스트는 sizer가 상수 `8L`이라 모든 메시지 크기가 같아 결함이 보이지 않는 구성이다. 나머지 둘: `GrpcStreamAdmission`(같은 TOCTOU + `perCaller` 맵 무제한) · `GrpcOutcomeReplay`(제거· TTL·개수 상한 없는 인메모리 저장소) · `GrpcCompletionReconciler`(요청 경로에서 동기화 없는 `ArrayList`). **정확한 참조 구현이 전부 같은 가족 안에 있다** — `GrpcRetryBudget`은 CAS 루프, `GrpcCancellationCoordinator`는 5개 메서드 전부 `synchronized`, `GrpcClientMessageDeduplicator`는 `endSession`으로 두 맵을 정리한다. **지식의 부재가 아니라 적용의 불균일이다.** **사이클 2 전수 통독이 더한 것 — 같은 형태가 둘 더 있고, 그중 하나는 더 무겁다.** `GrpcChannelRuntimeRegistry.rotate` 는 같은 파일의 `install` 이 `compareAndSet` 을 쓰는데도 조건 없는 `set` 을 쓴다. 두 회전이 겹치면 덮인 대체본이 `draining` 목록에 오르지 못해 배수도 회수도 되지 않는다 — 자격증명 쪽과 같은 형태다. `GrpcChannelRuntime.finishUnaryCall`·`closeStream` 은 `get() > 0` 후 별도 감소인데, 이 리프에서는 결과가 구체적이다. `quiescent()` 가 **정확히 0** 을 요구하므로 카운터가 음수가 되면 그 세대는 영원히 조용해지지 않고 `retireQuiescent` 가 결코 제거하지 않는다. 회전이 반복될수록 배수 목록이 자란다. 그리고 자격증명 회전의 더 무거운 절반이 통독에서 나왔다. ```java public void completeDrain() { State observed = state.get(); state.set(new State(observed.current(), null, null)); } ``` 읽기와 쓰기 사이에 회전이 일어나면 그 회전이 활성화한 세대가 지워지고 **이전 세대가 다시 현재가 된다.** 방금 교체된 자격 자재가 되살아난다는 뜻이고, 이 클래스의 존재 이유가 정확히 그 교체다. 수정은 `updateAndGet` 한 줄이다. (`grpc/grpc-policy` §17.2, `grpc/grpc-client` §17.1·§17.2) --- ## 6. Tests and verification coverage ### 6.1 실행한 것 | 대상 | 결과 | |---|---| | messaging 25 leaf `:test` 전량 | BUILD SUCCESSFUL 2m27s · classes=110 tests=851 failures=0 **skipped=0** | | gRPC 18 leaf `:test` 전량 | BUILD SUCCESSFUL 1m12s · classes=71 tests=579 failures=0 **skipped=0** | | gRPC 증거 레인 3종 (inProcess·netty·fault) | BUILD SUCCESSFUL · 7+9+9 · Netty는 실제 소켓 | | `:adapter:inbound:graphql:test` | classes=186 tests=1603 failures=0 skipped=0 | | `:adapter:inbound:websocket:test` | classes=91 tests=720 failures=0 skipped=0 | | `:adapter:inbound:grpc:test` + qualification | classes=8 tests=48 failures=0 skipped=0 | | `:app-bootstrap:test` | tests=1016 **failures=1** — 유일 실패는 환경 원인(`jq` 부재), 1,015 통과 | ### 6.2 실행하지 않은 것과 그 이유 컨테이너·브로커·DB·별도 서버가 필요한 레인: - `messagingCertificationTest` — Docker 필수. **의도적으로 skip 가드가 없다**(§6.4). - `grpcPerformanceTest` — 공유 러너 측정은 baseline이 될 수 없다. - JPA의 `jpaPlatformContractTest`·`MigrationTest`·`FailureTest`·`QueryPlanTest`·`SecurityTest`· `PoolContractTest` + readiness task 15종 — 실 PostgreSQL 필요. - mongo/redis/fileserver/objectstorage/httpclient의 Testcontainers 계열. - websocket 커스텀 레인 4종(nginx·brokerRelay·advanced·jetty). **이들이 통과한다는 것은 문서와 커밋된 manifest의 주장이고, 그중 messaging 인증만 CI가 강제한다**(§6.4). ### 6.3 fail-closed 레인 규약 JPA 레인이 이 저장소의 표준을 세웠다. ```groovy failOnNoDiscoveredTests = true outputs.upToDateWhen { false } ``` > `failOnNoDiscoveredTests`가 여기서는 평소보다 중요하다: 아무것도 발견하지 못한 선택된 레인은 > **성공을 보고**하고, 조용히 돌기를 멈춘 계약 suite는 **통과하는 것과 구별되지 않는다.** Docker 부재도 skip이 아니라 **에러**다: ```java throw new IllegalStateException( "Docker is required for the PostgreSQL contract suite and is not available; this lane" + " fails closed rather than skipping, because a skipped contract reports success" + " for a database nobody tested"); ``` `PostgreSqlVersion.parseSelection("")`도 **에러**다 — "빈 PostgreSQL 매트릭스 선택은 빈 실행이 아니라 에러다." ### 6.4 완전히 닫힌 게이트 하나 — messaging 인증 체인 이 저장소에서 유일하게 네 층을 모두 갖춘 게이트다 (`19` §2.3). ``` 레인이 파일을 쓴다 messagingCertificationTest → build/.../broker-certification-evidence.jsonl Gradle이 대조한다 verifyMessagingCertificationEvidence: ran ≠ shipped → GradleException (gitCommit·observedAt은 제거 후 비교, upToDateWhen{false}) CI가 게이트를 돌린다 messaging-certification.yml, src/messaging/** PR마다 가드를 일부러 안 단다 "a lane that skipped would report success for a broker nobody started" ``` 대조가 **양방향**이다 — 손으로 추가한 줄(`shipped - ran`)도, 기록되지 않은 실행 결과 (`ran - shipped`)도 실패시킨다. 그리고 커버리지를 손으로 유지하지 않는다. `CertifiedEvidence.knownGaps(adapter)`가 `all() − covered`로 **파생**하고, 빠진 시나리오를 이름과 이유까지 붙여 단언한다: ```java void aScenarioWithNoLineInTheManifestIsAGapRatherThanAnAbsence() { assertThat(CertifiedEvidence.knownGaps("messaging-kafka")) .as("a Kafka producer buffers before it learns a connection exists, so this stays unproven") .contains(NetworkFaultScenario.CONNECTION_REFUSED); assertThat(CertifiedEvidence.knownGaps("messaging-rabbit")) .as("no lane runs a fault scenario against RabbitMQ, so every scenario is a gap") .containsExactlyElementsOf(NetworkFaultScenario.all()); } ``` 등급도 boolean이 아니라 증거에서 파생된다: ```java public boolean hasLiveBrokerCertification() { return BrokerFailureMatrix.from(CertifiedEvidence.recorded()).hasLiveBrokerCoverage(adapter); } ``` > Read from the evidence rather than declared. **As a field it was a boolean an author set next to > the tier**, and RabbitMQ carried `true` while no fault scenario had ever been executed against it. ### 6.5 evidence manifest — JPA의 R1/R2 분리 `gradle/jpa-evidence.gradle`(917줄)이 active card 11개의 producer를 실행하고 **JUnit XML에서 exact selector와 executed/skipped/failure/error 수를 읽는다.** 각 manifest는 source revision/dirty digest, prerequisite manifest ID, **PostgreSQL image digest**, 드라이버/Hibernate/Flyway 버전을 담고 canonical JSON SHA-256 이름으로 생성된다. 후보 검증이 통과해도 **`attainedReadiness=R1`을 유지한다.** 각 manifest가 candidate profile, dirty source, 아직 R2가 아닌 prerequisite를 `readinessBlockers`에 보존해서 **후보 통과를 R2로 오인할 수 없다.** 진짜 게이트는 별도이고 clean revision + CI provenance + immutable image digest를 요구한다. 로컬 dirty worktree에서 `worktree-is-dirty` blocker로 **실패하는 것이 정식 동작**이다. ### 6.6 게이트가 통과하면서 아무것도 증명하지 않는 경우 — 14건 | 모듈 | § | 게이트가 실제로 하는 일 | |---|---|---| | 05 jpa | 52 | blocking release gate `collection-fetch-pagination`이 실제 위험을 증명하지 않음 | | 05 jpa | 132 | 선택된 base card `jpa-flyway-migration`의 producer가 현재 리비전에서 **실패** | | 05 jpa | 133 | base card 3개의 evidence tag가 **production code 없는 fixture**로 충족됨 | | 05 jpa | 125 | nightly workflow가 광고하는 세 가지 중 하나를 lane이 관측하지 않음 | | 06 mongo | 94 | 커버리지 gate 둘이 나란히 있고 하나는 **발화할 수 없음** | | 06 mongo | 95 | release gate가 실제로 차단하는 것은 hermetic test 3개, mongo용 CI workflow는 없음 | | 10 redis | 47 | "build gate"라 불리는 catalog drift 검사가 **어디에서도 실행되지 않음** | | 13 notification | 9 | 다중 타깃 검증을 확인한다는 테스트가 `hasMessageContaining("strategy")`로 **다른 가드**에 걸려 통과 | | 14 web | 48.1 | 크로스 스택 게이트가 검증하는 조립은 **픽스처의 조립** | | 18 bootstrap | 4.1c | 조건부 전송 게이트가 별도 레인에서만 돌아 **빨간 채로 방치된 이력**이 javadoc에 기록됨 | | 19 messaging | 3.7 | `everyRecordedScenarioIsALineTheCertificationLaneWrote`가 **같은 파일을 두 경로로 비교** | | 19 messaging | 9.3 | 문서 계약 테스트 단언 8개의 커버리지 밖에 **발견된 문서 드리프트 3건이 전부** | | 20 grpc | 3.2 | 릴리스 게이트가 읽는 증거를 아무도 생산하지 않음 (Gradle 태스크 0 · CI 0) | | 20 grpc | 3.3 | 증거 등급 모델(25개 테스트)이 `check` 밖 · CI 밖 | **JPA `jpaPlatformPoolContractTest`의 이름 변경 기록**이 이 유형의 정석적 수정이다: > `jpaPlatformPerformanceTest`였고, pool과 REQUIRES_NEW 압력을 **certify**한다고 기술됐고, > **나타나는 모든 곳에서 off가 기본인 boolean 뒤에** 게이트되어 있었다. 그래서 릴리스 게이트가 > **유일한 threshold assertion이 "threshold를 assert하지 않고 있다"인 레인**에 의존했고, > "certified"는 **어떤 latency나 throughput bound도 무언가와 비교된 적 없는 실행**을 기술했다. > > 프로퍼티는 사라졌다; 그 이름은 여기 일부러 반복하지 않는데, **주석 속의 이름이 다음 사람이 > 설정해 보려는 바로 그것**이기 때문이다. --- ## 7. 이 저장소에서 반복된 네 가지 형태 62개 leaf를 읽고 나서 남는 것이다. 개별 결함보다 이쪽이 값이 크다. 원본은 `99` §2–§5. ### 7.1 형태 A — 판정하는 코드는 있고, 부르는 코드가 없다 **20개 모듈 중 13개에 있다.** 저장소 자신이 이 형태에 이름을 세 번 붙였다: > `StartupProfileValidation`: "The Kafka, RabbitMQ and security validators **were all beans and > none of them was injected anywhere**: the context published a validator per broker and > **validated nothing.**" > > `DefaultMessagePublisher`: "the admission controller, access policy, runtime registry and > observation **existed as beans that no publish ever called.**" > > `RegisteredMessageCodecs`: "`MessageCodecRegistry` was **an interface with no implementation > anywhere** — declared, consumed by `DefaultMessagePublisher`, and satisfiable by nothing." | 모듈 | § | 무엇이 조립되지 않았나 | 규모 | |---|---|---|---| | 05 jpa | 23·69·78·91·116 | completion-evidence capability · runtime-role verification · byte quota · V8 atomic admin claim · vendor selector fail-fast | — | | 06 mongo | 49 | **subsystem 전체 미배선인데 켜는 flag는 startup 검사를 수행** | 497 파일 leaf | | 06 mongo | 32 | 문서가 지목한 deadline 메커니즘의 production 호출자 0 | — | | 07 identifier | 3 | 모듈의 존재 논거인 `UuidCodec`에 production 소비자 0 | 10 파일 leaf | | 09 objectstorage | 49 | APPLY를 켜는 설정은 있고 승인을 검증하는 bean은 없음 | — | | 10 redis | 6·15 | startup probe가 production에서 미실행 · SDK 진입점 둘에 구현 없음 | — | | 13 notification | 17.1 | "Every reveal is auditable"의 세 필드를 읽는 코드 0 | — | | 14 web | 8.1 | 스캔 제외 패키지의 **여섯 컴포넌트**를 두 자동설정 어느 것도 소유 안 함 | RFC 9457 23파일 | | 14 web | 16.1·20.1 | 용량 보호 41파일 · 멱등/durable 표면이 픽스처에서만 조립 | 41파일 | | 14 web | 32.2 | forwarded 헤더 Java 정책 **421 LOC** 미배선 | 421 LOC | | 14 web | 44.2 | `WebPlatformStartupValidator` 미실행 | — | | 16 graphql | 8.1·16.1·24.1 | 스키마 조립·해시 사슬 · 파서 한계 미설치 · **커서 서명 키를 요구하는데 서명하는 코드가 없음** | — | | 17 websocket | 4.1 | 세 안전 장치 호출자 0 | 약 90파일 | | 19 messaging | 3.4·3.5·4.3·4.5·5.1·5.2·7.1·8.2 | capability 거부 1/12 · 트랜잭션 validator · 스키마 호환성 · cloudevents · 관측 · ACL 자기점검 · outbox 체인 · admin 서비스 | 2,818 LOC 등 | | 20 grpc | 3.1·3.4 | 시작 검증기 13규칙 · 인터셉터 체인/어댑터 | 15종 | **원인이 세 갈래로 갈린다.** 세 경우의 조치가 다르다. **(a) 자동설정 루트 자체가 없다.** §5.3의 법칙이 여기서 나온다. **(b) 루트는 있는데 통제를 소유하지 않는다.** 모듈 14 §8.1(§3.1)과 모듈 20 §3.1이 이 경우다. `ProblemCatalog`는 빈이고 그것을 쓰는 `@RestControllerAdvice`는 빈이 아니다. **dependency 쪽만 소유되고 control 쪽은 소유되지 않았다.** **(c) 조건이 영원히 만족되지 않는다.** 모듈 19 §7.1(§4.3)이 유일한 순수 사례다. Spring은 이것을 오류로 보고하지 않으므로 **"꺼져 있음"과 "조립될 수 없음"이 런타임에서 구별되지 않는다.** ### 7.2 형태 B — 게이트가 통과하면서 아무것도 증명하지 않는다 §6.6에 14건의 표가 있다. 판정 기준 셋은 전부 저장소 자신의 문장에서 나왔다: 1. **"class existence is not composition evidence."** — `ConditionalTransportCompositionContractTest` 2. **"A gate that is red in a lane nobody runs locally is a gate that reports whatever the last person to run it saw."** — 같은 테스트 3. **"the matrix and the support document agreed with each other and with nothing that had executed."** — `CompatibilityMatrix`. **문서와 상수가 서로 일치하는 것으로는 아무것도 증명되지 않는다.** ### 7.3 형태 C — 중복 장치에서 조립된 쪽이 약한 쪽이다 | 모듈 | § | 조립된 장치 | 조립되지 않은 / 우회된 장치 | |---|---|---|---| | 06 mongo | 58 | 규칙 없는 TTL 선언 | 규칙을 가진 TTL 선언 (아무도 안 씀) | | 10 redis | 56 | NOSCRIPT 복구 5벌 중 4벌이 레지스트리를 안 지남 | 레지스트리를 지나는 1벌 | | 10 redis | 64 | 의미 어댑터 5종이 `CommandPolicyGuard`를 안 지남 | `CommandPolicyGuard` | | 13 notification | 25.1 | 클라이언트 제공 Web Push 엔드포인트가 쓰는 **약한 검사의 private 사본** | SSRF 가드 | | 13 notification | 17.2 | 프로덕션이 타는 오버로드 | Thymeleaf 예외 메시지 삭제 가드가 있는 오버로드 | | 14 web | 24.1 | 배선된 캐시 필터의 `no-store` | 조건부 읽기 경로(무력화) · 조정하려던 패키지(참조 0) | | 14 web | 32.1 | 클라이언트가 요청 식별자를 고르게 하는 다른 배선 필터 | "고를 수 없다"는 정책 | | 14 web | 32.2 | Nginx 설정의 forwarded 헤더 신뢰 판정 | Java 정책 421 LOC | | 19 messaging | 5.3 | 인가 거부를 `CONFIGURATION`으로 분류 | `DestinationAccessValidator`(`MessageAuthorizationException`) | | 19 messaging | 5.4 | `CredentialRuntimeRegistry`(동시성 계약 테스트 있음) | `CredentialRotationPlan`(참조 0) | | 19 messaging | 6.5 | **둘 다 조립됨** — `kafkaSeamProducer` · `messagingKafkaProducer` | — (MSG-015) | | 05 jpa | §17 P1 | `SpringPolicyTransactionPort` + `TransactionRetryBackoff` | `FullTransactionRetryCoordinator`(주입처 0) | | 05 jpa | §12.5 | `audit/AuditableEntity` | `auditing/AuditMetadata`(완성됐고 조립 안 됨) | **방향에 규칙이 있다.** 13건 중 11건에서 조립된 쪽이 더 약하거나 덜 정확하다. 이유는 §1.4가 설명한다 — 잘 만들어진 정책 객체는 프레임워크 표면 밖(95.5%)에 있고, 실제로 요청 경로에 놓이는 것은 프레임워크 표면 안(4.5%)에서 손으로 배선한 것이다. **변종: 원자적 타입을 쓰면서 원자적으로 하지 않는다** (`20` §7, §5.5). 두 장치가 아니라 **같은 문제의 옳은 해법과 틀린 해법이 한 가족 안에 공존**한다. ### 7.4 형태 D — 문서 드리프트, 그리고 그 방향 | 모듈 | § | 드리프트 | 방향 | |---|---|---|---| | 06 mongo | 4 | README의 활성화 recipe를 그대로 따르면 애플리케이션이 시작되지 않음 | **과대** | | 07 identifier | 5 | 문서는 UUIDv7이라 말하고 생성되는 것은 v4 | **과대** | | 07 identifier | 6·7·8 | CLAUDE.md 의존성 서술 **세 항목 모두** 틀림 · README 사실 오류 3건 · 대는 두 가드 중 하나가 저장소에 없음 | **과대** | | 08 fileserver | 4 | README가 "노출된 setting도 bean도 없다"고 적은 능력들에 production bean 8개 | **과소** | | 10 redis | 5 | README readiness 표와 build.gradle 주석이 실제 소스와 어긋남 | — | | 12 messaging-adapter | 4 | README의 `jackson-databind` 부재 주장이 현재 상태와 어긋남 | — | | 14 web | 40.2 | 리액티브 활성화 조건에 대한 `build.gradle` 서술이 코드와 다름 | — | | 16 graphql | 20.1 | 등급표 13행 중 한 행만 관측과 어긋나고 **방향이 표가 금지한 "높게 표현" 쪽** | **과대** | | 19 messaging | **6.3** | 지원 매트릭스가 Kafka `deduplicatedPublish`를 `O`로 적고 코드는 `false` | **과대** | | 19 messaging | 6.4 | 지원 매트릭스가 "모든 leaf build-only" — 실제 18/25 출하 | **과대** | | 19 messaging | 6.2 | Rabbit이 선택 불가라는 사실이 운영 문서에 없음 | **과소** | | 20 grpc | 3.6 | CLAUDE.md의 `grpc-discovery` 행이 UDS resolver를 빠뜨림 | **과소** | | 20 grpc | 2.6 | 지원 매트릭스가 "Not released / build-only"를 정확히 공시 | **일치** | | 05 jpa | §17 P3 | `docs/**` 5개 문서가 "exactly 19 leaf"라고 적음 (실제 62) | — | **과대 진술이 과소보다 위험하다.** 20개 문서에서 나온 P1급 문서 드리프트는 단 하나이고(`19` §6.3), 그것이 과대 방향이다. 이유는 그 문서를 읽은 팀이 **자기 코드를 생략하기 때문**이다: > `KafkaMessagingTransport` javadoc: "Declaring it true means `PublishDeduplication` is accepted > and silently does nothing — **the caller believes the broker is deduplicating and skips the > idempotency it would otherwise build.**" **문서 계약 테스트의 경계가 드리프트 위치를 예측한다** (`19` §9.3). messaging에는 doc rot를 막기 위한 `MessagingDocumentationContractTest`가 있고 단언 8개를 갖는다. 그 단언이 붙드는 것(등급 이름 · Kafka 버전 문자열 · 존재하지 않는 두 enum 상수)은 **전부 정확하고**, 붙들지 않는 것(capability 표 60칸 · runtime membership 문장 · 브로커 등급표의 "제한" 칸)에 **발견된 드리프트 3건이 전부** 있다. 우연이 아니다. 그 테스트의 javadoc이 좁은 단언을 고른 이유까지 옳게 적는다 — "Asserting on wording would make every edit a test failure and **the check would be deleted**." 결함은 좁게 고른 것이 아니라 **그 경계가 어디인지가 문서에도 테스트에도 적혀 있지 않다**는 점이다. ### 7.5 공시 스펙트럼 — 자기 미완성을 얼마나 말했는가 같은 저장소 안에서 모듈마다 공시 정도가 다르고, **그 차이가 P1 개수와 거의 정확히 반비례한다.** | 등급 | 모듈 | 공시 방식 | P1 | |---|---|---|---| | **완전 공시** | 20 grpc | 지원 매트릭스가 "Not released … build-only"와 미해결 게이트 입력 2건을 스스로 나열 | **0** | | **완전 공시** | 16 graphql | `CLAUDE.md`가 4등급을 정의하고 13행 중 **일곱을 스스로 강등** | **0** | | **완전 공시** | 18 bootstrap | 조건부 전송 게이트가 자기 이력(빨간 채 방치)을 javadoc에 기록 | **0** | | 부분 공시 | 17 websocket | `CLAUDE.md`가 `stomp` 8파일만 서술 — 90개 플랫폼 파일은 덮지 않음 | 0(하향) | | 부분 공시 | 19 messaging | 가족 `CLAUDE.md`가 MSG-015를 P0 미해결로 명시하고 자기 문장의 오류까지 정정 — **그러나 운영 문서는 미수정** | 1 | | **역방향 공시** | 14 web | README가 반대를 서술 | **6** | **그러나 공시가 조립을 대체하지 않는다.** 모듈 20은 P1 0이면서 P2 10건이다. ### 7.6 학습 전이 — messaging → grpc 같은 문제를 두 번 푼 흔적이 있고, 두 번째가 첫 번째에서 무엇을 가져왔는지 추적할 수 있다. | messaging이 배운 것 | grpc로 옮겨졌나 | 근거 | |---|---|---| | 애플리케이션이 플랫폼에 도달하는 bridge를 먼저 정하라 (MSG-015) | **✔** | `adapter-inbound-grpc`가 레지스트리상 이 가족을 볼 수 없음 (`20` §2.3) | | framework-free 계약 leaf | **✔ 더 강해짐** | `grpc-core-api/build.gradle`의 `dependencies {}`가 비어 있음 | | 전송 선택은 classpath 사고가 아니라 속성 | **✔** | 두 가족 모두 단일 master switch + fail-closed | | 등급은 boolean이 아니라 증거에서 파생 | **✘** | `GrpcReleaseEvidence` 5성분 전부 호출자 제공. 유일 생성 지점이 자기 테스트 | | 게이트를 CI가 돌려야 한다 | **✘** | Gradle 태스크 0 · 28개 워크플로 중 grpc 언급 0 | | 시작 검증기를 조립에 연결하라 | **✘** | `GrpcPlatformStartupValidator` main 참조 0 | **설계는 옮겨졌고 강제 배선은 옮겨지지 않았다.** 옮겨진 셋은 전부 **레지스트리와 build.gradle로 표현되는 것**이고, 안 옮겨진 셋은 전부 **Gradle 태스크와 CI YAML로 표현되는 것**이다. 전자는 문서에 적으면 다음 사람이 따라 하고, 후자는 적어도 따라 하지 않는다. --- ## 8. Confirmed problems ### 8.1 P1 — 지금 출하되는 아티팩트에서 틀린 동작 | # | 발견 | 위치 | |---|---|---| | 1 | **지원 매트릭스가 Kafka `deduplicatedPublish`를 `O`로 적고 코드는 `false`.** capability 표 60칸 중 유일한 불일치이고, 하필 12개 중 유일하게 실제 거부를 발생시키는 플래그다 | `19` §6.3 | | 2 | RFC 9457 계약 23파일이 출하 애플리케이션에 등록되지 않음 (§3.1) | `14` §8.1 | | 3 | 플랫폼 요청 컨텍스트가 서블릿에 생산자가 없고 리액티브에 익명 액터로 고정 | `14` §12.1 | | 4 | 용량 보호 계층 41파일이 자기 테스트 픽스처 안에서만 실행 | `14` §16.1 | | 5 | 멱등 실행 계층·durable-operation 표면이 픽스처에서만 조립 | `14` §20.1 | | 6 | 리액티브 절반 29파일이 어떤 출하 배포에서도 활성화 불가 | `14` §40.1 | | 7 | 크로스 스택 게이트가 픽스처의 조립을 검증 | `14` §48.1 | | 8 | mongo 출하 default 조합이 첫 write에서 예외 (§4.2) | `06` §23 | | 9 | mongo high-water mark가 재전달 이벤트를 삼켜 변경이 영구 소실 (§4.2) | `06` §67 | | 10 | mongo 프로파일의 TLS·타임아웃·풀·Stable API가 driver에 도달하지 않음 | `06` §75 | | 11 | `cause.getMessage()` 때문에 PII-safe logging 계약이 성립하지 않음 | `04` §4 | | 12 | notification consumer가 diagnostic failure를 authoritative failure로 바꿀 수 있음 | `04` §5 | | 13 | notification admin atomic claim contract가 service에서 사용되지 않음 | `03` §12.4 | | 14 | UUIDv7 계약과 실제 validation 불일치 | `01` §11 | | 15 | jpa: 상태·펜싱·쿼터 계열 다수 (§59.1·69·70·71·78·79·88·89·97·98·107·108·132) | `05` | > **사이클 2에서 P1 하나가 철회되었다.** 사이클 1의 11번 — "영구 TLS 실패가 재시도 가능한 > CONNECT로 분류" — 은 제품 결함이 아니라 테스트 픽스처의 듀얼스택 호스트명이 원인이었다. > 위 표는 그 철회를 반영해 15건이며, 근거는 §14와 `EVD-332`다. **#11의 재현이 특히 구체적이다** (`04` §4.3). support source와 README가 "logger method가 body/recipient/payload를 받지 않기 때문에 PII가 log에 닿지 않는다"고 주장하는데, `logFailure`가 `cause.getClass().getSimpleName() + ": " + cause.getMessage()`를 그대로 넣는다. 실제 probe: ``` 입력: RuntimeException("provider rejected recipient secret@gmail.com body=secret-body-content") 출력 WARN: error="RuntimeException: provider rejected recipient secret@gmail.com body=secret-body-content" ``` 기존 테스트가 통과한 이유는 fixture의 exception이 단순히 `"connection refused"`라서다 — **payload object가 직접 argument로 전달되지 않는다는 것만** 확인할 뿐 exception message를 통한 leakage를 검사하지 않는다. app-bootstrap의 `LogMaskingPatterns`도 arbitrary email/free-form body PII를 제거하는 규칙이 없고, README 스스로 regex masking을 **보증이 아니라 defence-in-depth**라고 적는다. ### 8.2 P2 — 명확한 실패 시나리오를 가진 실질적 공백 형태별로 §7에 정리했다. 가장 무거운 여덟: 1. **messaging 관측 경로 전체가 no-op** — 대시보드·감사·트레이스 모두 없음 (`19` §5.1, §5.2) 2. **messaging outbox/inbox 2,818 LOC이 만족 불가 조건 뒤** (`19` §7.1, §4.3) 3. **messaging 마이그레이션 스트림 미적용 + 적용 시 `V2` 중복 실패** (`19` §7.2, §4.3) 4. **capability 12개 중 거부하는 것 1개** — javadoc의 "fails loudly" 미성립 (`19` §3.4) 5. **gRPC 릴리스 게이트가 자기 증거를 읽지 않음** — messaging이 이미 닫은 모양의 재발 (`20` §3.2) 6. **gRPC 구현 층의 원자성 결함 4건** — `GrpcAdmissionController`(조립되는 bean) 포함 (`20` §7) 7. **JPA 재시도 구현이 둘이고 하나는 아무도 호출하지 않음** (`05` §17 P1) — 실제로 도는 재시도는 `COMMAND_SERIALIZABLE_REPLAY_SAFE` 정책에만 적용되고, `DefaultJpaRetryPolicy`의 6단계 순서· `IrreversibleSideEffectContext` 확인·`RetryBudget` elapsed 상한이 전부 **아무도 호출하지 않는 경로**에 있다. `support-matrix.md`는 "Full-transaction retry | Stable"이라고 선언한다. 8. **JPA 플랫폼 capability 대부분에 production 소비자가 없다** (`05` §17 P8) — `springdata` / `hibernate.*` / `postgresql.{write,lock,json,array,range,copy}` / `cache` / `envers` / `querydsl` 전부. 결정적 증거는 **같은 leaf 안의 두 스토어**(fileserver 25파일, notification 53파일, 합 350KB)가 JPA 플랫폼 타입을 **하나도 import하지 않고** 같은 문제(큐 클레임·정렬·충돌 판정)를 각자 다시 만들었다는 것이다. **사이클 2 전수 통독이 더한 P2 — 다섯.** 전부 미배선 블록 안쪽이므로 §8.3 의 등급 완화가 함께 적용되고, "조립되는 즉시 성립" 이라는 조건이 붙는다. 9. **gRPC 연산 원장의 insert-first 주장이 Spring Data 의 `save` 계약과 어긋난다** — 두 번째 청구가 유니크 위반을 일으키지 않고 커밋된 결과를 덮어쓴다. 테스트 이중이 INSERT 를 흉내 내 그 차이를 가린다 (`grpc/grpc-operation-ledger-jpa` §17.1) 10. **`GrpcPlatformStartupValidator` 가 시작 시 실행되지 않는다** — 그 검증기가 유일한 소비자인 설정 키 넷(`transport`·`tls-enabled`·`trust-all-certificates`·`operation-ledger-enabled`)이 아무것도 게이트하지 않는다 (`grpc/grpc-spring-boot-starter` §17.1) 11. **`GrpcCredentialRotationManager.completeDrain()` 이 진행 중인 회전을 되돌린다** — 방금 교체된 자격 자재가 다시 현재가 된다 (`grpc/grpc-policy` §17.2, §5.5) 12. **NATS 가 `deduplicatedPublish` 를 무조건 참으로 선언한다** — 프로파일에 중복 제거 창이 없으면 `Nats-Msg-Id` 를 보내지 않으므로 서버가 중복을 제거하지 않는다. 이 플래그는 부재가 예외를 만드는 유일한 능력이라 창 없는 목적지가 그 가드를 통과한다 (`messaging/messaging-nats-experimental` §17.1) 13. **Kafka 트랜잭션 검증기가 감싸이지 않았고, 능력은 무조건 참을 답한다** — 검증기는 `messaging-kafka` 가 소유하고 그것을 시작 시 부르는 배선은 스타터가 소유한다. 어느 쪽 문서도 혼자서는 이 사실을 말할 수 없다 (`messaging/messaging-kafka` §17.2, `messaging/messaging-spring-boot-starter` §17.2) 그리고 **진단 마스킹의 IPv4 전용 가정** 은 형태가 달라 따로 둔다 — `GrpcDiagnosticsRedactor.maskAddress` 가 IPv4 가 아닌 입력을 그대로 돌려주고, 스냅숏의 "마스킹되지 않은 주소" 검사가 결과==입력을 통과로 읽으므로 IPv6·호스트 이름·유닉스 소켓 경로가 전부 통과한다 (`grpc/grpc-advanced-diagnostics` §17.1). 사이클 2 가 철회한 P1 의 원인과 같은 계열의 가정이다. ### 8.3 심각도가 등급 때문에 낮아진 것 `runtime_memberships: []`인 27 leaf(729 파일 / main 479)의 미조립은 채택 시점 부채로 기록했다. - `adapter-inbound-websocket` — 약 90개 플랫폼 파일에 조립 지점 없음, 세 안전 장치 호출자 0 (`17` §4.1, P1→P2 하향) - gRPC 가족 18 leaf 전체 — P1 0건인 이유가 이것이다 (`20` §5). 다만 §7.1의 `GrpcAdmissionController`는 조립되는 9개 bean 중 하나이므로 채택 시 가장 먼저 청구된다. --- ## 9. Reusable criteria and rules 이 20편을 쓰는 데 실제로 쓴 규칙이다. 전부 저장소 자신의 문장에서 나왔거나 반복 관찰에서 굳었다. 원본은 `99` §9. 1. **`runtime_memberships`를 먼저 읽는다.** build-only leaf의 미조립은 오늘의 사고가 아니라 채택 시점의 부채다. (`17` §26.6 · `19` §1.1 · `20` §1.1) 2. **클래스가 로드된다는 것은 조립 증거가 아니다.** (`18` §4.1c) 3. **`@Bean`이 있다는 것도 조립 증거가 아니다.** 주입처를 확인한다. (`19` §3.5 · `20` §3.1 · `14` §8.1) 4. **`@ConditionalOnBean`은 만족 *가능성*을 확인해야 한다.** 뿌리 타입의 구현이 저장소 안에 있고 그것을 만드는 자동설정이 있는지. (`19` §7.1) 5. **문서와 상수가 서로 일치하는 것으로는 아무것도 증명되지 않는다.** 실행된 것과 대조한다. (`19` §6.6) 6. **아무도 돌리지 않는 레인의 게이트는 마지막으로 돌린 사람이 본 것을 보고한다.** `check` 연결과 CI 워크플로를 확인한다. (`18` §4.1c · `20` §3.3) 7. **문서 계약 테스트가 있으면 그 단언 목록을 먼저 읽는다.** 드리프트는 그 경계 밖에 있다. (`19` §9.3) 8. **중복 장치를 찾으면 조립된 쪽이 어느 쪽인지 확인한다.** 13건 중 11건이 약한 쪽이었다. (§7.3) 9. **과대 진술 문서를 과소보다 먼저 고친다.** 과대는 팀이 자기 안전장치를 생략하게 만든다. (`19` §6.3) 10. **조립하는 쪽을 먼저 읽는다.** 정정 네 건 전부가 이 순서를 어겨서 생겼다. (§13) 11. **`Atomic*` 타입의 존재는 원자성의 증거가 아니다.** `get()`으로 비교하고 별도로 `increment`/`set` 하는 것은 `AtomicInteger`를 쓴 check-then-act다. (`20` §7) 12. **빠뜨림이 통과가 되는 게이트는 게이트가 아니다.** `JpaModuleBoundaryTest`의 카탈로그 **정확한 동등성**, `PersistenceEntityScanCoverageTest`, `failOnNoDiscoveredTests = true`, `verifyDocumentedLeafCount`의 트리 walk가 전부 같은 교훈이다. (`05` §1·§16) 13. **모르는 것은 일급 결과여야 한다.** `RetryDisposition.RECONCILE`, `TransactionResult.Indeterminate`, `WriteDisposition.UNDETERMINED`, `PublishCompletion.AMBIGUOUS`, `ReplicaLagMonitor.replayedThrough(): Optional`. "모른다"를 성공이나 실패로 접으면 그 정보가 영원히 사라진다. (`05` §16 · `19` §3.2) 14. **위험한 상태는 타입이 표현할 수 없게 만든다.** `completionUnknown && retryable` 거부, `PublishEvidence`의 compact constructor. 리뷰 규칙이 아니라 생성자다. (`05` §2.2 · `19` §3.2) 15. **이름은 값이 아니라 registry key다.** 쿼리·큐·업서트·JSON path·COPY 이름 전부. **이름이 statement를 선택하지, statement의 일부가 되지 않는다.** (`05` §16) 16. **path/identifier는 등록, value는 바인딩.** 파라미터로 바인딩할 수 **없는** 것만 registry로 고정한다. (`05` §7.5) 17. **시간은 DB에서, 그리고 락 이후에 읽는다.** (`05` §10) 18. **로컬 환경이 다른 DB면 로컬 테스트는 다른 시스템에 대한 진술이다.** `char(64)`와 `fs_cleanup_item` 두 사건이 `application-local.yml`을 PostgreSQL 기본으로 바꿨다. (`05` §8.5) 19. **계약 테스트는 SQL을 재타이핑하지 말고 어댑터가 실제로 돌리는 statement를 실행해야 한다.** "테스트 작성자와 어댑터 작성자가 쿼리에 대해 합의했다"는 아무도 필요로 하지 않는 속성이다. (`05` §11.2) 20. **버그를 고칠 때 왜 그 버그가 가능했는지를 코드 옆에 남긴다.** 이 저장소 javadoc의 상당량이 사후 기록이고, 다 읽고 나면 **같은 실수가 다시 들어오는 걸 막는 유일하게 작동하는 장치**로 보인다. (`05` §16) --- ## 10. Explicit project decisions 문서·javadoc에 근거가 명시된 설계 결정 중, 읽고 나서 **성립을 확인한** 것들이다. "왜 이렇게 했나"의 답이 코드 옆에 있는 것만 골랐다. ### 10.1 계약과 경계 | 결정 | 근거 문장 | 출처 | |---|---|---| | 계약 leaf는 framework-free | `grpc-core-api`의 빈 `dependencies {}` — 클래스패스로 강제 | `20` §2.1 | | 상태 코드는 미러링하고 번역은 한 곳이 소유 | "a failure context that names `io.grpc.Status` would put the transport inside the contract that exists to describe what the transport did" | `20` §2.1 | | 도메인은 리포지토리를 소유하고 플랫폼은 base repository를 만들지 않는다 | "모든 애그리거트가 강제로 통과해야 하는 generic API, 그리고 한 애그리거트의 요구가 전부의 동작을 조용히 바꾸는 단일 지점" | `05` §1·§5.1 | | 어댑터가 `@Transactional` 경계를 소유하지 않는다 | 유스케이스가 `TransactionPort`로 소유 | `03` §4 | | DB 드라이버는 벤더 패키지 밖에 나올 수 없다 | ArchUnit `PERSISTENCE_RDBMS_STAYS_VENDOR_NEUTRAL` | `05` §1 | | 중립 port가 adapter 타입 의존을 대체한다 | GraphQL persisted-operation registry가 `OperationalRecordStorePort`에 의존 | `02` | ### 10.2 실패와 불확실성 | 결정 | 근거 문장 | 출처 | |---|---|---| | completion-unknown은 절대 retryable이 아니다 | 생성자가 그 조합을 거부. 이중 방어로 `forceCompletionUnknown` | `05` §2.2 | | 증거를 먼저 기록하고 결론을 나중에 고른다 | "lets an operator answer 'could the broker be holding this message?' from a stored result" | `19` §3.2 | | 커밋 모호성 규칙을 넓히지 않는다 | "운영자는 그 큐를 **읽지 않고 비우는 습관**을 배운다 — 그러면 중요했던 한 건이 나머지와 같이 지워진다" | `05` §3.5 | | `PropagationMode`는 셋뿐 | NESTED/SUPPORTS/NOT_SUPPORTED/NEVER는 "호출자의 작업이 트랜잭션 안에 있는지 자체를 조용히 바꾸기 때문에" 없다 | `05` §2.3 | | `READ_UNCOMMITTED`가 없다 | PostgreSQL이 READ COMMITTED로 취급하니 "프로파일이 DB가 제공하지 않는 격리를 주장하게 된다" | `05` §2.3 | | 미인식 SQLSTATE는 추측하지 않는다 | "미지의 상태를 직렬화 실패로 분류하면 재시도 코디네이터가 **이미 성공한 쓰기를 기꺼이 다시 돌린다**" | `05` §7.1 | | 예외 메시지를 버리고 타입만 남긴다 | "드라이버 메시지는 routing key, payload 조각, connection string을 담을 수 있고 `FailureDescriptor`는 로깅·export되도록 설계됐다" | `19` §3.2 | ### 10.3 조립과 활성화 | 결정 | 근거 문장 | 출처 | |---|---|---| | 마스터 스위치는 루트 하나가 소유하고 자식은 조건을 갖지 않는다 | "a bean added to any child next month is gated without anyone remembering to repeat a condition" | `19` §6.1 · `05` §14.1 | | 전송 선택은 classpath 사고가 아니라 속성 | "Nothing failed; the message simply went somewhere nobody chose." | `19` §6.1 | | "꺼짐"은 구조적이어야 한다 | 빈 없음, 소켓/풀/스레드 없음, 설정 미바인딩, 스키마 기대치 없음 | `05` §16 · `19` §6.1 | | 프레임워크 자동설정까지 막는다 | 프로젝트 조건만으로는 부족 — JPA/Flyway starter가 Boot의 import metadata로 자기 것을 기여한다 | `05` §14.1 | | 질문은 "JPA가 켜졌나"가 아니라 "관계형 커넥션이 필요한 capability가 있나" | 풀은 JPA의 사유물이 아니다 — outbox, JDBC idempotency, multi-instance lock, notification, fileserver가 전부 필요로 한다 | `05` §14.1 | | 클래스패스에 있는 것은 동의가 아니다 | experimental 게이트를 static이 아니라 **생성자 파라미터**로 받아 "컴파일러가 요구하는 인자를 잊을 수 없게" | `05` §13 | | admin plane의 파괴적 작업은 자동설정하지 않는다 | "an operator tool that needs purge or delete registers one itself, with an admin credential this runtime does not hold" | `19` §8.1 | ### 10.4 데이터와 경계값 | 결정 | 근거 문장 | 출처 | |---|---|---| | 인코딩 한도는 보고 기준이 아니라 할당 경계 | "a process-wide outage caused by one message" — `BoundedByteSink`가 한도를 넘기는 write에서 실패 | `19` §4.1 | | 기본 코덱을 "먼저 등록된 것"으로 고르지 않는다 | "a wire-format decision made by accident" | `19` §4.2 | | raw-bytes 코덱은 기본이 될 수 없다 | "인코딩을 선언하지 않은 모든 destination이 스키마 검증을 조용히 건너뛴다" | `19` §4.2 | | keyset 페이지네이션에 offset 필드를 두지 않는다 | "필드의 부재가 나중에 하나 추가되는 걸 막는다" | `05` §2.4 | | keyset에 total count도 page number도 없다 | 같은 predicate에 두 번째 집계 쿼리가 필요하고 "움직이는 데이터셋에서 그 숫자는 클라이언트에 닿기 전에 이미 낡았다" | `05` §2.4 | | 커서를 서명하는 이유는 기밀성이 아니라 무결성 | "서명 없는 커서는 클라이언트가 제어하는 정렬 상태이고 ... **접근 제어 우회**다" | `05` §2.4 | | JSONB 문서에 타입 메타데이터를 넣지 않는다 | "문서 안의 타입 메타데이터는 JSONB 컬럼을 **역직렬화 가젯**으로 만든다" | `05` §7.5 | | 범위는 두 컬럼이 아니라 range 타입 | "`[09:00, 10:00)`과 `[10:00, 11:00)`이 겹치는지는 **값이 아니라 bracket**에 달렸다" | `05` §7.5 | | tenant id는 절대 메트릭 태그가 되지 않는다 | 카디널리티가 정의상 unbounded이고 "텔레메트리 안의 tenant id는 그렇게 취급되지 않는 시스템 안의 고객 데이터" | `05` §13.2 | ### 10.5 증거와 게이트 | 결정 | 근거 문장 | 출처 | |---|---|---| | 등급은 boolean이 아니라 증거에서 파생 | "As a field it was a boolean an author set next to the tier" | `19` §6.6 | | in-process 결과로 전송 능력을 주장할 수 없다 | `GrpcEvidenceGrade.requireCertifies` — `CONTRACT` 등급의 `tls` 주장은 예외 | `20` §2.4 | | 성능 레인은 릴리스 게이트에 넣지 않는다 | "a measurement in the release gate is a flaky test on a shared CI runner" | `20` §2.5 · `05` §15.3 | | 인증 레인만 Docker 가드를 달지 않는다 | "a lane that skipped would report success for a broker nobody started" | `19` §2.3 | | 문서 부재는 후속 과제가 아니라 릴리스 차단 사유 | "the first person to meet it is the one who has to work it out at three in the morning" | `20` §3.2 | | 지원 수준은 추론이 아니라 선언 | "evidence suite가 돌지 않은 capability는 컴파일이 된다는 이유로 STABLE이 되지 않는다" | `05` §2.5 | | Repair는 모드가 아니다 | "Repair는 그 질문을 물을 수 없게 만들어서 답한다" — schema history를 다시 써서 증거를 지운다 | `05` §8.1 | | 예약 헤더 위조 방어 | "A forged `msg.id` corrupts **another** message's inbox deduplication" | `19` §6.8 | | actuator 엔드포인트는 읽기 전용이고 재식별 표면을 만들지 않는다 | "a diagnostic that is worse than absent, because it looks like an answer" | `19` §8.4 | | 설정 오타를 바인딩 섹션 **안에서** 거부하고 적법 키를 settings record에서 파생 | "the one setting the operator came to change is the only one that did not take" | `19` §9.1 | --- ## 11. Unresolved questions 각각 왜 답하지 못했는지 적는다. 원본은 `99` §10. **1. 컨테이너·브로커·DB가 필요한 레인의 실제 결과.** §6.2 목록. 이들이 통과한다는 것은 문서와 커밋된 manifest의 주장이고, 그중 messaging 인증만 CI가 강제한다. **가장 값싼 해소**: Docker가 있는 환경에서 `./gradlew jpaPlatformReleaseGate`와 `:messaging:messaging-kafka:verifyMessagingCertificationEvidence` 두 개를 돌리는 것. **2. `sample-portfolio`의 내부** (279파일 · main 201 · 스테레오타입 50). 사용자 지시로 제외했다. 두 번째 런타임 조합이고 8개 leaf를 갖는다 — `adapter-inbound-web`, `persistence-jpa`, `objectstorage`(이 조합에만 출하), `identifier`, `domain-core`, `application-core`, `shared-contract`. 따라서 `14`·`05`·`09`의 판정 일부는 이 조합에서 다르게 나올 수 있다. **3. 런타임 관측.** 이 분석은 전부 정적이다. 부팅하지 않았고, 로그·액추에이터 응답·메트릭 시리즈를 보지 않았다. `evidence/browser`·`evidence/terminal`·`evidence/svg`가 비어 있는 이유다. **가장 값싼 해소가 여기 있다**: `19` §5.1(관측 경로 no-op)은 부팅 후 `/actuator/metrics`에 `messaging.*` 시리즈가 없다는 것으로 **1분 만에 확증**된다. 같은 부팅에서 `ConditionEvaluationReport`를 켜면 `19` §7.1(outbox 조건 사슬)도 함께 확정된다. **4. `@ConditionalOnBean` 사슬의 실제 평가 순서.** Spring의 조건 평가는 등록 순서에 민감하고, 정적 읽기로는 "이 조건이 만족될 수 있는가"까지만 판정했다. `05` §14.4가 기록한 사고 — `@ConditionalOnBean(DataSource.class)`가 **클래스 파싱 시점에** 평가되어 모든 실제 배포에서 false였고 여덟 빈이 조용히 사라진 것 — 가 이 질문이 사소하지 않다는 증거다. **5. 성능·용량 주장.** 어떤 모듈에서도 측정하지 않았다. `20` §2.5가 확인한 것은 성능 레인이 존재하고 기본 `test`에서 제외됐다는 사실까지다. `05` §15.3의 `jpaPlatformPoolContractTest`도 지금은 **행동 계약**만 검증하고 threshold를 갖지 않는다. **6. gRPC 도메인 로직 정확성.** `20` §7의 읽기는 초점이 동시성과 경계였다. `grpc-proto-contract`의 스키마 규칙 판정(3 main / 605 LOC), `grpc-codegen`의 매니페스트 해시 규약, `grpc-advanced-resilience`의 hedging 적격성·xDS 실패 정책, `grpc-advanced-compat`의 Servlet/gRPC-Web 프로파일 판정은 구조와 도달성만 확인했다. --- ## 12. Evidence index | | | |---|---| | `evidence/raw` | **297 파일** (번호 최대 268). 각 파일 헤더에 리비전·cwd·명령·원본 출력 | | `evidence/meta` | 6 파일 | | `evidence/browser` · `terminal` · `svg` | **비어 있음** — 정적 분석만 수행 (§11.3) | | `source-index.md` | **SRC 173행 · EVD 81행**. 각 행이 sub-scope ↔ 증거 파일 ↔ 판정을 연결 | | 모듈 문서 | `analysis/01`–`20`, 각 문서 말미에 커버리지 원장과 §검증 | | 교차 종합 | `analysis/99-cross-scope.md` | | 상태 정본 | `state.json` — leaf 62개의 status · analysisFile · sourceRevision · coverage · notes 5건 | 증거 파일은 **원본 출력만** 담는다. 가공한 표는 전부 모듈 문서에 있고, 그 표의 각 숫자가 어느 증거 파일의 어느 줄에서 왔는지는 `source-index.md`의 해당 SRC 행이 기술한다. --- ## 13. Limits of this analysis **1. 정적 분석이다.** §11.3. **2. 컨테이너 의존 레인 — 사이클 2에서 일부 해소.** 사이클 1 시점의 제약이었다. 사이클 2는 이 컨테이너에 Docker가 있음을 확인하고(client 29.1.3 / server 29.6.1) Testcontainers 기반 lane을 실제로 돌렸다 — persistence-jpa·mongo·cache-redis 포함. 여전히 기동하지 않은 것은 compose 스택 자체다(§14, `EVD-334`). **3. `sample-portfolio` 제외.** 사용자 지시(2026-08-30). §11.2. **4. 리비전 이동을 겪었다.** 분석 도중 코드베이스가 `a24ece9c` → `21234e38`으로 이동했다. ``` git diff --stat a24ece9c..HEAD → 400 files changed, 40217 insertions(+), 4 deletions(-) 변경 경로: src/grpc* · modules.json(18항목 추가) · src/build.gradle · docs 15개 ``` 차이가 gRPC 가족 추가에 국한됨을 diff로 확인한 뒤 모듈 20으로 닫았다. 모듈 01~19가 다룬 경로는 변경되지 않았다. (`20` §0, `99` §8) **5. 정정 다섯 건이 있었다.** 이미 닫은 모듈로 되돌아가 판정을 바꾼 것 넷 + 분모 정정 하나. | # | 대상 | 언제 발견 | 정정 | |---|---|---|---| | 1 | `14` §8.1 | `15` grpc 분석 중 | "한 API가 두 에러 와이어 형식을 낸다" → "RFC 9457 23파일이 출하 애플리케이션에 등록되지 않는다" | | 2 | `17` §4.1 | `18` app-bootstrap 분석 중 | P1 → **P2**. `runtime_memberships=[]`가 기계 강제되는 build-only 등급임을 확인 | | 3 | `18` §4.1 | 자기 초안 검토 중 | "출하 스위치가 다섯보다 많다" → **결함 아님** + 더 좁은 §4.1b/§4.1c | | 4 | `14` §8.1 | 교차 분석 중 | "넘겨받을 자동설정이 작성되지 않았다" → 자동설정 둘은 **존재하고 import되며**, 협력자만 소유하고 여섯 컴포넌트를 소유하지 않는다 | | 5 | 분모 | 교차 분석 중 | `state.json` 44 스코프 vs 레지스트리 62 — 리비전 이동이 원인, 모듈 20으로 해소 | **전부 원인이 같다 — 조립 결함을 판정하려면 조립하는 쪽을 먼저 읽어야 한다.** 네 번째 정정이 특히 중요하다. 결함을 없애지 않고 **더 좁고 정확하게** 만들었고, 그 결과 권고가 "새 자동설정을 만든다"에서 "기존 자동설정에 `@Bean` 여섯 개를 추가한다"로 작아졌다. **6. 모듈별 읽기 깊이가 균일하지 않다.** 커버리지 원장의 `FULL_READ`/`STRUCTURAL_ONLY` 비율이 모듈마다 크게 다르다. `20`(gRPC)은 2026-08-31 보강으로 383 파일 중 전문 판독 **45개**가 됐고 (`20` §7, 동시성·경계 초점) 그 결과 P2가 4건에서 10건이 됐다. 남은 미독은 **도메인 로직 정확성**이다(§11.6). 각 모듈의 실제 비율은 그 문서의 커버리지 원장이 정본이다. **7. P1/P2/P3 총계 — 사이클 2에서 제시한다.** 사이클 1은 표기 형식이 갈려 기계 집계가 중복된다는 이유로 총계를 내지 않았다. 사이클 2는 두 형식(모듈 findings 표 · §17 `### P` 헤딩)을 모두 파싱하고 문서 내 중복을 제거해 **422건**(P1 29 · P2 135 · P3 258)을 얻었다. 다만 이 총계는 정본 목록만 센 것이고, 본문 산문 안의 언급은 포함하지 않는다. 모듈별 확정 수치는 여전히 각 문서의 §발견 종합이 정본이다. 그 뒤 23개 리프를 다시 읽으면서 그 몫이 60→100 으로 늘었고, 총계를 **462건**(P1 30 · P2 147 · P3 285)으로 조정했다. **조정은 델타이지 재측정이 아니다** — 23개 리프의 100건은 직접 셌고, 나머지 38개 문서는 이번 재작업의 대상이 아니어서 두 형식 파서를 다시 돌리지 않았다. 462는 422가 정확했다는 가정 위에 있다. --- ## 14. 사이클 2 — 18개 리프 재검증과 23개 리프 전수 통독 ### 14.1 18개 리프 재검증 사이클 1이 남긴 18개 리프 문서(`01`–`18`)를 HEAD에서 다시 검증했다. 전량은 `analysis/99-cross-scope.md`에 있고, 여기서는 이 정본 문서의 판정에 영향을 준 것만 옮긴다. **소스는 움직이지 않았다.** 18개 문서가 기준으로 삼은 `a24ece9c`와 HEAD `21234e38` 사이는 커밋 하나이며, 그 커밋은 grpc 블록과 공통 파일 둘만 건드렸다. 18개 리프 경로의 변경 파일 수는 전부 0이고, 공통 파일 둘도 이 18개에 영향이 없다 (`EVD-333`). 따라서 §1~§13의 서술은 HEAD에서도 유효하다. **lane을 다시 돌렸다.** 실패 5건 — httpclient 3 · fileserver 1 · app-bootstrap 1. 셋 다 실행 환경 결손이며 프로덕션 결함이 아니다 (`EVD-332`, `EVD-334`). | 리프 | 사이클 1 판정 | 사이클 2 재측정 | |---|---|---| | `08` fileserver | 환경(로케일) | 확인 — `LANG=C.utf8`에서 통과 | | `18` app-bootstrap | 환경(`jq` 부재) + 가드 비대칭 P3 | 확인 + 공백 보강 — 15개 레인 계약을 독립 경로로 검증 | | `11` httpclient | **P1 제품 결함** | **철회** — 픽스처의 듀얼스택 호스트명이 원인 | **철회된 P1.** 사이클 1은 `ApacheFailureClassifier`의 분기 순서를 읽고 "Apache가 TLS 실패를 `HttpHostConnectException`으로 감싸므로 CONNECT 분기가 TLS 분기를 가린다"고 결론했다. 예외 사슬을 실제로 출력하면 그 사슬에 `SSLHandshakeException`이 없다. 원인은 `MockHttpServer.uri()`가 호스트명 `localhost`를 돌려주는데 이 컨테이너의 `localhost`가 `127.0.0.1`과 `::1` 양쪽으로 풀리고 `MockWebServer`는 IPv4에만 바인딩한다는 것이었다. Apache의 다중 주소 루프가 첫 주소의 진짜 TLS 실패를 삼키고 마지막 주소의 연결 거부만 승격시킨다. 접속 호스트를 `127.0.0.1`로 바꾸면 세 건 모두 `TLS_PERMANENT`가 된다 (`EVD-332`). 남는 것은 두 가지다. 픽스처가 호스트명을 쓰는 것(P3), 그리고 다중 주소 호스트에서 패밀리별 실패 양상이 다르면 영구 TLS 실패가 재시도 가능한 `CONNECT`로 강등될 수 있다는 성질(P2/기록, 이 모듈에서 고칠 수 없다). **17/18은 확인, 1/18은 번복.** 이 비율이 사이클 1 문서에 대한 이 사이클의 측정치다. **finding 총계를 처음으로 제시한다.** 61개 COMPLETE 문서의 정본 목록만 파싱해 **422건** (P1 29 · P2 135 · P3 258)을 얻었다. 두 가지 표기 형식 — 모듈 findings 표, 그리고 §17 「손볼 것」의 `### P` 헤딩 — 을 모두 읽고 문서 내 중복을 제거한 수치다. ### 14.2 23개 리프 전수 통독 재검증과 별개로, 사이클 2 는 `FULL_READ_REQUIRED` 로 열려 있던 23개 리프(messaging 5 · grpc 18)를 닫았다. 그 리프들의 사이클 1 문서는 production 구현을 `STRUCTURAL_ONLY` 로 판정하고 파일 이름·LOC·`build.gradle` 주석으로 서술한 상태였다. **이 통독은 두 번 했고, 첫 번째는 통독이 아니었다.** 첫 판에서 23개 리프를 `FULL_READ_DONE` 으로 표시하고 "395파일 전수 통독" 이라고 적었으나, 리프마다 실제로 읽은 것은 일부였다. Coverage ledger 가 사실이 아닌 상태였으므로 23개 리프를 파일 단위로 다시 세고 처음부터 다시 읽었다. 정직한 분모는 이렇다. | 대상 | 리프 | main 파일 | main 줄 | test 파일 | test 줄 | |---|---:|---:|---:|---:|---:| | grpc 계열 | 18 | 260 | 18,726 | 73 | 10,719 | | messaging 계열 | 5 | 97 | 10,816 | 48 | 9,037 | | **합계** | **23** | **357** | **29,542** | **121** | **19,756** | 통독 후 `STRUCTURAL_ONLY` 잔여는 0 이고, 23개 SSOT 의 Coverage ledger 를 이 숫자로 다시 썼다. 23개 문서의 finding 은 **100건**(P1 2 · P2 31 · P3 67)이고, 그중 8건은 가족 문서(`19`·`20`)가 이미 기록한 것을 canonical 리프로 옮긴 것이다. **통독에서 처음 나온 것은 92건 — P1 2 · P2 25 · P3 65.** **앞선 판이 "P1 0" 이라고 적은 것은 부분 통독의 결과였다.** 다시 읽으니 P1 이 둘 나왔고, 둘 다 messaging 계열이며 둘 다 배선되는 경로 위에 있다. 1. **운영 프로파일에 TLS 와 브로커 인증을 요구해 놓고, 그 둘이 없는 생산자를 만든다.** `KafkaProfileValidator` 는 전송 보안 없는 운영 프로파일의 기동을 거부하고 테스트가 그 거부를 지킨다. 그런데 실제 조립되는 `KafkaProducer` 설정에는 `security.protocol` 이 없다 — Kafka 기본값 `PLAINTEXT` 다. 그 값을 만드는 `KafkaSecurityConfigurer` 의 production 호출자는 저장소 전역에서 0 이다. (`messaging/messaging-spring-boot-starter` §17.1) 2. **지원 문서가 `deduplicatedPublish` 를 지원으로 적고 코드는 거짓이며, 그 차이가 정확히 코드가 경고한 피해다.** (`messaging/messaging-kafka` §17.1) 그 아래 층위에서는 앞선 판의 성격 서술이 그대로 유효하다 — 새 사고가 아니라 이미 알려진 패턴이 **블록 안 어디에서 성립하는지** 를 찾은 것이다. 가족 문서는 "블록 전체가 미배선" 층위에서 멈췄고, 통독은 그 블록이 배선되더라도 성립하지 않을 것들을 찾았다. **§1~§13 에 대한 영향.** 앞선 판은 "없다" 고 적었다. **정정한다 — 위 두 P1 은 출하 경로 위에 있다.** `messaging-spring-boot-starter` 와 `messaging-kafka` 는 `app-bootstrap` 소속이므로(§2 의 미소속 27개 목록에 없다) grpc 블록과 달리 "켜면 성립하는" 층위가 아니다. 나머지 90건은 여전히 미배선 블록 안쪽이고, 그 부분에 대해서는 §8.2 와 §5.5 의 반영이 그대로 유효하다. **가장 무거운 셋(미배선 블록 안쪽).** 1. **`JpaGrpcOperationLedger.claim` 의 insert-first 주장이 Spring Data 의 `save` 계약과 어긋난다.** 엔티티 식별자가 배정값이라 `save` 가 `merge` 로 가고, 파생 기본 키(`caller|method|keyHash`)가 유니크 제약과 같은 행을 가리키므로 두 번째 청구가 위반을 일으키지 않고 **커밋된 결과를 덮어쓴다.** 테스트 이중의 `save` 가 INSERT 를 흉내 내 그 차이를 가린다. (`grpc/grpc-operation-ledger-jpa` §17.1) 2. **`GrpcCredentialRotationManager.completeDrain()` 이 진행 중인 회전을 되돌린다.** 읽기와 쓰기 사이에 회전이 일어나면 방금 교체된 자격증명이 다시 현재가 된다. (`grpc/grpc-policy` §17.2) 3. **`GrpcPlatformStartupValidator` 가 시작 시 실행되지 않는다.** 그 검증기가 유일한 소비자인 설정 키 넷이 아무것도 게이트하지 않는다. (`grpc/grpc-spring-boot-starter` §17.1) **통독이 만든 새 형태 셋** — 상세는 `analysis/99-cross-scope.md` §3.5 · §3.2 · §3.7. | 형태 | 곳 | 요지 | |---|---:|---| | 원자 타입을 쓰면서 비교 후 교체를 하지 않음 | 5 | 정본이 같은 저장소에 둘 있다(`GrpcRetryBudget`·`GrpcHedgingBudget`) | | 선언되고 주입되지 않는 검증기 | **9** | 재통독이 다섯을 더 찾았다 — 아래 | | 능력 선언이 프로파일에서 파생되지 않음 | 3 | NATS 의 `deduplicatedPublish` 가 가장 무겁다 — 부재가 예외를 만드는 유일한 플래그다 | | **게이트라고 적힌 채 조립되지 않음** | 5 | 검증기 9 중 다섯. 문서가 "이것이 빌드를 실패시킨다" 고 단언한다 | | **선언만 있고 코드가 닿지 않는 project 의존** | 6 | 의존 그래프가 코드보다 넓다 | | **테스트 이름이 검사하지 않는 것을 검사한다고 말함** | 5 | 커버리지 지도가 틀린다 | **넷째 형태가 이번 재통독의 가장 큰 수확이다.** 앞의 "선언되고 주입되지 않는 검증기" 는 공백이지만, 이 다섯은 **오해**다 — 문서가 그 게이트가 돈다고 단언하기 때문이다. | 게이트 | 상태 | 그렇게 적은 곳 | |---|---|---| | `GrpcProtoContractValidator` | 코드 호출자 0 · 빌드 파일 어디에도 없음 | `buf.yaml` 과 `GrpcBufPolicy` 가 각각 "이것이 이 저장소의 빌드를 실패시킨다" 고 적는다 | | `GrpcBufPolicy` 의 네 Buf 태스크 | 어떤 빌드 파일에도 없음 | javadoc 이 "a missing stage is a test failure rather than a stage nobody noticed was gone" 라고 적는다 | | `GrpcAdvancedModuleGuard.requireStableStarterIsClean` | 호출자 0 | javadoc 이 "a fat jar, a shaded artifact, a test harness — is checked too" 라고 적는다 | | `NatsJetStreamProfileValidator` | 코드 0 · 테스트 0 · 흔적은 javadoc `{@link}` 한 줄 | 전송 javadoc 이 "refuses the combination at startup" 이라고 적는다 | | `PulsarProfileValidator` | 자기 선언 한 줄 말고 저장소 전체에 없음 | 그 리프 SSOT 가 검증을 서술했다(이번에 정정) | 앞의 둘이 서로를 가리킨다 — `buf.yaml` 은 CLI 가 없으니 자바 검증기가 게이트라고 하고, `GrpcBufPolicy` 는 자바 검증기가 실제 게이트라고 한다. 두 쪽 다 상대가 게이트라고 말하고 어느 쪽도 실행되지 않는다. --- ## 부록 A. 모듈 문서 지도 | 문서 | 줄 | 대상 | leaf | 이 문서에서 | |---|---|---|---|---| | `00-project-overview.md` | 141 | 초기 sizing 스냅샷 | — | §1 | | `01-domain-core.md` | 215 | `domain-core` | 1 | §2.3 | | `02-shared-contract.md` | 161 | `shared-contract` | 1 | §2.3 · §5.1 | | `03-application-core.md` | 379 | `application-core` | 1 | §3.2 · §3.5 | | `04-adapter-outbound-support.md` | 645 | `support` | 1 | §8.1 #12·#13 | | `05-adapter-outbound-persistence-jpa.md` | **4,674** | `persistence-jpa` | 1 | §4.1 · §5.1–5.3 · §6.3·6.5 · §10 | | `06-adapter-outbound-persistence-mongo.md` | 1,496 | `persistence-mongo` | 1 | §4.2 | | `07-adapter-outbound-identifier.md` | 188 | `identifier` | 1 | §7.1 · §7.4 | | `08-adapter-outbound-fileserver.md` | 656 | `fileserver` | 1 | §4.4 | | `09-adapter-outbound-objectstorage.md` | 793 | `objectstorage` | 1 | §4.4 | | `10-adapter-outbound-cache-redis.md` | 933 | `cache-redis` | 1 | §4.4 · §7.3 | | `11-adapter-outbound-httpclient.md` | 716 | `httpclient` | 1 | §8.1 #11 | | `12-adapter-outbound-messaging.md` | 379 | `adapter-outbound-messaging` | 1 | §2.4 | | `13-adapter-outbound-notification.md` | 1,062 | `notification` | 1 | §3.5 | | `14-adapter-inbound-web.md` | 1,702 | `web` | 1 | §3.1 · §7.1 · §8.1 | | `15-adapter-inbound-grpc.md` | 244 | `adapter-inbound-grpc` | 1 | §2.3 | | `16-adapter-inbound-graphql.md` | 1,122 | `graphql` | 1 | §7.1 · §7.5 | | `17-adapter-inbound-websocket.md` | 546 | `websocket` | 1 | §8.3 | | `18-app-bootstrap.md` | 571 | `app-bootstrap` | 1 | §5.3 · §7.2 | | `19-messaging-platform.md` | 1,284 | messaging 가족 | **25** | §3.3 · §4.3 · §5.2·5.4 · §6.4 | | `20-grpc-platform.md` | 716 | gRPC 가족 | **18** | §3.4 · §5.5 · §7.6 | | `99-cross-scope.md` | 364 | 교차 | — | §7 · §9 | **분석 단위와 문서 단위가 1:1이 아니다.** messaging 25 leaf와 gRPC 18 leaf는 leaf 경계를 넘는 계약(capability 선언 → validator → 인증 증거 → 지원 문서)이 실제 설계 단위라 각각 한 문서로 통합했다. `state.json`의 `scopes`가 leaf 단위 정본이고, 각 항목의 `analysisFile`이 담당 문서를 가리킨다. ## 부록 B. 자주 쓸 명령 ```bash cd src # 아키텍처 경계 ./gradlew verifyCleanArchitectureDependencies --console=plain ./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain ./gradlew verifyRuntimeModuleMembership verifyDocumentedLeafCount --console=plain # 플랫폼 가족 전량 ./gradlew $(python3 - <<'PY' import json,io d=json.load(io.open("config/architecture/modules.json")) print(" ".join(m["gradle_path"]+":test" for m in d["modules"] if m["id"].startswith("messaging-"))) PY ) --console=plain ./gradlew $(python3 - <<'PY' import json,io d=json.load(io.open("config/architecture/modules.json")) print(" ".join(m["gradle_path"]+":test" for m in d["modules"] if m["id"].startswith("grpc-"))) PY ) --console=plain # gRPC 증거 레인 (Docker 불필요, 실제 소켓) ./gradlew :grpc:grpc-testkit:grpcInProcessContractTest \ :grpc:grpc-testkit:grpcNettyContractTest \ :grpc:grpc-testkit:grpcFaultTest --console=plain # 컨테이너 필요 — 이 분석에서 실행하지 않은 것들 ./gradlew jpaPlatformReleaseGate # 실 PostgreSQL 6레인 ./gradlew :messaging:messaging-kafka:verifyMessagingCertificationEvidence # 실 Kafka + 게이트 ./gradlew :adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence ./gradlew :adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence -PjpaEvidenceProfile=r2 # SQL 안전 게이트 ./gradlew :adapter:outbound:persistence-jpa:verifyJpaSqlConstructionSafety ./gradlew :adapter:outbound:persistence-jpa:verifyJpaSecurityFixtures # §11.3의 값싼 확증 (부팅 1회로 P2 두 건 확정) # 1) app-bootstrap 부팅 후 /actuator/metrics 에 messaging.* 시리즈 부재 확인 → 19 §5.1 # 2) 같은 부팅에 debug=true 로 ConditionEvaluationReport 확인 → 19 §7.1 ``` ## 부록 C. 다시 읽는다면 이 순서 1. `src/config/architecture/modules.json` — leaf 정체와 runtime membership 2. 각 가족의 `CLAUDE.md` — 규칙과 계약 표 (`src/messaging/`, `src/grpc/`, `src/grpc-advanced/`) 3. `src/app-bootstrap/.../CaSkeletonApplication.java` — 스캔 경계와 `AUTO_CONFIGURED_PACKAGES` 4. 8개 `AutoConfiguration.imports` — 조립될 수 있는 것의 전체 집합 5. `shared-contract/api` → `domain-core` → `application-core` port — 계약층 6. `persistence-jpa/api` + `transaction` — 가장 밀도 높음 7. `messaging-core-api` + `messaging-runtime-core/DefaultMessagePublisher` — 단일 publish 경로 8. 각 leaf의 `build.gradle` + `config/**` + `gradle/*-evidence.gradle` — 검증 체계 9. `docs/**/support-matrix.md` — 무엇을 약속했는가 (그리고 §7.4로 대조)