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,176 @@
---
kind: CASE
slug: a07-f001-uuidcodec
title: UuidCodec 의 메서드를 부르는 줄은 자기 명세 다섯뿐이다
topic: identity-and-identifier
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a07-f001-uuidcodec
evidenceCapturedOn: 2026-09-02
body: case-a07-f001-uuidcodec.body.md
assets:
- key: a07-f001-uuidcodec
file: ../../../final/evidence/rendered/a07-f001-uuidcodec.svg
- key: a07-f001-uuidcodec-elsewhere
file: ../../../final/evidence/rendered/a07-f001-uuidcodec-elsewhere.svg
evidence:
- ../../../final/evidence/raw/a07-f001-uuidcodec.txt
- ../../../final/evidence/raw/a07-f001-uuidcodec-elsewhere.txt
source:
- 원본 분석 절은 analysis/07-adapter-outbound-identifier.md#L73 이다. 등급은 P2 다. 세 타입의 리프 밖 소비자 계수, 메서드 호출이 자기 명세뿐이라는 사실, 문자열에서 직접 만드는 경로가 여럿이라는 지적, 컬럼 변환을 하이버네이트가 처리한다는 서술, 그리고 수정 두 가지가 그 절에 있다.
- 그 절은 직접 만드는 파일이 스무 개 이상이라 적는데, 그 절이 예시로 든 다섯이 모두 프로덕션 소스이므로 같은 정의에서 맞는 수는 열다섯 파일 스물한 줄이다. 소스 세트를 가리지 않고 세면 서른넷이고 그중 열아홉이 테스트다.
- 애너테이션이 잇는 구간이 UUID 값과 컬럼 사이라는 것, GraphQL 스칼라가 표준 파서보다 좁게 거른다는 것, 그리고 모듈 의존 규칙이 스물한 줄 중 열세 줄을 막는다는 것은 이 기록에서 확인했다.
---
# UuidCodec 의 메서드를 부르는 줄은 자기 명세 다섯뿐이다
이 리프를 아웃바운드 어댑터 모듈 밖에 둔 근거로 UUID 식별자와 코덱 능력이 제시된다. 그 능력을 구현한 타입을 부르는 프로덕션 코드가 없다.
## 관계
- **@Bean이 있다는 것은 조립 증거가 아니다**
타입의 존재와 사용을 나눠 세는 규칙이다.
- **소비자가 없는 fixture 셋**
선언된 타입 수와 프로덕션 호출자 수를 각각 세어 배선되지 않았다는 것을 확인한 다른 리프다.
- **normalize는 canonical이 아닌 입력을 받아 다른 UUID로 조용히 바꾼다**
이 타입을 단일 경로로 올릴 때 먼저 고쳐야 하는 것이다.
- **문서가 선언됐다는 둘은 없고, 선언된 하나는 그 문장에 없다**
같은 리프의 문서 정합성 사례다.
## 문제
모듈 문서가 배치 근거를 한 문장으로 적는다. UUID 식별자와 코덱 능력은 인프라이지 아웃바운드 연동 지점이 아니므로 그 모듈 밖에 둔다는 것이다.
리프의 공개 타입은 셋이다. 가명화기와 업로드 식별자 팩토리와 UUID 코덱이다.
## 결론
앞의 둘은 리프 밖에서 생성된다. 가명화기는 부트스트랩과 예제 애플리케이션에서, 업로드 식별자 팩토리는 부트스트랩에서다.
셋째는 없다. 그 이름이 리프 밖에 나오는 자리는 넷인데 둘은 몽고 테스트킷이 임포트하는 드라이버의 동명 타입이고 하나는 그 드라이버 설정을 적은 ADR 문서다. 이 리프의 타입을 가리키는 것은 하나뿐이고, 그것은 호출이 아니라 예제 애플리케이션 README 의 산문이다. 그 산문은 코덱을 쓰지 않는 이유를 적는다.
메서드를 부르는 줄은 저장소 전체에서 다섯이고 다섯 다 자기 명세다.
리프 밖 프로덕션에서 문자열을 UUID 로 바꾸는 자리를 세면 파일 열다섯에 줄 스물하나다. 그 열다섯이 하는 일이 다 같지는 않다. GraphQL 의 UUID 스칼라는 표준 파서가 관대하다는 것을 자바독에 적고 정규형 정규식으로 먼저 거른 뒤에야 파서를 부른다. 코덱으로 갈아 끼우면 검사 폭이 줄어든다.
문서가 코덱의 다른 목적으로 든 컬럼 변환은 하이버네이트의 타입 코드 애너테이션이 맡는다. 다만 그 애너테이션이 붙은 서른여덟 자리의 필드 타입은 전부 UUID 다. 애너테이션이 잇는 것은 UUID 값과 컬럼 사이이고, 문자열과 UUID 사이는 여전히 손으로 짜여 있다. 예제의 영속 매퍼가 그 두 겹을 한 파일에서 보여 준다.
이 분산은 배치 규칙의 결과다. 모듈 레지스트리에서 이 리프에 의존해도 되는 모듈은 예순둘 중 둘, 부트스트랩과 예제 애플리케이션뿐이다. 그중 열세 줄은 규칙을 손대기 전에는 그 타입에 닿을 수 없는 자리다. 남는 여덟 줄 중 둘은 예제 README 가 쓰지 않는 이유를 이미 적어 두었다.
코드 자체에는 결함이 없다. 서른 줄짜리 유틸이고 자기 시험은 다 통과한다. 어긋난 것은 논거다.
그래서 선택지는 좁다. 능력을 논거에서 지우거나 배치 규칙을 손보는 것뿐이다. 그 타입을 실제 단일 경로로 올리려면 규칙을 먼저 바꿔야 하고, 그 다음에는 같은 문서 다음 절이 다루는 정규화 문제를 고쳐야 한다.
이 기록이 세는 것은 소비자의 유무이지 단일 경로 여부가 아니다. 단일 경로를 물으면 앞의 둘도 통과하지 못한다. 리프 밖 프로덕션 스물다섯 파일이 표준 라이브러리 생성기를 직접 부른다.
## 검증 환경
확인 방식 : 저장소 전수 참조 계수, 모듈 의존 레지스트리 조회
소스 수정 : x
## 재현 조건
1. 모듈 문서가 적은 배치 근거를 읽는다.
2. 리프의 공개 타입을 나열한다.
3. 각 타입 이름이 리프 밖에 나오는 곳을 확장자 제한 없이 전수로 센다.
4. 그중 이 리프의 타입을 가리키는 것과 동명의 다른 타입을 가른다.
5. 그 타입의 메서드를 부르는 줄을 저장소 전체에서 센다.
6. 문자열에서 UUID 를 직접 만드는 리프 밖 프로덕션 파일과 줄을 전부 나열한다.
7. 그중 표준 파서를 그대로 쓰지 않는 것이 있는지 확인한다.
8. 컬럼 변환 애너테이션이 붙은 필드의 타입을 센다.
9. 레지스트리에서 이 리프의 소비자 후보를 센다.
## 본문
<!-- body:start -->
모듈 문서가 이 리프를 아웃바운드 어댑터 모듈 밖에 둔 이유를 세 줄로 적는다.
## 논거와 공개 타입
:::evidence key="a07-f001-uuidcodec" alt="모듈 문서가 적은 배치 근거 세 줄과 리프의 공개 타입 셋. 각 타입 이름이 리프 밖에 나오는 곳을 확장자 제한 없이 전수로 검색한 결과. 그리고 UUID 코덱의 메서드를 부르는 줄을 저장소 전체에서 검색한 결과를 출력한 터미널 기록." caption="가명화기는 리프 밖 생성 둘, 업로드 식별자 팩토리는 하나 · 코덱 이름이 나오는 넷 중 둘은 드라이버의 동명 타입, 하나는 ADR 문서, 하나는 예제 README 의 산문 · 메서드 호출은 자기 명세 다섯 줄 — 40줄 · exit 0" zoom="true"
:::
```text
- Kept out of `adapter-outbound` on purpose: a UUID id/codec capability is
infrastructure, not an outbound integration point, so `adapter-outbound` keeps its
documented meaning (external HTTP / messaging / cache / notifications).
```
능력은 하나로 적혀 있고, 공개 타입은 셋이다. 셋 중 둘은 리프 밖에서 생성된다 — 가명화기는 부트스트랩과 예제 애플리케이션에서, 업로드 식별자 팩토리는 부트스트랩에서.
## 이름이 겹친 참조들
코덱 이름이 리프 밖에 나오는 자리는 넷이다.
```text
docs/adr/ADR-MONGO-002-bson-representation.md:44:pins `UuidCodec(STANDARD)` explicitly …
src/sample-portfolio/README.md:333:- … `UuidCodec` 같은 공용
src/adapter/outbound/persistence-mongo/src/testkit/.../MongoBsonSnapshot.java:16:import org.bson.codecs.UuidCodec;
src/adapter/outbound/persistence-mongo/src/testkit/.../MongoBsonSnapshot.java:59: … new UuidCodec(UuidRepresentation.STANDARD)),
```
뒤 둘은 몽고 드라이버의 동명 타입이고, ADR 은 그 드라이버 설정을 적은 문서다. 이 리프의 타입을 가리키는 것은 예제 README 한 줄뿐인데, 그 줄은 호출이 아니라 쓰지 않는 이유를 적는 산문이다.
저장소 전체에서 그 타입의 메서드를 부르는 줄은 다섯이고, 다섯 다 자기 명세 파일이다.
## 같은 변환을 하는 다른 자리들
:::evidence key="a07-f001-uuidcodec-elsewhere" alt="문자열에서 UUID 를 직접 만드는 리프 밖 프로덕션 파일 수와 호출 줄 수와 그 파일 전부의 목록. 그중 GraphQL 스칼라가 표준 파서의 관대함을 적고 정규형 정규식으로 먼저 거르는 구간. 컬럼 변환 애너테이션을 단 프로덕션 파일 수와 애너테이션 수와 그 애너테이션이 붙은 필드의 타입 분포. 예제의 영속 매퍼가 문자열 구간을 따로 처리하는 줄. 그리고 모듈 레지스트리에서 이 리프에 의존해도 되는 모듈 수와 예제 README 가 코덱을 쓰지 않는 이유를 적은 세 줄을 출력한 터미널 기록." caption="직접 만드는 곳은 열다섯 파일 스물한 줄 · GraphQL 스칼라는 정규형 정규식으로 먼저 거름 · 컬럼 변환 애너테이션은 스물세 파일 서른여덟 개이고 붙은 필드는 전부 UUID · 리프에 의존해도 되는 모듈은 예순둘 중 둘 — 43줄 · exit 0" zoom="true"
:::
문자열에서 UUID 를 만드는 일은 리프 밖 프로덕션 열다섯 파일에서 스물한 줄이 한다. 애플리케이션 코어의 파일·업로드 식별자, 세 메시징 리프의 매퍼, GraphQL 의 UUID 스칼라, 몽고의 커서 코덱, 알림의 라우팅 계획 코덱, JPA 의 멱등 청구 저장소, 예제의 웹 컨트롤러 넷과 영속 매퍼 둘이다.
열다섯이 모두 같은 일을 하지는 않는다.
```java
* <p>{@link UUID#fromString} is lenient it happily accepts {@code "1-1-1-1-1"} so accepting
* whatever it parses would make the wire contract depend on a JDK quirk and let two different
* strings denote the same identifier. The canonical form is enforced explicitly instead.
```
GraphQL 스칼라는 표준 파서의 관대함을 알고 정규형 정규식으로 먼저 거른다. 이쪽을 코덱으로 바꾸면 검사가 느슨해진다.
## 컬럼 변환은 다른 구간을 잇는다
```text
# @JdbcTypeCode(SqlTypes.UUID) 를 단 프로덕션 파일 : 23
# 그 애너테이션 개수 : 38
# 그 애너테이션이 붙은 필드의 타입 : {'UUID': 38}
```
애너테이션이 잇는 것은 UUID 값과 PostgreSQL 컬럼 사이다. 문서가 `toUuid`/`fromUuid` 의 목적으로 적은 두 구간 중 문자열과 UUID 사이는 여기에 없고, 위의 스물한 줄이 각자 처리한다. 예제의 영속 매퍼가 두 겹을 한 파일에서 보여 준다 — 엔티티는 애너테이션으로 컬럼을 잇고, 매퍼가 표준 파서로 문자열을 잇는다.
```java
public static UUID toUuid(WorkLogId id) {
return UUID.fromString(id.value());
```
## 이 분산은 배치 규칙의 결과다
```text
# 전체 62 중 2 : app-bootstrap, sample-portfolio
```
모듈 레지스트리에서 이 리프에 의존해도 되는 모듈은 둘뿐이다. 스물한 줄 중 열세 줄은 규칙을 먼저 바꾸지 않으면 그 타입을 부를 수 없다. 남는 여덟 줄 중 둘에 대해서는 예제 README 가 이유를 적어 두었다.
```text
- 36자 canonical UUID 와 PostgreSQL native `uuid`(128비트)를 서로 변환합니다. `UuidCodec` 같은 공용
코덱이 아니라 JDK `java.util.UUID` 를 **직접** 쓰는 이유: 영속 어댑터는 경계 규칙상
`adapter-outbound`(코덱이 있는 곳)에 의존하면 안 되기 때문입니다(stdlib 이라 의존 문제 자체가 없음).
```
모듈을 그 자리에 둔 규칙이 그 모듈의 능력을 부를 수 없게 만든다.
## 남는 선택지
이 타입은 서른 줄짜리 유틸이고 자기 명세를 통과한다. 논거에서 그 능력을 빼거나, 배치 규칙을 다시 여는 것이 남는다. 단일 경로로 올리는 쪽은 규칙 변경이 선행이고, 그 다음에는 같은 문서 다음 절이 다루는 정규화 문제가 온다.
이 기록이 센 것은 소비자의 유무다. 단일 경로 여부를 물으면 앞의 두 타입도 통과하지 못한다 — 리프 밖 프로덕션 스물다섯 파일이 표준 라이브러리 생성기를 직접 부른다.
## 확인하지 못한 것
스물한 줄 중 열세 줄은 모듈 의존 규칙상 이 타입을 부를 수 없어 동작 비교를 물을 단계가 아니다. 규칙이 허용하는 여덟 줄에 대해서만 대체 시 동작이 같은지 확인하지 않았다.
<!-- body:end -->
@@ -0,0 +1,208 @@
---
kind: CASE
slug: a07-f002-normalize
title: 정규형에서 한 글자를 지운 입력이 거부되지 않고 정규형으로 채워진다
topic: identity-and-identifier
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a07-f002-normalize
evidenceCapturedOn: 2026-09-02
body: case-a07-f002-normalize.body.md
assets:
- key: a07-f002-normalize
file: ../../../final/evidence/rendered/a07-f002-normalize.svg
- key: a07-f002-normalize-probe
file: ../../../final/evidence/rendered/a07-f002-normalize-probe.svg
evidence:
- ../../../final/evidence/raw/a07-f002-normalize.txt
- ../../../final/evidence/raw/a07-f002-normalize-probe.txt
source:
- 원본 분석 절은 analysis/07-adapter-outbound-identifier.md#L89 이다. 등급은 P2 다. 계약과 구현의 어긋남, 표준 파서가 다섯 그룹을 길이 검사 없이 받는다는 사실, 탐침이 보인 재작성, 명세의 거부 케이스가 하나뿐인 이유, 관대함이 남은 자리가 신뢰 경계라는 판정, 도달성이 0 이라는 사실, 그리고 수정 두 가지가 그 절에 있다.
- 어느 글자를 지우느냐에 따라 같은 값이 되기도 하고 다른 값이 되기도 한다는 것, 길이가 36 이어도 대시 위치가 다르면 관대한 경로로 떨어진다는 것, 초과 자릿수가 상위 비트를 버린 채 통과한다는 것, 부호와 비라틴 숫자가 받아들여진다는 것, 그리고 UUID 스칼라의 관문이 세 진입점을 모두 지난다는 것은 이 기록에서 확인했다.
- 그 절의 수정안 첫 절은 길이 36 검사다. 위의 36자 반례가 그것만으로는 부족함을 보인다.
---
# 정규형에서 한 글자를 지운 입력이 거부되지 않고 정규형으로 채워진다
정규화 메서드의 계약은 정규형만 받고 형식 오류는 예외로 거부한다고 적는다. 구현이 위임하는 표준 파서는 대시로 나뉜 다섯 그룹이면 각 그룹의 자릿수를 보지 않는다. 거부됐어야 할 입력이 앞을 0 으로 채운 정규형 식별자로 돌아온다.
## 관계
- **UuidCodec 의 메서드를 부르는 줄은 자기 명세 다섯뿐이다**
이 결함이 아직 노출되지 않은 이유다.
- **sanitize가 아니라 reject가 기본이다**
이 계약이 따르겠다고 적은 규칙이다.
- **그 속성을 보일 수 없는 대역 위에서 통과한 테스트는 증인이 아니다**
기존 명세가 이것을 놓친 이유다.
## 문제
계약은 자바독과 README 양쪽에 적혀 있다. 대소문자를 가리지 않는 정규형 문자열을 받아 36자 소문자형을 돌려주고, 형식이 잘못된 값에는 예외를 던진다는 것이다.
구현은 한 줄이다. 표준 라이브러리의 문자열 파서를 부르고 결과를 다시 문자열로 만든다.
## 결론
빠른 경로 조건은 길이 36 과 대시 위치 8·13·18·23 이고, 거기서 벗어난 값은 다섯 그룹으로 잘라 각각 수로 읽힌다. 자릿수는 검사하지 않고, 규정 자릿수를 넘으면 상위 비트를 버린 채 통과시킨다.
실행으로 확인했다. 정규형 36자에서 마지막 그룹의 한 글자를 지운 입력이 예외 없이 통과하고, 지운 자리 앞을 0 으로 채운 정규형 문자열이 돌아온다.
어느 글자를 지우느냐가 결과를 가른다. 앞자리 0 은 지워도 수가 같아서 결과가 입력과 일치한다. 그 외 자리를 지우면 형식이 올바른 다른 식별자가 돌아온다. 대시를 지운 값은 예외로 떨어진다.
두 번째가 이 결함의 실질이다. 잘린 식별자가 거부되지 않고 다른 대상을 가리키는 식별자가 된다.
길이만으로는 막히지 않는다. 길이가 36 이어도 대시 위치가 다르면 같은 관대한 경로로 떨어진다.
받아들이는 범위가 십육진으로 한정되지도 않는다. 부호가 붙은 값과 아라비아·인도 숫자가 통과한다.
거부 자체는 작동한다. 대시를 뺀 32자나 UUID 형태가 아닌 값은 예외로 떨어진다. 계약과 어긋나는 지점은 거부 기준이다.
관대함이 이 메서드에 갇혀 있지도 않다. 같은 파서를 쓰는 형제 메서드가 재작성된 128비트 값을 그대로 호출자에게 넘긴다.
기존 명세가 이것을 놓친 이유도 코드에 있다. 거부 단언이 하나뿐이고, 그 문자열은 대시 그룹이 다섯이 아니라 관대한 경로에 닿지 않는다.
호출자가 준 텍스트를 저장 형태로 바꾸는 지점이 이 메서드다. 관대함이 남은 자리가 하필 신뢰 경계다. 다만 지금 이 메서드를 부르는 프로덕션 코드가 없어 노출은 0 이고, 그 타입을 단일 경로로 올리는 순간 결함이 된다.
수정은 파서에 넘기기 전에 정규형 정규식으로 거르는 것이다. 길이 검사만으로는 부족하다. 또는 계약 문구를 실제 동작에 맞추는 것이다. 앞의 것이 문서가 말하는 바다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 실행 탐침
소스 수정 : x
## 재현 조건
1. 클래스 산출물이 현재 소스보다 새로운지 확인한다.
2. 자바독과 README 의 계약 문구를 읽는다.
3. 정규형 36자에서 마지막 그룹의 선행 0, 마지막 글자, 중간 글자, 그리고 대시를 각각 하나씩 지워 넣는다.
4. 각 결과가 원본과 같은지 비교한다.
5. 길이가 36 이면서 대시 위치가 다른 문자열을 넣는다.
6. 그룹이 규정 자릿수를 넘는 문자열과 부호·비라틴 숫자를 넣는다.
7. 대시가 없는 32자와 UUID 가 아닌 문자열을 넣는다.
8. 같은 파서를 쓰는 형제 메서드에 같은 입력을 넣는다.
9. 명세의 거부 단언 수를 센다.
10. 같은 저장소에서 이 파서를 쓰면서 먼저 거르는 코드를 찾는다.
## 본문
<!-- body:start -->
계약은 자바독과 README 양쪽에 적혀 있고, 구현은 한 줄이다.
## 계약과 한 줄
:::evidence key="a07-f002-normalize" alt="정규화 메서드의 자바독 계약과 한 줄짜리 구현, 같은 계약이 적힌 README 두 줄. 같은 파서를 쓰는 형제 메서드. 명세의 거부 케이스와 그 수. 그리고 같은 저장소의 GraphQL UUID 스칼라가 쓰는 정규형 정규식 상수와 파서보다 먼저 그것을 돌리는 관문, 그 관문을 지나는 호출 지점들을 출력한 터미널 기록." caption="계약은 형식 오류에 예외를 던진다고 적음 · 구현은 표준 파서 호출 한 줄 · 명세의 거부 단언은 하나 · UUID 스칼라는 정규식 상수를 두고 파서보다 먼저 돌린다 — 47줄 · exit 0" zoom="true"
:::
```java
/**
* Accepts a case-insensitive canonical UUID string and returns the canonical 36-character
* lowercase form; {@code null} input returns {@code null}.
*
* @throws IllegalArgumentException on a malformed UUID
*/
public static String normalize(String input) {
if (input == null) {
return null;
}
return UUID.fromString(input).toString();
}
```
표준 파서는 길이 36 이면서 대시가 8·13·18·23 에 있는 빠른 경로를 벗어나면, 대시로 나뉜 다섯 그룹을 각각 수로 읽는다. 자릿수는 검사하지 않는다.
## 마지막 그룹에서 한 글자를 지운 입력
:::evidence key="a07-f002-normalize-probe" alt="클래스 산출물이 소스보다 새로운지 확인한 결과와 JVM 판본. 계약이 말하는 두 갈래의 입력을 넣은 결과. 정규형 36자에서 선행 0·마지막 글자·중간 글자·대시를 각각 하나씩 지운 입력의 결과와 원본과의 동일 여부. 길이가 36 이면서 대시 위치가 다른 문자열, 그룹이 규정 자릿수를 넘는 문자열, 부호와 비라틴 숫자를 넣은 결과. 서로 다른 다섯 입력이 어떤 식별자들로 모이는지. 그리고 같은 파서를 쓰는 형제 메서드의 결과를 출력한 터미널 기록." caption="선행 0 을 지우면 원본과 같은 값, 다른 자리를 지우면 다른 값, 대시를 지우면 예외 · 길이 36 이어도 대시 위치가 다르면 통과 · 초과 자릿수는 상위 비트를 버림 · 부호와 아라비아·인도 숫자도 통과 · 다섯 입력이 두 식별자로 모임 — 36줄 · exit 0" zoom="true"
:::
정규형 36자에서 자리를 바꿔 가며 한 글자씩 지워 넣었다.
```text
normalize("0190bd6e-7c3e-7abc-8def-123456789ab") -> "0190bd6e-7c3e-7abc-8def-0123456789ab"
원본과 같은가 : true
normalize("0190bd6e-7c3e-7abc-8def-0123456789a") -> "0190bd6e-7c3e-7abc-8def-00123456789a"
원본과 같은가 : false
normalize("0190bd6e7c3e-7abc-8def-0123456789ab") -> IllegalArgumentException: Invalid UUID string: …
원본과 같은가 : false
```
세 결과가 다르다. 선행 0 을 지우면 값이 같아 원본이 돌아온다. 마지막 글자를 지우면 앞을 0 으로 채운 **다른** 식별자가 돌아온다. 대시를 지우면 예외가 난다.
가운데가 이 결함의 실질이다. 잘린 문자열이 거부되지 않고, 형식이 올바르면서 다른 대상을 가리키는 식별자가 된다.
## 길이 검사로는 막히지 않는다
```text
normalize("0000001-00001-0001-0001-000000000001") -> "00000001-0001-0001-0001-000000000001"
```
이 입력은 36자다. 대시 위치가 다를 뿐인데 빠른 경로를 벗어나 관대한 경로로 떨어진다. `length() != 36` 만 검사하는 수정은 이것을 통과시킨다.
받는 것이 십육진에 한정되지도 않는다.
```text
normalize("100000001-1-1-1-1") -> "00000001-0001-0001-0001-000000000001"
normalize("+1-1-1-1-1") -> "00000001-0001-0001-0001-000000000001"
normalize("١-1-1-1-1") -> "00000001-0001-0001-0001-000000000001"
```
첫 줄은 아홉 자리 그룹이고, 상위 비트가 버려진 채 통과한다. 나머지 둘은 부호와 아라비아·인도 숫자다.
거부가 고장 난 것은 아니다. 대시 없는 32자와 UUID 가 아닌 문자열은 예외를 던진다. 계약과 다른 것은 거부의 기준이다.
## 서로 다른 입력이 모이는 자리
```text
00000001-0001-0001-0001-000000000001 <- "1-1-1-1-1", "01-01-01-01-01", "+1-1-1-1-1", "00000001-0001-0001-0001-000000000001"
00000001-0001-0001-0001-000000000002 <- "1-1-1-1-2"
```
넷이 하나로 모이고, 다섯째는 따로 선다. 결과만 보고는 앞의 셋이 넷째와 다른 문자열이었다는 것을 알 수 없다.
같은 파서를 쓰는 형제 메서드도 같은 값을 낸다. 관대함은 정규화 메서드에 갇혀 있지 않고, 재작성된 128비트 값이 그대로 호출자에게 간다.
```text
toUuid("1-1-1-1-1") = 00000001-0001-0001-0001-000000000001
```
## 명세의 거부 케이스
```groovy
def "normalize 는 형식이 잘못된 UUID 를 거부한다"() {
when:
UuidCodec.normalize("not-a-uuid")
then:
thrown(IllegalArgumentException)
}
```
명세 전체의 거부 단언은 이 하나다. 그 문자열은 대시 그룹이 다섯이 아니라 관대한 경로에 닿지 않는다.
## UUID 스칼라는 파서보다 정규식을 먼저 돌린다
같은 저장소의 다른 리프가 같은 파서를 쓰면서 계약을 문구가 아니라 코드로 지킨다.
```java
public static UUID parse(String value) {
if (value == null || !CANONICAL.matcher(value).matches()) {
throw new CoercingParseValueException("invalid UUID");
}
```
`CANONICAL` 은 정규형 정규식 상수이고, 값을 받는 세 진입점이 모두 이 한 메서드를 지난다. 수정에 필요한 정규식이 이미 저장소 안에 있다.
## 지금은 아무도 부르지 않는다
이 타입의 메서드를 부르는 줄은 저장소 전체에서 자기 명세 다섯뿐이다. 프로덕션 호출자가 없으므로 현재 노출은 0 이다.
이 메서드는 호출자가 준 텍스트를 저장 형태로 바꾸는 지점이다. 관대함이 남은 자리가 신뢰 경계라는 것이 이 어긋남의 무게이고, 그 무게는 이 타입을 문자열에서 UUID 로 가는 단일 경로로 올리는 순간 실현된다.
## 확인하지 못한 것
표준 라이브러리 판본에 따라 관대한 경로가 달라지는지 확인하지 않았다. 확인한 것은 이 프로젝트가 쓰는 판본이다.
<!-- body:end -->
@@ -0,0 +1,148 @@
---
kind: CASE
slug: a07-f004-claude
title: 문서가 선언됐다는 둘은 없고, 선언된 하나는 그 문장에 없다
topic: identity-and-identifier
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a07-f004-claude
evidenceCapturedOn: 2026-09-02
assets:
- key: a07-f004-claude
file: ../../../final/evidence/rendered/a07-f004-claude.svg
evidence:
- ../../../final/evidence/raw/a07-f004-claude.txt
source:
- 원본 분석 절은 analysis/07-adapter-outbound-identifier.md#L131 이다. 등급은 P3 이다. 선언 블록이 두 줄이라는 사실, 도메인 코어와 외부 라이브러리가 선언돼 있지 않다는 판정, 애플리케이션 코어가 언급되지 않았다는 지적, 그리고 실제 선언이 허용 목록의 부분집합이라는 결론이 그 절에 있다.
- 그 절은 문서 인용에서 `:application-core` 를 `:application-code` 로 적었다. 원문은 `core` 다.
- 앞 문장의 공유 계약이 강제되는 허용 목록에 없다는 것은 이 기록이 새로 확인했다. 그 라이브러리가 이 리프의 클래스패스에 없다는 판정은 그 절에 있고, 여기서는 잠금 파일의 빈 구성 표기로 그것을 다시 확인했다.
---
# 문서가 선언됐다는 둘은 없고, 선언된 하나는 그 문장에 없다
리프 지침 문서의 의존성 문단은 두 문장이다. 앞 문장이 든 허용 목록 셋 중 하나는 강제되는 레지스트리에 없다. 뒤 문장이 선언돼 있다고 적은 둘은 어느 쪽도 없고, 실제 선언 하나는 그 문장에 없다.
## 관계
- **사라진 가드가 문서에만 남았고 그 부재는 이미 승인된 결정이다**
같은 문서의 다른 오류다.
- **README의 세 가지 사실 오류**
같은 리프의 문서 오류다.
- **허용 의존 목록은 상한이므로 미사용을 잡지 않는다**
실제 선언이 그 상한의 부분집합인 이유다.
## 문제
지침 문서에서 허용을 적은 절은 두 문장이다. 앞 문장은 이 리프가 의존할 수 있는 것을 셋으로 적고, 뒤 문장은 지금 빌드 파일에 실제로 선언돼 있다는 것을 적는다. 뒤 문장이 드는 둘 중 하나는 앞 목록의 항목이고 다른 하나는 그 목록에 없는 외부 라이브러리다.
## 결론
빌드 파일의 선언 블록은 두 줄이다. 애플리케이션 코어 프로젝트와 Spock 시험 라이브러리다.
뒤 문장이 든 둘은 어느 쪽도 거기 없다. 도메인 코어는 표기를 가리지 않고 세어도 빌드 파일에 0 건이다. 외부 UUID 생성 라이브러리는 이 리프의 잠금 파일에 없고, 그 잠금 파일은 컴파일과 런타임 클래스패스가 비어 있다고 적는다. 이 저장소는 모든 구성을 엄격 모드로 잠그므로, 그 라이브러리가 시험 경로에만 없는 것이 아니라 프로덕션 경로 자체에 없다. 실제로 그것을 선언하는 곳은 예제 애플리케이션 한 줄이다.
실제로 선언된 애플리케이션 코어는 그 문장에 나오지 않는다. only 라고 쓴 이상 하나를 빠뜨린 것도 위반이다.
앞 문장은 셋 중 둘이 맞고 하나가 어긋난다. 강제되는 목록은 모듈 레지스트리에 있고 거기 적힌 것은 도메인 코어와 애플리케이션 코어다. 문서가 든 셋 중 공유 계약은 그 목록에 없다.
목록의 성격은 서술이 아니라 게이트다. 루트 빌드 파일이 레지스트리에서 허용 간선 표를 파생하고, 허용 밖 간선을 만나면 빌드를 실패시키며, 모든 리프의 검사가 그 태스크에 의존한다. 공유 계약을 실제로 선언하면 빌드가 멈춘다.
빌드 자체는 정합하다. 실제 선언이 허용 목록의 부분집합이다. 어긋난 범위는 문단 하나이고, 그 안의 진술 넷이 사실과 다르다. 판정은 P3 다.
## 검증 환경
Gradle : 9.0.0
확인 방식 : 지침 문서와 빌드 파일·잠금 파일·모듈 레지스트리 대조
소스 수정 : x
## 재현 조건
1. 리프 지침 문서의 허용 절 두 문장을 읽는다.
2. 빌드 파일의 선언 블록을 연다.
3. 도메인 코어를 표기를 가리지 않고 빌드 파일에서 센다.
4. 외부 라이브러리를 리프 잠금 파일에서 세고, 그 잠금 파일이 덮는 구성과 비어 있는 구성을 읽는다.
5. 잠금 모드 설정을 확인한다.
6. 그 라이브러리를 실제로 선언하는 곳을 찾는다.
7. 모듈 레지스트리에서 이 리프의 허용 목록을 읽고 앞 문장의 셋과 대조한다.
8. 그 목록이 어떻게 강제되는지 사슬을 따라간다.
## 본문
<!-- body:start -->
지침 문서의 허용 절은 두 문장이다.
## 허용 절과 선언 블록
:::evidence key="a07-f004-claude" alt="리프 지침 문서의 허용 절과 빌드 파일의 선언 블록. 도메인 코어를 빌드 파일에서, 외부 UUID 생성 라이브러리를 리프 잠금 파일에서 각각 센 결과와 그 잠금 파일이 덮는 구성 목록, 비어 있는 구성, 잠금 모드 설정. 그 라이브러리를 실제로 선언하는 곳. 그리고 모듈 레지스트리의 허용 목록과 문서가 든 셋의 차이, 그 목록을 강제하는 코드 사슬을 출력한 터미널 기록." caption="문서는 도메인 코어와 uuid-creator 가 선언됐다고 적음 · 선언 블록은 애플리케이션 코어와 Spock 두 줄 · 빌드 파일의 도메인 코어 언급 0, 잠금 파일의 uuid-creator 0이고 컴파일·런타임 클래스패스는 비어 있음 · 레지스트리 허용 목록에 공유 계약 없음 · 허용 밖 간선은 빌드를 실패시킴 — 39줄 · exit 0" zoom="true"
:::
```text
- `:application-core`, `:domain-core`, `:shared-contract` (Gradle matrix). Currently
only `:domain-core` + `com.github.f4b6a3:uuid-creator` are declared in
[build.gradle](build.gradle).
```
선언 블록은 두 줄이다.
```groovy
dependencies {
implementation project(':application-core')
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
}
```
## 뒤 문장의 두 이름
```text
# 빌드 파일의 domain-core 언급(모든 표기) : 0
# 잠금 파일 줄 수 / uuid-creator·f4b6a3 : 154 / 0
# 프로덕션 클래스패스의 잠금 상태 : empty=compileClasspath,runtimeClasspath
336: lockAllConfigurations()
337: lockMode = LockMode.STRICT
```
도메인 코어는 어떤 표기로도 빌드 파일에 없다. 외부 라이브러리 쪽은 잠금 파일이 더 강하게 말한다 — 이 저장소는 모든 구성을 엄격 모드로 잠그고, 이 리프의 컴파일·런타임 클래스패스는 잠긴 채 비어 있다. 시험 경로에만 없는 것이 아니라 프로덕션 경로 자체에 아무것도 없다.
그 라이브러리를 선언하는 곳은 따로 있다.
```text
sample-portfolio/build.gradle:52: implementation 'com.github.f4b6a3:uuid-creator:6.1.1'
```
선언 블록의 유일한 프로젝트 의존은 그 문장에 나오지 않는다. 문장이 `only` 라고 적어 선언 전체를 배타적으로 주장했으므로, 빠뜨린 것도 어긋남이다.
## 앞 문장의 세 이름
```text
# 레지스트리 allowed_dependencies : ['domain-core', 'application-core']
# 문서가 든 셋 중 목록에 없는 것 : ['shared-contract']
```
레지스트리가 이 리프에 허용한 것은 둘이다. 셋째 항목은 여기 없다.
이 목록은 서술이 아니라 게이트다.
```groovy
1419: Map<String, Set<String>> allowedProjectDependencies = registry.modules.collectEntries { module ->
...
Set<String> forbidden = actual - allowed
if (!forbidden.isEmpty()) {
throw new GradleException(
...
577: dependsOn rootProject.tasks.named('verifyCleanArchitectureDependencies')
```
루트 빌드 파일이 레지스트리에서 허용 간선 표를 파생하고, 허용 밖 간선을 만나면 빌드를 실패시킨다. 모든 리프의 검사가 그 태스크에 의존하므로 이 리프도 지난다. 문서가 매트릭스라고 적은 셋째 항목을 실제로 선언하면 빌드가 멈춘다.
## 실제 선언과 허용 목록
실제 선언은 허용 목록 안에 있다. 간선 쪽에서 고칠 것은 없다. 어긋난 것은 문단 하나이고, 그 문단이 담은 진술 넷이 사실과 다르다.
## 확인하지 못한 것
문서가 든 두 이름이 과거 어느 시점에 실제로 선언되어 있었는지 확인하지 않았다.
<!-- body:end -->
@@ -0,0 +1,157 @@
---
kind: CASE
slug: a07-f006-claude
title: 사라진 가드가 문서에만 남았고 그 부재는 이미 승인된 결정이다
topic: identity-and-identifier
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a07-f006-claude
evidenceCapturedOn: 2026-09-02
assets:
- key: a07-f006-claude
file: ../../../final/evidence/rendered/a07-f006-claude.svg
evidence:
- ../../../final/evidence/raw/a07-f006-claude.txt
source:
- 원본 분석 절은 analysis/07-adapter-outbound-identifier.md#L160 이다. 등급은 P3 이다. 아키텍처 규칙이 실재한다는 확인, 훅 파일과 그 디렉터리가 추적되지 않는다는 판정, `.claude/` 에 로컬 설정 파일 하나뿐이라는 관측, 그리고 복제본에서는 그 서술이 성립하지 않는다는 결론이 그 절에 있다.
- 이 기록이 더한 것은 넷이다. 무시 목록이 그 디렉터리를 덮고 전 이력에서 추적된 적이 없다는 것, 문서가 특정한 규칙 번호가 해석되지 않는다는 것, 남은 규칙이 같은 줄의 Spring Web 을 덮지 않는다는 것, 그리고 그 훅을 고치겠다던 계획이 폐기되고 대체 문서가 하네스 부재를 목표에 적었다는 것이다.
---
# 사라진 가드가 문서에만 남았고 그 부재는 이미 승인된 결정이다
리프 지침 문서가 금지 사항의 근거로 가드 둘을 든다. 아키텍처 규칙 쪽은 실재하고 CI 에서 돈다. 훅 스크립트 쪽은 이 저장소에 없고, 없는 이유가 다른 문서에 결정으로 적혀 있다.
## 관계
- **문서가 선언됐다는 둘은 없고, 선언된 하나는 그 문장에 없다**
같은 문서의 다른 오류다.
- **산문이 선언한 게이트는 빌드에 있는 게이트가 아니다**
이 사례가 그 규칙의 형태다.
- **release gate가 실제로 차단하는 것은 hermetic test 3개이고, mongo용 CI workflow는 없다**
릴리스 게이트가 실제로 무엇을 막는지 센 문서다.
## 문제
지침 문서가 이 리프에 금지된 것을 나열한다. 첫 항목은 퍼시스턴스와 웹 기술이고, 거기에 강제 근거가 둘 붙는다. 아키텍처 규칙 이름 하나와, 훅 스크립트의 규칙 번호 하나다.
## 결론
아키텍처 규칙은 실재하고 실제로 돈다. 시험 상수에 애너테이션이 붙어 있고 클래스 단위 분석 대상이 저장소 전체이며, 조립 루트가 이 리프를 물고 있어 검사 대상이 비어 있지 않다. CI 품질 게이트가 그 검사를 포함한 태스크를 돌린다.
다만 그 규칙이 막는 이름 목록에 Spring Web 이 없다. 같은 줄이 함께 금지한 기술이고, 사라진 쪽 가드가 덮기로 돼 있던 것이다.
그 기술들을 이 리프 밖에 두는 첫 방어선은 규칙이 아니다. 잠금 파일이 컴파일과 런타임 클래스패스가 비어 있다고 적는다. 컴파일에서 이미 불가능하고, 아키텍처 규칙은 그 뒤에 선다.
훅 스크립트 쪽은 저장소에 없다. 그 디렉터리 아래 추적되는 파일이 0 이고, 전 이력에서도 추적된 적이 없다. 무시 목록 셋째 줄이 그 디렉터리를 덮는다. 작업 트리에 남아 있는 것은 로컬 설정 파일 하나이고 훅 디렉터리는 만들어진 적이 없다.
규칙 번호 쪽도 해석되는 대상이 없다. 그 번호를 쓰는 곳은 자기 자신 말고 없다.
없는 이유는 무시 목록이 아니라 다른 문서에 있다. 그 스크립트를 고치겠다던 계획은 첫 줄에 폐기가 적혀 있고, 그것을 대체한 개정안의 목표 문장이 부재한 개발 하네스를 되살리지 않은 채 Gradle 과 아키텍처 규칙으로만 의존성 강제를 복구하겠다고 적는다. 계획이 약속한 디렉터리 역시 존재하지 않는다.
그러니 이것은 빠뜨린 파일이 아니라 이미 내려진 결정이다. 그 결정 이전의 문장을 그대로 들고 있는 것이 리프 지침 문서다. 판정은 P3 다.
## 검증 환경
확인 방식 : 지침 문서와 저장소 추적 목록·전 이력·무시 규칙·계획 문서 대조
소스 수정 : x
## 재현 조건
1. 리프 지침 문서의 금지 절과 첫 항목에 붙은 근거 둘을 읽는다.
2. 아키텍처 규칙의 선언과 그것이 막는 이름 목록을 읽는다.
3. 그 규칙이 실행되는 경로와 검사 대상이 비어 있지 않은지 확인한다.
4. 리프 잠금 파일의 빈 구성 표기를 읽는다.
5. 훅이 있다는 디렉터리의 추적 파일 수와 전 이력 추적 여부와 무시 규칙을 확인한다.
6. 문서가 특정한 규칙 번호가 저장소에서 해석되는지 센다.
7. 그 훅을 고치겠다던 계획 문서의 첫 줄과 그것을 대체한 문서의 목표를 읽는다.
## 본문
<!-- body:start -->
지침 문서가 금지 사항 첫 항목에 강제 근거를 둘 붙인다.
## 금지 절과 근거 두 줄
:::evidence key="a07-f006-claude" alt="리프 지침 문서의 금지 절과 첫 항목에 붙은 근거 둘. 아키텍처 규칙의 선언과 그것이 막는 이름 목록, 그 규칙의 실행 경로와 검사 대상, 리프 잠금 파일의 빈 구성 표기. 훅이 있다는 디렉터리의 추적 파일 수와 전 이력 추적 여부, 작업 트리 내용, 무시 규칙 확인 결과. 문서가 특정한 규칙 번호가 저장소에 나오는 곳. 그리고 그 훅을 고치겠다던 계획 문서의 폐기 배너와 그것을 대체한 문서의 목표 문장을 출력한 터미널 기록." caption="아키텍처 규칙은 실재하고 CI 의 check 로 돌지만 막는 목록에 Spring Web 이 없음 · 리프의 컴파일·런타임 클래스패스는 잠긴 채 비어 있음 · 훅 디렉터리는 추적 0, 전 이력 0, 무시 목록에 포함 · 규칙 번호는 이 문장에만 나옴 · 그 훅을 고치겠다던 계획은 폐기됐고 대체 문서가 하네스 부재를 목표에 적음 — 54줄 · exit 0" zoom="true"
:::
```text
## Forbidden
- Persistence or web technology (JPA/Hibernate/Spring Data/Spring Web) — ArchUnit
`identifier_adapter_does_not_depend_on_other_adapters_or_bootstrap`;
`.claude/hooks/ca_import_gate.py` G4 가 쓰기 시점에 차단.
```
## 아키텍처 규칙은 있고 CI 에서 돈다
```java
@ArchTest
static final ArchRule IDENTIFIER_ADAPTER_DOES_NOT_DEPEND_ON_OTHER_ADAPTERS_OR_BOOTSTRAP =
```
선언만으로는 돈다고 말할 수 없어 실행 경로도 봤다. 클래스에 `@AnalyzeClasses(packages = "dev.caskeleton")` 가 붙어 있고, 조립 루트가 이 리프를 `implementation` 으로 물고 있어 검사 대상이 비어 있지 않으며, CI 품질 게이트가 `./gradlew check` 를 돌린다.
## 남은 규칙이 덮지 않는 것
```java
"..adapter.inbound.web..",
"..adapter.outbound.persistence..",
"..bootstrap..",
"org.springframework.data.repository..",
"org.springframework.data.jpa.repository..",
"jakarta.persistence..",
"javax.persistence..",
"org.hibernate.."
```
금지 줄이 함께 든 Spring Web 이 이 목록에 없다. 사라진 가드가 덮기로 돼 있던 것 중 하나를 남은 가드가 덮지 않는다.
그리고 이 리프에 그 기술들이 들어오지 못하게 하는 첫 줄은 규칙이 아니다.
```text
154:empty=compileClasspath,runtimeClasspath
```
프로덕션 클래스패스가 잠긴 채 비어 있으므로 컴파일에서 이미 불가능하다. 아키텍처 규칙은 그 뒤에 선 둘째 줄이다.
## 훅이 있다는 디렉터리
```text
# git ls-files .claude : 0
# 전 이력에서 .claude 아래 추적된 적 : 0
# 작업 트리 : settings.local.json
.gitignore:3:.claude/ .claude/hooks/ca_import_gate.py
```
추적된 적이 한 번도 없고, 무시 목록이 그 디렉터리를 덮는다. 작업 트리에도 로컬 설정 파일 하나뿐이고 훅 하위 디렉터리 자체가 없다.
문서가 특정한 규칙 번호도 해석되지 않는다.
```text
src/adapter/outbound/identifier/CLAUDE.md:39: `.claude/hooks/ca_import_gate.py` G4 가 쓰기 시점에 차단.
```
`G4` 가 임포트 게이트의 규칙 번호로 쓰인 곳은 이 문장 자신뿐이다. 없는 파일 안의, 정의되지 않은 번호를 특정한다.
## 없는 이유는 이미 적혀 있다
```text
> **SUPERSEDED — HISTORICAL PROVENANCE ONLY (2026-07-25):** The user-approved harness-free
> Mode B amendment supersedes this plan. …
**Goal:** Restore Gradle bootstrap and Clean Architecture dependency enforcement without recreating
the absent development harness.
```
그 훅을 고치겠다던 계획은 폐기됐고, 대체한 개정안의 목표 문장이 부재한 개발 하네스를 되살리지 않겠다고 적는다. 그 계획이 만들겠다던 디렉터리도 없다.
무시 목록은 그 파일이 배포되지 않는 경로를 말할 뿐이다. 왜 없는지는 이 개정안이 말한다. 빠뜨린 파일이 아니라 승인된 결정이고, 그 결정 이전의 문장을 그대로 들고 있는 것이 리프 지침 문서다.
## 확인하지 못한 것
다른 개발자 머신에 그 스크립트가 있는지는 확인하지 않았다. 다만 여기서 본 것은 새 복제본이 아니라 로컬 브랜치와 작업 트리 이력을 가진 실제 작업 저장소이고, 그 한 대에도 훅은 없었다.
<!-- body:end -->
@@ -0,0 +1,99 @@
---
kind: CASE
slug: analysis-finding-a07-f005
title: README의 세 가지 사실 오류
topic: identity-and-identifier
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:analysis-finding-a07-f005
evidenceCapturedOn: 2026-09-01
body: case-analysis-finding-a07-f005.body.md
assets:
- key: analysis-finding-a07-f005
file: ../../../final/evidence/rendered/analysis-finding-a07-f005.svg
evidence:
- ../../../final/evidence/raw/analysis-finding-a07-f005.txt
source:
- 원본 분석 절은 analysis/07-adapter-outbound-identifier.md#L150 이다.
---
# README의 세 가지 사실 오류
리프 README 의 세 서술이 각각 패키지 루트와 테스트 라이브러리 판본과 의존성 허용 경로에서 실제와 다르다. 셋 다 메커니즘 자체는 실재하고 동작한다. 틀린 것은 이름과 판본이다.
## 관계
- **문서가 선언됐다는 둘은 없고, 선언된 하나는 그 문장에 없다**
같은 리프의 다른 문서 오류다.
- **사라진 가드가 문서에만 남았고 그 부재는 이미 승인된 결정이다**
같은 계열이다.
- **문서가 아니라 빌드 파일이 의존성의 진실이다**
셋째 오류가 그 규칙의 형태다.
## 문제
리프 README 가 패키지 루트와 테스트 스택과 의존성 허용 경로를 적는다. 그 셋을 실제와 대조했다.
## 결론
세 서술이 모두 다르다.
패키지 루트는 어댑터 다음에 바로 리프 이름이 오는 형태로 적혀 있는데, 실제는 그 사이에 방향 구획이 하나 더 있다. 같은 저장소의 리프 지침 문서 쪽은 정확하다.
테스트 스택은 Spock 판본에 붙는 그루비 변형을 4 계열로 적는데, 실제 선언은 5 계열이다.
셋째가 가장 구조적이다.
README 는 이 리프의 프로젝트 간 의존성 간선이 루트 빌드 파일의 허용 맵으로 허용된다고 적고, 그 맵의 키를 방향 구획이 빠진 이름으로 든다.
실제 그 허용 맵은 리터럴 맵이 아니다. 레지스트리의 모듈 목록을 순회해 파생된다. 그리고 이 모듈의 키는 방향 구획이 들어간 이름이다.
즉 키 이름도 틀렸고 그 맵이 어디서 오는지도 틀렸다.
셋 다 메커니즘 자체는 실재하고 동작한다. 패키지는 있고 테스트는 돌고 간선은 허용된다. 틀린 것은 이름과 판본이다.
그래서 판정은 P3 다.
## 검증 환경
Gradle : 9.0.0
확인 방식 : README 와 소스 트리, 빌드 파일 대조
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/140 계열에 있다.
1. README 의 패키지 루트 줄을 읽고 실제 소스 트리와 대조한다.
2. 테스트 스택 줄을 읽고 빌드 파일의 선언과 대조한다.
3. 의존성 허용 경로 줄을 읽는다.
4. 루트 빌드 파일에서 허용 맵이 어떻게 만들어지는지 확인한다.
5. 레지스트리에서 이 모듈의 키 이름을 확인한다.
## 본문
<!-- body:start -->
README에 세 가지 사실 오류가 있다.
| README | 실제 |
|---|---|
| \:3 패키지 루트 `dev.caskeleton.adapter.identifier` | `dev.caskeleton.adapter.outbound.identifier` (CLAUDE.md\:11은 정확) |
| \:59 "Spock 2.4 / **Groovy 4.0** variant" | `spock-core:2.4-groovy-**5.0**` |
| \:64 edge는 `src/build.gradle``allowedProjectDependencies['**adapter-identifier**']`로 허용 | `build.gradle:1416``allowedProjectDependencies`는 리터럴 맵이 아니라 `registry.modules.collectEntries { … }`로 **레지스트리에서 파생**되며, 이 모듈의 키는 `adapter-outbound-identifier`다 |
## README 의 세 문장
:::evidence key="analysis-finding-a07-f005" alt="분석 문서 analysis/07-adapter-outbound-identifier.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/07-adapter-outbound-identifier.md 발췌 — 15줄" zoom="true"
:::
## 메커니즘은 실재하고 동작한다
틀린 것은 이름과 버전이다. P3.
## 확인하지 못한 것
README 의 서술이 언제부터 어긋났는지 확인하지 않았다.
<!-- body:end -->