Files
document-haness/docs/clean-architecture-backend-template/final/evidence/rendered/a14-f010-no-store-conditional.svg
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

176 lines
15 KiB
XML

<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1844" viewBox="0 0 1200 1844" role="img">
<title>terminal evidence</title>
<desc>Terminal-style rendering generated from retained command output. Sensitive-looking values are redacted in the visual asset.</desc>
<rect x="1" y="1" width="1198" height="1842" rx="14" fill="#0d1117" stroke="#30363d"/>
<rect x="1" y="1" width="1198" height="44" rx="14" fill="#161b22"/>
<rect x="1" y="30" width="1198" height="14" fill="#161b22"/>
<circle cx="24" cy="22" r="6" fill="#ff5f57"/>
<circle cx="44" cy="22" r="6" fill="#febc2e"/>
<circle cx="64" cy="22" r="6" fill="#28c840"/>
<text x="92" y="27" fill="#8b949e" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">terminal evidence</text>
<text x="24" y="68" fill="#c9d1d9" font-size="15" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">$ set -e
set -o pipefail
D=$(mktemp -d); trap &#x27;rm -rf &quot;$D&quot;&#x27; EXIT
C=/shared/codebase/clean-architecture-backend-template
</tspan><tspan x="24" dy="22">M=$C/src/sample-portfolio
CACHE=/root/.gradle/caches/modules-2/files-2.1
java -version 2&gt;&amp;1 | grep version
cp=&quot;&quot;
while I</tspan><tspan x="24" dy="22">FS= read -r line; do
coord=${line%%=*}; confs=${line#*=}
case &quot;,$confs,&quot; in *&quot;,testRuntimeClasspath,&quot;*) ;; *) contin</tspan><tspan x="24" dy="22">ue ;; esac
g=${coord%%:*}; rest=${coord#*:}; n=${rest%%:*}; v=${rest##*:}
# 잠금 파일의 좌표 하나가 캐시에 없다. 없</tspan><tspan x="24" dy="22">는 것은 건너뛴다.
jar=$(find &quot;$CACHE/$g/$n/$v&quot; -name &#x27;*.jar&#x27; ! -name &#x27;*sources*&#x27; ! -name &#x27;*javadoc*&#x27; 2&gt;/dev/null | hea</tspan><tspan x="24" dy="22">d -1) || true
[ -n &quot;$jar&quot; ] &amp;&amp; cp=&quot;$cp:$jar&quot;
done &lt; &lt;(grep -E &#x27;^[a-zA-Z0-9._-]+:[^=]+=&#x27; &quot;$M/gradle.lockfile&quot;)
# 형제 </tspan><tspan x="24" dy="22">모듈은 고정 리비전 산출물만 붙인다.
# sample-portfolio 는 고정 리비전에 -plain 만 있으므로 둘 다 받는다.
SIB=$(find &quot;$C/</tspan><tspan x="24" dy="22">src&quot; -path &#x27;*/build/libs/*+21234e38cdb9*.jar&#x27; ! -name &#x27;*-testkit.jar&#x27; | sort | tr &#x27;\n&#x27; &#x27;:&#x27;)
cat &gt; &quot;$D/CacheConditionalPr</tspan><tspan x="24" dy="22">obe.java&quot; &lt;&lt;&#x27;JAVA&#x27;
import dev.caskeleton.adapter.inbound.web.filter.CacheControlFilter;
import dev.caskeleton.applicatio</tspan><tspan x="24" dy="22">n.transaction.TransactionPort;
import dev.caskeleton.sample.portfolio.adapter.inbound.web.controller.WorkLogController;
</tspan><tspan x="24" dy="22">import dev.caskeleton.sample.portfolio.application.worklog.GetWorkLogUseCase;
import dev.caskeleton.sample.portfolio.dom</tspan><tspan x="24" dy="22">ain.worklog.Period;
import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory;
import dev.caskeleton.sample.por</tspan><tspan x="24" dy="22">tfolio.domain.worklog.WorkLog;
import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId;
import dev.caskeleton.sa</tspan><tspan x="24" dy="22">mple.portfolio.domain.worklog.WorkLogPage;
import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository;
impo</tspan><tspan x="24" dy="22">rt dev.caskeleton.sample.portfolio.domain.worklog.WorkLogSortField;
import java.time.LocalDate;
import java.util.List;
i</tspan><tspan x="24" dy="22">mport java.util.Optional;
import java.util.function.Supplier;
import org.springframework.http.MediaType;
import org.spri</tspan><tspan x="24" dy="22">ngframework.http.ResponseEntity;
import org.springframework.security.web.header.HeaderWriterFilter;
import org.springfra</tspan><tspan x="24" dy="22">mework.security.web.header.writers.CacheControlHeadersWriter;
import org.springframework.test.web.servlet.MockMvc;
impor</tspan><tspan x="24" dy="22">t org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockMvcRequestBuil</tspan><tspan x="24" dy="22">ders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.</tspan><tspan x="24" dy="22">GetMapping;
import org.springframework.web.bind.annotation.RestController;
/** 캐시 필터와 조건부 읽기 경로를 한 사슬에</tspan><tspan x="24" dy="22"> 올려 응답 헤더를 읽는다. */
public final class CacheConditionalProbe {
private static final String ID = &quot;0190bd6e-7c</tspan><tspan x="24" dy="22">3e-7abc-8def-0123456789ab&quot;;
/** 저장된 표현 하나. version 3 이므로 ETag 는 W/&quot;3&quot; 이다. */
private static WorkLog sa</tspan><tspan x="24" dy="22">mple() {
return WorkLog.rehydrate(
WorkLogId.of(ID), &quot;샘플&quot;, WorkCategory.DATABASE, &quot;s&quot;, &quot;c&quot;,
List.o</tspan><tspan x="24" dy="22">f(&quot;postgres&quot;), List.of(&quot;https://example.com&quot;),
new Period(LocalDate.of(2025, 1, 1), null), 3L);
}
/** 조회</tspan><tspan x="24" dy="22">만 하는 저장소. 나머지 연산은 이 프로브가 부르지 않는다. */
private static final class OneRow implements WorkLogReposi</tspan><tspan x="24" dy="22">tory {
@Override public WorkLog save(WorkLog workLog) { throw new UnsupportedOperationException(); }
@Override p</tspan><tspan x="24" dy="22">ublic Optional&lt;WorkLog&gt; findById(WorkLogId id) { return Optional.of(sample()); }
@Override public WorkLogPage findPa</tspan><tspan x="24" dy="22">ge(
int page, int size, WorkLogSortField sortField, boolean ascending, WorkCategory category) {
throw new </tspan><tspan x="24" dy="22">UnsupportedOperationException();
}
@Override public boolean existsById(WorkLogId id) { return true; }
@Overr</tspan><tspan x="24" dy="22">ide public void deleteById(WorkLogId id) { throw new UnsupportedOperationException(); }
}
/** 경계만 흉내 내고 그대</tspan><tspan x="24" dy="22">로 실행하는 트랜잭션 포트. */
private static final class Direct implements TransactionPort {
@Override public &lt;T&gt; </tspan><tspan x="24" dy="22">T inWrite(Supplier&lt;T&gt; action) { return action.get(); }
@Override public &lt;T&gt; T inRootWrite(Supplier&lt;T&gt; action) { retu</tspan><tspan x="24" dy="22">rn action.get(); }
@Override public &lt;T&gt; T inRead(Supplier&lt;T&gt; action) { return action.get(); }
@Override public &lt;</tspan><tspan x="24" dy="22">T&gt; T inNew(Supplier&lt;T&gt; action) { return action.get(); }
}
/** 스스로 캐시 지시자를 붙이는 대조용 엔드포인트. */
@</tspan><tspan x="24" dy="22">RestController
public static class OptsIn {
@GetMapping(&quot;/opts-in&quot;)
public ResponseEntity&lt;String&gt; get() {
</tspan><tspan x="24" dy="22"> return ResponseEntity.ok().header(&quot;Cache-Control&quot;, &quot;max-age=60&quot;).body(&quot;ok&quot;);
}
}
private static void row(Stri</tspan><tspan x="24" dy="22">ng label, MvcResult result) {
System.out.printf(&quot; %-24s %-6d %-46s %s%n&quot;, label,
result.getResponse().getSt</tspan><tspan x="24" dy="22">atus(),
String.valueOf(result.getResponse().getHeader(&quot;Cache-Control&quot;)),
String.valueOf(result.getRespon</tspan><tspan x="24" dy="22">se().getHeader(&quot;ETag&quot;)));
}
public static void main(String[] args) throws Exception {
GetWorkLogUseCase get = n</tspan><tspan x="24" dy="22">ew GetWorkLogUseCase(new OneRow(), new Direct());
WorkLogController controller =
new WorkLogController(null,</tspan><tspan x="24" dy="22"> null, null, null, get, null, null);
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller, new OptsIn())
</tspan><tspan x="24" dy="22"> .addFilters(new CacheControlFilter())
.build();
System.out.println();
System.out.println(&quot;[조건부 읽기</tspan><tspan x="24" dy="22"> 경로에 세 번 요청한다]&quot;);
System.out.printf(&quot; %-24s %-6s %-46s %s%n&quot;, &quot;요청&quot;, &quot;상태&quot;, &quot;Cache-Control&quot;, &quot;ETag&quot;);
</tspan><tspan x="24" dy="22"> row(&quot;첫 요청&quot;, mvc.perform(
MockMvcRequestBuilders.get(&quot;/worklogs/&quot; + ID).accept(MediaType.APPLICATION_JSON))
</tspan><tspan x="24" dy="22"> .andReturn());
row(&quot;받은 태그를 되돌려&quot;, mvc.perform(
MockMvcRequestBuilders.get(&quot;/worklogs/&quot; + ID)
</tspan><tspan x="24" dy="22"> .header(&quot;If-None-Match&quot;, &quot;W/\&quot;3\&quot;&quot;).accept(MediaType.APPLICATION_JSON))
.andReturn());
row(&quot;맞지</tspan><tspan x="24" dy="22"> 않는 태그&quot;, mvc.perform(
MockMvcRequestBuilders.get(&quot;/worklogs/&quot; + ID)
.header(&quot;If-None-Match&quot;, &quot;W/</tspan><tspan x="24" dy="22">\&quot;2\&quot;&quot;).accept(MediaType.APPLICATION_JSON))
.andReturn());
System.out.println();
System.out.println(&quot;[</tspan><tspan x="24" dy="22">스스로 지시자를 붙이는 엔드포인트]&quot;);
row(&quot;직접 붙인 값&quot;, mvc.perform(MockMvcRequestBuilders.get(&quot;/opts-in&quot;)).andRet</tspan><tspan x="24" dy="22">urn());
// 이 컨트롤러가 사는 모듈은 SecurityConfig 를 스캔에서 빼므로 프레임워크 기록기가 사슬에 남는다.
MockM</tspan><tspan x="24" dy="22">vc withWriter = MockMvcBuilders.standaloneSetup(controller, new OptsIn())
.addFilters(new CacheControlFilter(),
</tspan><tspan x="24" dy="22"> new HeaderWriterFilter(java.util.List.of(new CacheControlHeadersWriter())))
.build();
MockMvc wr</tspan><tspan x="24" dy="22">iterOnly = MockMvcBuilders.standaloneSetup(controller, new OptsIn())
.addFilters(new HeaderWriterFilter(java.uti</tspan><tspan x="24" dy="22">l.List.of(new CacheControlHeadersWriter())))
.build();
System.out.println();
System.out.println(&quot;[프레</tspan><tspan x="24" dy="22">임워크 캐시 기록기가 사슬에 함께 있을 때]&quot;);
row(&quot;둘 다 있을 때&quot;, withWriter.perform(
MockMvcRequestBuilders</tspan><tspan x="24" dy="22">.get(&quot;/worklogs/&quot; + ID).accept(MediaType.APPLICATION_JSON))
.andReturn());
row(&quot;기록기만 있을 때&quot;, writerOnl</tspan><tspan x="24" dy="22">y.perform(
MockMvcRequestBuilders.get(&quot;/worklogs/&quot; + ID).accept(MediaType.APPLICATION_JSON))
.andReturn(</tspan><tspan x="24" dy="22">));
}
}
JAVA
javac -encoding UTF-8 -cp &quot;$SIB$cp&quot; -d &quot;$D/out&quot; &quot;$D/CacheConditionalProbe.java&quot; 2&gt;&amp;1 | grep -v &#x27;^$&#x27; || tr</tspan><tspan x="24" dy="22">ue
# 부팅 로그는 시각을 달고 나오므로 끈다. 이 프로브가 읽는 것은 응답 헤더뿐이다.
printf &#x27;%s\n&#x27; &#x27;&lt;configuration&gt;&lt;root l</tspan><tspan x="24" dy="22">evel=&quot;OFF&quot;/&gt;&lt;/configuration&gt;&#x27; &gt; &quot;$D/out/logback.xml&quot;
java -Dstdout.encoding=UTF-8 -cp &quot;$D/out:$SIB$cp&quot; CacheConditionalP</tspan><tspan x="24" dy="22">robe
</tspan></text>
<text x="24" y="1432" fill="#8b949e" font-size="13" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">cwd: /shared/codebase/clean-architecture-backend-template/src</tspan></text>
<text x="24" y="1454" fill="#8b949e" font-size="13" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">time: 2026-09-03T00:50:44+00:00 · exit 0</tspan></text>
<line x1="24" y1="1468" x2="1176" y2="1468" stroke="#30363d"/>
<text x="24" y="1508" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">openjdk version &quot;21.0.12&quot; 2026-07-21</tspan></text>
<text x="24" y="1530" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"></tspan></text>
<text x="24" y="1552" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">[조건부 읽기 경로에 세 번 요청한다]</tspan></text>
<text x="24" y="1574" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 요청 상태 Cache-Control ETag</tspan></text>
<text x="24" y="1596" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 첫 요청 200 no-store W/&quot;3&quot;</tspan></text>
<text x="24" y="1618" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 받은 태그를 되돌려 304 no-store W/&quot;3&quot;</tspan></text>
<text x="24" y="1640" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 맞지 않는 태그 200 no-store W/&quot;3&quot;</tspan></text>
<text x="24" y="1662" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"></tspan></text>
<text x="24" y="1684" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">[스스로 지시자를 붙이는 엔드포인트]</tspan></text>
<text x="24" y="1706" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 직접 붙인 값 200 max-age=60 null</tspan></text>
<text x="24" y="1728" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"></tspan></text>
<text x="24" y="1750" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">[프레임워크 캐시 기록기가 사슬에 함께 있을 때]</tspan></text>
<text x="24" y="1772" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 둘 다 있을 때 200 no-store W/&quot;3&quot;</tspan></text>
<text x="24" y="1794" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 기록기만 있을 때 200 no-cache, no-store, max-age=0, must-revalidate W/&quot;3&quot;</tspan></text>
</svg>