Files
document-haness/docs/clean-architecture-backend-template/analysis/02-shared-contract.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

18 KiB
Raw Blame History

shared-contract 상세 분석

SSOT identity — 2026-08-31 재검증

  • registered leaf id: shared-contract
  • canonical state analysisFile: analysis/02-shared-contract.md (이 문서) — 이 leaf의 단일 SSOT
  • source path: src/shared-contract · Gradle :shared-contract
  • registry allowed_dependencies: []
  • registry runtime_memberships: ["app-bootstrap", "sample-portfolio"]
  • coverage ledger: FULL_READ 82 / STRUCTURAL_ONLY 4 / EXCLUDED 0 / UNCLASSIFIED 0
  • 최초 분석 revision a24ece9c → 재검증 revision 21234e38 · 이 리프의 변경 파일 0
  • 재검증 증거: EVD-333(소스 드리프트 0), EVD-334(lane 재실행)

재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.


분석 상태

  • scope: shared-contract
  • source path: src/shared-contract
  • Gradle path: :shared-contract
  • source revision: a24ece9cf797f7ea647e33bf846b115208ed1ba5
  • analysis cycle: 1 / normal
  • result: COMPLETE
  • registry dependencies: production project dependency 0
  • runtime memberships: app-bootstrap, sample-portfolio

역할과 경계

shared-contract는 특정 도메인이나 Spring/Jackson/JPA 구현을 소유하지 않고 여러 adapter와 composition root가 공유하는 운영 계약을 보관하는 leaf module이다. build.gradle의 production dependency block은 비어 있으며, CLAUDE.md도 Java standard library only를 명시한다. 실제 production source에서도 Spring/Jackson/JPA type은 관찰되지 않았다.

이 모듈이 제공하는 계약은 단일 관심사라기보다 다음의 skeleton-wide boundary 묶음이다.

  • error taxonomy와 framework-neutral exception carrier
  • API response/bulk/pagination/long-running-operation shape
  • partial-update의 3-state Patch
  • resource:action permission value
  • provider-neutral edge rate-limit contract
  • metric naming/cardinality guardrail
  • traceparent/baggage/span-error seam
  • domain/business context propagation seam
  • compare-and-set operational record storage port
  • adapter master-switch parser
  • Redis semantic health snapshot projection
  • messaging envelope JSON Schema v1와 checked-in SHA-256 digest

따라서 이 module의 핵심 아키텍처적 의미는 "공통 유틸리티"가 아니라, 서로 다른 outer module이 한쪽 adapter의 type에 의존하지 않고 합의할 수 있는 중립 계약 지점이다. OperationalRecordStorePort의 실제 consumer인 GraphQL persisted-operation registry가 inbound adapter 자체의 저장소 interface를 선언하지 않고 이 중립 port에 의존하는 것이 그 방향성을 직접 보여준다.

주요 계약과 불변식

Error contract

ApiErrorCode는 code/category/httpStatus/retryable의 최소 표면을 제공하고 OperationalError가 registry mirror 역할을 한다. Category는 VALIDATION, AUTH, AUTHZ, NOT_FOUND, CONFLICT, RATE_LIMIT, TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL의 10개 값으로 고정되어 있으며 테스트가 정확한 vocabulary를 pin 한다.

OperationalErrorTest는 단순 enum 존재보다 category × retryable 의미를 강하게 검증한다. deterministic VALIDATION/AUTHZ/NOT_FOUND는 retryable=false이고, transient INTERNAL은 기본적으로 retryable=true이되 deploy-time/configuration/terminal 상태인 일부 code는 명시적 예외로 false다. AUTH_KID_UNKNOWN은 key rotation 중 JWKS refresh 가능성을 이유로 AUTH 중 유일한 retryable case로 pin 되어 있다. upstream 4xx 전체를 permanent/non-retryable로 분류하면서 408/429의 의미 차이가 남는다는 점은 source comment와 README가 이미 known edge로 기록한다.

DependencyFailureExceptionPersistenceFailureExceptionApiErrorCarrier를 통해 transport adapter에 stable error code를 전달하면서 raw cause/diagnostic message를 server-side 정보로 남긴다. AdapterDisabledException은 carrier를 구현하지 않고 별도 mapping 대상이다.

Response / operation contract

Envelope, BulkEnvelope, ResponseMeta, PageMeta, Operation은 framework-neutral record/factory로 API shape를 전달한다. 여기서는 중요한 enforcement boundary 차이가 관찰된다.

