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>
15 lines
8.3 KiB
JSON
15 lines
8.3 KiB
JSON
{
|
|
"assetKey": "a14-f010-no-store-conditional",
|
|
"kind": "terminal",
|
|
"command": "set -e\nset -o pipefail\nD=$(mktemp -d); trap 'rm -rf \"$D\"' EXIT\nC=/shared/codebase/clean-architecture-backend-template\nM=$C/src/sample-portfolio\nCACHE=/root/.gradle/caches/modules-2/files-2.1\njava -version 2>&1 | grep version\ncp=\"\"\nwhile IFS= read -r line; do\n coord=${line%%=*}; confs=${line#*=}\n case \",$confs,\" in *\",testRuntimeClasspath,\"*) ;; *) continue ;; esac\n g=${coord%%:*}; rest=${coord#*:}; n=${rest%%:*}; v=${rest##*:}\n # 잠금 파일의 좌표 하나가 캐시에 없다. 없는 것은 건너뛴다.\n jar=$(find \"$CACHE/$g/$n/$v\" -name '*.jar' ! -name '*sources*' ! -name '*javadoc*' 2>/dev/null | head -1) || true\n [ -n \"$jar\" ] && cp=\"$cp:$jar\"\ndone < <(grep -E '^[a-zA-Z0-9._-]+:[^=]+=' \"$M/gradle.lockfile\")\n# 형제 모듈은 고정 리비전 산출물만 붙인다.\n# sample-portfolio 는 고정 리비전에 -plain 만 있으므로 둘 다 받는다.\nSIB=$(find \"$C/src\" -path '*/build/libs/*+21234e38cdb9*.jar' ! -name '*-testkit.jar' | sort | tr '\\n' ':')\ncat > \"$D/CacheConditionalProbe.java\" <<'JAVA'\nimport dev.caskeleton.adapter.inbound.web.filter.CacheControlFilter;\nimport dev.caskeleton.application.transaction.TransactionPort;\nimport dev.caskeleton.sample.portfolio.adapter.inbound.web.controller.WorkLogController;\nimport dev.caskeleton.sample.portfolio.application.worklog.GetWorkLogUseCase;\nimport dev.caskeleton.sample.portfolio.domain.worklog.Period;\nimport dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory;\nimport dev.caskeleton.sample.portfolio.domain.worklog.WorkLog;\nimport dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId;\nimport dev.caskeleton.sample.portfolio.domain.worklog.WorkLogPage;\nimport dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository;\nimport dev.caskeleton.sample.portfolio.domain.worklog.WorkLogSortField;\nimport java.time.LocalDate;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.function.Supplier;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.security.web.header.HeaderWriterFilter;\nimport org.springframework.security.web.header.writers.CacheControlHeadersWriter;\nimport org.springframework.test.web.servlet.MockMvc;\nimport org.springframework.test.web.servlet.MvcResult;\nimport org.springframework.test.web.servlet.request.MockMvcRequestBuilders;\nimport org.springframework.test.web.servlet.setup.MockMvcBuilders;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\n/** 캐시 필터와 조건부 읽기 경로를 한 사슬에 올려 응답 헤더를 읽는다. */\npublic final class CacheConditionalProbe {\n\n private static final String ID = \"0190bd6e-7c3e-7abc-8def-0123456789ab\";\n\n /** 저장된 표현 하나. version 3 이므로 ETag 는 W/\"3\" 이다. */\n private static WorkLog sample() {\n return WorkLog.rehydrate(\n WorkLogId.of(ID), \"샘플\", WorkCategory.DATABASE, \"s\", \"c\",\n List.of(\"postgres\"), List.of(\"https://example.com\"),\n new Period(LocalDate.of(2025, 1, 1), null), 3L);\n }\n\n /** 조회만 하는 저장소. 나머지 연산은 이 프로브가 부르지 않는다. */\n private static final class OneRow implements WorkLogRepository {\n @Override public WorkLog save(WorkLog workLog) { throw new UnsupportedOperationException(); }\n @Override public Optional<WorkLog> findById(WorkLogId id) { return Optional.of(sample()); }\n @Override public WorkLogPage findPage(\n int page, int size, WorkLogSortField sortField, boolean ascending, WorkCategory category) {\n throw new UnsupportedOperationException();\n }\n @Override public boolean existsById(WorkLogId id) { return true; }\n @Override public void deleteById(WorkLogId id) { throw new UnsupportedOperationException(); }\n }\n\n /** 경계만 흉내 내고 그대로 실행하는 트랜잭션 포트. */\n private static final class Direct implements TransactionPort {\n @Override public <T> T inWrite(Supplier<T> action) { return action.get(); }\n @Override public <T> T inRootWrite(Supplier<T> action) { return action.get(); }\n @Override public <T> T inRead(Supplier<T> action) { return action.get(); }\n @Override public <T> T inNew(Supplier<T> action) { return action.get(); }\n }\n\n /** 스스로 캐시 지시자를 붙이는 대조용 엔드포인트. */\n @RestController\n public static class OptsIn {\n @GetMapping(\"/opts-in\")\n public ResponseEntity<String> get() {\n return ResponseEntity.ok().header(\"Cache-Control\", \"max-age=60\").body(\"ok\");\n }\n }\n\n private static void row(String label, MvcResult result) {\n System.out.printf(\" %-24s %-6d %-46s %s%n\", label,\n result.getResponse().getStatus(),\n String.valueOf(result.getResponse().getHeader(\"Cache-Control\")),\n String.valueOf(result.getResponse().getHeader(\"ETag\")));\n }\n\n public static void main(String[] args) throws Exception {\n GetWorkLogUseCase get = new GetWorkLogUseCase(new OneRow(), new Direct());\n WorkLogController controller =\n new WorkLogController(null, null, null, null, get, null, null);\n MockMvc mvc = MockMvcBuilders.standaloneSetup(controller, new OptsIn())\n .addFilters(new CacheControlFilter())\n .build();\n\n System.out.println();\n System.out.println(\"[조건부 읽기 경로에 세 번 요청한다]\");\n System.out.printf(\" %-24s %-6s %-46s %s%n\", \"요청\", \"상태\", \"Cache-Control\", \"ETag\");\n row(\"첫 요청\", mvc.perform(\n MockMvcRequestBuilders.get(\"/worklogs/\" + ID).accept(MediaType.APPLICATION_JSON))\n .andReturn());\n row(\"받은 태그를 되돌려\", mvc.perform(\n MockMvcRequestBuilders.get(\"/worklogs/\" + ID)\n .header(\"If-None-Match\", \"W/\\\"3\\\"\").accept(MediaType.APPLICATION_JSON))\n .andReturn());\n row(\"맞지 않는 태그\", mvc.perform(\n MockMvcRequestBuilders.get(\"/worklogs/\" + ID)\n .header(\"If-None-Match\", \"W/\\\"2\\\"\").accept(MediaType.APPLICATION_JSON))\n .andReturn());\n\n System.out.println();\n System.out.println(\"[스스로 지시자를 붙이는 엔드포인트]\");\n row(\"직접 붙인 값\", mvc.perform(MockMvcRequestBuilders.get(\"/opts-in\")).andReturn());\n\n // 이 컨트롤러가 사는 모듈은 SecurityConfig 를 스캔에서 빼므로 프레임워크 기록기가 사슬에 남는다.\n MockMvc withWriter = MockMvcBuilders.standaloneSetup(controller, new OptsIn())\n .addFilters(new CacheControlFilter(),\n new HeaderWriterFilter(java.util.List.of(new CacheControlHeadersWriter())))\n .build();\n MockMvc writerOnly = MockMvcBuilders.standaloneSetup(controller, new OptsIn())\n .addFilters(new HeaderWriterFilter(java.util.List.of(new CacheControlHeadersWriter())))\n .build();\n\n System.out.println();\n System.out.println(\"[프레임워크 캐시 기록기가 사슬에 함께 있을 때]\");\n row(\"둘 다 있을 때\", withWriter.perform(\n MockMvcRequestBuilders.get(\"/worklogs/\" + ID).accept(MediaType.APPLICATION_JSON))\n .andReturn());\n row(\"기록기만 있을 때\", writerOnly.perform(\n MockMvcRequestBuilders.get(\"/worklogs/\" + ID).accept(MediaType.APPLICATION_JSON))\n .andReturn());\n }\n}\nJAVA\njavac -encoding UTF-8 -cp \"$SIB$cp\" -d \"$D/out\" \"$D/CacheConditionalProbe.java\" 2>&1 | grep -v '^$' || true\n# 부팅 로그는 시각을 달고 나오므로 끈다. 이 프로브가 읽는 것은 응답 헤더뿐이다.\nprintf '%s\\n' '<configuration><root level=\"OFF\"/></configuration>' > \"$D/out/logback.xml\"\njava -Dstdout.encoding=UTF-8 -cp \"$D/out:$SIB$cp\" CacheConditionalProbe\n",
|
|
"cwd": "/shared/codebase/clean-architecture-backend-template/src",
|
|
"exitCode": 0,
|
|
"executedAt": "2026-09-03T00:50:44+00:00",
|
|
"sourceRevision": "21234e38cdb9a926cbc92bb97a2aee2e4a7d2916",
|
|
"raw": "evidence/raw/a14-f010-no-store-conditional.txt",
|
|
"svg": "evidence/rendered/a14-f010-no-store-conditional.svg",
|
|
"rawSha256": "370a155d118b32adbf1395ea91f339a8d078c99a61d16e6138f8096013fb6fa6",
|
|
"lines": 14,
|
|
"redaction": "none — 요청 헤더와 표현은 이 프로브가 넣은 것이다"
|
|
}
|