Files
document-haness/docs/clean-architecture-backend-template/analysis/07-adapter-outbound-identifier.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

234 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 07 · adapter-outbound-identifier
## SSOT identity — 2026-08-31 재검증
- registered leaf id: `adapter-outbound-identifier`
- canonical state `analysisFile`: `analysis/07-adapter-outbound-identifier.md` (이 문서) — 이 leaf의 단일 SSOT
- source path: `src/adapter/outbound/identifier` · Gradle `:adapter:outbound:identifier`
- registry `allowed_dependencies`: `["domain-core", "application-core"]`
- registry `runtime_memberships`: `["app-bootstrap", "sample-portfolio"]`
- coverage ledger: `FULL_READ` **10** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0**
- 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0**
- 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행)
> 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다.
> 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.
---
> 상태: COMPLETE
> revision: `a24ece9cf797f7ea647e33bf846b115208ed1ba5`
> 경로: `src/adapter/outbound/identifier` · Gradle: `:adapter:outbound:identifier`
## 0. Denominator와 coverage ledger
이 leaf는 tracked file **10개**다. 하위 범위로 나눌 크기가 아니라 한 범위로 처리한다.
| 구분 | 파일 | LOC | 상태 |
|---|---|---|---|
| governance (`CLAUDE.md`, `README.md`, `build.gradle`, `gradle.lockfile`) | 4 | 297 | FULL_READ |
| main Java (`HmacUserPrincipalPseudonymizer`, `RandomUploadIdentifierFactory`, `UuidCodec`, `package-info`) | 4 | 114 | FULL_READ |
| test Java (`HmacUserPrincipalPseudonymizerTest`) | 1 | 112 | FULL_READ |
| test Groovy (`UuidCodecSpec`) | 1 | 39 | FULL_READ |
| **합계** | **10** | **562** | **10 / 10 FULL_READ** |
structural-only 0 · excluded 0 · unclassified 0.
manifest: `evidence/raw/139-identifier-module-inventory.txt`.
probe(정적 + 실행): `evidence/raw/140-identifier-negative-space-probes.txt`.
레지스트리 항목:
```json
{ "id": "adapter-outbound-identifier",
"gradle_path": ":adapter:outbound:identifier",
"allowed_dependencies": ["domain-core", "application-core"],
"runtime_memberships": ["app-bootstrap", "sample-portfolio"] }
```
## 1. 이 모듈이 존재하는 이유
CLAUDE.md와 README가 같은 논거를 편다: **외부 시스템 연동이 없는(non-IO) 인프라 능력**만 모아 두어, `adapter-outbound`가 문서화된 의미("외부 HTTP / messaging / cache / notifications")를 유지하게 한다는 것이다.
> Kept out of `adapter-outbound` on purpose: a UUID id/codec capability is infrastructure, not an outbound integration point.
그 논거의 예시로 드는 능력이 **UUID id/codec**이다. §3에서 보듯 그 능력에는 production 소비자가 없고, 실제로 배선돼 도는 것은 나머지 둘(가명화, 업로드 식별자)이다.
## 2. Confirmed — `HmacUserPrincipalPseudonymizer`는 이 leaf에서 가장 잘 만들어진 부분이다
`UserPrincipalPseudonymizerPort`(application-core)의 유일한 구현이고, `app-bootstrap``sample-portfolio`가 각자 싱글톤 빈으로 배선한다(`140-...` §8.1). 결정들이 코드와 문서 양쪽에 맞물려 있다.
- **thread safety를 타입이 아니라 수명으로 푼다.** `Mac`은 thread-safe가 아니므로 `pseudonymize` 호출마다 새로 만든다 — 그래서 공유 싱글톤으로 안전하다. 주석이 그 이유를 그 자리에 적는다.
- **salt를 스스로 조달하지 않는다.** `APP_PRIVACY_PSEUDONYMIZATION_SALT`에서 `app-bootstrap`이 공급하고(분류 secret, 90일 회전), 생성자는 null/빈 배열을 거부하며 거부 메시지가 그 환경변수 이름을 그대로 말한다.
- **방어적 복사.** `salt.clone()`으로 호출자 배열을 붙들지 않는다.
- **도달 불가 예외를 정직하게 감싼다.** `HmacSHA256`은 JCA 필수 알고리즘이라 `NoSuchAlgorithmException`·`InvalidKeyException`은 사실상 도달 불가이고, 호출부에 checked exception 잡음을 남기지 않으려 `IllegalStateException`으로 감싸며 주석이 "this should never happen on a compliant JDK"라고 적는다.
- **Spring-free.** 어노테이션이 없고 빈 생성은 composition root 책임이다.
test 11개가 경계를 실제로 나눠 덮는다 — 생성자 가드 2, null/blank/empty 입력 3, 같은 salt의 결정성(같은 인스턴스·다른 인스턴스) 2, salt 민감도 1, 단방향성 2(출력≠입력, 출력이 입력을 부분문자열로 포함하지 않음), 출력 포맷 1(`^[0-9a-f]{64}$`). 값 하나를 고정하는 golden test가 아니라 **성질**을 검사한다.
`RandomUploadIdentifierFactory`의 판단도 기록해 둘 만하다. 파일 식별자가 공개 핸들이므로 시퀀스나 타임스탬프가 아니라 암호학적 난수에서 뽑는다고 적고, 그 대가까지 명시한다 — "A time-ordered identifier would be **the better database key**, and is deliberately not used: it would let anyone holding one id infer when neighbouring files were created and enumerate towards them." 결정과 그 결정이 포기한 것을 함께 적는 서술이다.
경계 규칙도 실재한다. CLAUDE.md가 이름을 대는 ArchUnit 규칙은 `CleanArchitectureTest`**대문자 상수** `IDENTIFIER_ADAPTER_DOES_NOT_DEPEND_ON_OTHER_ADAPTERS_OR_BOOTSTRAP`로 존재하고, inbound web · persistence · bootstrap · Spring Data · JPA · Hibernate를 금지하며 형제 배제(`..adapter.outbound..` 중 자기 패키지 제외)까지 처리한다(`140-...` §8.4e). CLAUDE.md의 표기가 snake_case일 뿐 가드는 진짜다 — **confirmed match**.
## 3. P2 — 모듈의 존재 논거인 `UuidCodec`에 production 소비자가 없다
`140-...` §8.1·§8.1b의 저장소 전수 검색 결과:
| 타입 | leaf 밖 production 소비자 |
|---|---|
| `HmacUserPrincipalPseudonymizer` | `app-bootstrap`, `sample-portfolio` (각 1) |
| `RandomUploadIdentifierFactory` | `app-bootstrap` (1) |
| **`UuidCodec`** | **0** |
`UuidCodec.` 형태의 호출은 저장소 전체에서 **자기 Spock 스펙 5줄뿐**이다. 이름이 겹쳐 걸린 나머지 둘은 무관하다 — mongo testkit의 `org.bson.codecs.UuidCodec`(드라이버 타입)과 `sample-portfolio/README.md:333`의 산문 언급("`UuidCodec` 같은 공용…").
그 자리를 대신 채우고 있는 것들이 있다. `UUID.fromString`을 직접 부르는 파일이 leaf 밖에 20개 이상이고(graphql `UuidScalar`, notification `JacksonRoutingPlanCodec`, jpa `PostgreSqlIdempotencyClaimRepository`, application-core `FileId`/`UploadId` …), CLAUDE.md가 `toUuid`/`fromUuid`의 목적으로 든 **PostgreSQL `uuid` 컬럼 변환(D10)**은 실제로는 Hibernate의 `@JdbcTypeCode(SqlTypes.UUID)`가 처리한다(`140-...` §8.4b, JPA 엔티티 다수).
**판정: P2.** 코드 자체에는 결함이 없다 — 30줄짜리 유틸이고 자기 스펙을 통과한다. 문제는 §1의 논거다. 모듈을 `adapter-outbound` 밖에 두는 근거로 "UUID id/codec 능력"을 들고 있는데, 그 능력은 아무도 쓰지 않고 같은 일이 저장소 곳곳에서 각자 수행된다. 나머지 두 타입(가명화·업로드 식별자)만으로도 non-IO 능력 모듈의 논거는 성립하므로, 수정은 둘 중 하나다: `UuidCodec`을 실제 단일 경로로 만들거나(그러면 §4가 먼저 고쳐져야 한다), 모듈의 논거에서 빼는 것.
## 4. P2 — `normalize`는 canonical이 아닌 입력을 받아 다른 UUID로 조용히 바꾼다
`UuidCodec.normalize`의 계약은 Javadoc과 README 양쪽에 적혀 있다.
> Accepts a **case-insensitive canonical UUID** string and returns the canonical 36-character lowercase form… `@throws IllegalArgumentException` on a **malformed** UUID.
> (README) 형식 오류 UUID 에는 `IllegalArgumentException`.
구현은 `UUID.fromString(input).toString()` 한 줄이다. JDK의 `UUID.fromString`은 길이 36 fast path 밖에서 **대시로 나뉜 5개 hex 그룹을 길이 검사 없이** 받는다. 실행 probe로 확인했다(`140-...` EXECUTION PROBE).
```
PROBE normalize("0190BD6E-7C3E-7ABC-8DEF-0123456789AB") -> "0190bd6e-7c3e-7abc-8def-0123456789ab" ← 의도된 동작
PROBE normalize("1-1-1-1-1") -> "00000001-0001-0001-0001-000000000001"
PROBE normalize("0-0-0-0-0") -> "00000000-0000-0000-0000-000000000000"
PROBE normalize("1-2-3-4-5") -> "00000001-0002-0003-0004-000000000005"
PROBE normalize("0190bd6e7c3e7abc8def0123456789ab") -> IllegalArgumentException
PROBE normalize("not-a-uuid") -> IllegalArgumentException
```
`"1-1-1-1-1"`은 canonical UUID가 아니다. 계약대로면 `IllegalArgumentException`이어야 하는데, 수용된 뒤 **다른 문자열로 재작성되어** 반환된다. 결과적으로 서로 다른 두 입력(`"1-1-1-1-1"``"00000001-0001-0001-0001-000000000001"`)이 같은 식별자로 정규화되고, 거부됐어야 할 값이 정상적으로 보이는 id가 된다. `normalize`는 D3 — 호출자가 준 텍스트를 저장 형태로 바꾸는 지점 — 이므로, 관대함이 남는 위치가 하필 신뢰 경계다.
기존 스펙이 이것을 놓친 이유도 코드에 있다. `UuidCodecSpec`의 거부 케이스는 `"not-a-uuid"` **하나**이고, 그 문자열은 대시 그룹이 5개가 아니라 관대한 경로에 닿지 않는다.
**도달성.** 지금 이 메서드를 부르는 production 코드는 없다(§3). 그래서 현재 노출은 0이고, `UuidCodec`을 단일 경로로 승격하는 순간 결함이 된다. **판정: P2.** 수정은 `input.length() != 36`이거나 대시 위치가 8-13-18-23이 아니면 먼저 거부하는 것 — 또는 계약 문구를 실제 동작(JDK 관대 파싱)에 맞추는 것이다. 전자가 문서가 말하는 바다.
## 5. P2 — 문서는 UUIDv7이라고 말하고, 생성되는 것은 v4다
CLAUDE.md:21과 README:19가 같은 문장을 쓴다 — `UuidCodec`은 "JDK `java.util.UUID` (**RFC 9562 UUIDv7**)" 위에서 동작한다. README:12도 이 모듈의 능력을 "식별자 생성/인코딩(**UUIDv7**)"이라 적는다. 그리고 이 주장은 leaf 밖으로도 번져 있다 — `CleanArchitectureTest`가 이 패키지를 설명하는 세 곳(:948, :981, :1310)이 전부 "UUIDv7 id/event-id generation & codec"이라고 쓴다.
실행 probe(`140-...`):
```
PROBE newFileId version=4 variant=2
PROBE newUploadId version=4 variant=2
PROBE UUID.randomUUID() version=4 (RFC 9562 UUIDv7 would report version=7)
```
`java.util.UUID`에는 v7 생성기가 없고, 이 leaf의 유일한 생성기 `RandomUploadIdentifierFactory``UUID.randomUUID()`(v4)를 쓴다. 더 분명한 것은 **그 클래스의 javadoc이 v7을 명시적으로 거부한다**는 점이다 — "A time-ordered identifier would be the better database key, and is **deliberately not used**." 즉 코드는 숙고된 결정을 내렸고, 모듈 문서와 아키텍처 test의 설명문이 **정반대 결정을 서술**한다.
저장소의 진짜 UUIDv7은 다른 곳에 있다: `sample-portfolio``com.github.f4b6a3:uuid-creator`를 자기 `build.gradle`에 선언하고 `UuidCreator.getTimeOrderedEpochPlus1()`을 쓴다(`SampleOperationStore:27`).
**판정: P2.** 런타임 동작은 옳다 — 잘못된 것은 세 문서와 한 test의 설명문이다. 그러나 "이 모듈이 UUIDv7을 제공한다"는 서술은 fork가 시간정렬 키를 기대하게 만들고, 그 기대는 인덱스 지역성과 열거 가능성 양쪽에서 반대 방향의 결과를 낳는다.
## 6. P3 — CLAUDE.md의 의존성 서술이 세 항목 모두 틀렸다
CLAUDE.md:3133:
> `:application-code`, `:domain-core`, `:shared-contract` (Gradle matrix). **Currently only `:domain-core` + `com.github.f4b6a3:uuid-creator` are declared in build.gradle.**
`build.gradle`의 실제 `dependencies` 블록은 두 줄이다.
```groovy
implementation project(':application-core')
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
```
- `:domain-core` — 선언돼 있지 **않다**.
- `uuid-creator` — 선언돼 있지 **않다**(저장소 검색상 이 leaf의 classpath에 없다; `sample-portfolio``app-bootstrap`의 lockfile에만 있다).
- `:application-core` — 선언돼 **있는데** 문장은 언급하지 않는다.
레지스트리의 `allowed_dependencies`(`domain-core`, `application-core`)와 실제 선언(부분집합)은 정합한다. 어긋난 것은 CLAUDE.md의 서술뿐이다. **P3.**
## 7. P3 — 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`다 |
셋 다 메커니즘 자체는 실재하고 동작한다 — 틀린 것은 이름과 버전이다. **P3.**
## 8. P3 — CLAUDE.md가 대는 두 가드 중 하나는 저장소에 없다
CLAUDE.md:3839가 금지 사항의 근거로 둘을 든다.
1. ArchUnit `identifier_adapter_does_not_depend_on_other_adapters_or_bootstrap`**존재한다**(§2, 상수명은 대문자). confirmed.
2. `.claude/hooks/ca_import_gate.py` **G4가 쓰기 시점에 차단**`.claude/` 디렉터리에는 `settings.local.json` 하나뿐이고 `hooks/` 하위 디렉터리도 `ca_import_gate.py`**tracked 되어 있지 않다**(`140-...` §8.4e).
개발자 머신에 로컬로 존재할 여지는 있으나, 저장소를 새로 clone한 사람에게 그 가드는 없다. "쓰기 시점에 차단된다"는 서술은 clone에서 성립하지 않는다. **P3.**
## 9. P3/기록 — 결정 SSOT가 이 revision에서 해석되지 않는다
CLAUDE.md와 README가 `UuidCodec`의 동작을 **D3**(normalize)와 **D10**(toUuid/fromUuid)로 지목하고, 모듈 분류 근거로 "feature-resource-identifier-contract §4 taxonomy"를 든다. `CleanArchitectureTest`도 같은 문서를 §4·D5·D9·D17로 네 곳에서 인용한다.
그 문서는 이 revision에 **파일로 존재하지 않는다**(`find -iname '*resource-identifier*'` 매치 0; `docs/`에서 걸리는 D3/D10은 전부 MongoDB의 무관한 노출 평면 표기다). `CleanArchitectureTest:2432`의 주석이 이유를 밝힌다 — "decision SSOT: **resource-identifier branch**". 즉 다른 브랜치에 있다.
은폐가 아니라 명시된 상태이므로 결함으로 올리지 않고 기록한다. 다만 이 leaf의 문서가 자기 동작의 근거로 대는 결정 ID들은 이 브랜치만 읽어서는 확인할 수 없고, §4·§5의 어긋남이 "구현이 결정을 벗어난 것"인지 "결정이 그 사이 바뀐 것"인지도 여기서는 판정 불가다. **P3/기록.**
## 10. Negative-space probes
- **8.1 public-surface reachability**: 세 production 타입의 저장소 전수 소비자 계수. `UuidCodec` 0(§3), 나머지 둘은 composition root가 배선.
- **8.2 조건부 형제 비교**: `UuidCodec`의 세 자매 메서드가 null을 다르게 다룬다 — `normalize(null)``null`(문서화됨), `toUuid(null)``NullPointerException`, `fromUuid(null)``NullPointerException`(둘 다 미문서화). 실행 probe로 확인. 계약 문구는 `normalize`에만 있다. **P3.**
- **8.2b 계약 ↔ 구현 대조**: `normalize`의 "canonical만 수용" 주장과 JDK 관대 파싱(§4). 실행 probe로 확정.
- **8.3 중복 mechanism sweep**: UUID 문자열 변환이 leaf 밖 20+ 파일에서 `UUID.fromString`으로 각자 수행되고, D10이 지목한 PostgreSQL `uuid` 컬럼 변환은 Hibernate `@JdbcTypeCode(SqlTypes.UUID)`가 담당(§3). 저장소의 UUIDv7 생성은 `sample-portfolio``UuidCreator`(§5).
- **8.4 문서/개수 drift**: §5(UUID 버전, leaf 문서 3곳 + 아키텍처 test 3곳) · §6(의존성 서술 3항목) · §7(패키지 루트·Groovy 버전·설정 키) · §8(가드 하나 부재) · §9(결정 SSOT 미해석).
## 11. Findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| **P2** | `UuidCodec.normalize`가 canonical이 아닌 5-그룹 입력(`"1-1-1-1-1"` 등)을 수용해 다른 UUID로 재작성한다 — Javadoc·README는 canonical만 받고 malformed는 거부한다고 적는다 (실행 probe) | 현재 호출자 0; `UuidCodec`을 단일 경로로 쓰는 순간 신뢰 경계 결함 |
| **P2** | `UuidCodec`에 production 소비자가 0인데, 모듈을 `adapter-outbound` 밖에 두는 논거가 바로 이 "UUID id/codec 능력"이다. 같은 변환이 leaf 밖 20+ 파일에서 각자 수행되고 D10의 대상은 Hibernate가 처리한다 | 문서/모듈 경계 논거 |
| **P2** | leaf 문서 3곳과 `CleanArchitectureTest` 설명문 3곳이 "RFC 9562 UUIDv7"이라 적지만 유일한 생성기는 v4를 만들고, 그 클래스의 javadoc은 시간정렬 id를 명시적으로 거부한다 (실행 probe: version=4) | fork가 시간정렬 키를 기대하는 경우 |
| **P3** | CLAUDE.md:3133의 build.gradle 선언 서술이 세 항목 모두 사실과 다르다 | 문서 |
| **P3** | README의 패키지 루트·Spock/Groovy variant·`allowedProjectDependencies` 키 3건 오류 | 문서 |
| **P3** | CLAUDE.md가 대는 write-time 가드 `.claude/hooks/ca_import_gate.py`가 저장소에 tracked 되어 있지 않다 | 새 clone |
| **P3** | `UuidCodec` 세 자매 메서드의 null 처리 비대칭이 `normalize`에만 문서화돼 있다 | 호출 시점 |
| **P3/기록** | 결정 SSOT `feature-resource-identifier-contract`(§4·D3·D5·D9·D10·D17)가 다른 브랜치에 있어 이 revision에서 해석되지 않는다 | 결정 대조 |
## 12. 완료 조건
- denominator **10 / 10 FULL_READ** — structural-only 0 · excluded 0 · unclassified 0 (§0)
- §8.1(공개 표면 도달성) · §8.2(조건부 형제) · §8.3(중복 mechanism) · §8.4(문서/개수 drift) 네 종 probe 수행
- 정적으로 결정 불가한 세 지점(normalize의 실제 수용 범위, 세 자매의 null 처리, 생성되는 UUID 버전)을 실행 probe로 확정(`140-...`)
- CLAUDE.md가 대는 두 가드를 각각 추적해 **하나는 실재(confirmed)**, 하나는 부재로 분리 판정 — 이름 표기 차이를 결함으로 올리지 않았다
- 임시 probe class 1개 추가 후 제거, `git status --short` = 0, 소스 미변경
## Source anchors
이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **8개** (main 3 · test 1 · 기타 4).
```
src/adapter/outbound/identifier/build.gradle
src/config/architecture/modules.json (adapter-outbound-identifier 항목)
main:
src/main/java/dev/caskeleton/adapter/outbound/identifier/HmacUserPrincipalPseudonymizer.java
src/main/java/dev/caskeleton/adapter/outbound/identifier/RandomUploadIdentifierFactory.java
src/main/java/dev/caskeleton/adapter/outbound/identifier/UuidCodec.java
test:
src/test/java/dev/caskeleton/adapter/outbound/identifier/HmacUserPrincipalPseudonymizerTest.java
기타:
CLAUDE.md
README.md
src/build.gradle
src/sample-portfolio/README.md
해석되지 않은 인용 (3종) — 외부 타입·문서상 약칭 등:
evidence/raw/139-identifier-module-inventory.txt
evidence/raw/140-identifier-negative-space-probes.txt
settings.local.json
```