Envelope.ok/failure, BulkEnvelope.allOk/partial, Operation.pending/succeeded/failed factory는 문서의 정상 shape를 생성하고 테스트도 이 factory path를 검증한다. 그러나 canonical record constructor 자체는 success/data/error의 배타성, operation status와 result/error의 조합, pagination 범위 등을 검증하지 않는다. 따라서 이 규칙은 rate-limit value object처럼 intrinsic constructor invariant가 아니라 factory/adapter usage contract다. 현재 source와 test가 일치하므로 즉시 결함으로 분류하지 않지만, raw constructor가 외부 module에 public인 만큼 invalid shape 생성 가능성은 P1 hardening 후보로 남는다.

Patch<T>는 ABSENT / PRESENT_NULL / PRESENT_VALUE의 3-state를 명확하게 보존하며 absent에서 value()를 호출하면 실패한다. 이는 JSON Merge Patch 계열에서 "필드 미전송"과 "명시적 null"을 구분해야 하는 boundary를 framework type 없이 표현한다.

Permission

Permission은 정확히 한 개의 colon으로 resource:action을 분리하고 trim/lowercase normalization을 수행한다. 테스트는 mixed case, surrounding whitespace, blank component, 0/2+ colon을 검증한다. 다만 source는 component 내부 character set을 제한하지 않는다. 즉 "lowercase colon-delimited"는 normalization 결과이지 [a-z0-9-]+ 같은 strict grammar는 아니다. 현재 test 역시 이를 요구하지 않으므로 observed contract로만 기록한다.

Edge rate-limit contract

이 영역은 shared-contract 안에서도 가장 강하게 self-validating 된다. RateLimitPolicy, RateParameters, RateLimitRequest, RateLimitDecision, RateLimitOutcome, RateLimitEvaluationDedupPolicy가 생성 시점에 bounded representation과 arithmetic safety를 검증한다.

  • Lua exact integer range를 9_007_199_254_740_991로 제한한다.
  • sliding counter/token bucket fixed-point 계산에 scale 1_000_000을 사용하며 중간 합/곱도 exact-range를 넘지 않게 검증한다.
  • window/cleanup/retry duration은 whole milliseconds만 허용하고 상한을 둔다.
  • policy id/revision, subject digest, evaluation id는 bounded regex로 제한한다.
  • raw edge identity는 EdgeRateLimitSubject에서만 잠시 존재하고 provider request에는 pseudonymous digest만 전달하도록 type/regex로 가드한다.
  • v1 failure policy는 FAIL_CLOSED만 허용한다.
  • unavailable / indeterminate / incompatible를 evaluated denial과 분리하여 transport/provider ambiguity를 숨기지 않는다.
  • response-loss replay dedup은 TTL, entry count, logical stored bytes를 동시에 제한한다.

별도 edgeRateLimitContractTest source set이 provider-neutrality와 bounded request semantics를 qualification lane으로 다시 pin 한다.

Metrics and tracing

MetricNaming은 Micrometer-facing dot.case naming과 seconds/bytes/total suffix vocabulary를 framework dependency 없이 보존한다. CardinalityBounds는 bounded tag의 상한을 Java mirror로 제공하고, ForbiddenMetricTags는 request_id/user_id/raw URL/query/header/IP 같은 unbounded source를 metric label에서 금지한다. request_id가 baggage에는 허용되지만 metric label에는 금지되는 비대칭은 test에서 의도적으로 pin 되어 있다.

TraceParent는 이 skeleton이 지원하는 strict v00 subset을 parse/render한다. lowercase hex, non-zero trace/span id, 2-byte flags를 검사하고 wrong version을 거부한다. BaggageAllowlisttenant_id, request_id만 보존하는 단순 parse/filter/render utility다. 이는 full W3C baggage grammar validator라기보다 propagation boundary allowlist다. SpanErrorRecorder.NOOP은 tracer library가 없는 기본 template에서도 outer adapter가 동일 seam을 호출할 수 있게 한다.

Domain context propagation

DomainContextPropagator는 diagnostic MDC와 분리된 domain/business context channel이다. default ThreadLocalDomainContextPropagator는 plain ThreadLocal을 쓰고 implicit inheritance를 금지하며 capture()/restore()wrap()으로 명시적 hand-off를 수행한다. virtual-thread test는 wrap을 썼을 때 전달되고 쓰지 않았을 때 상속되지 않으며 scope close 뒤 worker context가 복원되는 것을 검증한다.

이 seam은 문서상 계획에 그치지 않는다. production reachability 검색에서 app-bootstrapDomainContextConfig, AsyncContextTaskDecorator, AsyncExecutorConfig, persistence-jpa audit adapter, sample composition config가 실제로 소비하는 것이 확인됐다.

DomainContextKey equality/hash는 name only이고 read 시 요청 key의 Class<T>로 cast한다. 동일 이름의 서로 다른 type key를 만들면 같은 slot을 공유할 수 있고 잘못된 type으로 읽을 때 ClassCastException 가능성이 있다. source javadoc이 name-only identity를 명시하므로 hidden implementation bug로 단정하지 않지만, 현재 test는 same-name/different-type collision을 pin 하지 않는다. P1 contract-hardening 후보로 남긴다.

