Files
document-haness/docs/clean-architecture-backend-template/final/document.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:59 +09:00

116 KiB
Raw Blame History

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-logicca.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 0413
adapter/inbound 4 2 1,443 982 1417
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/archJpaArchitectureRules가 그중 6종을 담당한다.

규칙 막는 것
noEntityFromWeb() 컨트롤러가 엔티티 반환 → 트랜잭션 밖 lazy association 직렬화
entitiesFollowPortableMappingRules() final 엔티티(프록시 불가 → 모든 lazy 참조가 eager), no-arg 생성자
entitiesStayOutOfWebPackages() web 패키지 안의 엔티티는 노출될 엔티티
domainDoesNotDependOnHibernate() 도메인이 ORM에 의존
tenantScopedRepositoriesDoNotInheritBroadCrud() tenant-scoped 엔티티 리포지토리가 CRUD 상속
noGenericRepository() 플랫폼이 CrudRepository 재구현

tenantScopedRepositoriesDoNotInheritBroadCrud의 설명이 이 팩의 성격을 보여준다:

이름은 중요한 속성이 아니다. tenantId를 가진 엔티티에 대해 JpaRepository를 확장하는 OrderRepositoryfindById(UUID), findAll(), deleteById(UUID)를 노출한다 — 전부 tenant-blind, 전부 상속, 그리고 리뷰어가 볼 만한 어디에도 적혀 있지 않다.

2.3 검증된 경계 — 실제로 성립하는 것

domain-core (01). 허용 의존 0개. main 7파일 / 104 LOC. 이 모듈이 소유하는 것은 재사용 가능한 도메인 "내용"이 아니라 도메인 모델링 계약이다 — ResourceId<SELF>, IdFactory<T>, 그리고 @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). 이 저장소에서 본 가장 강한 형태의 자기 제약이다.

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-starterallowed_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-starterapp-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<String,String> messagingKafkaProducer : Producer<byte[],byte[]>
조건 @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-webapplication-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

ProblemCatalogWebProblemFactory는 빈으로 존재하고, 그것을 사용하는 @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<T>
adapter:
  SpringTransactionPort implements PolicyTransactionPort
    ├─ 레거시 4모드 → 미리 만들어 둔 TransactionTemplate 3개
    └─ inTransaction  → SpringPolicyTransactionPort (package-private 위임체)

템플릿을 생성 시점에 미리 만들어 두는 이유: TransactionTemplate은 thread-safe지만 mutable이라, 호출마다 propagation/readOnly를 바꿔 쓰면 같은 빈을 공유하는 동시 요청 사이에 race window가 생긴다.

inRootWriteREQUIRES_NEW로 suspend해서 "root인 척"하지 않는다. 그러면 호출자 트랜잭션과 독립 커밋되는 silent 의미 변경이 생기므로, 대신 fail-fast한다:

