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>
This commit is contained in:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -0,0 +1,180 @@
---
kind: CASE
slug: an-unselectable-broker-listed-with-features
title: 실 브로커로 증명된 어댑터가 선택하면 기동이 실패하는 이름으로 등록돼 있다
topic: drift-direction
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:an-unselectable-broker-listed-with-features
evidenceCapturedOn: 2026-09-04
body: case-an-unselectable-broker-listed-with-features.body.md
assets:
- key: an-unselectable-broker-listed-with-features
file: ../../../final/evidence/rendered/an-unselectable-broker-listed-with-features.svg
evidence:
- ../../../final/evidence/raw/an-unselectable-broker-listed-with-features.txt
source:
- 원본 분석 절은 analysis/19-messaging-platform.md §6.2 이다.
---
# 실 브로커로 증명된 어댑터가 선택하면 기동이 실패하는 이름으로 등록돼 있다
`RabbitBrokerIT``rabbitmq:4.3-management` 컨테이너를 띄우고 브로커의 큐 깊이를 단언하므로 rabbit 전송 경로는 실 브로커로 돌아간다. 그런데 선택기를 직접 부르면 `broker=rabbit``IllegalStateException` 으로 끝나고, `:147``selectImports` 가 자동설정을 import 하기 전에 그 검사를 지나므로 채택자가 seam 을 구현해도 고를 수 없다.
## 관계
- **과대 진술 문서를 과소보다 먼저 고친다**
이 사례가 그 우선순위의 대상이다. 운영 설정 문서가 실제보다 많은 선택지를 제시한다.
- **같은 개념의 두 어휘가 공존하면 하나를 죽은 것으로 표시한다**
`rabbit` 은 등록 목록에 남고 `assemblableBrokerIds()` 에서는 빠진다. 그 필드 자바독이 이 이름이 언제 맵을 떠나는지까지 적는다.
- **문서 계약 테스트의 단언 경계 밖에 발견된 드리프트 세 건이 전부 있었다**
그 기록이 경계 밖으로 지목한 셋 중 하나가 브로커 등급 표의 제한 칸이고, 이 사례가 그 칸에서 빠진 사실이다.
## 문제
메시징 전송은 app.messaging.broker 값 하나로 고른다. 등록되지 않은 이름과 클래스패스에 없는 클라이언트를 각각 다른 메시지로 거절한다.
세 번째 거절 사유가 있다. 등록은 됐지만 전송이 아직 없는 브로커다. 그 상태가 코드와 문서와 시험에서 어떻게 나타나는지 확인했다.
## 결론
거절 자체는 정확하다. :63 의 맵이 이유 문자열을 담고 :121 이 그것을 읽어 예외에 넣으며, 같은 메시지가 assemblableBrokerIds() 로 오늘 고를 수 있는 이름 목록을 붙인다. 그 필드의 자바독 :51~:62 는 항목이 맵을 떠나는 조건까지 적어 둔다.
전송 클래스는 있다. MessagingTransport 를 구현한다고 선언한 여섯 중 넷이 main 어댑터이고 RabbitMessagingTransport 가 그중 하나다. main 에서 빠진 것은 그 생성자가 받는 RabbitChannelPublisher 의 구현인데, 그 인터페이스는 추상 메서드가 둘이라 람다로도 채울 수 없다.
그 구현이 시험에는 둘 있는데 성격이 다르다. RabbitRuntimeTest:48 이 만드는 것은 아무 데도 붙지 않는 더블이다. RabbitBrokerIT:93 은 nextPublishSequence 를 channel.getNextPublishSeqNo 로 잇고 publish 를 :187 의 channel.basicPublish 까지 위임하며, :61 의 Testcontainer 에 붙어 :154·:161 이 브로커의 큐 깊이를 읽는다.
채택자가 그 seam 을 채워도 소용이 없다. messaging-rabbit/build.gradle:12~:14 는 seam 을 구현할 소비자를 위해 spring-amqp 를 api 로 노출한다고 적는다. 그런데 :147 의 selectImports 가 selectedBroker 를 먼저 부르므로 :123 의 예외가 자동설정 import 이전에 터진다. KafkaMessagingAutoConfiguration:163 은 @ConditionalOnMissingBean 으로 자기 전송 빈에 탈출구를 두었는데 rabbit 에는 그 자리가 없다.
RabbitMessagingAutoConfiguration 이 선언하는 빈은 :35·:48·:70·:94 넷이고 그중 전송이 없다. 선택이 먼저 던지므로 이 넷도 만들어지지 않는다.
그런데도 어댑터는 출하된다. modules.json:429~:430 이 이 리프의 runtime_memberships 를 app-bootstrap 으로 적고, 부트스트랩 락파일이 amqp 클라이언트를 프로덕션 런타임 클래스패스에 싣는다. 파일 스무 개, 주석과 빈 줄까지 세면 2443 줄이다.
이 상태를 적는 문서는 있다. src/messaging/CLAUDE.md:62 가 Rabbit 을 shipped, inactive, unqualified 로 분류하고 docs/reviews/2026-08-14-messaging-module-code-review.md:444 는 production 구현이 없다고 적는다.
적지 않는 쪽은 운영 설정 문서다. docs/messaging/support-matrix.md:34 의 RabbitMQ 행은 등급 Experimental 과 인증 기준 4.3.x 와 Stable 기능 일곱을 적는다. 제한 칸에 적힌 둘은 증거가 없다는 것과 특정 기능군을 지원하지 않는다는 것이고, 선택 자체가 막혔다는 항목은 없다.
같은 문서 :23~:27 은 messaging 리프가 모두 runtime_memberships 가 비어 build-only 라는 단서를 표 전체에 붙인다. 그 단서도 지금은 맞지 않는다. configuration-reference.md:132 은 이 브로커의 설정 예시를 싣고, docs/registries/env-keys.yaml 은 이 프로퍼티에 허용값 목록도 검증 규칙도 걸지 않는다.
문서 계약 시험은 이 차이를 볼 수 없다. 여덟 중 두 개만 브로커 등급 표에 닿고, 그 둘이 확인하는 것은 어댑터 이름과 등급 낱말의 조합뿐이다.
거절 로직 자체도 시험이 없다. 이름을 가진 시험 파일이 0 개이고, env-keys.yaml:3326 이 요구하는 adapter-contract:messaging-broker-selection 을 정의한 자리도 0 개다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 선택기의 거절 맵과 자바독과 예외 인용, 그 선택을 붙드는 시험 파일과 호출자 계수 및 레지스트리가 요구하는 시험 id 의 정의 여부, 채널 발행자가 나오는 자리 전수와 구현 형태별 계수, 두 익명 구현의 본문과 컨테이너 배선 인용, build.gradle 의 seam 공개 주석과 Kafka 쪽 조건 애너테이션과 selectImports 순서 대조, 두 자동설정의 빈 목록, 출하 파일 수와 물리적 줄과 빈 줄 제외 줄, 전송 클래스의 자기 호칭과 호환성 표의 등급, 지원 매트릭스 표와 그 전체에 붙은 단서와 startup 언급 전수, 문서 계약 시험의 단언 범위, 이 상태를 적는 문서와 운영 설정 문서 대조
소스 수정 : x
## 재현 조건
1. 선택기의 거절 맵과 자바독, 그리고 예외를 던지는 자리를 인용한다.
2. 그 선택을 검증하는 시험 파일과 selectedBroker 호출자를 세고, 레지스트리가 요구하는 시험 id 가 정의돼 있는지 본다.
3. 채널 발행자 이름이 나오는 자리를 전부 찾아 implements 와 익명 구현을 나눠 세고, 대조 타입 MessagingTransport 로 같은 검색을 걸어 0 이 아닌 수가 나오는지 확인한다.
4. 두 익명 구현의 본문과 그 파일의 컨테이너 배선을 인용한다.
5. build.gradle 의 의존 노출 주석, Kafka 전송 빈의 조건 애너테이션, selectImports 의 호출 순서를 나란히 놓는다.
6. 두 브로커의 자동설정이 만드는 빈을 대조한다.
7. 출하 파일 수와 줄 수를 물리적 줄과 빈 줄 제외로 나눠 센다.
8. 지원 매트릭스의 표와 그 위에 붙은 전체 단서, 그리고 startup 언급을 전부 싣는다.
9. 문서 계약 시험이 단언하는 범위를 읽는다.
10. 이 상태를 적는 개발 문서와 적지 않는 운영 설정 문서를 나란히 놓는다.
## 본문
<!-- body:start -->
`MessagingProviderSelection``app.messaging.broker` 값 하나로 전송을 고른다. 등록되지 않은 이름, 클래스패스에 없는 클라이언트, 전송이 아직 없는 브로커를 각각 다른 메시지로 거절한다.
## 선택기가 rabbit 을 거절하는 자리
:::evidence key="an-unselectable-broker-listed-with-features" alt="저장소 루트에서 돌린 정적 검색과 선택기 프로브의 출력 296줄. 먼저 MessagingProviderSelection 48~70번 줄이 실려 BROKERS_WITHOUT_A_TRANSPORT 맵과 그 필드 자바독이 보이는데, rabbit 어댑터가 검증기와 보안 설정은 출하하지만 전송이 없고 네이티브 채널 다리에 시험 구현만 있다는 것, 그리고 항목이 이 맵을 떠나는 날은 전송이 실제로 생기는 날이라는 것을 적는다. 118~132번 줄이 선택 시 던지는 예외를 만드는 코드다. 이어서 그 선택기를 직접 부른 프로브 결과가 나온다. 등록된 이름은 kafka 와 rabbit 이고 조립 가능한 이름은 kafka 뿐이며, broker=kafka 는 kafka 로 선택되고, broker=rabbit 은 IllegalStateException 과 함께 전송이 구현되지 않아 발행이 타고 갈 것이 없다는 메시지를 내며, broker=pulsar 는 등록되지 않은 전송이라는 다른 메시지를, 빈 값은 브로커를 지정하라는 또 다른 메시지를 낸다. 다음으로 그 거절을 붙드는 시험이 없다는 것이 나온다. MessagingProviderSelection 을 참조하는 파일은 넷인데 전부 main 이고, env-keys.yaml 3326번 줄이 required_test 로 adapter-contract:messaging-broker-selection 을 선언하는데 그 id 를 정의한 자리는 0 개다. RabbitChannelPublisher 18~41번 줄이 실려 추상 메서드가 nextPublishSequence 와 publish 둘이라는 것이 보인다. 그 이름이 나오는 자리는 여섯이고 implements 를 가진 파일은 0 개 익명 구현을 가진 파일은 2 개이며, 대조로 실은 implements MessagingTransport 목록은 여섯인데 넷이 main 어댑터이고 둘은 시험 클래스다. 그 두 익명 구현이 나란히 실린다. RabbitBrokerIT 89~108번 줄은 RabbitMessagingTransport 를 만들면서 nextPublishSequence 를 channel.getNextPublishSeqNo 로 잇고 publish 를 그 시험 클래스의 publish 로 위임한다. RabbitRuntimeTest 44~63번 줄은 시퀀스를 AtomicLong 으로 세고 메시지를 리스트에 담는 인메모리 더블이다. 그 IT 가 붙는 브로커로 rabbitmq:4.3-management 컨테이너 선언과 basicPublish 호출과 messageCount 단언 줄이 나온다. 그 아래에 build.gradle 12~14번 줄의 seam 공개 주석, KafkaMessagingAutoConfiguration 163번 줄의 ConditionalOnMissingBean, MessagingProviderSelection 146~147번 줄의 selectImports 가 차례로 실린다. 두 자동설정이 선언하는 빈은 Kafka 일곱과 Rabbit 넷이다. 출하 여부로는 modules.json 417~431번 줄이 messaging-rabbit 의 runtime_memberships 를 app-bootstrap 으로 적고 app-bootstrap/gradle.lockfile 77번 줄이 amqp-client 를 productionRuntimeClasspath 에 싣는다. main 자바 20 개 파일 물리적 줄 2443 빈 줄 제외 2232 이고, RabbitMessagingTransport 27번 줄은 자기를 Stable RabbitMQ adapter 라 부르는데 CompatibilityMatrix 92번 줄은 같은 항목을 EXPERIMENTAL 로 적는다. 운영 문서로는 support-matrix.md 29~38번 줄의 등급 표와 22~27번 줄의 단서, configuration-reference.md 132~146번 줄의 RabbitMQ 설정 절, env-keys.yaml 의 allowed_values null 과 validation none 이 나온다. 문서 계약 시험은 여덟이고 그중 61번과 70번이 등급 이름을 단언하며 제한이나 선택 가능 여부를 담은 줄은 0 개다. 마지막으로 src/messaging/CLAUDE.md 56~63번 줄이 실려 대부분의 leaf 가 app-bootstrap 멤버십을 갖고 배포된 아티팩트가 싣고 있다는 것과 Rabbit 이 shipped, inactive, unqualified 라는 것을 적고, 코드 리뷰 문서도 같은 상태를 적는다." caption="선택기의 거절 맵과 그것을 직접 부른 프로브 네 경우 · 그 거절을 붙드는 시험 0 과 정의되지 않은 required_test · 추상 메서드 둘과 여섯 자리와 두 익명 구현의 본문 · seam 공개와 Kafka 의 탈출구와 selectImports 순서 · modules.json 의 runtime_memberships 와 락파일의 productionRuntimeClasspath · 자기 호칭과 호환성 등급 · 운영 문서 세 곳과 문서 계약 시험 여덟 · 이 상태를 적는 개발 문서 — 296줄 · exit 0" zoom="true"
:::
`:63``BROKERS_WITHOUT_A_TRANSPORT``rabbit` 하나를 담고 값은 이유 문자열이다. `:121` 이 그 값을 꺼내고 `:123` 이 프로퍼티 이름과 이유와 오늘 조립 가능한 브로커 목록을 붙여 `IllegalStateException` 을 만든다.
자바독은 이 설계의 이유를 적는다. 거절하지 않으면 코어 설정 깊은 곳에서 `MessagingTransport` 의존이 충족되지 않아, 운영자에게는 자기가 고른 전송이 미완성이라는 사실 대신 빈이 없다는 말이 도달한다는 것이다.
## 그 거절을 붙드는 시험이 없다
`MessagingProviderSelection` 이나 브로커 선택을 이름에 가진 시험 파일은 0 개다. `selectedBroker` 를 부르는 자리는 `:93` 의 선언과 `:147` 의 호출 둘뿐이고 둘 다 main 이다.
`docs/registries/env-keys.yaml:3326` 은 이 프로퍼티의 `required_test``adapter-contract:messaging-broker-selection` 을 선언한다. 그 id 를 정의한 자리는 저장소에 0 개다.
맵을 비우거나 키를 고쳐도 실패하는 시험이 없다.
## 시험에 있는 두 구현은 성격이 다르다
`RabbitChannelPublisher:18`\~`:41` 은 추상 메서드로 `nextPublishSequence``publish` 둘을 요구한다. 함수형 인터페이스가 아니라 람다로 채울 수도 없다.
그 이름은 저장소에 여섯 번 나온다. `:13` 의 인터페이스, `RabbitMessagingTransport` 의 필드와 생성자 인자 셋, 시험 둘의 익명 구현이다. `implements` 를 가진 파일은 0 개이고 익명 구현을 가진 파일이 2 개다.
같은 검색식을 `implements MessagingTransport` 에 걸면 여섯 파일이 나온다. 넷은 `KafkaMessagingTransport:41` · `NatsJetStreamTransport:54` · `PulsarMessagingTransport:47` · `RabbitMessagingTransport:39` 어댑터이고, 둘은 `DefaultMessagePublisherTest:426``MessagingRuntimeRegistryTest:230` 의 시험 클래스다. 그 0 은 검색식이 깨져서 나온 값이 아니다.
`RabbitRuntimeTest:44`\~`:63` 이 만드는 것은 인메모리 더블이다. 시퀀스를 `AtomicLong` 으로 세고 메시지를 리스트에 담는다.
`RabbitBrokerIT:89`\~`:108` 은 다르다. `nextPublishSequence``channel.getNextPublishSeqNo()` 를 부르고, `publish` 는 그 시험 클래스의 메서드로 위임해 `:187``channel.basicPublish` 까지 간다. `:61``rabbitmq:4.3-management` 컨테이너를 띄우고 `:154``:161``channel.messageCount(QUEUE)` 로 브로커의 큐 깊이를 단언한다.
전송 경로는 실 브로커 상대로 끝까지 돈다. 프로덕션 배선만 없다.
## 채택자가 seam 을 채워도 고를 수 없다
`messaging-rabbit/build.gradle:12`\~`:14``org.springframework.amqp:spring-rabbit``api` 로 노출하는 이유를 적는다 — 이 seam 을 구현하는 소비자가 그 타입을 컴파일 클래스패스에 두어야 하기 때문이다. 확장점으로 공개해 둔 것이다.
`KafkaMessagingAutoConfiguration:163` 은 전송 빈에 `@ConditionalOnMissingBean(MessagingTransport.class)` 를 걸어 두었다. 애플리케이션이 자기 전송을 주면 양보한다.
rabbit 에는 그 자리가 없다. `:146`\~`:147``selectImports``PROVIDER_CONFIGURATIONS.get(selectedBroker(environment))` 를 부르므로, 자동설정이 import 되기도 전에 `:123` 의 예외가 터진다. `RabbitChannelPublisher` 를 직접 구현하고 `MessagingTransport` 빈까지 준 배포도 `broker=rabbit` 을 고를 수 없다.
## RabbitMessagingAutoConfiguration 에는 전송 빈이 없고 그 넷도 만들어지지 않는다
`KafkaMessagingAutoConfiguration` 은 빈 일곱을 만들고 `:164` 가 전송이다. `RabbitMessagingAutoConfiguration` 은 넷이고 전송에 해당하는 빈이 없다.
선택이 먼저 던지므로 이 자동설정은 import 되지 않고 그 넷도 만들어지지 않는다.
그래도 어댑터 자체는 출하된다. `modules.json:429`\~`:430` 이 이 리프의 `runtime_memberships``app-bootstrap` 으로 적고, `app-bootstrap/gradle.lockfile:77``com.rabbitmq:amqp-client``productionRuntimeClasspath` 에 싣는다. main 자바 20 개, 물리적 줄 2443, 빈 줄을 빼면 2232 다.
`RabbitMessagingTransport:27` 의 클래스 자바독은 자기를 "The Stable RabbitMQ adapter" 라고 부른다. `CompatibilityMatrix` 는 같은 항목을 `EXPERIMENTAL` 로 적는다.
## 적는 문서와 적지 않는 문서
`src/messaging/CLAUDE.md:60`\~`:63` 은 Rabbit 을 shipped, inactive, unqualified 로 분류하고 그 구분을 `MessagingMembershipQualificationTest` 가 붙든다고 적는다. `docs/reviews/2026-08-14-messaging-module-code-review.md:444` 는 production 구현이 없다고 직접 적는다.
운영 설정 문서는 다르다. `docs/messaging/support-matrix.md:34` 의 RabbitMQ 행은 등급 Experimental, 인증 기준 4.3.x, Stable 기능 일곱을 적고 제한 칸에는 장애 시나리오 레인 미실행과 stream 미지원만 적는다. `configuration-reference.md:132` 는 RabbitMQ 설정 절을 두고, `env-keys.yaml:3319``allowed_values``null``:3324``validation``none` 으로 적는다.
그 표에 단서가 하나 붙어 있는데 그것도 지금은 맞지 않는다. `support-matrix.md:23`\~`:27` 은 registry 의 messaging leaf 가 모두 `runtime_memberships` 가 비어 build-only 라고 적지만, `modules.json:429`\~`:430` 은 이 리프에 `app-bootstrap` 을 적고 `src/messaging/CLAUDE.md:56`\~`:57` 도 대부분의 leaf 가 그 멤버십을 갖고 배포 아티팩트가 싣고 있다고 적는다. 같은 문서 안의 두 번째 어긋남이다.
그 문서에서 startup 이라는 낱말이 나오는 줄은 `:42` 하나이고, 브로커 선택이 아니라 destination profile 이야기다.
## 문서 계약 시험이 보는 칸
`MessagingDocumentationContractTest` 에는 시험이 여덟 있다. 등급 표의 값을 보는 것은 `:61``:70` 이다. 앞엣것은 코드가 STABLE 로 분류한 어댑터 이름이 문서에 있는지 보고, 뒤엣것은 EXPERIMENTAL 인 어댑터가 Stable 등급으로 적히지 않았는지 본다. 나머지 여섯은 문서 존재 여부와 Kafka 버전 문자열과 없는 상수 둘과 실험 정책 문장과 문서 길이를 본다.
그 파일에서 제한 칸이나 선택 가능 여부를 담은 줄은 0 개다.
## 원문과 갈리는 자리
원문은 `RabbitChannelPublisher` 의 구현이 main·test 통틀어 0 건이라고 적었다. `implements` 로 센 것은 0 이 맞지만 시험 두 파일에 익명 구현이 있고, 그중 하나는 실 컨테이너에 붙는다. `BROKERS_WITHOUT_A_TRANSPORT` 의 자바독 자신이 시험 구현만 있다고 적어 이 상태를 정확히 서술한다.
원문은 이 어댑터가 출하 아티팩트에 들어 있다고 적었다. 맞다. 다만 같은 운영 문서 `:23`\~`:27` 이 messaging 리프 전체를 build-only 라고 적어 그 사실과 어긋난다.
원문은 결함이 운영 문서에 있다고 보았다. 이 기록은 그 판정을 유지하되 범위를 좁힌다 — `src/messaging/CLAUDE.md` 와 코드 리뷰 문서는 이 상태를 적는다.
원문이 다루지 않은 것이 둘 있다. 거절 로직에 시험이 없다는 것과, `selectImports` 의 순서 때문에 seam 을 구현한 채택자도 이 브로커를 고를 수 없다는 것이다.
## 확인하지 못한 것
`app.messaging.broker=rabbit` 을 넣고 애플리케이션을 띄워 실패 메시지를 받아 보지 않았다. 정적으로는 예외를 만드는 거절 분기까지 따라갔다.
컨테이너를 띄우는 그 통합 시험을 돌리지 않았다. 배선과 단언 줄을 읽었다.
문서 계약 시험을 실행하지 않았다. 여덟 시험의 단언 코드를 읽었다.
`support-matrix.md:22`\~`:27` 의 단서를 읽고 그 브로커를 고른 사례가 있었는지는 저장소 안에서 확인할 수 없다.
`support-matrix.md:23`\~`:27` 의 단서가 언제부터 낡았는지는 이력으로 추적하지 않았다.
## 등급에 대해
원본은 P2 다. 근거는 코드가 아니라 운영 문서에 결함이 있다는 것이다. 맵도 표도 이 리비전에서 그대로이므로 등급을 새로 매기지 않는다.
<!-- body:end -->
@@ -0,0 +1,91 @@
---
kind: CASE
slug: documented-uuidv7-generates-v4
title: 문서가 UUIDv7이라 말하고 생성되는 것은 v4다
topic: drift-direction
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:documented-uuidv7-generates-v4
evidenceCapturedOn: 2026-09-01
assets:
- key: documented-uuidv7-generates-v4
file: ../../../final/evidence/rendered/documented-uuidv7-generates-v4.svg
evidence:
- ../../../final/evidence/raw/documented-uuidv7-generates-v4.txt
source:
- 원본 분석 절은 final/document.md#7-4 · analysis/07 §5, §6, §7, §8 · analysis/01 §11 이다.
---
# 문서가 UUIDv7이라 말하고 생성되는 것은 v4다
식별자 리프의 문서 두 곳이 RFC 9562 UUIDv7 을 명시한다. 실제 생성기는 JDK 의 randomUUID 를 부르고 그것은 v4 다.
## 관계
- **과대 진술 문서를 과소보다 먼저 고친다**
이 사례가 그 우선순위의 대상이다.
- **문서와 상수가 서로 일치하는 것으로는 아무것도 증명되지 않는다**
같은 계열의 규칙이다.
## 문제
리프의 문서 두 곳이 같은 주장을 한다.
CLAUDE.md 는 UUID 처리가 JDK 의 java.util.UUID 위에서 RFC 9562 UUIDv7 로 동작한다고 적는다
README.md 는 이 리프가 식별자 생성과 인코딩을 UUIDv7 로 담당한다고 적고, 같은 문장을 한 번 더 반복한다
UUIDv7 은 시간 순서를 갖는 식별자다. 정렬 가능성과 인덱스 지역성이 그 선택의 이유다.
## 결론
생성기는 v4 를 만든다.
RandomUploadIdentifierFactory 는 두 메서드에서 UUID.randomUUID 를 부른다. 그 메서드는 버전 4 를 만든다. 무작위이고 시간 순서를 갖지 않는다.
같은 클래스의 javadoc 은 randomUUID 가 시드된 SecureRandom 을 쓰므로 공유해도 안전하다고 적는다. 그 서술은 정확하고, 버전에 대해서는 아무 말도 하지 않는다.
드리프트의 방향이 과대 진술이다. 문서를 읽고 시간 순서 식별자를 전제한 설계 — 예를 들어 식별자 순 페이징이나 시간 기반 파티셔닝 — 는 성립하지 않는다.
그리고 이 주장은 두 문서에 각각 적혀 있으므로, 하나를 고쳐도 다른 하나가 남는다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 문서 문장과 구현 대조
소스 수정 : x
## 재현 조건
1. 식별자 리프의 CLAUDE.md 와 README.md 에서 UUIDv7 언급을 찾는다.
2. 리프의 main 소스에서 UUID 를 생성하는 지점을 찾는다.
3. 그 호출이 어느 버전을 만드는지 확인한다.
## 본문
<!-- body:start -->
문서는 UUIDv7을 말하고 구현은 v4를 만든다.
## 문서의 버전과 구현의 버전
:::evidence key="documented-uuidv7-generates-v4" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## 같은 leaf의 다른 문서 오류들
`CLAUDE.md`의 의존성 서술 **세 항목이 모두** 틀렸고, README에 사실 오류가 셋 있으며, `CLAUDE.md`가 근거로 대는 두 가드 중 하나는 저장소에 없다.
## 함께 읽어야 할 사실
10 파일짜리 leaf에서 문서 오류가 이만큼 나오는 것은 이 leaf에 production 소비자가 없다는 사실(§7 §3)과 함께 읽어야 한다 — 아무도 쓰지 않으면 문서도 검증되지 않는다.
## 확인하지 못한 것
생성된 값의 버전 비트를 실제로 읽어 확인하지 않았다. randomUUID 가 v4 를 만든다는 것은 JDK 계약이다.
리프의 다른 식별자 경로가 별도의 v7 생성기를 갖는지 전수 확인하지 않았다. 확인한 것은 업로드 식별자 팩토리다.
없음
<!-- body:end -->
@@ -0,0 +1,86 @@
---
kind: CASE
slug: five-documents-say-nineteen-leaves
title: 다섯 문서가 "exactly 19 leaf"라고 적고 레지스트리는 62다
topic: drift-direction
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:five-documents-say-nineteen-leaves
evidenceCapturedOn: 2026-09-01
assets:
- key: five-documents-say-nineteen-leaves
file: ../../../final/evidence/rendered/five-documents-say-nineteen-leaves.svg
evidence:
- ../../../final/evidence/raw/five-documents-say-nineteen-leaves.txt
source:
- 원본 분석 절은 final/document.md#7-4 · analysis/05 §17 P3 이다.
---
# 다섯 문서가 "exactly 19 leaf"라고 적고 레지스트리는 62다
여러 문서가 이 저장소를 19개 리프로 서술한다. 레지스트리의 실제 리프 수는 62 다. 그 수를 검사하는 게이트가 없다.
## 관계
- **문서의 수치는 세지 말고 파생하거나 게이트로 붙든다**
이 사례가 그 규칙을 만든 형태다.
- **빠뜨림이 통과가 되는 게이트는 게이트가 아니다**
수치에 게이트가 없다는 점이 같은 계열이다.
## 문제
여러 문서가 리프 수를 문장 안에 적는다. 저장소 적응 문서들과 ADR 과 코드 리뷰가 그렇다.
그 수는 19 다.
레지스트리의 실제 항목 수는 62 다. messaging 25 개와 grpc 18 개가 더해진 결과다.
## 결론
수치가 문서에 하드코딩되어 있고 그것을 붙드는 게이트가 없다.
빌드 설정에는 리프 수를 검사하는 코드가 없다. 레지스트리 항목이 늘어도 문서의 숫자는 그대로 남는다.
이 드리프트의 성질은 앞의 사례들과 다르다. 능력 표의 불일치는 동작에 대한 오해를 만들지만, 이 숫자는 동작을 바꾸지 않는다. 대신 문서 전체의 신뢰도를 깎는다. 19 라는 수를 근거로 삼은 서술 — 예를 들어 모듈 경계 설명이나 의존 그래프 서술 — 이 어느 시점의 것인지 알 수 없게 된다.
그리고 이 수는 여러 문서에 흩어져 있으므로 하나를 고쳐도 나머지가 남는다. 파생하거나 게이트로 붙들지 않는 한 같은 드리프트가 반복된다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 문서 문장 검색과 레지스트리 항목 계수
소스 수정 : x
## 재현 조건
1. 문서 전체에서 리프 수를 언급하는 문장을 찾는다.
2. 레지스트리의 항목 수를 센다.
3. 빌드 설정에 리프 수를 검사하는 코드가 있는지 확인한다.
## 본문
<!-- body:start -->
다섯 문서가 "exactly 19 leaf identities"와 "`src/settings.gradle` throws when the registry does not contain exactly 19 modules"를 적는데, 레지스트리는 62개이고 `settings.gradle`에는 `19`도 수 검사도 없다 — 검증은 플러그인에 위임됐다.
## 다섯 문서가 적은 수와 레지스트리의 수
:::evidence key="five-documents-say-nineteen-leaves" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## 게이트의 walk 대상에 docs가 없다
`verifyDocumentedLeafCount`가 존재하지만 `CLAUDE.md`·`AGENTS.md`·leaf `build.gradle`만 walk하고 `docs/**`는 대상이 아니다.
## 그 게이트 자신이 같은 형태의 사고를 기록한다
주석이 "이름이 적힌 목록은 다섯 개의 모듈 CLAUDE.md와 네 개의 leaf build.gradle을 놓쳤다"고 적는다.
## 확인하지 못한 것
19 라고 적은 각 문서가 어느 시점을 기준으로 한 것인지 추적하지 않았다. 일부는 messaging 과 grpc 가 추가되기 전에 작성된 것으로 보인다.
없음
<!-- body:end -->
@@ -0,0 +1,108 @@
---
kind: CASE
slug: mongo-default-throws-on-first-write
title: 출하 default 조합이 첫 write에서 예외를 던진다
topic: drift-direction
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:mongo-default-throws-on-first-write
evidenceCapturedOn: 2026-09-01
body: case-mongo-default-throws-on-first-write.body.md
assets:
- key: mongo-default-throws-on-first-write
file: ../../../final/evidence/rendered/mongo-default-throws-on-first-write.svg
evidence:
- ../../../final/evidence/raw/mongo-default-throws-on-first-write.txt
source:
- 원본 분석 절은 final/document.md#4-2 · analysis/06 §23 이다.
---
# 출하 default 조합이 첫 write에서 예외를 던진다
기본 설정은 정책 인식 타입 매퍼를 모든 변환기에 설치하고 레지스트리는 비어 있게 둔다. 그 조합으로 평범한 쓰기를 하면 예외가 난다. 같은 문서를 스프링 기본 매퍼로 쓰면 정상이다.
## 관계
- **README의 활성화 recipe를 그대로 따르면 애플리케이션이 시작되지 않는다**
같은 조합이 문서 쪽에서 드러난 사례다.
- **Bean 애너테이션이 있다는 것은 조립 증거가 아니다**
기본 빈이 존재하는 것과 그 조합이 동작하는 것이 다르다는 사례다.
## 문제
두 개의 기본 빈이 함께 온다.
정책 인식 타입 매퍼가 모든 매핑 변환기에 설치된다
타입 메타데이터 레지스트리는 빈 것으로 만들어진다
이 조합에서 등록되지 않은 타입을 쓰면 어떻게 되는가.
## 결론
예외가 난다. 실행해서 확인했다.
probe 는 임시 분석 테스트로 추가하고 실행 후 제거했다. 결과는 이렇다.
policyFor 는 CLASS_METADATA_ALLOWED 를 돌려준다
쓰기 타입 결정은 IllegalStateException
루트 문서 쓰기는 IllegalStateException
중첩 문서 쓰기도 IllegalStateException
같은 문서를 스프링 기본 매퍼로 쓰면 키 세 개가 정상 기록된다
예외 메시지는 그 타입에 대한 타입 메타데이터 정책이 등록되지 않았다고 말하고, 저장된 문서의 타입 메타데이터는 클래스보다 오래 살아남으므로 그 정책은 기본값이 아니라 기록해야 할 결정이라고 덧붙인다.
그 설계 의도는 타당하다. 문제는 같은 컴포넌트가 등록되지 않은 타입에 대해 세 가지 다른 답을 준다는 점이다.
정책 조회는 스프링 기본값으로 대체한다
쓰기 경로는 예외를 던진다
조회가 관대하고 쓰기가 엄격하면, 설정을 점검하는 코드는 통과하고 실제 쓰기는 실패한다.
그리고 출하 기본값이 이 조합이다. 애플리케이션이 타입을 하나도 등록하지 않은 채 mongo 를 켜면 첫 쓰기에서 실패한다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
확인 방식 : 임시 분석 테스트를 추가해 실행하고 제거. 실행 전 작업 트리 변경 0
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/129-mongo-empty-type-registry-write-probe.txt 이고 probe 소스는 129a 다.
1. 빈 레지스트리로 정책 인식 매퍼를 만든다.
2. 등록되지 않은 타입에 대해 policyFor 를 호출한다. 기본값이 돌아온다.
3. 같은 타입으로 쓰기 타입 결정과 루트 쓰기와 중첩 쓰기를 시도한다. 셋 다 예외다.
4. 같은 문서를 스프링 기본 매퍼로 쓴다. 정상 기록된다.
5. 기본 빈 정의에서 레지스트리가 비어 있게 만들어지는지, 매퍼가 모든 변환기에 설치되는지 확인한다.
## 본문
<!-- body:start -->
세 사실이 겹친다.
1. 기본 bean이 **비어 있는** registry이고 그 javadoc이 "An empty registry so a deployment with no long-lived collection still starts"로 의도를 적는다.
2. configurer가 policy-aware mapper를 **모든** `MappingMongoConverter`에 무조건 설치한다.
3. 그 mapper가 미등록 타입 write에 `IllegalStateException`을 던진다.
## MappingMongoConverter 참조 위치
:::evidence key="mongo-default-throws-on-first-write" alt="코드베이스에서 MappingMongoConverter 를 검색한 출력 2줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MappingMongoConverter 코드베이스 검색 — 2줄 · exit 0" zoom="true"
:::
## 시작은 하고 첫 write에서 실패한다
module을 켜고 type metadata를 등록하지 않은 배포가 그렇다. probe가 shipped default 조합을 실제로 구성해 확인했고, Spring 기본 mapper로 바꾸면 같은 write가 성공한다.
## 같은 컴포넌트가 같은 질문에 세 가지로 답한다
미등록 타입의 정책은 `CLASS_METADATA_ALLOWED`, type-restricted **query**는 class name을 predicate에 쓰고, **write**는 예외다. 읽기와 쓰기가 정반대로 답하고 어느 쪽도 registry가 문서화한 기본값과 일치하지 않는다.
## 확인하지 못한 것
실제 MongoDB 서버에 대고 재현하지 않았다. probe 는 매퍼와 레지스트리 수준에서 관측했다.
<!-- body:end -->
@@ -0,0 +1,98 @@
---
kind: CASE
slug: support-matrix-said-the-opposite-of-the-code
title: 지원 매트릭스가 코드와 반대를 적었고, 그 오해가 소비자에게 자기 멱등성을 생략하게 한다
topic: drift-direction
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:support-matrix-said-the-opposite-of-the-code
evidenceCapturedOn: 2026-09-01
body: case-support-matrix-said-the-opposite-of-the-code.body.md
assets:
- key: support-matrix-said-the-opposite-of-the-code
file: ../../../final/evidence/rendered/support-matrix-said-the-opposite-of-the-code.svg
evidence:
- ../../../final/evidence/raw/support-matrix-said-the-opposite-of-the-code.txt
- ../../../final/evidence/raw/tl-kafka-dedup-drift.txt
source:
- 원본 분석 절은 final/document.md#8-1 · analysis/19 §6.3 이다.
---
# 지원 매트릭스가 코드와 반대를 적었고, 그 오해가 소비자에게 자기 멱등성을 생략하게 한다
Kafka 어댑터의 능력 상수는 중복 제거 발행을 거짓으로 선언한다. 지원 매트릭스는 그것을 지원으로 적는다. 능력 표 60칸 중 유일한 불일치이고, 하필 실제 거부를 일으키는 유일한 플래그다.
## 관계
- **과대 진술 문서를 과소보다 먼저 고친다**
이 사례가 그 우선순위를 만든 형태다.
- **문서 계약 테스트의 단언 경계 밖에 발견된 드리프트 세 건이 전부 있었다**
이 드리프트가 왜 잡히지 않았는지 설명한다.
- **문서와 상수가 서로 일치하는 것으로는 아무것도 증명되지 않는다**
같은 계열의 규칙이다.
## 문제
메시징 어댑터는 자기 능력을 불리언 열둘로 선언한다. 열 번째가 중복 제거 발행이다.
Kafka 어댑터의 상수에서 열 번째 값은 거짓이다.
지원 매트릭스의 해당 행은 Kafka 열에 지원 표시를 적는다.
능력 표는 어댑터 다섯 곱하기 플래그 열둘로 60칸이다. 이 한 칸이 유일한 불일치다.
## 결론
하필 그 한 칸이 실제 거부를 일으키는 유일한 플래그다.
발행기는 열두 플래그 중 하나만 강제한다. 프로파일이 중복 제거 발행을 요구하는데 전송이 그 능력을 갖지 않으면 거부한다.
그래서 오해의 방향이 나쁜 쪽이다. 매트릭스를 읽고 Kafka 가 중복 제거를 해 준다고 믿은 소비자는 자기 멱등성 처리를 생략한다. 실제로는 브로커가 중복 제거를 하지 않으므로 중복이 그대로 소비자에게 도달한다.
그리고 그 소비자가 프로파일에 중복 제거를 요구하면 발행 자체가 거부된다. 두 결과 중 어느 쪽도 매트릭스를 읽은 사람이 예상한 것이 아니다.
문서 계약 테스트가 이 불일치를 잡지 못한 이유는 그 테스트의 단언 여덟 개가 등급 이름과 버전 문자열과 존재하지 않는 상수를 붙들고 능력 표 60칸은 붙들지 않기 때문이다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
확인 방식 : 코드 상수와 문서 표의 위치 대조
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/tl-kafka-dedup-drift.txt 와 255-messaging-capability-doc-vs-code-drift.txt 에 있다.
1. 능력 레코드의 컴포넌트 순서를 확인해 열 번째가 중복 제거 발행임을 확인한다.
2. Kafka 전송의 능력 상수에서 열 번째 값을 읽는다. 거짓이다.
3. 지원 매트릭스의 해당 행에서 Kafka 열을 읽는다. 지원으로 적혀 있다.
4. 발행기에서 능력 플래그를 강제하는 지점을 찾는다. 중복 제거 발행 하나뿐이다.
## 본문
<!-- body:start -->
capability 표 60칸을 코드 배열과 전수 대조한 결과 불일치가 정확히 한 칸이다 — Kafka `deduplicatedPublish`가 문서 `O`, 코드 `false`.
## 60칸을 전수 대조한 결과
:::evidence key="support-matrix-said-the-opposite-of-the-code" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## 하필 그 플래그다
12개 중 유일하게 실제 거부를 발생시키는 것이고, 코드 javadoc이 `true``false` 변경 이력과 피해를 직접 이름 붙인다 — "the caller believes the broker is deduplicating and skips the idempotency it would otherwise build."
## 나머지 48칸은 일치한다
Pulsar의 `keyedOrdering`은 문서가 두 배열 차이까지 반영해 코드보다 정밀하다.
## 확인하지 못한 것
실제 브로커에 대고 중복 발행을 시도해 거부가 나는 것을 재현하지 않았다. 이 기록은 선언과 문서의 불일치, 그리고 그 플래그가 강제되는 유일한 것이라는 사실에 대한 것이다.
없음 — 60칸 전수 대조
<!-- body:end -->
@@ -0,0 +1,88 @@
---
kind: CASE
slug: the-readme-recipe-does-not-start
title: README의 활성화 recipe를 그대로 따르면 애플리케이션이 시작되지 않는다
topic: drift-direction
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:the-readme-recipe-does-not-start
evidenceCapturedOn: 2026-09-01
assets:
- key: the-readme-recipe-does-not-start
file: ../../../final/evidence/rendered/the-readme-recipe-does-not-start.svg
evidence:
- ../../../final/evidence/raw/the-readme-recipe-does-not-start.txt
source:
- 원본 분석 절은 final/document.md#7-4 · analysis/06 §4, §23 이다.
---
# README의 활성화 recipe를 그대로 따르면 애플리케이션이 시작되지 않는다
리프의 README 는 mongo 를 켜는 절차를 적는다. 그 절차만 따르면 타입 메타데이터가 하나도 등록되지 않고, 그 상태의 첫 쓰기는 예외로 끝난다.
## 관계
- **출하 default 조합이 첫 write에서 예외를 던진다**
같은 조합을 실행으로 확인한 사례다.
- **과대 진술 문서를 과소보다 먼저 고친다**
문서가 성립하지 않는 절차를 적었을 때의 우선순위다.
## 문제
README 는 이 리프를 활성화하는 절차를 안내한다. 마스터 스위치를 켜고 접속 설정을 주는 형태다.
그 절차에는 타입 메타데이터 등록이 없다.
## 결론
절차만 따르면 첫 쓰기가 예외로 끝난다.
기본 빈 정의가 정책 인식 타입 매퍼를 모든 매핑 변환기에 설치하고 레지스트리는 빈 것으로 만든다. 등록되지 않은 타입에 대한 쓰기 경로는 예외를 던진다.
실행으로 확인한 결과는 관련 Case 에 있다. 빈 레지스트리에서 루트 쓰기와 중첩 쓰기가 모두 IllegalStateException 이고, 같은 문서를 스프링 기본 매퍼로 쓰면 정상이다.
즉 README 의 절차는 문서 안에서는 완결되어 보이지만 실행하면 완결되지 않는다. 빠진 단계가 무엇인지도 문서에 없다.
드리프트의 방향이 나쁜 쪽이다. 문서가 실제보다 적게 요구한다. 읽는 사람은 절차를 다 따랐다고 믿고, 실패는 배포 후 첫 쓰기에서 나타난다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
확인 방식 : README 절차와 기본 빈 정의 대조, 그리고 별도 probe 의 실행 결과 참조
소스 수정 : x
## 재현 조건
1. 리프의 README 에서 활성화 절차를 읽는다.
2. 그 절차가 타입 메타데이터 등록을 요구하는지 확인한다.
3. 기본 빈 정의에서 레지스트리가 비어 있게 만들어지는지 확인한다.
4. 빈 레지스트리 조합의 쓰기 결과를 확인한다. 원문은 evidence/raw/129-mongo-empty-type-registry-write-probe.txt 다.
## 본문
<!-- body:start -->
README가 제시하는 활성화 절차를 그대로 따르면 시작은 하고 첫 write에서 예외가 난다.
## README 가 제시하는 절차
:::evidence key="the-readme-recipe-does-not-start" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## 세 사실이 겹친다
빈 registry가 기본 bean이고, policy-aware mapper가 모든 converter에 무조건 설치되며, 미등록 타입 write가 `IllegalStateException`을 던진다.
## 실패의 출처를 probe 가 좁혔다
shipped default 조합을 실제로 구성해 확인했고, 같은 converter에 Spring 기본 mapper를 두면 같은 write가 성공한다 — 실패는 문서·엔티티 형태가 아니라 이 leaf가 설치한 mapper에서 온다.
## 확인하지 못한 것
README 절차를 처음부터 끝까지 실제로 수행해 애플리케이션을 기동하지 않았다. 확인한 것은 절차에 빠진 단계와 그 단계가 없을 때의 쓰기 결과다.
실제 MongoDB에 붙이지 않았다(probe는 converter 수준)
<!-- body:end -->