Operational record store

OperationalRecordStorePort는 durable operational state를 특정 inbound/outbound adapter에 종속시키지 않는 neutral CAS port다. record version 0은 absent를 뜻하며 compareAndSet/compareAndRemove의 expectedVersion이 lost update 방지 evidence 역할을 한다. GraphQL persisted-operation adapter가 이 port를 실제 production dependency로 사용하며, durable provider implementation 자체는 해당 inbound adapter에 들어있지 않다.

OperationalRecord는 namespace/key non-blank와 version >= 0은 강제하지만 javadoc의 "bounded"라는 표현에 대응하는 길이/character limit은 source에 없다. 이는 문서와 constructor enforcement 강도의 차이이며 P2 확인 후보로 남긴다.

Activation and health snapshot

MasterSwitchParser는 unset=false, true/false case-insensitive만 허용하며 yes, 1, on, whitespace-padded value를 invalid로 처리한다. canonical+legacy가 동시에 있으면 값이 같아도 ambiguous로 실패하고 legacy-only는 replacement property를 반환한다. 이는 operator configuration을 permissive coercion하지 않는 fail-closed contract다.

RedisHealthSnapshotProvider는 Redis client/connection/credential을 shared boundary로 새지 않게 role/capability/state/reason/semantic freshness만 projection한다. eviction policy는 runtime CONFIG 조회 증명이 아니라 configured expectation임을 enum 이름과 javadoc으로 명시한다.

Messaging envelope schema

contracts/messaging/envelope/v1.schema.json은 Draft 2020-12 schema resource이며 envelopeVersion/eventId/contractId/payloadVersion/logicalDestination/aggregate/occurredAt/correlationId/contentType/payload를 required로 고정하고 top-level/aggregate에 unevaluatedProperties:false를 둔다. checked-in SHA-256은 bf6f2e13fafe01b8ef4cbb73d7ba3f5703bfc68d145bdfe43190bf606dbd00b1이다.

MessagingEnvelopeSchemaResourceTest는 schema text 자체, identifier regex parity, Java int/long 경계 vector, strict UTF-8, exact digest를 JDK API로 검증한다. 이 테스트는 resource drift와 digest mismatch를 강하게 막지만 README가 명시하듯 실제 Draft 2020-12 validator interoperability나 broker runtime discovery를 증명하지는 않는다.

Reachability / wiring evidence

  • DomainContextPropagator: app-bootstrap composition + async decorator, persistence-jpa audit adapter, sample composition에서 production use 확인.
  • OperationalRecordStorePort: inbound GraphQL persisted-operation registry에서 production use 확인. 이 방향성은 adapter-specific storage interface를 outbound가 구현하는 역방향 dependency를 피한다.
  • registry상 shared-contract는 다른 production project를 의존하지 않는 leaf이며 app-bootstrap/sample-portfolio runtime membership을 가진다.
  • rate-limit, response, error 등의 세부 consumer 전체는 각 adapter/application bounded scope에서 추가 분석할 대상이며 이번 scope에서는 representative reachability와 contract 자체를 완전 읽기 대상으로 삼았다.

Verification

실제 실행 결과:

  • ./gradlew :shared-contract:test --console=plain → BUILD SUCCESSFUL, exit 0
  • ./gradlew :shared-contract:edgeRateLimitContractTest --console=plain → BUILD SUCCESSFUL, exit 0
  • source revision 확인: a24ece9cf797f7ea647e33bf846b115208ed1ba5
  • git status --short → output 없음, working tree clean

Coverage ledger

분모는 src/main 전체 파일, src/test 전체 파일, custom edgeRateLimitContractTest source, 그리고 module-level CLAUDE.md, README.md, build.gradle이다.

  • FULL_READ: 82
    • main production/resource 55
    • unit/contract test 23
    • edgeRateLimitContractTest 1
    • module policy/rationale/build 3
  • STRUCTURAL_ONLY: 4
    • production placeholder .gitkeep 3
    • test placeholder .gitkeep 1
  • EXCLUDED: 0
  • UNCLASSIFIED: 0

따라서 selected bounded scope는 completion standard를 충족한다.

Open questions / improvement backlog

P1 — response/LRO invariant enforcement boundary

Envelope, BulkEnvelope, Operation, PageMeta의 문서상 valid shape가 factory tests에는 고정되어 있지만 public canonical constructor에서 강제되지 않는다. raw constructor 사용이 실제로 허용된 extension surface인지, 아니면 constructor-level validation으로 invalid state를 막아야 하는지 결정이 필요하다.