public <T> T inRootWrite(Supplier<T> 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 훅이 실패

핵심 실행 루틴:

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에 밀어 넣는다:

"select set_config('statement_timeout', ?, true)"
"select set_config('lock_timeout', ?, true)"
"select set_config('idle_in_transaction_session_timeout', ?, true)"

세 번째 인자 truetransaction-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에서 표현 불가능한 조합을 거부한다:

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가 전부 같은 패턴이다:

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 넣지 마세요"를 코드 리뷰 규칙이 아니라 정규식을 통과 못 하면 생성자가 던진다로 만들었다.

핵심 불변식이 하나 있다.

// JpaFailureContext
if (completionUnknown && retryable) {
  throw new IllegalArgumentException("completion unknown failures are never retryable");
}

이게 이 플랫폼 전체가 존재하는 이유다. 커밋됐을지도 모르는 작업을 자동 재실행하는 것이 할 수 있는 최악의 일이고, 그래서 타입 시스템이 그 상태를 표현하는 것 자체를 거부한다. 이중 방어도 있다 — TransactionCompletionUnknownException 생성자가 forceCompletionUnknown(context)로 컨텍스트를 다시 만들어, 정책 버그가 retryable한 completion-unknown을 만들 방법이 없다. RetryProfileCOMPLETION_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 권한을 뺐기 때문이다:

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_coreV1__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. MongoTypeMetadataConfigurerPolicyAwareMongoTypeMapper모든 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 뒤에 있다.

@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:115classpath: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개 테이블. 가장 좋은 제약이 이것이다:

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-contractCategory 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-jpaFailureCategory 16값 — 플랫폼 내부의 안정 예외 계층. api.error 23개 파일이고, 메시지를 서브클래스가 만들지 않는다 — 루트가 bounded 조각과 고정 라벨로 조립한다.

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-contractForbiddenMetricTags가 같은 규칙을 프로젝트 전체에 건다. 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-webWebPlatformStartupValidator (14 §44.2)
  • grpc-spring-boot-starterGrpcPlatformStartupValidator (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).

@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 + 시작 검증기 쌍:
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 중 하나다.

int running = inFlight.get();
if (running < maxConcurrentCalls) {
  inFlight.incrementAndGet();          // 검사와 증가 사이가 열려 있다

경계에 있는 N개 스레드가 모두 같은 running을 읽고 모두 통과한다. 클래스 javadoc이 "under load"를 두 번 강조하는데, 부하 아래에서 지키라고 만든 경계가 부하 아래에서만 샌다. release()get() > 0 후 감소라 카운터가 −1이 될 수 있고, 그러면 경계가 영구적으로 느슨해진다. promoteFromQueue()inFlight를 경계와 대조하지도 않는다.

GrpcCredentialRotationManagerAtomicReferenceget()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.

GrpcSerializedStreamWriterDROP_OLDEST — 동기화는 정확한데(9곳) 바이트 회계가 틀렸다.

queuedBytes = Math.max(0L, queuedBytes - nextBytes);   // nextBytes = 들어오는 메시지 크기

버려지는 것은 dropped인데 빼는 값은 새로 들어오는 메시지의 크기다. GrpcStreamEnvelope가 크기를 담지 않으므로 알 방법이 애초에 없다. 그리고 테스트는 sizer가 상수 8L이라 모든 메시지 크기가 같아 결함이 보이지 않는 구성이다.

나머지 둘: GrpcStreamAdmission(같은 TOCTOU + perCaller 맵 무제한) · GrpcOutcomeReplay(제거· TTL·개수 상한 없는 인메모리 저장소) · GrpcCompletionReconciler(요청 경로에서 동기화 없는 ArrayList).

정확한 참조 구현이 전부 같은 가족 안에 있다GrpcRetryBudget은 CAS 루프, GrpcCancellationCoordinator는 5개 메서드 전부 synchronized, GrpcClientMessageDeduplicatorendSession으로 두 맵을 정리한다. 지식의 부재가 아니라 적용의 불균일이다.

사이클 2 전수 통독이 더한 것 — 같은 형태가 둘 더 있고, 그중 하나는 더 무겁다.

GrpcChannelRuntimeRegistry.rotate 는 같은 파일의 installcompareAndSet 을 쓰는데도 조건 없는 set 을 쓴다. 두 회전이 겹치면 덮인 대체본이 draining 목록에 오르지 못해 배수도 회수도 되지 않는다 — 자격증명 쪽과 같은 형태다.

GrpcChannelRuntime.finishUnaryCall·closeStreamget() > 0 후 별도 감소인데, 이 리프에서는 결과가 구체적이다. quiescent()정확히 0 을 요구하므로 카운터가 음수가 되면 그 세대는 영원히 조용해지지 않고 retireQuiescent 가 결코 제거하지 않는다. 회전이 반복될수록 배수 목록이 자란다.

그리고 자격증명 회전의 더 무거운 절반이 통독에서 나왔다.

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 레인이 이 저장소의 표준을 세웠다.

failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }

failOnNoDiscoveredTests가 여기서는 평소보다 중요하다: 아무것도 발견하지 못한 선택된 레인은 성공을 보고하고, 조용히 돌기를 멈춘 계약 suite는 통과하는 것과 구별되지 않는다.

Docker 부재도 skip이 아니라 에러다:

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파생하고, 빠진 시나리오를 이름과 이유까지 붙여 단언한다:

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이 아니라 증거에서 파생된다:

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 deduplicatedPublishO로 적고 코드는 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.mdstomp 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.gradledependencies {}가 비어 있음
전송 선택은 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 deduplicatedPublishO로 적고 코드는 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에 닿지 않는다"고 주장하는데, logFailurecause.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 의 등급 완화가 함께 적용되고, "조립되는 즉시 성립" 이라는 조건이 붙는다.

  1. gRPC 연산 원장의 insert-first 주장이 Spring Data 의 save 계약과 어긋난다 — 두 번째 청구가 유니크 위반을 일으키지 않고 커밋된 결과를 덮어쓴다. 테스트 이중이 INSERT 를 흉내 내 그 차이를 가린다 (grpc/grpc-operation-ledger-jpa §17.1)
  2. GrpcPlatformStartupValidator 가 시작 시 실행되지 않는다 — 그 검증기가 유일한 소비자인 설정 키 넷(transport·tls-enabled·trust-all-certificates·operation-ledger-enabled)이 아무것도 게이트하지 않는다 (grpc/grpc-spring-boot-starter §17.1)
  3. GrpcCredentialRotationManager.completeDrain() 이 진행 중인 회전을 되돌린다 — 방금 교체된 자격 자재가 다시 현재가 된다 (grpc/grpc-policy §17.2, §5.5)
  4. NATS 가 deduplicatedPublish 를 무조건 참으로 선언한다 — 프로파일에 중복 제거 창이 없으면 Nats-Msg-Id 를 보내지 않으므로 서버가 중복을 제거하지 않는다. 이 플래그는 부재가 예외를 만드는 유일한 능력이라 창 없는 목적지가 그 가드를 통과한다 (messaging/messaging-nats-experimental §17.1)
  5. 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.requireCertifiesCONTRACT 등급의 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/metricsmessaging.* 시리즈가 없다는 것으로 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/0120, 각 문서 말미에 커버리지 원장과 §검증
교차 종합 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. 리비전 이동을 겪었다. 분석 도중 코드베이스가 a24ece9c21234e38으로 이동했다.

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<n> 헤딩)을 모두 파싱하고 문서 내 중복을 제거해 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개 리프 문서(0118)를 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를 돌려주는데 이 컨테이너의 localhost127.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<n> 헤딩 — 을 모두 읽고 문서 내 중복을 제거한 수치다.

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-startermessaging-kafkaapp-bootstrap 소속이므로(§2 의 미소속 27개 목록에 없다) grpc 블록과 달리 "켜면 성립하는" 층위가 아니다. 나머지 90건은 여전히 미배선 블록 안쪽이고, 그 부분에 대해서는 §8.2 와 §5.5 의 반영이 그대로 유효하다.

가장 무거운 셋(미배선 블록 안쪽).

  1. JpaGrpcOperationLedger.claim 의 insert-first 주장이 Spring Data 의 save 계약과 어긋난다. 엔티티 식별자가 배정값이라 savemerge 로 가고, 파생 기본 키(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.yamlGrpcBufPolicy 가 각각 "이것이 이 저장소의 빌드를 실패시킨다" 고 적는다
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.15.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.jsonscopes가 leaf 단위 정본이고, 각 항목의 analysisFile이 담당 문서를 가리킨다.

부록 B. 자주 쓸 명령

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/apidomain-coreapplication-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로 대조)