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>
137 lines
14 KiB
Markdown
137 lines
14 KiB
Markdown
---
|
|
kind: CASE
|
|
slug: analysis-finding-a05-f034
|
|
title: 세 카드의 태그를 채우는 시험이 프로덕션 타입을 한 줄도 부르지 않는다
|
|
topic: multitenancy-isolation
|
|
project: clean-architecture-backend-template
|
|
status: 게시 전
|
|
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
rootTreeNode: case:analysis-finding-a05-f034
|
|
evidenceCapturedOn: 2026-09-04
|
|
body: case-analysis-finding-a05-f034.body.md
|
|
assets:
|
|
- key: analysis-finding-a05-f034
|
|
file: ../../../final/evidence/rendered/analysis-finding-a05-f034.svg
|
|
evidence:
|
|
- ../../../final/evidence/raw/analysis-finding-a05-f034.txt
|
|
source:
|
|
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md §133 이다.
|
|
---
|
|
|
|
# 세 카드의 태그를 채우는 시험이 프로덕션 타입을 한 줄도 부르지 않는다
|
|
|
|
`readiness-cards.yaml` 의 `selected` 카드 일곱 중 셋이 `postgresqlIntegrationTest` 의 시험을 시나리오로 지목하는데, 그 세 파일은 `dev.caskeleton` 을 `import` 하는 줄이 0 이다. 대조로 센 `jpa-transaction-runtime` 의 시험은 15 줄을 `import` 한다.
|
|
|
|
## 관계
|
|
|
|
- **증거 등급과 provenance — R1과 R2를 가르는 것**
|
|
그 등급의 조건은 결과가 어디서 왔는지를 보지 무엇을 지났는지를 보지 않는다. 시나리오가 지목한 시험이 프로덕션 코드를 부르는지는 R1 에서도 R2 에서도 검사되지 않는다.
|
|
- **빠뜨림이 통과가 되는 게이트는 게이트가 아니다**
|
|
여기서는 빠뜨림이 아니라 지목이 문제다. 태그마다 시나리오가 붙어 있어 레지스트리 검사는 통과하는데, 그 시나리오가 도는 것이 시험이 자기 `@BeforeAll` 로 만든 표다.
|
|
- **후보 증거는 통과해도 R1에 머무르고 R2는 별도 게이트가 판정한다**
|
|
그 결정이 후보 검증에 맡긴 것은 건너뛴 시험과 스키마와 내용 해시와 선행 조건이다. 시나리오가 프로덕션 타입을 한 줄도 부르지 않는 것은 그 넷 중 어디에도 걸리지 않는다.
|
|
|
|
## 문제
|
|
|
|
준비도 카드는 태그마다 그것을 덮는 시나리오나 task-claim 을 적고, 게이트가 그 selector 를 실제로 돌린다.
|
|
|
|
그 selector 가 무엇을 지나는지 확인했다.
|
|
|
|
## 결론
|
|
|
|
selected 일곱 모두에서 no-skip 이 비어 있는데, 이것은 결함이 아니라 설계다. build.gradle:1672~:1674 가 그 태그를 필수로 요구하면서 :1678~:1680 이 클레임 가능한 태그에서 빼고, jpa-evidence.gradle:680~:684 가 JUnit XML 의 실행·건너뜀·실패 건수로 그 태그를 채운다.
|
|
|
|
jpa-primary-foundation 의 base-card-manifests 도 생성기가 채운다. 그 카드는 readiness-task 자체가 나머지 여섯을 모으는 롤업이라 시나리오가 0 인 것이 정상이고, support-task 는 다섯 걸려 있다.
|
|
|
|
남는 것은 셋이다. jpa-aggregate-store 와 jpa-query-model 은 시나리오가 하나씩이고, jpa-observability-lifecycle 은 둘이다.
|
|
|
|
그 셋이 지목하는 세 파일은 dev.caskeleton 을 import 하는 줄이 0 이다. import 없이 쓰는 같은 패키지 타입까지 잡으려고 대문자 이름을 전부 뽑아 다시 셌는데 그것도 0 이다. 대조로 센 PostgreSqlTransactionIntegrationTest 는 두 방식 모두 15 다.
|
|
|
|
그 시험들이 읽고 쓰는 표는 자기 @BeforeAll 이 만든 것이다. readiness_aggregate 도 readiness_query 도 프로덕션 소스와 마이그레이션 4714 개 파일 어디에도 없다.
|
|
|
|
세 번째 카드의 observability 는 표가 아니라 문자열이다. 견주는 쪽과 견주어지는 쪽이 모두 PostgreSqlLifecycleIntegrationTest 안에 있고, 그 리터럴 postgresql-primary 는 프로덕션에 0 건이다.
|
|
|
|
## 검증 환경
|
|
|
|
OpenJDK : 21.0.12
|
|
확인 방식 : 카드 전체 계수와 selected 카드의 태그·시나리오·task-claim·support-task 계수, 레지스트리가 덮을 수 없는 두 태그의 근거를 빌드 스크립트와 생성기에서 인용, 세 카드의 시나리오 selector 나열, 그 세 시험의 본문 인용, import 계수와 소스 세트 파일 수 대조, 표 이름과 문자열의 프로덕션 등장 계수와 훑은 파일 수 대조, 시나리오 일곱 카드의 import 전수
|
|
소스 수정 : x
|
|
|
|
## 재현 조건
|
|
|
|
1. 카드 전체를 상태별로 세고, selected 카드마다 필수 태그와 시나리오와 task-claim 과 support-task 를 센다.
|
|
2. no-skip 과 base-card-manifests 가 레지스트리 밖에서 채워지는 자리를 빌드 스크립트와 생성기에서 인용한다.
|
|
3. 남은 세 카드의 시나리오 selector 를 나열하고 그 시험 본문을 싣는다.
|
|
4. 세 파일의 dev.caskeleton import 줄을 세고, 파일이 쓰는 대문자 이름 가운데 프로덕션에 정의된 타입이 몇인지도 센다.
|
|
5. 두 표 이름과 postgresql-primary 가 프로덕션에 나오는지 세고 훑은 파일 수를 함께 센다.
|
|
6. 시나리오 일곱을 가진 카드의 시험이 무엇을 import 하는지 나열한다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
`readiness-cards.yaml` 은 카드마다 필수 증거 태그를 적고, 그 태그를 덮는 시나리오나 task-claim 을 함께 적는다.
|
|
|
|
## 태그 커버리지에서 두 태그는 빼고 읽어야 한다
|
|
|
|
:::evidence key="analysis-finding-a05-f034" alt="저장소 루트에서 돌린 준비도 레지스트리 분석과 정적 검색 출력 237줄. 먼저 카드가 열일곱이고 implemented-candidate 여섯, not-implemented 넷, selected 일곱이라고 나온다. 그 일곱마다 필수 태그 수와 시나리오 수와 task-claim 수와 support-task 수와 레지스트리가 덮지 않는 태그가 나열되는데, jpa-observability-lifecycle 은 태그 넷에 시나리오 둘, jpa-security-baseline 은 태그 여섯에 시나리오 셋과 task-claim 하나, jpa-flyway-migration 은 태그 넷에 시나리오 넷, jpa-transaction-runtime 은 태그 넷에 시나리오 일곱, jpa-aggregate-store 와 jpa-query-model 은 각각 태그 넷에 시나리오 하나, jpa-primary-foundation 은 태그 넷에 시나리오 0 과 task-claim 둘과 support-task 다섯이다. 덮지 않는 태그는 여섯 카드가 no-skip 하나씩이고 jpa-primary-foundation 만 base-card-manifests 가 더 있다. 이어서 그 두 태그를 레지스트리가 덮을 수 없는 근거가 실린다. build.gradle 1672~1674번이 no-skip 을 필수 태그에 넣으라고 강제하고 1678~1680번이 클레임 가능한 태그 집합에서 no-skip 을 빼며, jpa-evidence.gradle 676~692번이 JUnit XML 의 실행 건수와 건너뜀과 실패와 오류 건수로 noSkipResult 를 계산해 no-skip 을 채우고, cardId 가 jpa-primary-foundation 이고 선행 조건이 모두 매니페스트를 가졌으면 base-card-manifests 를 채운다. 다음으로 남은 세 카드의 시나리오가 나열된다. jpa-aggregate-store 는 필수 태그 real-postgresql 과 mapping 과 optimistic-conflict 와 no-skip 에 시나리오 하나가 앞의 셋을 함께 덮고, jpa-query-model 도 keyset 과 query-plan 을 시나리오 하나가 함께 덮으며, jpa-observability-lifecycle 은 시나리오 둘이 각각 real-postgresql 과 lifecycle 을, observability 를 덮는다. 그 아래에 세 시험의 본문이 실린다. PostgreSqlAggregateIntegrationTest 는 19~30번 BeforeAll 이 Docker 가용성을 확인하고 create table readiness_aggregate 를 실행하며, 36~84번이 JDBC 로 UUID 와 Instant 를 넣고 다시 읽어 값을 견주고, 66~84번이 커넥션 둘을 열어 version 을 조건에 넣은 갱신을 각각 실행해 먼저 커밋한 쪽이 1 이고 두 번째가 0 임을 확인한다. PostgreSqlQueryIntegrationTest 는 22~35번이 readiness_query 표와 인덱스를 만들고 generate_series 로 천 행을 넣으며, 45~80번이 keyset 페이지를 읽고 70번에서 set enable_seqscan=off 를 실행한 뒤 72번 explain format json 의 계획에 인덱스 이름이 들어 있는지 78번에서 확인한다. PostgreSqlLifecycleIntegrationTest 는 51~66번이 크기 2 인 풀을 만들어 커넥션 둘을 쥐고 상태가 SATURATED 인지와 active 와 maximum 이 2 인지를 확인한 뒤 63~65번이 boundedTags 를 component 가 postgresql-primary 이고 state 가 saturated 인 맵과 견주는데, 107~119번의 private record 안 115~117번이 바로 그 맵을 만드는 자리다. 다음으로 세 시험이 dev.caskeleton 을 import 하는 줄이 각각 0 개이고 같은 소스 세트에 파일이 스물일곱이며, 이름 단위로 훑어도 세 파일이 쓰는 대문자 이름 중 프로덕션에 정의된 타입이 각각 0 개인데 대조로 건 PostgreSqlTransactionIntegrationTest 는 15 개라고 나온다. 두 시험이 쓰는 표 이름 readiness_aggregate 와 readiness_query 가 프로덕션 소스와 마이그레이션에 0 건이고 그 검색이 훑은 main 파일이 4714 개이며, postgresql-primary 도 프로덕션에 0 건이다. 마지막으로 대조 카드 jpa-transaction-runtime 의 시나리오 일곱이 각각 어떤 태그를 덮는지 나열되고, 그 시험이 import 하는 dev.caskeleton 타입 열다섯 중 여덟이 실린다." caption="카드 열일곱과 selected 일곱의 태그 커버리지 · no-skip 과 base-card-manifests 를 레지스트리 밖에서 채우는 자리 · 남은 세 카드의 시나리오 · 그 세 시험의 본문 · import 와 이름 단위 계수 대조 · 표 이름과 문자열의 프로덕션 등장 0 · 시나리오 일곱 카드의 대조 — 237줄 · exit 0" zoom="true"
|
|
:::
|
|
|
|
카드는 열일곱이고 `selected` 가 일곱이다. 일곱 모두 `no-skip` 이 시나리오로도 task-claim 으로도 덮이지 않는다.
|
|
|
|
그것이 설계다. `build.gradle:1672`\~`:1674` 가 모든 카드에 `no-skip` 을 필수 태그로 넣으라고 강제하고, `:1678`\~`:1680` 이 클레임 가능한 태그 집합을 만들 때 그 하나를 뺀다. 카드가 `no-skip` 을 덮겠다고 적으면 검증기가 거절한다.
|
|
|
|
대신 `jpa-evidence.gradle:680`\~`:684` 가 매니페스트를 만들 때 JUnit XML 의 실행 건수와 건너뜀·실패·오류 건수를 읽어 `noSkipResult` 를 계산하고 그 태그를 채운다.
|
|
|
|
`base-card-manifests` 는 `jpa-primary-foundation` 에만 있는 태그이고, `jpa-evidence.gradle:685`\~`:691` 이 그 카드의 선행 조건 여섯이 모두 매니페스트를 가졌을 때 채운다. `readiness-task` 가 `verifyJpaPrimaryFoundationEvidence` 이고 선행 조건이 나머지 여섯 base 카드 전부라, 이 카드는 자기 시나리오를 갖지 않는 쪽이 맞다.
|
|
|
|
## 남는 세 카드
|
|
|
|
`jpa-aggregate-store` 는 시나리오 하나가 `real-postgresql` 과 `mapping` 과 `optimistic-conflict` 를 함께 덮는다.
|
|
|
|
`jpa-query-model` 도 시나리오 하나가 `real-postgresql` 과 `keyset` 과 `query-plan` 을 함께 덮는다.
|
|
|
|
`jpa-observability-lifecycle` 은 둘인데, 하나가 `real-postgresql` 과 `lifecycle` 을, 다른 하나가 `observability` 를 덮는다.
|
|
|
|
## 그 시나리오들이 도는 것
|
|
|
|
`PostgreSqlAggregateIntegrationTest` 의 `@BeforeAll:19`\~`:30` 이 `assertDockerAvailable()` 뒤에 `create table readiness_aggregate` 를 실행한다. `:36`\~`:64` 가 `UUID` 와 `Instant` 를 JDBC 로 넣고 다시 읽어 값을 견준다. `:66`\~`:84` 는 커넥션 둘을 열어 `where id = ? and version = ?` 갱신을 각각 실행하고, 먼저 커밋한 쪽의 갱신 건수가 1 이고 두 번째가 0 인 것을 확인한 뒤 롤백한다.
|
|
|
|
`optimistic-conflict` 를 덮는 것이 그 부분이다. `@Version` 이 아니라 손으로 쓴 조건부 갱신이고, 대상은 이 시험이 만든 표다.
|
|
|
|
`PostgreSqlQueryIntegrationTest:22`\~`:35` 가 `readiness_query` 와 인덱스를 만들고 `generate_series` 로 천 행을 넣는다. `:45`\~`:68` 이 keyset 페이지를 읽고, `:70` 이 `set enable_seqscan=off` 를 실행한 뒤 `:72` 의 `explain (format json)` 계획에 인덱스 이름이 들어 있는지를 `:78` 에서 확인한다.
|
|
|
|
`query-plan` 태그는 실제 EXPLAIN 계획을 본다. 다만 대안을 끈 뒤에 본 것이고, 대상은 역시 이 시험이 만든 표다.
|
|
|
|
`PostgreSqlLifecycleIntegrationTest:51`\~`:66` 은 크기 2 인 풀에 커넥션 둘을 쥐고 상태와 `active` 와 `maximum` 을 확인한다. `observability` 를 덮는 것은 `:63`\~`:65` 의 `boundedTags()` 비교인데, 그 맵을 만드는 `:115`\~`:117` 이 같은 파일 private record 안에 있고 리터럴도 같은 파일에 있다.
|
|
|
|
## 세 파일 모두 프로덕션 타입에 닿지 않는다
|
|
|
|
`dev.caskeleton` 을 `import` 하는 줄이 세 파일 다 0 이다. 같은 패키지 타입은 `import` 없이 쓸 수 있으므로 파일이 쓰는 대문자 이름을 전부 뽑아 프로덕션에 같은 이름의 `.java` 가 있는지도 셌는데, 그것도 셋 다 0 이다. 같은 소스 세트에 파일이 스물일곱 있다.
|
|
|
|
표 이름 둘과 `boundedTags()` 가 견주는 `postgresql-primary` 를 프로덕션 소스와 마이그레이션에서 찾으면 셋 다 0 건이다. 그 검색이 훑은 main 파일은 4714 개다.
|
|
|
|
## 시나리오 일곱을 가진 카드는 다르다
|
|
|
|
`jpa-transaction-runtime` 은 태그 넷에 시나리오 일곱이고, 하나가 여러 태그를 겸하는 대신 태그마다 여러 시나리오가 붙는다.
|
|
|
|
그 시나리오가 가리키는 `PostgreSqlTransactionIntegrationTest` 는 `dev.caskeleton` 을 15 줄 `import` 한다. `PersistenceExceptionTranslator`, `StandardSqlStateErrorMapping`, `PostgreSqlLocalTimeoutConfigurer`, `PostgreSqlSqlStateErrorMapping`, `JpaTransactionSettings`, `SpringTransactionPort` 같은 것들이다. 이름 단위로 세도 15 다.
|
|
|
|
## 원문에 없는 것
|
|
|
|
원문은 이 세 카드의 시나리오가 프로덕션 경로를 지나지 않는다고 적는다. 여기에 더한 것은 그 판정을 남기기 위해 무엇을 빼야 하는지다.
|
|
|
|
`no-skip` 은 일곱 카드 모두에서 비어 있지만 레지스트리가 덮을 수 없는 태그이고, `jpa-primary-foundation` 의 시나리오 0 은 롤업 카드의 정상 상태다. 둘을 빼고 나면 남는 것이 셋이고, 그 셋에 대해서는 `import` 계수와 이름 단위 계수가 같은 답을 낸다.
|
|
|
|
## 확인하지 못한 것
|
|
|
|
매니페스트를 생성해 등급이 무엇으로 찍히는지 보지 않았다.
|
|
|
|
이름 단위 검사의 판정 기준은 프로덕션 트리에 같은 이름의 `.java` 가 있는지다. 시험 전용 타입과 이름이 겹치면 과대 계수될 수 있다.
|
|
|
|
`verifyJpaCandidateEvidence` 가 이 세 카드에 어떤 블로커를 붙이는지 빌드를 돌려 보지 않았다.
|
|
|
|
`verifyJpaCandidateEvidence` 가 이 세 카드에 어떤 블로커를 붙이는지 빌드를 돌려 보지 않았다.
|
|
|
|
<!-- body:end -->
|