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

19 KiB
Raw Blame History

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.

레지스트리 항목:

{ "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-bootstrapsample-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의 유일한 생성기 RandomUploadIdentifierFactoryUUID.randomUUID()(v4)를 쓴다. 더 분명한 것은 그 클래스의 javadoc이 v7을 명시적으로 거부한다는 점이다 — "A time-ordered identifier would be the better database key, and is deliberately not used." 즉 코드는 숙고된 결정을 내렸고, 모듈 문서와 아키텍처 test의 설명문이 정반대 결정을 서술한다.

저장소의 진짜 UUIDv7은 다른 곳에 있다: sample-portfoliocom.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 블록은 두 줄이다.

implementation project(':application-core')
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
  • :domain-core — 선언돼 있지 않다.
  • uuid-creator — 선언돼 있지 않다(저장소 검색상 이 leaf의 classpath에 없다; sample-portfolioapp-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.gradleallowedProjectDependencies['**adapter-identifier**']로 허용 build.gradle:1416allowedProjectDependencies는 리터럴 맵이 아니라 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.pytracked 되어 있지 않다(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-portfolioUuidCreator(§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