P1 — DomainContextKey same-name different-type collision

key identity가 name only인 반면 retrieval은 requested type cast를 수행한다. 동일 name의 다른 Class<T> key를 선언하는 것이 forbidden contract라면 creation-time collision 방지 또는 registry rule/test가 필요하고, 의도적으로 허용한다면 failure semantics를 문서화할 필요가 있다.

P2 — bounded operational record identifiers

OperationalRecord javadoc은 namespace/key를 bounded라고 설명하지만 constructor는 blank 여부만 확인한다. provider key size/character-set 제한을 shared contract가 소유해야 하는지 확인이 필요하다.

P2 — permission component grammar

permission은 colon segment 수, blank, normalization은 강제하지만 segment character grammar는 제한하지 않는다. registry SSOT가 더 좁은 grammar를 요구한다면 shared value object와 parity test가 필요하다.

P2 — messaging schema qualification boundary

현재 JDK-only test는 exact resource/digest/selected semantic vectors를 검증한다. Draft 2020-12 validator 호환성은 별도 qualification evidence가 필요하며 현재 module test 성공만으로 이를 주장해서는 안 된다.

다음 scope

queue의 동일 active project를 유지하고 다음 PENDING scope인 application-core를 다음 실행에서 분석한다.

Source anchors

이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 40개 (main 35 · test 2 · 기타 3).

src/shared-contract/build.gradle
src/config/architecture/modules.json                    (shared-contract 항목)

main:
  src/main/java/dev/caskeleton/shared/activation/MasterSwitchParser.java
  src/main/java/dev/caskeleton/shared/concurrency/DomainContextKey.java
  src/main/java/dev/caskeleton/shared/concurrency/DomainContextPropagator.java
  src/main/java/dev/caskeleton/shared/concurrency/ThreadLocalDomainContextPropagator.java
  src/main/java/dev/caskeleton/shared/error/AdapterDisabledException.java
  src/main/java/dev/caskeleton/shared/error/ApiErrorCarrier.java
  src/main/java/dev/caskeleton/shared/error/ApiErrorCode.java
  src/main/java/dev/caskeleton/shared/error/Category.java
  src/main/java/dev/caskeleton/shared/error/DependencyFailureException.java
  src/main/java/dev/caskeleton/shared/error/OperationalError.java
  src/main/java/dev/caskeleton/shared/error/PersistenceFailureException.java
  src/main/java/dev/caskeleton/shared/health/RedisHealthSnapshotProvider.java
  src/main/java/dev/caskeleton/shared/metrics/CardinalityBounds.java
  src/main/java/dev/caskeleton/shared/metrics/ForbiddenMetricTags.java
  src/main/java/dev/caskeleton/shared/metrics/MetricNaming.java
  src/main/java/dev/caskeleton/shared/operation/Operation.java
  src/main/java/dev/caskeleton/shared/opstore/OperationalRecord.java
  src/main/java/dev/caskeleton/shared/opstore/OperationalRecordStorePort.java
  src/main/java/dev/caskeleton/shared/ratelimit/EdgeRateLimitSubject.java
  src/main/java/dev/caskeleton/shared/ratelimit/RateLimitDecision.java
  src/main/java/dev/caskeleton/shared/ratelimit/RateLimitEvaluationDedupPolicy.java
  src/main/java/dev/caskeleton/shared/ratelimit/RateLimitOutcome.java
  src/main/java/dev/caskeleton/shared/ratelimit/RateLimitPolicy.java
  src/main/java/dev/caskeleton/shared/ratelimit/RateLimitRequest.java
  src/main/java/dev/caskeleton/shared/ratelimit/RateParameters.java
  src/main/java/dev/caskeleton/shared/request/Patch.java
  src/main/java/dev/caskeleton/shared/response/BulkEnvelope.java
  src/main/java/dev/caskeleton/shared/response/Envelope.java
  src/main/java/dev/caskeleton/shared/response/PageMeta.java
  src/main/java/dev/caskeleton/shared/response/ResponseMeta.java
  src/main/java/dev/caskeleton/shared/security/Permission.java
  src/main/java/dev/caskeleton/shared/tracing/BaggageAllowlist.java
  src/main/java/dev/caskeleton/shared/tracing/SpanErrorRecorder.java
  src/main/java/dev/caskeleton/shared/tracing/TraceParent.java
  src/main/resources/contracts/messaging/envelope/v1.schema.json

test:
  src/test/java/dev/caskeleton/shared/contract/messaging/MessagingEnvelopeSchemaResourceTest.java
  src/test/java/dev/caskeleton/shared/error/OperationalErrorTest.java

기타:
  CLAUDE.md
  README.md
  src/build.gradle