docs(keycloak-session-store): remake all 28 diagrams through the techviz pipeline

The originating repository's SVGs were drawn by hand and every one of them
put a title, a subtitle and an explanation band inside the canvas. This
repository forbids both, so they could not be carried over — the whole set
was rebuilt through the skill's pipeline instead.

Each diagram went through prepare, references, prompt, a VizSpec 1.1 citing
document line ranges, lint, and render. All 28 pass lint and produce the
same eight formats the existing keycloak project has. Sentences moved out of
the canvas into <desc> and the paragraph beside each figure; the drawings
carry names only.

Two lint rules did real work rather than formatting work:

  edge-through-node                  caught arrows crossing an unrelated
                                     node and implying an adjacency that
                                     does not exist — four diagrams had to
                                     be restructured, not just relaid out
  evidence-outside-prepared-context  caught a diagram citing another
                                     section; its anchor moved from B-0 to
                                     B-1 so all three sections it draws on
                                     are inside the prepared context

lab-topology also had to change profile: its context offers a different
candidate set, and query-fanout with shard roles is what the section
actually shows — one entry point spreading to two Keycloak nodes.

The document now carries all 28 inline, one per claim that needed one, and
the section recording what was still missing is updated: the diagram gap is
closed, Studio records remain.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-05 11:03:10 +09:00
co-authored by Claude Opus 5
parent b2963105a8
commit 75bed382c8
286 changed files with 65385 additions and 710 deletions
@@ -0,0 +1,22 @@
# 발견과 전송은 다른 경로다
## Alternative text
두 노드가 데이터베이스로는 이어져 있고 TCP 7800 으로는 끊긴 구성. 세션 조회는 살아 있고 무효화 통지는 막힌다.
## Long description
노드가 서로를 찾는 경로와 실제로 이야기하는 경로가 다르다. 발견은 PostgreSQL 의 JGROUPS_PING 테이블을 쓰고 전송은 TCP 7800 을 쓴다. 7800 만 막으면 둘 다 데이터베이스에 등록된 채로 남아 서로 존재한다고 믿지만 메시지는 오가지 않는다. 세션은 데이터베이스에 있으므로 교차 노드 refresh 는 200 을 유지하고, 로그아웃 무효화 통지는 7800 을 타므로 400 이어야 할 것이 200 이 된다.
## Elements and evidence
- **keycloak-0** (service): 로그아웃을 처리한 노드. Evidence: L183L189.
- **TCP 7800** (channel): 무효화 통지가 지나는 길. NetworkPolicy 로 막았다. Evidence: L191L196.
- **keycloak-1** (service): 통지를 못 받아 세션이 죽은 것을 모른다. Evidence: L183L189.
- **PostgreSQL** (datastore): 세션이 여기 있어 7800 과 무관하다. Evidence: L186L190.
## Relationships
- **keycloak-0 → TCP 7800:** 무효화 통지. Evidence: L186L190.
- **keycloak-1 → PostgreSQL:** 세션은 여기서 읽는다 — 7800 과 무관. Evidence: L186L189.
- **TCP 7800 → keycloak-1:** 막혀서 닿지 않는다. Evidence: L186L190.
@@ -0,0 +1,18 @@
# 발견과 전송은 다른 경로다
# Question: TCP 7800 을 끊으면 무엇이 깨지고 무엇이 남는가
direction: down
n0: "keycloak-0" {
shape: rectangle
}
n1: "TCP 7800" {
shape: rectangle
}
n2: "keycloak-1" {
shape: rectangle
}
n3: "PostgreSQL" {
shape: cylinder
}
n0 -> n1: "무효화 통지"
n1 -> n2: "막혀서 닿지 않는다"
n2 -> n3: "세션은 여기서 읽는다 — 7800 과 무관"
@@ -0,0 +1,12 @@
digraph techviz {
graph [rankdir=TB, splines=ortho, nodesep=0.55, ranksep=0.85];
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
n0 [label="keycloak-0", shape=box, style="rounded,filled"];
n1 [label="TCP 7800", shape=box, style="rounded,filled"];
n2 [label="keycloak-1", shape=box, style="rounded,filled"];
n3 [label="PostgreSQL", shape=cylinder, style="rounded,filled"];
n0 -> n1 [label="무효화 통지", style=solid];
n1 -> n2 [label="막혀서 닿지 않는다", style=solid];
n2 -> n3 [label="세션은 여기서 읽는다 — 7800 과 무관", style=solid];
}
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="a1-transport-vs-discovery" name="발견과 전송은 다른 경로다">
<mxGraphModel dx="680" dy="696" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_k0" value="keycloak-0" tooltip="로그아웃을 처리한 노드. | Evidence: L183-L189" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="92.5" y="60.0" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="n_transport" value="TCP 7800&lt;br/&gt;8080·9000 만 열고 누락시킨다" tooltip="무효화 통지가 지나는 길. NetworkPolicy 로 막았다. | Evidence: L191-L196" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="80.5" y="220.0" width="174.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_k1" value="keycloak-1&lt;br/&gt;400 이어야 할 refresh 가 200" tooltip="통지를 못 받아 세션이 죽은 것을 모른다. | Evidence: L183-L189" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="70.0" y="387.0" width="195.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_db" value="PostgreSQL&lt;br/&gt;교차 노드 refresh 200" tooltip="세션이 여기 있어 7800 과 무관하다. | Evidence: L186-L190" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;shape=cylinder3;boundedLbl=1;backgroundOutline=1;fillColor=#eef6fb;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="91.0" y="554.0" width="153.0" height="97.0" as="geometry"/>
</mxCell>
<mxCell id="e_k0-t" value="무효화 통지" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_k0" target="n_transport">
<mxGeometry relative="1" as="geometry">
<mxPoint x="191.5" y="172.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_k1-db" value="세션은 여기서 읽는다 — 7800 과 무관" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_k1" target="n_db">
<mxGeometry relative="1" as="geometry">
<mxPoint x="191.5" y="506.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_t-k1" value="막혀서 닿지 않는다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_transport" target="n_k1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="191.5" y="339.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,586 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-k0-t",
"type": "arrow",
"x": 167.5,
"y": 124.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 563171480,
"version": 1,
"versionNonce": 1937314071,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-k0",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-transport",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-k0-t",
"type": "text",
"x": 146.5,
"y": 160.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1595638930,
"version": 1,
"versionNonce": 1546274523,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "무효화 통지",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "무효화 통지",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-k1-db",
"type": "arrow",
"x": 167.5,
"y": 458.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1363167760,
"version": 1,
"versionNonce": 1170603998,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-k1",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-db",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-k1-db",
"type": "text",
"x": 99.5,
"y": 494.0,
"width": 184,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 107988814,
"version": 1,
"versionNonce": 1427420655,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "세션은 여기서 읽는다 — 7800 과 무관",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "세션은 여기서 읽는다 — 7800 과 무관",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-t-k1",
"type": "arrow",
"x": 167.5,
"y": 291.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 681804025,
"version": 1,
"versionNonce": 1772524548,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-transport",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-k1",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-t-k1",
"type": "text",
"x": 146.5,
"y": 327.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 174704529,
"version": 1,
"versionNonce": 1007619210,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "막혀서 닿지 않는다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "막혀서 닿지 않는다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-k0",
"type": "rectangle",
"x": 92.5,
"y": 60.0,
"width": 150.0,
"height": 64.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 772945349,
"version": 1,
"versionNonce": 30235372,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-k0",
"type": "text",
"x": 102.5,
"y": 70.0,
"width": 130.0,
"height": 44.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1107535317,
"version": 1,
"versionNonce": 630670490,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "keycloak-0",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "keycloak-0",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-transport",
"type": "rectangle",
"x": 80.5,
"y": 220.0,
"width": 174.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 24015876,
"version": 1,
"versionNonce": 215653166,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-transport",
"type": "text",
"x": 90.5,
"y": 230.0,
"width": 154.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1471007992,
"version": 1,
"versionNonce": 896858137,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "TCP 7800\n8080·9000 만 열고 누락시킨다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "TCP 7800\n8080·9000 만 열고 누락시킨다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-k1",
"type": "rectangle",
"x": 70.0,
"y": 387.0,
"width": 195.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1338591829,
"version": 1,
"versionNonce": 367320954,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-k1",
"type": "text",
"x": 80.0,
"y": 397.0,
"width": 175.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1741943564,
"version": 1,
"versionNonce": 368770159,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "keycloak-1\n400 이어야 할 refresh 가 200",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "keycloak-1\n400 이어야 할 refresh 가 200",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-db",
"type": "rectangle",
"x": 91.0,
"y": 554.0,
"width": 153.0,
"height": 97.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#e7f5ff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 285058125,
"version": 1,
"versionNonce": 1824840415,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-db",
"type": "text",
"x": 101.0,
"y": 564.0,
"width": 133.0,
"height": 77.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1231096363,
"version": 1,
"versionNonce": 313576462,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "PostgreSQL\n교차 노드 refresh 200",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "PostgreSQL\n교차 노드 refresh 200",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "a1-transport-vs-discovery",
"spec_version": "1.1",
"spec_sha256": "af42cd905290687548b620849f1e143752f9ab346de4de352701256cc6657e32",
"source_context": {
"document": "docs/keycloak-session-store/final/document.md",
"document_sha256": "1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe",
"anchor": {
"kind": "heading",
"value": "A-1 · JGroups 전송(TCP 7800) 차단",
"line": 185
}
},
"outputs": [
"a1-transport-vs-discovery.svg",
"a1-transport-vs-discovery.mmd",
"a1-transport-vs-discovery.d2",
"a1-transport-vs-discovery.dot",
"a1-transport-vs-discovery.drawio",
"a1-transport-vs-discovery.excalidraw",
"a1-transport-vs-discovery.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "component-flow",
"reference_ids": [
"payment-event-flow"
],
"diagram_only": true
}
@@ -0,0 +1,10 @@
%% 발견과 전송은 다른 경로다
%% question: TCP 7800 을 끊으면 무엇이 깨지고 무엇이 남는가
flowchart TB
n0["keycloak-0"]
n1["TCP 7800"]
n2["keycloak-1"]
n3[("PostgreSQL")]
n0 -->|"무효화 통지"| n1
n1 -->|"막혀서 닿지 않는다"| n2
n2 -->|"세션은 여기서 읽는다 — 7800 과 무관"| n3
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="680" height="696" viewBox="0 0 680 696" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">발견과 전송은 다른 경로다</title>
<desc id="diagram-description">노드가 서로를 찾는 경로와 실제로 이야기하는 경로가 다르다. 발견은 PostgreSQL 의 JGROUPS_PING 테이블을 쓰고 전송은 TCP 7800 을 쓴다. 7800 만 막으면 둘 다 데이터베이스에 등록된 채로 남아 서로 존재한다고 믿지만 메시지는 오가지 않는다. 세션은 데이터베이스에 있으므로 교차 노드 refresh 는 200 을 유지하고, 로그아웃 무효화 통지는 7800 을 타므로 400 이어야 할 것이 200 이 된다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;a1-transport-vs-discovery&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;docs/keycloak-session-store/final/document.md&quot;,&quot;document_sha256&quot;:&quot;1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;heading&quot;,&quot;value&quot;:&quot;A-1 · JGroups 전송(TCP 7800) 차단&quot;,&quot;line&quot;:185}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="680" height="696" />
<polyline class="edge kind-request style-solid emphasis-normal" points="167.5,124.0 167.5,172.0 167.5,172.0 167.5,220.0" data-evidence="186-190" />
<rect class="edge-label-bg" x="162.4" y="158.0" width="58.2" height="22" rx="3" />
<text class="edge-label" x="191.5" y="173.0">무효화 통지</text>
<polyline class="edge kind-read style-solid emphasis-normal" points="167.5,458.0 167.5,506.0 167.5,506.0 167.5,554.0" data-evidence="186-189" />
<rect class="edge-label-bg" x="105.5" y="492.0" width="172.1" height="22" rx="3" />
<text class="edge-label" x="191.5" y="507.0">세션은 여기서 읽는다 — 7800 과 무관</text>
<polyline class="edge kind-blocked style-solid emphasis-normal" points="167.5,291.0 167.5,339.0 167.5,339.0 167.5,387.0" data-evidence="186-190" />
<rect class="edge-label-bg" x="149.0" y="325.0" width="85.0" height="22" rx="3" />
<text class="edge-label" x="191.5" y="340.0">막혀서 닿지 않는다</text>
<g id="node-k0">
<rect class="node-shape kind-service emphasis-primary role-source" data-evidence="183-189" x="92.5" y="60.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="167.5" y="90.0">keycloak-0</text>
</g>
<g id="node-transport">
<rect class="node-shape kind-channel emphasis-warning role-control" data-evidence="191-196" x="80.5" y="220.0" width="174.0" height="71.0" rx="7" />
<text class="node-label" x="167.5" y="247.0">TCP 7800</text>
<line class="node-detail-divider" x1="94.5" y1="268.0" x2="240.5" y2="268.0" />
<text class="node-detail" x="96.5" y="285.0">8080·9000 만 열고 누락시킨다</text>
</g>
<g id="node-k1">
<rect class="node-shape kind-service emphasis-primary role-target" data-evidence="183-189" x="70.0" y="387.0" width="195.0" height="71.0" rx="7" />
<text class="node-label" x="167.5" y="414.0">keycloak-1</text>
<line class="node-detail-divider" x1="84.0" y1="435.0" x2="251.0" y2="435.0" />
<text class="node-detail" x="86.0" y="452.0">400 이어야 할 refresh 가 200</text>
</g>
<g id="node-db">
<rect class="node-shape kind-datastore emphasis-primary role-store" data-evidence="186-190" x="91.0" y="567.0" width="153.0" height="71.0" /><ellipse class="node-shape kind-datastore emphasis-primary role-store" cx="167.5" cy="567.0" rx="76.5" ry="13.0" /><path class="storage-bottom" d="M 91.0 638.0 A 76.5 13.0 0 0 0 244.0 638.0" />
<text class="node-label" x="167.5" y="594.0">PostgreSQL</text>
<line class="node-detail-divider" x1="105.0" y1="615.0" x2="230.0" y2="615.0" />
<text class="node-detail" x="107.0" y="632.0">교차 노드 refresh 200</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.0 KiB

@@ -0,0 +1,22 @@
# 200 과 디스크 사이의 빈 구간
## Alternative text
클라이언트가 200 을 받은 뒤에도 WAL 이 아직 디스크에 닿지 않은 구간이 남아 있는 구성.
## Long description
Keycloak 은 트랜잭션마다 SET LOCAL synchronous_commit TO OFF 를 건다. PostgreSQL 은 COMMIT 을 WAL 디스크 기록을 기다리지 않고 즉시 반환하고, 클라이언트는 200 과 토큰을 받는다. 실측된 wal_writer_delay 는 200ms 이며 그 사이에 프로세스가 죽으면 그만큼이 사라진다. 153건 중 4건이 그렇게 유실됐다. 버그가 아니라 의도된 설계이고 그 대가를 숫자로 확인한 것이다.
## Elements and evidence
- **클라이언트** (actor): 200 과 토큰을 받는다. Evidence: L207L213.
- **Keycloak** (service): 트랜잭션마다 synchronous_commit 을 끈다. Evidence: L211L215.
- **PostgreSQL** (datastore): COMMIT 을 즉시 반환한다. WAL 은 아직 메모리에 있다. Evidence: L211L215.
- **디스크의 WAL** (datastore): 여기 닿아야 살아남는다. Evidence: L207L215.
## Relationships
- **클라이언트 → Keycloak:** 로그인 요청. Evidence: L207L211.
- **Keycloak → PostgreSQL:** 세션 INSERT · COMMIT. Evidence: L211L215.
- **PostgreSQL → 디스크의 WAL:** 지연된 WAL 기록. Evidence: L211L215.
@@ -0,0 +1,18 @@
# 200 과 디스크 사이의 빈 구간
# Question: 로그인이 성공했는데 세션이 없을 수 있는 이유는 무엇인가
direction: down
n0: "클라이언트" {
shape: person
}
n1: "Keycloak" {
shape: rectangle
}
n2: "PostgreSQL" {
shape: cylinder
}
n3: "디스크의 WAL" {
shape: cylinder
}
n0 -> n1: "로그인 요청"
n1 -> n2: "세션 INSERT · COMMIT"
n2 -> n3: "지연된 WAL 기록"
@@ -0,0 +1,12 @@
digraph techviz {
graph [rankdir=TB, splines=ortho, nodesep=0.55, ranksep=0.85];
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
n0 [label="클라이언트", shape=box, style="rounded,dashed,filled"];
n1 [label="Keycloak", shape=box, style="rounded,filled"];
n2 [label="PostgreSQL", shape=cylinder, style="rounded,filled"];
n3 [label="디스크의 WAL", shape=cylinder, style="rounded,filled"];
n0 -> n1 [label="로그인 요청", style=solid];
n1 -> n2 [label="세션 INSERT · COMMIT", style=solid];
n2 -> n3 [label="지연된 WAL 기록", style=solid];
}
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="a3-commit-to-disk-gap" name="200 과 디스크 사이의 빈 구간">
<mxGraphModel dx="680" dy="742" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_client" value="클라이언트&lt;br/&gt;로그인 153건 성공" tooltip="200 과 토큰을 받는다. | Evidence: L207-L213" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;dashed=1;fillColor=#f5f7fa;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="134.5" y="60.0" width="150.0" height="84.0" as="geometry"/>
</mxCell>
<mxCell id="n_keycloak" value="Keycloak&lt;br/&gt;SET LOCAL synchronous_commit TO OFF" tooltip="트랜잭션마다 synchronous_commit 을 끈다. | Evidence: L211-L215" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="70.0" y="240.0" width="279.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_pg" value="PostgreSQL&lt;br/&gt;wal_writer_delay 200ms" tooltip="COMMIT 을 즉시 반환한다. WAL 은 아직 메모리에 있다. | Evidence: L211-L215" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;shape=cylinder3;boundedLbl=1;backgroundOutline=1;fillColor=#eef6fb;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="115.5" y="407.0" width="188.0" height="97.0" as="geometry"/>
</mxCell>
<mxCell id="n_disk" value="디스크의 WAL&lt;br/&gt;DB 에 실제로 존재 149건" tooltip="여기 닿아야 살아남는다. | Evidence: L207-L215" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;shape=cylinder3;boundedLbl=1;backgroundOutline=1;fillColor=#eef6fb;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="134.5" y="600.0" width="150.0" height="97.0" as="geometry"/>
</mxCell>
<mxCell id="e_c-k" value="로그인 요청" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_client" target="n_keycloak">
<mxGeometry relative="1" as="geometry">
<mxPoint x="233.5" y="192.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_k-pg" value="세션 INSERT · COMMIT" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_keycloak" target="n_pg">
<mxGeometry relative="1" as="geometry">
<mxPoint x="233.5" y="359.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_pg-disk" value="지연된 WAL 기록" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_pg" target="n_disk">
<mxGeometry relative="1" as="geometry">
<mxPoint x="233.5" y="552.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,586 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-c-k",
"type": "arrow",
"x": 209.5,
"y": 144.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 637304825,
"version": 1,
"versionNonce": 1285963241,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-client",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-keycloak",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-c-k",
"type": "text",
"x": 188.5,
"y": 180.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1672464756,
"version": 1,
"versionNonce": 775794122,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "로그인 요청",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "로그인 요청",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-k-pg",
"type": "arrow",
"x": 209.5,
"y": 311.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1058895576,
"version": 1,
"versionNonce": 290419384,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-keycloak",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-pg",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-k-pg",
"type": "text",
"x": 161.5,
"y": 347.0,
"width": 144,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1504141155,
"version": 1,
"versionNonce": 265883063,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "세션 INSERT · COMMIT",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "세션 INSERT · COMMIT",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-pg-disk",
"type": "arrow",
"x": 209.5,
"y": 504.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 375875096,
"version": 1,
"versionNonce": 124607208,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-pg",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-disk",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-pg-disk",
"type": "text",
"x": 188.5,
"y": 540.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 326014364,
"version": 1,
"versionNonce": 1099775321,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "지연된 WAL 기록",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "지연된 WAL 기록",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-client",
"type": "rectangle",
"x": 134.5,
"y": 60.0,
"width": 150.0,
"height": 84.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1893748721,
"version": 1,
"versionNonce": 1322839316,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-client",
"type": "text",
"x": 144.5,
"y": 70.0,
"width": 130.0,
"height": 64.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 817706832,
"version": 1,
"versionNonce": 1332899567,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "클라이언트\n로그인 153건 성공",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "클라이언트\n로그인 153건 성공",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-keycloak",
"type": "rectangle",
"x": 70.0,
"y": 240.0,
"width": 279.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 819278378,
"version": 1,
"versionNonce": 1725278844,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-keycloak",
"type": "text",
"x": 80.0,
"y": 250.0,
"width": 259.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1265230567,
"version": 1,
"versionNonce": 1115669911,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Keycloak\nSET LOCAL synchronous_commit TO OFF",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Keycloak\nSET LOCAL synchronous_commit TO OFF",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-pg",
"type": "rectangle",
"x": 115.5,
"y": 407.0,
"width": 188.0,
"height": 97.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#e7f5ff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 495847287,
"version": 1,
"versionNonce": 1955484919,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-pg",
"type": "text",
"x": 125.5,
"y": 417.0,
"width": 168.0,
"height": 77.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1900836085,
"version": 1,
"versionNonce": 160871788,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "PostgreSQL\nwal_writer_delay 200ms",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "PostgreSQL\nwal_writer_delay 200ms",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-disk",
"type": "rectangle",
"x": 134.5,
"y": 600.0,
"width": 150.0,
"height": 97.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#e7f5ff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 492098394,
"version": 1,
"versionNonce": 862350580,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-disk",
"type": "text",
"x": 144.5,
"y": 610.0,
"width": 130.0,
"height": 77.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1696521973,
"version": 1,
"versionNonce": 731803796,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "디스크의 WAL\nDB 에 실제로 존재 149건",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "디스크의 WAL\nDB 에 실제로 존재 149건",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "a3-commit-to-disk-gap",
"spec_version": "1.1",
"spec_sha256": "29e279f39e07ba519e4e0685e5719d5d63b3ecf8b61a18816cbd5a1529baf854",
"source_context": {
"document": "docs/keycloak-session-store/final/document.md",
"document_sha256": "1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe",
"anchor": {
"kind": "heading",
"value": "A-2 · A-3 — DB 가 멈출 때와 죽을 때",
"line": 201
}
},
"outputs": [
"a3-commit-to-disk-gap.svg",
"a3-commit-to-disk-gap.mmd",
"a3-commit-to-disk-gap.d2",
"a3-commit-to-disk-gap.dot",
"a3-commit-to-disk-gap.drawio",
"a3-commit-to-disk-gap.excalidraw",
"a3-commit-to-disk-gap.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "component-flow",
"reference_ids": [
"payment-event-flow"
],
"diagram_only": true
}
@@ -0,0 +1,12 @@
%% 200 과 디스크 사이의 빈 구간
%% question: 로그인이 성공했는데 세션이 없을 수 있는 이유는 무엇인가
flowchart TB
n0(["클라이언트"])
n1["Keycloak"]
n2[("PostgreSQL")]
n3[("디스크의 WAL")]
n0 -->|"로그인 요청"| n1
n1 -->|"세션 INSERT · COMMIT"| n2
n2 -->|"지연된 WAL 기록"| n3
classDef external stroke-dasharray: 6 4
class n0 external
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="680" height="742" viewBox="0 0 680 742" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">200 과 디스크 사이의 빈 구간</title>
<desc id="diagram-description">Keycloak 은 트랜잭션마다 SET LOCAL synchronous_commit TO OFF 를 건다. PostgreSQL 은 COMMIT 을 WAL 디스크 기록을 기다리지 않고 즉시 반환하고, 클라이언트는 200 과 토큰을 받는다. 실측된 wal_writer_delay 는 200ms 이며 그 사이에 프로세스가 죽으면 그만큼이 사라진다. 153건 중 4건이 그렇게 유실됐다. 버그가 아니라 의도된 설계이고 그 대가를 숫자로 확인한 것이다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;a3-commit-to-disk-gap&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;docs/keycloak-session-store/final/document.md&quot;,&quot;document_sha256&quot;:&quot;1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;heading&quot;,&quot;value&quot;:&quot;A-2 · A-3 — DB 가 멈출 때와 죽을 때&quot;,&quot;line&quot;:201}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="680" height="742" />
<polyline class="edge kind-request style-solid emphasis-normal" points="209.5,144.0 209.5,192.0 209.5,192.0 209.5,240.0" data-evidence="207-211" />
<rect class="edge-label-bg" x="204.4" y="178.0" width="58.2" height="22" rx="3" />
<text class="edge-label" x="233.5" y="193.0">로그인 요청</text>
<polyline class="edge kind-write style-solid emphasis-normal" points="209.5,311.0 209.5,359.0 209.5,359.0 209.5,407.0" data-evidence="211-215" />
<rect class="edge-label-bg" x="164.2" y="345.0" width="138.6" height="22" rx="3" />
<text class="edge-label" x="233.5" y="360.0">세션 INSERT · COMMIT</text>
<polyline class="edge kind-write style-solid emphasis-normal" points="209.5,504.0 209.5,552.0 209.5,552.0 209.5,600.0" data-evidence="211-215" />
<rect class="edge-label-bg" x="191.0" y="538.0" width="85.0" height="22" rx="3" />
<text class="edge-label" x="233.5" y="553.0">지연된 WAL 기록</text>
<g id="node-client">
<g class="actor-symbol emphasis-primary" data-evidence="207-213"><circle cx="209.5" cy="80.0" r="11.0" /><line x1="209.5" y1="96.0" x2="209.5" y2="105.0" /><line x1="191.5" y1="106.0" x2="227.5" y2="106.0" /><line x1="209.5" y1="105.0" x2="194.5" y2="122.0" /><line x1="209.5" y1="105.0" x2="224.5" y2="122.0" /></g>
<text class="node-label" x="209.5" y="137.0">클라이언트</text>
</g>
<g id="node-keycloak">
<rect class="node-shape kind-service emphasis-normal role-control" data-evidence="211-215" x="70.0" y="240.0" width="279.0" height="71.0" rx="7" />
<text class="node-label" x="209.5" y="267.0">Keycloak</text>
<line class="node-detail-divider" x1="84.0" y1="288.0" x2="335.0" y2="288.0" />
<text class="node-detail" x="86.0" y="305.0">SET LOCAL synchronous_commit TO OFF</text>
</g>
<g id="node-pg">
<rect class="node-shape kind-datastore emphasis-warning role-control" data-evidence="211-215" x="115.5" y="420.0" width="188.0" height="71.0" /><ellipse class="node-shape kind-datastore emphasis-warning role-control" cx="209.5" cy="420.0" rx="94.0" ry="13.0" /><path class="storage-bottom" d="M 115.5 491.0 A 94.0 13.0 0 0 0 303.5 491.0" />
<text class="node-label" x="209.5" y="447.0">PostgreSQL</text>
<line class="node-detail-divider" x1="129.5" y1="468.0" x2="289.5" y2="468.0" />
<text class="node-detail" x="131.5" y="485.0">wal_writer_delay 200ms</text>
</g>
<g id="node-disk">
<rect class="node-shape kind-datastore emphasis-primary role-target" data-evidence="207-215" x="134.5" y="613.0" width="150.0" height="71.0" /><ellipse class="node-shape kind-datastore emphasis-primary role-target" cx="209.5" cy="613.0" rx="75.0" ry="13.0" /><path class="storage-bottom" d="M 134.5 684.0 A 75.0 13.0 0 0 0 284.5 684.0" />
<text class="node-label" x="209.5" y="640.0">디스크의 WAL</text>
<line class="node-detail-divider" x1="148.5" y1="661.0" x2="270.5" y2="661.0" />
<text class="node-detail" x="150.5" y="678.0">DB 에 실제로 존재 149건</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.3 KiB

@@ -0,0 +1,22 @@
# 노드를 잃는 두 가지
## Alternative text
kc-lab-2 를 잃으면 데이터베이스가 함께 사라지고, kc-lab-1 을 잃으면 진입 경로가 사라지는 구성.
## Long description
kc-lab-2 를 잃으면 keycloak-1 은 살아 있지만 PostgreSQL 이 같이 죽어 외부 응답이 503 이 된다. kc-lab-1 을 잃으면 keycloak-0 은 계속 돌고 있는데 traefik 과 API 서버가 사라져 연결 자체가 안 된다. 워크로드가 멀쩡한데 도달할 수 없어 장애다. 진입점이 단일 노드에 있으면 워크로드 이중화는 의미가 없다.
## Elements and evidence
- **외부 요청** (actor): 사용자. Evidence: L221L228.
- **진입 경로 (kc-lab-1)** (gateway): traefik 과 API 서버. 잃으면 000 이다. Evidence: L221L231.
- **keycloak-0 (kc-lab-2)** (service): 4b 에서는 계속 돌고 있었다. Evidence: L221L231.
- **PostgreSQL (kc-lab-2)** (datastore): 4a 에서 함께 죽는다. 잃으면 503 이다. Evidence: L221L233.
## Relationships
- **외부 요청 → 진입 경로 (kc-lab-1):** 요청. Evidence: L221L228.
- **진입 경로 (kc-lab-1) → keycloak-0 (kc-lab-2):** 라우팅. Evidence: L221L228.
- **keycloak-0 (kc-lab-2) → PostgreSQL (kc-lab-2):** 세션 조회. Evidence: L221L233.
@@ -0,0 +1,18 @@
# 노드를 잃는 두 가지
# Question: 워커를 잃는 것과 컨트롤 플레인을 잃는 것은 어떻게 다른가
direction: down
n0: "외부 요청" {
shape: person
}
n1: "진입 경로 (kc-lab-1)" {
shape: diamond
}
n2: "keycloak-0 (kc-lab-2)" {
shape: rectangle
}
n3: "PostgreSQL (kc-lab-2)" {
shape: cylinder
}
n0 -> n1: "요청"
n1 -> n2: "라우팅"
n2 -> n3: "세션 조회"
@@ -0,0 +1,12 @@
digraph techviz {
graph [rankdir=TB, splines=ortho, nodesep=0.55, ranksep=0.85];
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
n0 [label="외부 요청", shape=box, style="rounded,dashed,filled"];
n1 [label="진입 경로 (kc-lab-1)", shape=diamond, style="rounded,filled"];
n2 [label="keycloak-0 (kc-lab-2)", shape=box, style="rounded,filled"];
n3 [label="PostgreSQL (kc-lab-2)", shape=cylinder, style="rounded,filled"];
n0 -> n1 [label="요청", style=solid];
n1 -> n2 [label="라우팅", style=solid];
n2 -> n3 [label="세션 조회", style=solid];
}
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="a4-two-node-losses" name="노드를 잃는 두 가지">
<mxGraphModel dx="680" dy="716" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_client" value="외부 요청" tooltip="사용자. | Evidence: L221-L228" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;dashed=1;fillColor=#f5f7fa;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="92.5" y="60.0" width="150.0" height="84.0" as="geometry"/>
</mxCell>
<mxCell id="n_entry" value="진입 경로 (kc-lab-1)&lt;br/&gt;kubectl 도 불통" tooltip="traefik 과 API 서버. 잃으면 000 이다. | Evidence: L221-L231" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;rhombus;perimeter=rhombusPerimeter;fillColor=#fff7e8;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="92.5" y="240.0" width="150.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_workload" value="keycloak-0 (kc-lab-2)&lt;br/&gt;도달할 수 없을 뿐이다" tooltip="4b 에서는 계속 돌고 있었다. | Evidence: L221-L231" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="77.0" y="407.0" width="181.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_store" value="PostgreSQL (kc-lab-2)&lt;br/&gt;local-path PVC 라 재배치 불가" tooltip="4a 에서 함께 죽는다. 잃으면 503 이다. | Evidence: L221-L233" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;shape=cylinder3;boundedLbl=1;backgroundOutline=1;fillColor=#eef6fb;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="70.0" y="574.0" width="195.0" height="97.0" as="geometry"/>
</mxCell>
<mxCell id="e_c-e" value="요청" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_client" target="n_entry">
<mxGeometry relative="1" as="geometry">
<mxPoint x="191.5" y="192.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_e-w" value="라우팅" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_entry" target="n_workload">
<mxGeometry relative="1" as="geometry">
<mxPoint x="191.5" y="359.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_w-s" value="세션 조회" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_workload" target="n_store">
<mxGeometry relative="1" as="geometry">
<mxPoint x="191.5" y="526.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,586 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-c-e",
"type": "arrow",
"x": 167.5,
"y": 144.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1513220568,
"version": 1,
"versionNonce": 255748168,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-client",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-entry",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-c-e",
"type": "text",
"x": 146.5,
"y": 180.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 809267409,
"version": 1,
"versionNonce": 1101078427,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "요청",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "요청",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-e-w",
"type": "arrow",
"x": 167.5,
"y": 311.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 547479275,
"version": 1,
"versionNonce": 1997988007,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-entry",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-workload",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-e-w",
"type": "text",
"x": 146.5,
"y": 347.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1950725582,
"version": 1,
"versionNonce": 1719353100,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "라우팅",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "라우팅",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-w-s",
"type": "arrow",
"x": 167.5,
"y": 478.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 635791852,
"version": 1,
"versionNonce": 232690295,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-workload",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-store",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-w-s",
"type": "text",
"x": 146.5,
"y": 514.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 919243678,
"version": 1,
"versionNonce": 1666844084,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "세션 조회",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "세션 조회",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-client",
"type": "rectangle",
"x": 92.5,
"y": 60.0,
"width": 150.0,
"height": 84.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1893748721,
"version": 1,
"versionNonce": 1322839316,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-client",
"type": "text",
"x": 102.5,
"y": 70.0,
"width": 130.0,
"height": 64.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 817706832,
"version": 1,
"versionNonce": 1332899567,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "외부 요청",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "외부 요청",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-entry",
"type": "rectangle",
"x": 92.5,
"y": 240.0,
"width": 150.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#fff4e6",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1075845919,
"version": 1,
"versionNonce": 559732662,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-entry",
"type": "text",
"x": 102.5,
"y": 250.0,
"width": 130.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 910566709,
"version": 1,
"versionNonce": 1052126635,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "진입 경로 (kc-lab-1)\nkubectl 도 불통",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "진입 경로 (kc-lab-1)\nkubectl 도 불통",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-workload",
"type": "rectangle",
"x": 77.0,
"y": 407.0,
"width": 181.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 74091781,
"version": 1,
"versionNonce": 1219291209,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-workload",
"type": "text",
"x": 87.0,
"y": 417.0,
"width": 161.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 971306354,
"version": 1,
"versionNonce": 366449134,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "keycloak-0 (kc-lab-2)\n도달할 수 없을 뿐이다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "keycloak-0 (kc-lab-2)\n도달할 수 없을 뿐이다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-store",
"type": "rectangle",
"x": 70.0,
"y": 574.0,
"width": 195.0,
"height": 97.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#e7f5ff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1496341391,
"version": 1,
"versionNonce": 1595424416,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-store",
"type": "text",
"x": 80.0,
"y": 584.0,
"width": 175.0,
"height": 77.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 156936309,
"version": 1,
"versionNonce": 52632335,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "PostgreSQL (kc-lab-2)\nlocal-path PVC 라 재배치 불가",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "PostgreSQL (kc-lab-2)\nlocal-path PVC 라 재배치 불가",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "a4-two-node-losses",
"spec_version": "1.1",
"spec_sha256": "788cb5ef3210d1bb16d41feb27bd35f4041e93fea3e9826e4463391b82ccee5e",
"source_context": {
"document": "docs/keycloak-session-store/final/document.md",
"document_sha256": "1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe",
"anchor": {
"kind": "heading",
"value": "A-4 · 노드 상실 — 둘 다 전면 장애지만 이유가 다르다",
"line": 223
}
},
"outputs": [
"a4-two-node-losses.svg",
"a4-two-node-losses.mmd",
"a4-two-node-losses.d2",
"a4-two-node-losses.dot",
"a4-two-node-losses.drawio",
"a4-two-node-losses.excalidraw",
"a4-two-node-losses.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "component-flow",
"reference_ids": [
"payment-event-flow"
],
"diagram_only": true
}
@@ -0,0 +1,12 @@
%% 노드를 잃는 두 가지
%% question: 워커를 잃는 것과 컨트롤 플레인을 잃는 것은 어떻게 다른가
flowchart TB
n0(["외부 요청"])
n1{"진입 경로 (kc-lab-1)"}
n2["keycloak-0 (kc-lab-2)"]
n3[("PostgreSQL (kc-lab-2)")]
n0 -->|"요청"| n1
n1 -->|"라우팅"| n2
n2 -->|"세션 조회"| n3
classDef external stroke-dasharray: 6 4
class n0 external
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="680" height="716" viewBox="0 0 680 716" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">노드를 잃는 두 가지</title>
<desc id="diagram-description">kc-lab-2 를 잃으면 keycloak-1 은 살아 있지만 PostgreSQL 이 같이 죽어 외부 응답이 503 이 된다. kc-lab-1 을 잃으면 keycloak-0 은 계속 돌고 있는데 traefik 과 API 서버가 사라져 연결 자체가 안 된다. 워크로드가 멀쩡한데 도달할 수 없어 장애다. 진입점이 단일 노드에 있으면 워크로드 이중화는 의미가 없다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;a4-two-node-losses&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;docs/keycloak-session-store/final/document.md&quot;,&quot;document_sha256&quot;:&quot;1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;heading&quot;,&quot;value&quot;:&quot;A-4 · 노드 상실 — 둘 다 전면 장애지만 이유가 다르다&quot;,&quot;line&quot;:223}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="680" height="716" />
<polyline class="edge kind-request style-solid emphasis-normal" points="167.5,144.0 167.5,192.0 167.5,192.0 167.5,240.0" data-evidence="221-228" />
<rect class="edge-label-bg" x="169.5" y="178.0" width="44.0" height="22" rx="3" />
<text class="edge-label" x="191.5" y="193.0">요청</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="167.5,311.0 167.5,359.0 167.5,359.0 167.5,407.0" data-evidence="221-228" />
<rect class="edge-label-bg" x="169.5" y="345.0" width="44.0" height="22" rx="3" />
<text class="edge-label" x="191.5" y="360.0">라우팅</text>
<polyline class="edge kind-read style-solid emphasis-normal" points="167.5,478.0 167.5,526.0 167.5,526.0 167.5,574.0" data-evidence="221-233" />
<rect class="edge-label-bg" x="165.8" y="512.0" width="51.5" height="22" rx="3" />
<text class="edge-label" x="191.5" y="527.0">세션 조회</text>
<g id="node-client">
<g class="actor-symbol emphasis-primary" data-evidence="221-228"><circle cx="167.5" cy="80.0" r="11.0" /><line x1="167.5" y1="96.0" x2="167.5" y2="105.0" /><line x1="149.5" y1="106.0" x2="185.5" y2="106.0" /><line x1="167.5" y1="105.0" x2="152.5" y2="122.0" /><line x1="167.5" y1="105.0" x2="182.5" y2="122.0" /></g>
<text class="node-label" x="167.5" y="137.0">외부 요청</text>
</g>
<g id="node-entry">
<polygon class="node-shape kind-gateway emphasis-warning role-control" data-evidence="221-231" points="167.5,240.0 242.5,275.5 167.5,311.0 92.5,275.5" />
<text class="node-label" x="167.5" y="267.0">진입 경로 (kc-lab-1)</text>
<line class="node-detail-divider" x1="106.5" y1="288.0" x2="228.5" y2="288.0" />
<text class="node-detail" x="108.5" y="305.0">kubectl 도 불통</text>
</g>
<g id="node-workload">
<rect class="node-shape kind-service emphasis-normal role-control" data-evidence="221-231" x="77.0" y="407.0" width="181.0" height="71.0" rx="7" />
<text class="node-label" x="167.5" y="434.0">keycloak-0 (kc-lab-2)</text>
<line class="node-detail-divider" x1="91.0" y1="455.0" x2="244.0" y2="455.0" />
<text class="node-detail" x="93.0" y="472.0">도달할 수 없을 뿐이다</text>
</g>
<g id="node-store">
<rect class="node-shape kind-datastore emphasis-warning role-target" data-evidence="221-233" x="70.0" y="587.0" width="195.0" height="71.0" /><ellipse class="node-shape kind-datastore emphasis-warning role-target" cx="167.5" cy="587.0" rx="97.5" ry="13.0" /><path class="storage-bottom" d="M 70.0 658.0 A 97.5 13.0 0 0 0 265.0 658.0" />
<text class="node-label" x="167.5" y="614.0">PostgreSQL (kc-lab-2)</text>
<line class="node-detail-divider" x1="84.0" y1="635.0" x2="251.0" y2="635.0" />
<text class="node-detail" x="86.0" y="652.0">local-path PVC 라 재배치 불가</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

@@ -0,0 +1,22 @@
# 비대칭 차단은 가르지 못한다
## Alternative text
한 방향이 막혀도 반대 방향으로 연결이 성립하는 구성. 양방향을 다 막아야 두 멤버가 분리된다.
## Long description
JGroups 는 한 방향이 막혀도 열린 방향으로 재연결한다. 그래서 비대칭 차단으로는 cluster_size 가 2 로 유지된다. 양방향을 다 막으면 양쪽 모두 멤버 1개가 되지만 한쪽만 DOWN 이 된다. 코디네이터 쪽이 살아남고 분단된 쪽은 스스로 readiness 를 떨어뜨려 로드밸런서에서 빠지므로 외부 응답은 200 을 유지한다. 전면 장애 경로가 없다.
## Elements and evidence
- **keycloak-0** (service): 코디네이터. 분단돼도 살아남는다. Evidence: L226L236.
- **막은 방향** (channel): raw 테이블 PREROUTING 에서 끊는다. Evidence: L226L236.
- **keycloak-1** (service): 분단되면 스스로 로드밸런서에서 빠진다. Evidence: L226L236.
- **열린 반대 방향** (channel): 비대칭 차단에서는 여기로 재연결한다. Evidence: L226L232.
## Relationships
- **막은 방향 → keycloak-1:** 막혀서 닿지 않는다. Evidence: L226L232.
- **keycloak-0 → 막은 방향:** JGroups 메시지. Evidence: L226L232.
- **keycloak-1 → 열린 반대 방향:** 반대 방향으로 재연결. Evidence: L226L232.
@@ -0,0 +1,18 @@
# 비대칭 차단은 가르지 못한다
# Question: 한 방향만 막으면 클러스터가 갈라지는가
direction: down
n0: "keycloak-0" {
shape: rectangle
}
n1: "막은 방향" {
shape: rectangle
}
n2: "keycloak-1" {
shape: rectangle
}
n3: "열린 반대 방향" {
shape: rectangle
}
n0 -> n1: "JGroups 메시지"
n1 -> n2: "막혀서 닿지 않는다"
n2 -> n3: "반대 방향으로 재연결"
@@ -0,0 +1,12 @@
digraph techviz {
graph [rankdir=TB, splines=ortho, nodesep=0.55, ranksep=0.85];
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
n0 [label="keycloak-0", shape=box, style="rounded,filled"];
n1 [label="막은 방향", shape=box, style="rounded,filled"];
n2 [label="keycloak-1", shape=box, style="rounded,filled"];
n3 [label="열린 반대 방향", shape=box, style="rounded,filled"];
n0 -> n1 [label="JGroups 메시지", style=solid];
n1 -> n2 [label="막혀서 닿지 않는다", style=solid];
n2 -> n3 [label="반대 방향으로 재연결", style=solid];
}
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="a5-partition-asymmetry" name="비대칭 차단은 가르지 못한다">
<mxGraphModel dx="680" dy="677" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_k0" value="keycloak-0&lt;br/&gt;cluster_size 1" tooltip="코디네이터. 분단돼도 살아남는다. | Evidence: L226-L236" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="70.0" y="60.0" width="150.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_fwd" value="막은 방향&lt;br/&gt;conntrack 보다 먼저" tooltip="raw 테이블 PREROUTING 에서 끊는다. | Evidence: L226-L236" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="70.0" y="227.0" width="150.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_k1" value="keycloak-1&lt;br/&gt;readiness 실패" tooltip="분단되면 스스로 로드밸런서에서 빠진다. | Evidence: L226-L236" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="70.0" y="394.0" width="150.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_rev" value="열린 반대 방향&lt;br/&gt;가르지 못한다" tooltip="비대칭 차단에서는 여기로 재연결한다. | Evidence: L226-L232" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="70.0" y="561.0" width="150.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="e_f-k1" value="막혀서 닿지 않는다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_fwd" target="n_k1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="169.0" y="346.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_k0-f" value="JGroups 메시지" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_k0" target="n_fwd">
<mxGeometry relative="1" as="geometry">
<mxPoint x="169.0" y="179.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_k1-rev" value="반대 방향으로 재연결" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_k1" target="n_rev">
<mxGeometry relative="1" as="geometry">
<mxPoint x="169.0" y="513.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,586 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-f-k1",
"type": "arrow",
"x": 145.0,
"y": 298.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 754052919,
"version": 1,
"versionNonce": 1393818205,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-fwd",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-k1",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-f-k1",
"type": "text",
"x": 124.0,
"y": 334.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1611436970,
"version": 1,
"versionNonce": 1646108018,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "막혀서 닿지 않는다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "막혀서 닿지 않는다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-k0-f",
"type": "arrow",
"x": 145.0,
"y": 131.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 251939064,
"version": 1,
"versionNonce": 443740378,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-k0",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-fwd",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-k0-f",
"type": "text",
"x": 124.0,
"y": 167.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1066092082,
"version": 1,
"versionNonce": 522159602,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "JGroups 메시지",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "JGroups 메시지",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-k1-rev",
"type": "arrow",
"x": 145.0,
"y": 465.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 978841079,
"version": 1,
"versionNonce": 1381835331,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-k1",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-rev",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-k1-rev",
"type": "text",
"x": 124.0,
"y": 501.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 635184409,
"version": 1,
"versionNonce": 253961448,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "반대 방향으로 재연결",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "반대 방향으로 재연결",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-k0",
"type": "rectangle",
"x": 70.0,
"y": 60.0,
"width": 150.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 772945349,
"version": 1,
"versionNonce": 30235372,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-k0",
"type": "text",
"x": 80.0,
"y": 70.0,
"width": 130.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1107535317,
"version": 1,
"versionNonce": 630670490,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "keycloak-0\ncluster_size 1",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "keycloak-0\ncluster_size 1",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-fwd",
"type": "rectangle",
"x": 70.0,
"y": 227.0,
"width": 150.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 535996259,
"version": 1,
"versionNonce": 1320060582,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-fwd",
"type": "text",
"x": 80.0,
"y": 237.0,
"width": 130.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1947049900,
"version": 1,
"versionNonce": 493507574,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "막은 방향\nconntrack 보다 먼저",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "막은 방향\nconntrack 보다 먼저",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-k1",
"type": "rectangle",
"x": 70.0,
"y": 394.0,
"width": 150.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1338591829,
"version": 1,
"versionNonce": 367320954,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-k1",
"type": "text",
"x": 80.0,
"y": 404.0,
"width": 130.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1741943564,
"version": 1,
"versionNonce": 368770159,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "keycloak-1\nreadiness 실패",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "keycloak-1\nreadiness 실패",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-rev",
"type": "rectangle",
"x": 70.0,
"y": 561.0,
"width": 150.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1979585501,
"version": 1,
"versionNonce": 1354430280,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-rev",
"type": "text",
"x": 80.0,
"y": 571.0,
"width": 130.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 697822121,
"version": 1,
"versionNonce": 1254339695,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "열린 반대 방향\n가르지 못한다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "열린 반대 방향\n가르지 못한다",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "a5-partition-asymmetry",
"spec_version": "1.1",
"spec_sha256": "26b2dc620b94c979befdef3808475899361f52b7f622fc06a78b5c6c59cd72a7",
"source_context": {
"document": "docs/keycloak-session-store/final/document.md",
"document_sha256": "1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe",
"anchor": {
"kind": "heading",
"value": "A-5 · 비대칭 분단 — 전면 장애 경로가 없다",
"line": 246
}
},
"outputs": [
"a5-partition-asymmetry.svg",
"a5-partition-asymmetry.mmd",
"a5-partition-asymmetry.d2",
"a5-partition-asymmetry.dot",
"a5-partition-asymmetry.drawio",
"a5-partition-asymmetry.excalidraw",
"a5-partition-asymmetry.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "component-flow",
"reference_ids": [
"payment-event-flow"
],
"diagram_only": true
}
@@ -0,0 +1,10 @@
%% 비대칭 차단은 가르지 못한다
%% question: 한 방향만 막으면 클러스터가 갈라지는가
flowchart TB
n0["keycloak-0"]
n1["막은 방향"]
n2["keycloak-1"]
n3["열린 반대 방향"]
n0 -->|"JGroups 메시지"| n1
n1 -->|"막혀서 닿지 않는다"| n2
n2 -->|"반대 방향으로 재연결"| n3
@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="680" height="677" viewBox="0 0 680 677" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">비대칭 차단은 가르지 못한다</title>
<desc id="diagram-description">JGroups 는 한 방향이 막혀도 열린 방향으로 재연결한다. 그래서 비대칭 차단으로는 cluster_size 가 2 로 유지된다. 양방향을 다 막으면 양쪽 모두 멤버 1개가 되지만 한쪽만 DOWN 이 된다. 코디네이터 쪽이 살아남고 분단된 쪽은 스스로 readiness 를 떨어뜨려 로드밸런서에서 빠지므로 외부 응답은 200 을 유지한다. 전면 장애 경로가 없다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;a5-partition-asymmetry&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;docs/keycloak-session-store/final/document.md&quot;,&quot;document_sha256&quot;:&quot;1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;heading&quot;,&quot;value&quot;:&quot;A-5 · 비대칭 분단 — 전면 장애 경로가 없다&quot;,&quot;line&quot;:246}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="680" height="677" />
<polyline class="edge kind-blocked style-solid emphasis-normal" points="145.0,298.0 145.0,346.0 145.0,346.0 145.0,394.0" data-evidence="226-232" />
<rect class="edge-label-bg" x="126.5" y="332.0" width="85.0" height="22" rx="3" />
<text class="edge-label" x="169.0" y="347.0">막혀서 닿지 않는다</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="145.0,131.0 145.0,179.0 145.0,179.0 145.0,227.0" data-evidence="226-232" />
<rect class="edge-label-bg" x="123.2" y="165.0" width="91.7" height="22" rx="3" />
<text class="edge-label" x="169.0" y="180.0">JGroups 메시지</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="145.0,465.0 145.0,513.0 145.0,513.0 145.0,561.0" data-evidence="226-232" />
<rect class="edge-label-bg" x="123.2" y="499.0" width="91.7" height="22" rx="3" />
<text class="edge-label" x="169.0" y="514.0">반대 방향으로 재연결</text>
<g id="node-k0">
<rect class="node-shape kind-service emphasis-primary role-source" data-evidence="226-236" x="70.0" y="60.0" width="150.0" height="71.0" rx="7" />
<text class="node-label" x="145.0" y="87.0">keycloak-0</text>
<line class="node-detail-divider" x1="84.0" y1="108.0" x2="206.0" y2="108.0" />
<text class="node-detail" x="86.0" y="125.0">cluster_size 1</text>
</g>
<g id="node-fwd">
<rect class="node-shape kind-channel emphasis-warning role-control" data-evidence="226-236" x="70.0" y="227.0" width="150.0" height="71.0" rx="7" />
<text class="node-label" x="145.0" y="254.0">막은 방향</text>
<line class="node-detail-divider" x1="84.0" y1="275.0" x2="206.0" y2="275.0" />
<text class="node-detail" x="86.0" y="292.0">conntrack 보다 먼저</text>
</g>
<g id="node-k1">
<rect class="node-shape kind-service emphasis-normal role-target" data-evidence="226-236" x="70.0" y="394.0" width="150.0" height="71.0" rx="7" />
<text class="node-label" x="145.0" y="421.0">keycloak-1</text>
<line class="node-detail-divider" x1="84.0" y1="442.0" x2="206.0" y2="442.0" />
<text class="node-detail" x="86.0" y="459.0">readiness 실패</text>
</g>
<g id="node-rev">
<rect class="node-shape kind-channel emphasis-normal role-support" data-evidence="226-232" x="70.0" y="561.0" width="150.0" height="71.0" rx="7" />
<text class="node-label" x="145.0" y="588.0">열린 반대 방향</text>
<line class="node-detail-divider" x1="84.0" y1="609.0" x2="206.0" y2="609.0" />
<text class="node-detail" x="86.0" y="626.0">가르지 못한다</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB

@@ -0,0 +1,22 @@
# 지연이 곱해지는 두 단계
## Alternative text
네트워크 지연이 왕복 횟수만큼 누적되고 커넥션 풀 대기에서 다시 증폭되며 마지막에 readiness 실패로 이어지는 구성.
## Long description
주입한 지연은 200ms 인데 로그인 응답이 1,872ms 가 됐다. 로그인 한 번이 데이터베이스 왕복을 여러 번 하므로 왕복 횟수만큼 더해진다. 동시 20건에서는 22.2초가 됐다. 요청이 커넥션을 오래 붙들어 뒤의 요청이 풀에서 대기하기 때문이며 커넥션 획득 대기 최대가 20,000ms 였다. 마지막으로 readiness 프로브가 타임아웃으로 실패해 느린 노드가 로드밸런서에서 빠진다. 느림이 장애로 승격된다.
## Elements and evidence
- **주입한 지연** (process): tc netem 으로 넣는다. Evidence: L253L258.
- **왕복 횟수만큼 누적** (process): 로그인 한 번이 DB 를 여러 번 왕복한다. Evidence: L259L263.
- **커넥션 풀 대기** (component): 앞 요청이 커넥션을 붙들어 뒤가 밀린다. Evidence: L264L268.
- **readiness 실패** (process): 느린 노드가 로드밸런서에서 빠진다. Evidence: L269L273.
## Relationships
- **주입한 지연 → 왕복 횟수만큼 누적:** 왕복마다 더해진다. Evidence: L253L263.
- **커넥션 풀 대기 → readiness 실패:** 프로브도 타임아웃. Evidence: L264L273.
- **왕복 횟수만큼 누적 → 커넥션 풀 대기:** 커넥션 점유가 길어진다. Evidence: L259L268.
@@ -0,0 +1,18 @@
# 지연이 곱해지는 두 단계
# Question: 200밀리초가 어떻게 22초가 되는가
direction: down
n0: "주입한 지연" {
shape: rectangle
}
n1: "왕복 횟수만큼 누적" {
shape: rectangle
}
n2: "커넥션 풀 대기" {
shape: rectangle
}
n3: "readiness 실패" {
shape: rectangle
}
n0 -> n1: "왕복마다 더해진다"
n1 -> n2: "커넥션 점유가 길어진다"
n2 -> n3: "프로브도 타임아웃"
@@ -0,0 +1,12 @@
digraph techviz {
graph [rankdir=TB, splines=ortho, nodesep=0.55, ranksep=0.85];
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
n0 [label="주입한 지연", shape=box, style="rounded,filled"];
n1 [label="왕복 횟수만큼 누적", shape=box, style="rounded,filled"];
n2 [label="커넥션 풀 대기", shape=box, style="rounded,filled"];
n3 [label="readiness 실패", shape=box, style="rounded,filled"];
n0 -> n1 [label="왕복마다 더해진다", style=solid];
n1 -> n2 [label="커넥션 점유가 길어진다", style=solid];
n2 -> n3 [label="프로브도 타임아웃", style=solid];
}
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="a6-latency-multiplication" name="지연이 곱해지는 두 단계">
<mxGraphModel dx="680" dy="677" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_inject" value="주입한 지연&lt;br/&gt;200 ms" tooltip="tc netem 으로 넣는다. | Evidence: L253-L258" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="71.5" y="60.0" width="150.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_roundtrips" value="왕복 횟수만큼 누적&lt;br/&gt;66ms → 1,872ms" tooltip="로그인 한 번이 DB 를 여러 번 왕복한다. | Evidence: L259-L263" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="71.5" y="227.0" width="150.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_pool" value="커넥션 풀 대기&lt;br/&gt;획득 대기 최대 20,000ms" tooltip="앞 요청이 커넥션을 붙들어 뒤가 밀린다. | Evidence: L264-L268" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="70.0" y="394.0" width="153.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_probe" value="readiness 실패&lt;br/&gt;동시 20건 최대 22.2초" tooltip="느린 노드가 로드밸런서에서 빠진다. | Evidence: L269-L273" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="71.5" y="561.0" width="150.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="e_i-r" value="왕복마다 더해진다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_inject" target="n_roundtrips">
<mxGeometry relative="1" as="geometry">
<mxPoint x="170.5" y="179.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_p-pr" value="프로브도 타임아웃" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_pool" target="n_probe">
<mxGeometry relative="1" as="geometry">
<mxPoint x="170.5" y="513.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_r-p" value="커넥션 점유가 길어진다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_roundtrips" target="n_pool">
<mxGeometry relative="1" as="geometry">
<mxPoint x="170.5" y="346.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,586 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-i-r",
"type": "arrow",
"x": 146.5,
"y": 131.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 590595326,
"version": 1,
"versionNonce": 144296589,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-inject",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-roundtrips",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-i-r",
"type": "text",
"x": 125.5,
"y": 167.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1795354243,
"version": 1,
"versionNonce": 270464775,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "왕복마다 더해진다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "왕복마다 더해진다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-p-pr",
"type": "arrow",
"x": 146.5,
"y": 465.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1415151372,
"version": 1,
"versionNonce": 369564785,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-pool",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-probe",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-p-pr",
"type": "text",
"x": 125.5,
"y": 501.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 800431591,
"version": 1,
"versionNonce": 1649790496,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "프로브도 타임아웃",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "프로브도 타임아웃",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-r-p",
"type": "arrow",
"x": 146.5,
"y": 298.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1208893182,
"version": 1,
"versionNonce": 958848636,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-roundtrips",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-pool",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-r-p",
"type": "text",
"x": 122.5,
"y": 334.0,
"width": 96,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 194269574,
"version": 1,
"versionNonce": 208108007,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "커넥션 점유가 길어진다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "커넥션 점유가 길어진다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-inject",
"type": "rectangle",
"x": 71.5,
"y": 60.0,
"width": 150.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 939343234,
"version": 1,
"versionNonce": 1584547150,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-inject",
"type": "text",
"x": 81.5,
"y": 70.0,
"width": 130.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 379587616,
"version": 1,
"versionNonce": 1009589520,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "주입한 지연\n200 ms",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "주입한 지연\n200 ms",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-roundtrips",
"type": "rectangle",
"x": 71.5,
"y": 227.0,
"width": 150.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 708691311,
"version": 1,
"versionNonce": 1798580033,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-roundtrips",
"type": "text",
"x": 81.5,
"y": 237.0,
"width": 130.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 162889864,
"version": 1,
"versionNonce": 1688811133,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "왕복 횟수만큼 누적\n66ms → 1,872ms",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "왕복 횟수만큼 누적\n66ms → 1,872ms",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-pool",
"type": "rectangle",
"x": 70.0,
"y": 394.0,
"width": 153.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 23264887,
"version": 1,
"versionNonce": 1346443202,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-pool",
"type": "text",
"x": 80.0,
"y": 404.0,
"width": 133.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 864087689,
"version": 1,
"versionNonce": 1004607352,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "커넥션 풀 대기\n획득 대기 최대 20,000ms",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "커넥션 풀 대기\n획득 대기 최대 20,000ms",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-probe",
"type": "rectangle",
"x": 71.5,
"y": 561.0,
"width": 150.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1262508266,
"version": 1,
"versionNonce": 1472248480,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-probe",
"type": "text",
"x": 81.5,
"y": 571.0,
"width": 130.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 461284557,
"version": 1,
"versionNonce": 88654975,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "readiness 실패\n동시 20건 최대 22.2초",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "readiness 실패\n동시 20건 최대 22.2초",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "a6-latency-multiplication",
"spec_version": "1.1",
"spec_sha256": "c9d669f597dc9b26d68636bb52e7239c266a8c5985c73d51c3fe0b7ebd7ff231",
"source_context": {
"document": "docs/keycloak-session-store/final/document.md",
"document_sha256": "1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe",
"anchor": {
"kind": "heading",
"value": "A-6 · 지연 주입 — 200밀리초가 22초가 된다",
"line": 255
}
},
"outputs": [
"a6-latency-multiplication.svg",
"a6-latency-multiplication.mmd",
"a6-latency-multiplication.d2",
"a6-latency-multiplication.dot",
"a6-latency-multiplication.drawio",
"a6-latency-multiplication.excalidraw",
"a6-latency-multiplication.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "component-flow",
"reference_ids": [
"payment-event-flow"
],
"diagram_only": true
}
@@ -0,0 +1,10 @@
%% 지연이 곱해지는 두 단계
%% question: 200밀리초가 어떻게 22초가 되는가
flowchart TB
n0["주입한 지연"]
n1["왕복 횟수만큼 누적"]
n2["커넥션 풀 대기"]
n3["readiness 실패"]
n0 -->|"왕복마다 더해진다"| n1
n1 -->|"커넥션 점유가 길어진다"| n2
n2 -->|"프로브도 타임아웃"| n3
@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="680" height="677" viewBox="0 0 680 677" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">지연이 곱해지는 두 단계</title>
<desc id="diagram-description">주입한 지연은 200ms 인데 로그인 응답이 1,872ms 가 됐다. 로그인 한 번이 데이터베이스 왕복을 여러 번 하므로 왕복 횟수만큼 더해진다. 동시 20건에서는 22.2초가 됐다. 요청이 커넥션을 오래 붙들어 뒤의 요청이 풀에서 대기하기 때문이며 커넥션 획득 대기 최대가 20,000ms 였다. 마지막으로 readiness 프로브가 타임아웃으로 실패해 느린 노드가 로드밸런서에서 빠진다. 느림이 장애로 승격된다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;a6-latency-multiplication&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;docs/keycloak-session-store/final/document.md&quot;,&quot;document_sha256&quot;:&quot;1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;heading&quot;,&quot;value&quot;:&quot;A-6 · 지연 주입 — 200밀리초가 22초가 된다&quot;,&quot;line&quot;:255}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="680" height="677" />
<polyline class="edge kind-request style-solid emphasis-normal" points="146.5,131.0 146.5,179.0 146.5,179.0 146.5,227.0" data-evidence="253-263" />
<rect class="edge-label-bg" x="131.3" y="165.0" width="78.3" height="22" rx="3" />
<text class="edge-label" x="170.5" y="180.0">왕복마다 더해진다</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="146.5,465.0 146.5,513.0 146.5,513.0 146.5,561.0" data-evidence="264-273" />
<rect class="edge-label-bg" x="131.3" y="499.0" width="78.3" height="22" rx="3" />
<text class="edge-label" x="170.5" y="514.0">프로브도 타임아웃</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="146.5,298.0 146.5,346.0 146.5,346.0 146.5,394.0" data-evidence="259-268" />
<rect class="edge-label-bg" x="121.3" y="332.0" width="98.4" height="22" rx="3" />
<text class="edge-label" x="170.5" y="347.0">커넥션 점유가 길어진다</text>
<g id="node-inject">
<rect class="node-shape kind-process emphasis-warning role-source" data-evidence="253-258" x="71.5" y="60.0" width="150.0" height="71.0" rx="7" />
<text class="node-label" x="146.5" y="87.0">주입한 지연</text>
<line class="node-detail-divider" x1="85.5" y1="108.0" x2="207.5" y2="108.0" />
<text class="node-detail" x="87.5" y="125.0">200 ms</text>
</g>
<g id="node-roundtrips">
<rect class="node-shape kind-process emphasis-normal role-control" data-evidence="259-263" x="71.5" y="227.0" width="150.0" height="71.0" rx="7" />
<text class="node-label" x="146.5" y="254.0">왕복 횟수만큼 누적</text>
<line class="node-detail-divider" x1="85.5" y1="275.0" x2="207.5" y2="275.0" />
<text class="node-detail" x="87.5" y="292.0">66ms → 1,872ms</text>
</g>
<g id="node-pool">
<rect class="node-shape kind-component emphasis-warning role-control" data-evidence="264-268" x="70.0" y="394.0" width="153.0" height="71.0" rx="7" />
<text class="node-label" x="146.5" y="421.0">커넥션 풀 대기</text>
<line class="node-detail-divider" x1="84.0" y1="442.0" x2="209.0" y2="442.0" />
<text class="node-detail" x="86.0" y="459.0">획득 대기 최대 20,000ms</text>
</g>
<g id="node-probe">
<rect class="node-shape kind-process emphasis-warning role-target" data-evidence="269-273" x="71.5" y="561.0" width="150.0" height="71.0" rx="7" />
<text class="node-label" x="146.5" y="588.0">readiness 실패</text>
<line class="node-detail-divider" x1="85.5" y1="609.0" x2="207.5" y2="609.0" />
<text class="node-detail" x="87.5" y="626.0">동시 20건 최대 22.2초</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.0 KiB

@@ -0,0 +1,22 @@
# 재시작이 지우는 것과 남기는 것
## Alternative text
재시작으로 Infinispan 캐시가 0 이 되지만 PostgreSQL 의 세션 행은 그대로 남아 refresh 가 계속 통하는 구성.
## Long description
롤링 재시작은 한 번에 한 파드씩 바꾼다. 파드가 죽으면 그 노드의 Infinispan sessions 캐시는 0 으로 초기화된다. 그러나 PostgreSQL 의 세션 행은 151개가 그대로 남았고 재시작 전에 발급한 refresh token 이 여전히 200 을 받는다. 전 구간 서비스 중단이 없었다. volatile 모드였다면 같은 재시작에서 400 Session not active 가 되어 전원 로그아웃이 된다.
## Elements and evidence
- **롤링 재시작** (process): 한 번에 한 파드씩 교체한다. Evidence: L270L274.
- **Infinispan sessions 캐시** (component): 파드와 함께 사라진다. Evidence: L270L278.
- **PostgreSQL 세션 행** (datastore): 재시작과 무관하다. Evidence: L270L278.
- **재시작 전 발급한 refresh token** (component): 여전히 통한다. Evidence: L270L278.
## Relationships
- **PostgreSQL 세션 행 → 재시작 전 발급한 refresh token:** 세션이 남아 있어 통한다. Evidence: L270L278.
- **롤링 재시작 → Infinispan sessions 캐시:** 비운다. Evidence: L270L278.
- **롤링 재시작 → PostgreSQL 세션 행:** 건드리지 않는다. Evidence: L270L278.
@@ -0,0 +1,18 @@
# 재시작이 지우는 것과 남기는 것
# Question: 롤링 재시작 뒤에도 로그인이 유지되는 이유는 무엇인가
direction: down
n0: "롤링 재시작" {
shape: rectangle
}
n1: "Infinispan sessions 캐시" {
shape: rectangle
}
n2: "PostgreSQL 세션 행" {
shape: cylinder
}
n3: "재시작 전 발급한 refresh token" {
shape: rectangle
}
n0 -> n1: "비운다"
n0 -> n2: "건드리지 않는다"
n2 -> n3: "세션이 남아 있어 통한다"
@@ -0,0 +1,12 @@
digraph techviz {
graph [rankdir=TB, splines=ortho, nodesep=0.55, ranksep=0.85];
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
n0 [label="롤링 재시작", shape=box, style="rounded,filled"];
n1 [label="Infinispan sessions 캐시", shape=box, style="rounded,filled"];
n2 [label="PostgreSQL 세션 행", shape=cylinder, style="rounded,filled"];
n3 [label="재시작 전 발급한 refresh token", shape=box, style="rounded,filled"];
n0 -> n1 [label="비운다", style=solid];
n0 -> n2 [label="건드리지 않는다", style=solid];
n2 -> n3 [label="세션이 남아 있어 통한다", style=solid];
}
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="a8-cache-vs-session" name="재시작이 지우는 것과 남기는 것">
<mxGraphModel dx="680" dy="556" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_restart" value="롤링 재시작&lt;br/&gt;kubectl rollout restart" tooltip="한 번에 한 파드씩 교체한다. | Evidence: L270-L274" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="171.5" y="60.0" width="195.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_cache" value="Infinispan sessions 캐시&lt;br/&gt;entries_unique → 0" tooltip="파드와 함께 사라진다. | Evidence: L270-L278" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="70.0" y="240.0" width="188.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_db" value="PostgreSQL 세션 행&lt;br/&gt;151 → 151" tooltip="재시작과 무관하다. | Evidence: L270-L278" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;shape=cylinder3;boundedLbl=1;backgroundOutline=1;fillColor=#eef6fb;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="318.0" y="227.0" width="150.0" height="97.0" as="geometry"/>
</mxCell>
<mxCell id="n_refresh" value="재시작 전 발급한 refresh token&lt;br/&gt;200" tooltip="여전히 통한다. | Evidence: L270-L278" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="192.5" y="420.0" width="153.0" height="91.0" as="geometry"/>
</mxCell>
<mxCell id="e_db-rt" value="세션이 남아 있어 통한다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_db" target="n_refresh">
<mxGeometry relative="1" as="geometry">
<mxPoint x="331.0" y="344.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_r-c" value="비운다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_restart" target="n_cache">
<mxGeometry relative="1" as="geometry">
<mxPoint x="212.0" y="157.5" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_r-db" value="건드리지 않는다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_restart" target="n_db">
<mxGeometry relative="1" as="geometry">
<mxPoint x="335.5" y="151.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,586 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-db-rt",
"type": "arrow",
"x": 269.0,
"y": 324.0,
"width": 124.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 214320726,
"version": 1,
"versionNonce": 1768987917,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
124.0,
0.0
],
[
124.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-db",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-refresh",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-db-rt",
"type": "text",
"x": 279.0,
"y": 332.0,
"width": 104,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 273713751,
"version": 1,
"versionNonce": 286370415,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "세션이 남아 있어 통한다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "세션이 남아 있어 통한다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-r-c",
"type": "arrow",
"x": 164.0,
"y": 131.0,
"width": 96.0,
"height": 109.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 421159370,
"version": 1,
"versionNonce": 1082378856,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
96.0,
0.0
],
[
96.0,
54.5
],
[
0.0,
54.5
],
[
0.0,
109.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-restart",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-cache",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-r-c",
"type": "text",
"x": 167.0,
"y": 145.5,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 738095813,
"version": 1,
"versionNonce": 1982565255,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "비운다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "비운다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-r-db",
"type": "arrow",
"x": 278.0,
"y": 131.0,
"width": 115.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1542068113,
"version": 1,
"versionNonce": 611269416,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
115.0,
48.0
],
[
115.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-restart",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-db",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-r-db",
"type": "text",
"x": 290.5,
"y": 139.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 953082516,
"version": 1,
"versionNonce": 1040618264,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "건드리지 않는다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "건드리지 않는다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-restart",
"type": "rectangle",
"x": 171.5,
"y": 60.0,
"width": 195.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 541751545,
"version": 1,
"versionNonce": 112214280,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-restart",
"type": "text",
"x": 181.5,
"y": 70.0,
"width": 175.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 902264258,
"version": 1,
"versionNonce": 194970368,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "롤링 재시작\nkubectl rollout restart",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "롤링 재시작\nkubectl rollout restart",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-cache",
"type": "rectangle",
"x": 70.0,
"y": 240.0,
"width": 188.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1479106285,
"version": 1,
"versionNonce": 377767731,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-cache",
"type": "text",
"x": 80.0,
"y": 250.0,
"width": 168.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1704427303,
"version": 1,
"versionNonce": 762747802,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Infinispan sessions 캐시\nentries_unique → 0",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Infinispan sessions 캐시\nentries_unique → 0",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-db",
"type": "rectangle",
"x": 318.0,
"y": 227.0,
"width": 150.0,
"height": 97.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#e7f5ff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 285058125,
"version": 1,
"versionNonce": 1824840415,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-db",
"type": "text",
"x": 328.0,
"y": 237.0,
"width": 130.0,
"height": 77.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1231096363,
"version": 1,
"versionNonce": 313576462,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "PostgreSQL 세션 행\n151 → 151",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "PostgreSQL 세션 행\n151 → 151",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-refresh",
"type": "rectangle",
"x": 192.5,
"y": 420.0,
"width": 153.0,
"height": 91.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1598484681,
"version": 1,
"versionNonce": 1621202109,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-refresh",
"type": "text",
"x": 202.5,
"y": 430.0,
"width": 133.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 794432888,
"version": 1,
"versionNonce": 204506970,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "재시작 전 발급한 refresh token\n200",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "재시작 전 발급한 refresh token\n200",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "a8-cache-vs-session",
"spec_version": "1.1",
"spec_sha256": "60ae0f0551684a3d013de2a0eca5a41d5edf858f99511c29441388c02f7310c2",
"source_context": {
"document": "docs/keycloak-session-store/final/document.md",
"document_sha256": "1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe",
"anchor": {
"kind": "heading",
"value": "A-8 · 롤링 재시작 — 세션은 살아남고 캐시만 사라진다",
"line": 272
}
},
"outputs": [
"a8-cache-vs-session.svg",
"a8-cache-vs-session.mmd",
"a8-cache-vs-session.d2",
"a8-cache-vs-session.dot",
"a8-cache-vs-session.drawio",
"a8-cache-vs-session.excalidraw",
"a8-cache-vs-session.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "component-flow",
"reference_ids": [
"payment-event-flow"
],
"diagram_only": true
}
@@ -0,0 +1,10 @@
%% 재시작이 지우는 것과 남기는 것
%% question: 롤링 재시작 뒤에도 로그인이 유지되는 이유는 무엇인가
flowchart TB
n0["롤링 재시작"]
n1["Infinispan sessions 캐시"]
n2[("PostgreSQL 세션 행")]
n3["재시작 전 발급한 refresh token"]
n0 -->|"비운다"| n1
n0 -->|"건드리지 않는다"| n2
n2 -->|"세션이 남아 있어 통한다"| n3
@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="680" height="556" viewBox="0 0 680 556" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">재시작이 지우는 것과 남기는 것</title>
<desc id="diagram-description">롤링 재시작은 한 번에 한 파드씩 바꾼다. 파드가 죽으면 그 노드의 Infinispan sessions 캐시는 0 으로 초기화된다. 그러나 PostgreSQL 의 세션 행은 151개가 그대로 남았고 재시작 전에 발급한 refresh token 이 여전히 200 을 받는다. 전 구간 서비스 중단이 없었다. volatile 모드였다면 같은 재시작에서 400 Session not active 가 되어 전원 로그아웃이 된다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;a8-cache-vs-session&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;docs/keycloak-session-store/final/document.md&quot;,&quot;document_sha256&quot;:&quot;1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;heading&quot;,&quot;value&quot;:&quot;A-8 · 롤링 재시작 — 세션은 살아남고 캐시만 사라진다&quot;,&quot;line&quot;:272}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="680" height="556" />
<polyline class="edge kind-read style-solid emphasis-normal" points="393.0,324.0 393.0,372.0 269.0,372.0 269.0,420.0" data-evidence="270-278" />
<rect class="edge-label-bg" x="278.4" y="330.0" width="105.1" height="22" rx="3" />
<text class="edge-label" x="331.0" y="345.0">세션이 남아 있어 통한다</text>
<polyline class="edge kind-write style-solid emphasis-normal" points="260.0,131.0 260.0,185.5 164.0,185.5 164.0,240.0" data-evidence="270-278" />
<rect class="edge-label-bg" x="190.0" y="143.5" width="44.0" height="22" rx="3" />
<text class="edge-label" x="212.0" y="158.5">비운다</text>
<polyline class="edge kind-read style-solid emphasis-normal" points="278.0,131.0 278.0,179.0 393.0,179.0 393.0,227.0" data-evidence="270-278" />
<rect class="edge-label-bg" x="299.7" y="137.0" width="71.6" height="22" rx="3" />
<text class="edge-label" x="335.5" y="152.0">건드리지 않는다</text>
<g id="node-restart">
<rect class="node-shape kind-process emphasis-primary role-source" data-evidence="270-274" x="171.5" y="60.0" width="195.0" height="71.0" rx="7" />
<text class="node-label" x="269.0" y="87.0">롤링 재시작</text>
<line class="node-detail-divider" x1="185.5" y1="108.0" x2="352.5" y2="108.0" />
<text class="node-detail" x="187.5" y="125.0">kubectl rollout restart</text>
</g>
<g id="node-cache">
<rect class="node-shape kind-component emphasis-warning role-target" data-evidence="270-278" x="70.0" y="240.0" width="188.0" height="71.0" rx="7" />
<text class="node-label" x="164.0" y="267.0">Infinispan sessions 캐시</text>
<line class="node-detail-divider" x1="84.0" y1="288.0" x2="244.0" y2="288.0" />
<text class="node-detail" x="86.0" y="305.0">entries_unique → 0</text>
</g>
<g id="node-db">
<rect class="node-shape kind-datastore emphasis-primary role-target" data-evidence="270-278" x="318.0" y="240.0" width="150.0" height="71.0" /><ellipse class="node-shape kind-datastore emphasis-primary role-target" cx="393.0" cy="240.0" rx="75.0" ry="13.0" /><path class="storage-bottom" d="M 318.0 311.0 A 75.0 13.0 0 0 0 468.0 311.0" />
<text class="node-label" x="393.0" y="267.0">PostgreSQL 세션 행</text>
<line class="node-detail-divider" x1="332.0" y1="288.0" x2="454.0" y2="288.0" />
<text class="node-detail" x="334.0" y="305.0">151 → 151</text>
</g>
<g id="node-refresh">
<rect class="node-shape kind-component emphasis-primary role-target" data-evidence="270-278" x="192.5" y="420.0" width="153.0" height="91.0" rx="7" />
<text class="node-label" x="269.0" y="447.0">재시작 전 발급한 refresh</text>
<text class="node-label" x="269.0" y="465.0">token</text>
<line class="node-detail-divider" x1="206.5" y1="486.0" x2="331.5" y2="486.0" />
<text class="node-detail" x="208.5" y="503.0">200</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.2 KiB

@@ -0,0 +1,22 @@
# 덮어쓰기를 만드는 기본키
## Alternative text
두 브라우저 세션이 서로 다른 세션 행을 갖지만 토큰 테이블에서는 같은 행을 가리키는 구성.
## Long description
토큰을 PostgreSQL 로 옮겨 다중 인스턴스 문제는 풀렸다. 그러나 기본키가 client_registration_id 와 principal_name 의 조합이고 세션 id 가 들어 있지 않다. 같은 사용자의 두 세션이 같은 행을 쓰므로 나중 로그인이 앞의 토큰을 덮어쓴다. 그리고 로그아웃하면 Redis 세션은 0 키로 정리되지만 PostgreSQL 에는 평문 refresh token 이 한 행 그대로 남는다.
## Elements and evidence
- **브라우저 A 세션** (component): 세션 id 가 다르다. Evidence: L360L366.
- **브라우저 B 세션** (component): 세션 id 가 다르다. Evidence: L360L366.
- **토큰 행** (datastore): 기본키에 세션 id 가 없어 둘이 같은 행을 쓴다. Evidence: L370L377.
- **로그아웃 후 남는 것** (datastore): Redis 세션은 0 키인데 여기는 1 행이 남는다. Evidence: L378L384.
## Relationships
- **브라우저 A 세션 → 토큰 행:** principal 이름으로 쓴다. Evidence: L370L377.
- **브라우저 B 세션 → 토큰 행:** 같은 키로 덮어쓴다. Evidence: L370L377.
- **토큰 행 → 로그아웃 후 남는 것:** 로그아웃이 정리하지 않는다. Evidence: L378L384.
@@ -0,0 +1,18 @@
# 덮어쓰기를 만드는 기본키
# Question: 같은 사용자의 두 브라우저가 서로의 토큰을 지우는 이유는 무엇인가
direction: down
n0: "브라우저 A 세션" {
shape: rectangle
}
n1: "브라우저 B 세션" {
shape: rectangle
}
n2: "토큰 행" {
shape: cylinder
}
n3: "로그아웃 후 남는 것" {
shape: cylinder
}
n0 -> n2: "principal 이름으로 쓴다"
n1 -> n2: "같은 키로 덮어쓴다"
n2 -> n3: "로그아웃이 정리하지 않는다"
@@ -0,0 +1,12 @@
digraph techviz {
graph [rankdir=TB, splines=ortho, nodesep=0.55, ranksep=0.85];
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
n0 [label="브라우저 A 세션", shape=box, style="rounded,filled"];
n1 [label="브라우저 B 세션", shape=box, style="rounded,filled"];
n2 [label="토큰 행", shape=cylinder, style="rounded,filled"];
n3 [label="로그아웃 후 남는 것", shape=cylinder, style="rounded,filled"];
n0 -> n2 [label="principal 이름으로 쓴다", style=solid];
n1 -> n2 [label="같은 키로 덮어쓴다", style=solid];
n2 -> n3 [label="로그아웃이 정리하지 않는다", style=solid];
}
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="b2-primary-key-overwrite" name="덮어쓰기를 만드는 기본키">
<mxGraphModel dx="680" dy="555" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_s1" value="브라우저 A 세션" tooltip="세션 id 가 다르다. | Evidence: L360-L366" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="70.0" y="60.0" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="n_s2" value="브라우저 B 세션" tooltip="세션 id 가 다르다. | Evidence: L360-L366" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="280.0" y="60.0" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="n_token-row" value="토큰 행&lt;br/&gt;PRIMARY KEY (client_registration_id, principal_name)" tooltip="기본키에 세션 id 가 없어 둘이 같은 행을 쓴다. | Evidence: L370-L377" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;shape=cylinder3;boundedLbl=1;backgroundOutline=1;fillColor=#eef6fb;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="100.0" y="220.0" width="300.0" height="97.0" as="geometry"/>
</mxCell>
<mxCell id="n_leftover" value="로그아웃 후 남는 것&lt;br/&gt;평문 refresh token" tooltip="Redis 세션은 0 키인데 여기는 1 행이 남는다. | Evidence: L378-L384" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;shape=cylinder3;boundedLbl=1;backgroundOutline=1;fillColor=#eef6fb;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="175.0" y="413.0" width="150.0" height="97.0" as="geometry"/>
</mxCell>
<mxCell id="e_s1-t" value="principal 이름으로 쓴다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_s1" target="n_token-row">
<mxGeometry relative="1" as="geometry">
<mxPoint x="193.0" y="144.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_s2-t" value="같은 키로 덮어쓴다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_s2" target="n_token-row">
<mxGeometry relative="1" as="geometry">
<mxPoint x="307.0" y="144.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_t-l" value="로그아웃이 정리하지 않는다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_token-row" target="n_leftover">
<mxGeometry relative="1" as="geometry">
<mxPoint x="274.0" y="365.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,586 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-s1-t",
"type": "arrow",
"x": 145.0,
"y": 124.0,
"width": 96.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 188196126,
"version": 1,
"versionNonce": 660062316,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
96.0,
48.0
],
[
96.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-s1",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-token-row",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-s1-t",
"type": "text",
"x": 125.0,
"y": 132.0,
"width": 136,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1249885615,
"version": 1,
"versionNonce": 1560347761,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "principal 이름으로 쓴다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "principal 이름으로 쓴다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-s2-t",
"type": "arrow",
"x": 259.0,
"y": 124.0,
"width": 96.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 372831280,
"version": 1,
"versionNonce": 594516541,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
96.0,
0.0
],
[
96.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-s2",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-token-row",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-s2-t",
"type": "text",
"x": 262.0,
"y": 132.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1554494580,
"version": 1,
"versionNonce": 120007628,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "같은 키로 덮어쓴다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "같은 키로 덮어쓴다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-t-l",
"type": "arrow",
"x": 250.0,
"y": 317.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1868737350,
"version": 1,
"versionNonce": 1318954268,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-token-row",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-leftover",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-t-l",
"type": "text",
"x": 218.0,
"y": 353.0,
"width": 112,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1742074407,
"version": 1,
"versionNonce": 318513966,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "로그아웃이 정리하지 않는다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "로그아웃이 정리하지 않는다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-s1",
"type": "rectangle",
"x": 70.0,
"y": 60.0,
"width": 150.0,
"height": 64.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1674580432,
"version": 1,
"versionNonce": 1226920683,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-s1",
"type": "text",
"x": 80.0,
"y": 70.0,
"width": 130.0,
"height": 44.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 31564725,
"version": 1,
"versionNonce": 636086059,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "브라우저 A 세션",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "브라우저 A 세션",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-s2",
"type": "rectangle",
"x": 280.0,
"y": 60.0,
"width": 150.0,
"height": 64.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1579564630,
"version": 1,
"versionNonce": 1520235427,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-s2",
"type": "text",
"x": 290.0,
"y": 70.0,
"width": 130.0,
"height": 44.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1199224271,
"version": 1,
"versionNonce": 829864800,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "브라우저 B 세션",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "브라우저 B 세션",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-token-row",
"type": "rectangle",
"x": 100.0,
"y": 220.0,
"width": 300.0,
"height": 97.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#e7f5ff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 231634717,
"version": 1,
"versionNonce": 1632260043,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-token-row",
"type": "text",
"x": 110.0,
"y": 230.0,
"width": 280.0,
"height": 77.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1219720359,
"version": 1,
"versionNonce": 1419335304,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "토큰 행\nPRIMARY KEY (client_registration_id, principal_name)",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "토큰 행\nPRIMARY KEY (client_registration_id, principal_name)",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-leftover",
"type": "rectangle",
"x": 175.0,
"y": 413.0,
"width": 150.0,
"height": 97.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#e7f5ff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1735543634,
"version": 1,
"versionNonce": 743210434,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-leftover",
"type": "text",
"x": 185.0,
"y": 423.0,
"width": 130.0,
"height": 77.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1765460472,
"version": 1,
"versionNonce": 786800441,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "로그아웃 후 남는 것\n평문 refresh token",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "로그아웃 후 남는 것\n평문 refresh token",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "b2-primary-key-overwrite",
"spec_version": "1.1",
"spec_sha256": "653f795b5dd9a627e79a865316e22d8d7ca35f86cbdf6e72dea25c631687cadf",
"source_context": {
"document": "docs/keycloak-session-store/final/document.md",
"document_sha256": "1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe",
"anchor": {
"kind": "heading",
"value": "B-2 · 저장소를 나눠 풀자 다른 두 문제가 남았다",
"line": 365
}
},
"outputs": [
"b2-primary-key-overwrite.svg",
"b2-primary-key-overwrite.mmd",
"b2-primary-key-overwrite.d2",
"b2-primary-key-overwrite.dot",
"b2-primary-key-overwrite.drawio",
"b2-primary-key-overwrite.excalidraw",
"b2-primary-key-overwrite.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "component-flow",
"reference_ids": [
"payment-event-flow"
],
"diagram_only": true
}
@@ -0,0 +1,10 @@
%% 덮어쓰기를 만드는 기본키
%% question: 같은 사용자의 두 브라우저가 서로의 토큰을 지우는 이유는 무엇인가
flowchart TB
n0["브라우저 A 세션"]
n1["브라우저 B 세션"]
n2[("토큰 행")]
n3[("로그아웃 후 남는 것")]
n0 -->|"principal 이름으로 쓴다"| n2
n1 -->|"같은 키로 덮어쓴다"| n2
n2 -->|"로그아웃이 정리하지 않는다"| n3
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="680" height="555" viewBox="0 0 680 555" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">덮어쓰기를 만드는 기본키</title>
<desc id="diagram-description">토큰을 PostgreSQL 로 옮겨 다중 인스턴스 문제는 풀렸다. 그러나 기본키가 client_registration_id 와 principal_name 의 조합이고 세션 id 가 들어 있지 않다. 같은 사용자의 두 세션이 같은 행을 쓰므로 나중 로그인이 앞의 토큰을 덮어쓴다. 그리고 로그아웃하면 Redis 세션은 0 키로 정리되지만 PostgreSQL 에는 평문 refresh token 이 한 행 그대로 남는다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;b2-primary-key-overwrite&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;docs/keycloak-session-store/final/document.md&quot;,&quot;document_sha256&quot;:&quot;1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;heading&quot;,&quot;value&quot;:&quot;B-2 · 저장소를 나눠 풀자 다른 두 문제가 남았다&quot;,&quot;line&quot;:365}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="680" height="555" />
<polyline class="edge kind-write style-solid emphasis-normal" points="145.0,124.0 145.0,172.0 241.0,172.0 241.0,220.0" data-evidence="370-377" />
<rect class="edge-label-bg" x="127.0" y="130.0" width="131.9" height="22" rx="3" />
<text class="edge-label" x="193.0" y="145.0">principal 이름으로 쓴다</text>
<polyline class="edge kind-write style-solid emphasis-normal" points="355.0,124.0 355.0,172.0 259.0,172.0 259.0,220.0" data-evidence="370-377" />
<rect class="edge-label-bg" x="264.5" y="130.0" width="85.0" height="22" rx="3" />
<text class="edge-label" x="307.0" y="145.0">같은 키로 덮어쓴다</text>
<polyline class="edge kind-blocked style-solid emphasis-normal" points="250.0,317.0 250.0,365.0 250.0,365.0 250.0,413.0" data-evidence="378-384" />
<rect class="edge-label-bg" x="218.1" y="351.0" width="111.8" height="22" rx="3" />
<text class="edge-label" x="274.0" y="366.0">로그아웃이 정리하지 않는다</text>
<g id="node-s1">
<rect class="node-shape kind-component emphasis-primary role-source" data-evidence="360-366" x="70.0" y="60.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="145.0" y="90.0">브라우저 A 세션</text>
</g>
<g id="node-s2">
<rect class="node-shape kind-component emphasis-primary role-source" data-evidence="360-366" x="280.0" y="60.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="355.0" y="90.0">브라우저 B 세션</text>
</g>
<g id="node-token-row">
<rect class="node-shape kind-datastore emphasis-warning role-target" data-evidence="370-377" x="100.0" y="233.0" width="300.0" height="71.0" /><ellipse class="node-shape kind-datastore emphasis-warning role-target" cx="250.0" cy="233.0" rx="150.0" ry="13.0" /><path class="storage-bottom" d="M 100.0 304.0 A 150.0 13.0 0 0 0 400.0 304.0" />
<text class="node-label" x="250.0" y="260.0">토큰 행</text>
<line class="node-detail-divider" x1="114.0" y1="281.0" x2="386.0" y2="281.0" />
<text class="node-detail" x="116.0" y="298.0">PRIMARY KEY (client_registration_id, principal_name)</text>
</g>
<g id="node-leftover">
<rect class="node-shape kind-datastore emphasis-warning role-target" data-evidence="378-384" x="175.0" y="426.0" width="150.0" height="71.0" /><ellipse class="node-shape kind-datastore emphasis-warning role-target" cx="250.0" cy="426.0" rx="75.0" ry="13.0" /><path class="storage-bottom" d="M 175.0 497.0 A 75.0 13.0 0 0 0 325.0 497.0" />
<text class="node-label" x="250.0" y="453.0">로그아웃 후 남는 것</text>
<line class="node-detail-divider" x1="189.0" y1="474.0" x2="311.0" y2="474.0" />
<text class="node-detail" x="191.0" y="491.0">평문 refresh token</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

@@ -0,0 +1,22 @@
# 회전 경쟁에서 이긴 요청도 진다
## Alternative text
동시에 도착한 refresh 요청들이 경쟁을 일으키고, 그 결과 client session 자체가 지워지는 구성.
## Long description
revokeRefreshToken 을 켜고 refreshTokenMaxReuse 를 0 으로 둔 상태에서 같은 refresh token 으로 동시에 5건을 보냈다. 순차로 돌리면 재현되지 않으며 백그라운드로 띄우고 wait 해야 경합이 생긴다. 이긴 요청이 받은 새 토큰조차 쓸 수 없었다. Keycloak 이 경쟁을 감지하면 client session 을 지우기 때문이다.
## Elements and evidence
- **동시 refresh 5건** (actor): 같은 refresh token 을 쓴다. Evidence: L369L375.
- **회전 검사** (process): 이미 쓴 토큰인지 본다. Evidence: L369L377.
- **client session** (component): 경쟁이 감지되면 지워진다. Evidence: L378L384.
- **이긴 요청의 새 토큰** (component): 세션이 지워져 쓸 수 없다. Evidence: L378L384.
## Relationships
- **동시 refresh 5건 → 회전 검사:** 동시 도착. Evidence: L369L377.
- **client session → 이긴 요청의 새 토큰:** 세션이 없으니 못 쓴다. Evidence: L378L384.
- **회전 검사 → client session:** 경쟁 감지 시 삭제. Evidence: L378L384.
@@ -0,0 +1,18 @@
# 회전 경쟁에서 이긴 요청도 진다
# Question: 같은 refresh token 으로 동시에 여러 요청이 오면 어떻게 되는가
direction: down
n0: "동시 refresh 5건" {
shape: person
}
n1: "회전 검사" {
shape: rectangle
}
n2: "client session" {
shape: rectangle
}
n3: "이긴 요청의 새 토큰" {
shape: rectangle
}
n0 -> n1: "동시 도착"
n1 -> n2: "경쟁 감지 시 삭제"
n2 -> n3: "세션이 없으니 못 쓴다"
@@ -0,0 +1,12 @@
digraph techviz {
graph [rankdir=TB, splines=ortho, nodesep=0.55, ranksep=0.85];
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
n0 [label="동시 refresh 5건", shape=box, style="rounded,dashed,filled"];
n1 [label="회전 검사", shape=box, style="rounded,filled"];
n2 [label="client session", shape=box, style="rounded,filled"];
n3 [label="이긴 요청의 새 토큰", shape=box, style="rounded,filled"];
n0 -> n1 [label="동시 도착", style=solid];
n1 -> n2 [label="경쟁 감지 시 삭제", style=solid];
n2 -> n3 [label="세션이 없으니 못 쓴다", style=solid];
}
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="b3-rotation-contention" name="회전 경쟁에서 이긴 요청도 진다">
<mxGraphModel dx="680" dy="676" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_concurrent" value="동시 refresh 5건&lt;br/&gt;&amp; 와 wait 이 없으면 재현되지 않는다" tooltip="같은 refresh token 을 쓴다. | Evidence: L369-L375" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;dashed=1;fillColor=#f5f7fa;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="135.0" y="60.0" width="170.0" height="84.0" as="geometry"/>
</mxCell>
<mxCell id="n_rotation" value="회전 검사&lt;br/&gt;revokeRefreshToken=true · refreshTokenMaxReuse=0" tooltip="이미 쓴 토큰인지 본다. | Evidence: L369-L377" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="70.0" y="240.0" width="300.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_client-session" value="client session" tooltip="경쟁이 감지되면 지워진다. | Evidence: L378-L384" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="145.0" y="407.0" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="n_new-token" value="이긴 요청의 새 토큰" tooltip="세션이 지워져 쓸 수 없다. | Evidence: L378-L384" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="145.0" y="567.0" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="e_c-r" value="동시 도착" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_concurrent" target="n_rotation">
<mxGeometry relative="1" as="geometry">
<mxPoint x="244.0" y="192.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_cs-nt" value="세션이 없으니 못 쓴다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_client-session" target="n_new-token">
<mxGeometry relative="1" as="geometry">
<mxPoint x="244.0" y="519.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_r-cs" value="경쟁 감지 시 삭제" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_rotation" target="n_client-session">
<mxGeometry relative="1" as="geometry">
<mxPoint x="244.0" y="359.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,586 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-c-r",
"type": "arrow",
"x": 220.0,
"y": 144.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1593849987,
"version": 1,
"versionNonce": 148545859,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-concurrent",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-rotation",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-c-r",
"type": "text",
"x": 199.0,
"y": 180.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1032385081,
"version": 1,
"versionNonce": 839461234,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "동시 도착",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "동시 도착",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-cs-nt",
"type": "arrow",
"x": 220.0,
"y": 471.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 99440628,
"version": 1,
"versionNonce": 1024407807,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-client-session",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-new-token",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-cs-nt",
"type": "text",
"x": 196.0,
"y": 507.0,
"width": 96,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 58947181,
"version": 1,
"versionNonce": 1723624389,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "세션이 없으니 못 쓴다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "세션이 없으니 못 쓴다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-r-cs",
"type": "arrow",
"x": 220.0,
"y": 311.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1168095643,
"version": 1,
"versionNonce": 453468426,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-rotation",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-client-session",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-r-cs",
"type": "text",
"x": 199.0,
"y": 347.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 214571209,
"version": 1,
"versionNonce": 965394081,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "경쟁 감지 시 삭제",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "경쟁 감지 시 삭제",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-concurrent",
"type": "rectangle",
"x": 135.0,
"y": 60.0,
"width": 170.0,
"height": 84.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 116525699,
"version": 1,
"versionNonce": 47604175,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-concurrent",
"type": "text",
"x": 145.0,
"y": 70.0,
"width": 150.0,
"height": 64.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 328133387,
"version": 1,
"versionNonce": 230764220,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "동시 refresh 5건\n& 와 wait 이 없으면 재현되지 않는다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "동시 refresh 5건\n& 와 wait 이 없으면 재현되지 않는다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-rotation",
"type": "rectangle",
"x": 70.0,
"y": 240.0,
"width": 300.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1792227895,
"version": 1,
"versionNonce": 1813018350,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-rotation",
"type": "text",
"x": 80.0,
"y": 250.0,
"width": 280.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1123092473,
"version": 1,
"versionNonce": 125702461,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "회전 검사\nrevokeRefreshToken=true · refreshTokenMaxReuse=0",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "회전 검사\nrevokeRefreshToken=true · refreshTokenMaxReuse=0",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-client-session",
"type": "rectangle",
"x": 145.0,
"y": 407.0,
"width": 150.0,
"height": 64.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1317434994,
"version": 1,
"versionNonce": 1603889408,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-client-session",
"type": "text",
"x": 155.0,
"y": 417.0,
"width": 130.0,
"height": 44.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 848098034,
"version": 1,
"versionNonce": 1648263378,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "client session",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "client session",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-new-token",
"type": "rectangle",
"x": 145.0,
"y": 567.0,
"width": 150.0,
"height": 64.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 46113166,
"version": 1,
"versionNonce": 1651668549,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-new-token",
"type": "text",
"x": 155.0,
"y": 577.0,
"width": 130.0,
"height": 44.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 629939483,
"version": 1,
"versionNonce": 1848829025,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "이긴 요청의 새 토큰",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "이긴 요청의 새 토큰",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "b3-rotation-contention",
"spec_version": "1.1",
"spec_sha256": "3fbbe8b3528bbcc2ad23e48a820e9ddc6fe6072857bfbe2fa3df0e566fe99162",
"source_context": {
"document": "docs/keycloak-session-store/final/document.md",
"document_sha256": "1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe",
"anchor": {
"kind": "heading",
"value": "B-3 · Refresh Token Rotation 경쟁 (Q2)",
"line": 391
}
},
"outputs": [
"b3-rotation-contention.svg",
"b3-rotation-contention.mmd",
"b3-rotation-contention.d2",
"b3-rotation-contention.dot",
"b3-rotation-contention.drawio",
"b3-rotation-contention.excalidraw",
"b3-rotation-contention.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "component-flow",
"reference_ids": [
"payment-event-flow"
],
"diagram_only": true
}
@@ -0,0 +1,12 @@
%% 회전 경쟁에서 이긴 요청도 진다
%% question: 같은 refresh token 으로 동시에 여러 요청이 오면 어떻게 되는가
flowchart TB
n0(["동시 refresh 5건"])
n1["회전 검사"]
n2["client session"]
n3["이긴 요청의 새 토큰"]
n0 -->|"동시 도착"| n1
n1 -->|"경쟁 감지 시 삭제"| n2
n2 -->|"세션이 없으니 못 쓴다"| n3
classDef external stroke-dasharray: 6 4
class n0 external
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="680" height="676" viewBox="0 0 680 676" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">회전 경쟁에서 이긴 요청도 진다</title>
<desc id="diagram-description">revokeRefreshToken 을 켜고 refreshTokenMaxReuse 를 0 으로 둔 상태에서 같은 refresh token 으로 동시에 5건을 보냈다. 순차로 돌리면 재현되지 않으며 백그라운드로 띄우고 wait 해야 경합이 생긴다. 이긴 요청이 받은 새 토큰조차 쓸 수 없었다. Keycloak 이 경쟁을 감지하면 client session 을 지우기 때문이다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;b3-rotation-contention&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;docs/keycloak-session-store/final/document.md&quot;,&quot;document_sha256&quot;:&quot;1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;heading&quot;,&quot;value&quot;:&quot;B-3 · Refresh Token Rotation 경쟁 (Q2)&quot;,&quot;line&quot;:391}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="680" height="676" />
<polyline class="edge kind-request style-solid emphasis-normal" points="220.0,144.0 220.0,192.0 220.0,192.0 220.0,240.0" data-evidence="369-377" />
<rect class="edge-label-bg" x="218.2" y="178.0" width="51.5" height="22" rx="3" />
<text class="edge-label" x="244.0" y="193.0">동시 도착</text>
<polyline class="edge kind-blocked style-solid emphasis-normal" points="220.0,471.0 220.0,519.0 220.0,519.0 220.0,567.0" data-evidence="378-384" />
<rect class="edge-label-bg" x="194.8" y="505.0" width="98.4" height="22" rx="3" />
<text class="edge-label" x="244.0" y="520.0">세션이 없으니 못 쓴다</text>
<polyline class="edge kind-blocked style-solid emphasis-normal" points="220.0,311.0 220.0,359.0 220.0,359.0 220.0,407.0" data-evidence="378-384" />
<rect class="edge-label-bg" x="201.5" y="345.0" width="85.0" height="22" rx="3" />
<text class="edge-label" x="244.0" y="360.0">경쟁 감지 시 삭제</text>
<g id="node-concurrent">
<g class="actor-symbol emphasis-primary" data-evidence="369-375"><circle cx="220.0" cy="80.0" r="11.0" /><line x1="220.0" y1="96.0" x2="220.0" y2="105.0" /><line x1="202.0" y1="106.0" x2="238.0" y2="106.0" /><line x1="220.0" y1="105.0" x2="205.0" y2="122.0" /><line x1="220.0" y1="105.0" x2="235.0" y2="122.0" /></g>
<text class="node-label" x="220.0" y="137.0">동시 refresh 5건</text>
</g>
<g id="node-rotation">
<rect class="node-shape kind-process emphasis-warning role-control" data-evidence="369-377" x="70.0" y="240.0" width="300.0" height="71.0" rx="7" />
<text class="node-label" x="220.0" y="267.0">회전 검사</text>
<line class="node-detail-divider" x1="84.0" y1="288.0" x2="356.0" y2="288.0" />
<text class="node-detail" x="86.0" y="305.0">revokeRefreshToken=true · refreshTokenMaxReuse=0</text>
</g>
<g id="node-client-session">
<rect class="node-shape kind-component emphasis-warning role-target" data-evidence="378-384" x="145.0" y="407.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="220.0" y="437.0">client session</text>
</g>
<g id="node-new-token">
<rect class="node-shape kind-component emphasis-warning role-target" data-evidence="378-384" x="145.0" y="567.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="220.0" y="597.0">이긴 요청의 새 토큰</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.6 KiB

@@ -0,0 +1,22 @@
# 지우지 않으면 통과한다
## Alternative text
밖에서 들어온 위조 헤더가 프록시를 그대로 통과해 앱에 닿는 구성. 프록시가 그 이름을 설정할 때만 덮어쓴다.
## Long description
nginx 는 자기가 proxy_set_header 로 설정한 헤더만 덮어쓴다. 설정하지 않은 이름은 클라이언트가 보낸 값이 그대로 지나간다. 그래서 X-Auth-Request-Roles 같은 헤더를 앱이 믿으려면 프록시가 그 이름을 먼저 빈 값으로 지워야 한다. 그리고 IdP 에서 클레임을 바꿔도 반영되지 않는다. 12회 요청 6초 동안 옛 값이 갔고 세션을 지워 재인증한 뒤에야 새 값이 왔다. 세션은 로그인 시점의 스냅샷이다.
## Elements and evidence
- **밖에서 보낸 위조 헤더** (actor): 앱이 믿는 이름을 그대로 쓴다. Evidence: L393L400.
- **nginx** (gateway): 설정하지 않은 이름은 덮어쓰지 않는다. Evidence: L393L400.
- **oauth2-proxy** (gateway): 인증 결과를 헤더로 넣는다. Evidence: L393L400.
- **앱** (service): 헤더를 믿고 인가한다. Evidence: L401L408.
## Relationships
- **밖에서 보낸 위조 헤더 → nginx:** 위조 헤더. Evidence: L393L400.
- **nginx → oauth2-proxy:** 지우지 않으면 그대로 지난다. Evidence: L393L400.
- **oauth2-proxy → 앱:** 인가 헤더. Evidence: L393L408.
@@ -0,0 +1,18 @@
# 지우지 않으면 통과한다
# Question: Edge 가 넣어주는 인가 헤더를 앱이 믿어도 되는가
direction: down
n0: "밖에서 보낸 위조 헤더" {
shape: person
}
n1: "nginx" {
shape: diamond
}
n2: "oauth2-proxy" {
shape: diamond
}
n3: "앱" {
shape: rectangle
}
n0 -> n1: "위조 헤더"
n1 -> n2: "지우지 않으면 그대로 지난다"
n2 -> n3: "인가 헤더"
@@ -0,0 +1,12 @@
digraph techviz {
graph [rankdir=TB, splines=ortho, nodesep=0.55, ranksep=0.85];
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
n0 [label="밖에서 보낸 위조 헤더", shape=box, style="rounded,dashed,filled"];
n1 [label="nginx", shape=diamond, style="rounded,filled"];
n2 [label="oauth2-proxy", shape=diamond, style="rounded,filled"];
n3 [label="앱", shape=box, style="rounded,filled"];
n0 -> n1 [label="위조 헤더", style=solid];
n1 -> n2 [label="지우지 않으면 그대로 지난다", style=solid];
n2 -> n3 [label="인가 헤더", style=solid];
}
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="b4-header-trust-boundary" name="지우지 않으면 통과한다">
<mxGraphModel dx="680" dy="690" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_attacker" value="밖에서 보낸 위조 헤더&lt;br/&gt;X-Auth-Request-Roles" tooltip="앱이 믿는 이름을 그대로 쓴다. | Evidence: L393-L400" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;dashed=1;fillColor=#f5f7fa;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="124.5" y="60.0" width="170.0" height="84.0" as="geometry"/>
</mxCell>
<mxCell id="n_nginx" value="nginx&lt;br/&gt;proxy_set_header ... &quot;&quot; 로 먼저 지워야 한다" tooltip="설정하지 않은 이름은 덮어쓰지 않는다. | Evidence: L393-L400" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;rhombus;perimeter=rhombusPerimeter;fillColor=#fff7e8;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="70.0" y="240.0" width="279.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_proxy" value="oauth2-proxy&lt;br/&gt;--set-xauthrequest" tooltip="인증 결과를 헤더로 넣는다. | Evidence: L393-L400" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;rhombus;perimeter=rhombusPerimeter;fillColor=#fff7e8;" vertex="1" parent="1">
<mxGeometry x="129.5" y="407.0" width="160.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_app" value="앱&lt;br/&gt;세션은 로그인 시점의 스냅샷" tooltip="헤더를 믿고 인가한다. | Evidence: L401-L408" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="134.5" y="574.0" width="150.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="e_a-n" value="위조 헤더" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_attacker" target="n_nginx">
<mxGeometry relative="1" as="geometry">
<mxPoint x="233.5" y="192.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_n-p" value="지우지 않으면 그대로 지난다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_nginx" target="n_proxy">
<mxGeometry relative="1" as="geometry">
<mxPoint x="233.5" y="359.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_p-a" value="인가 헤더" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_proxy" target="n_app">
<mxGeometry relative="1" as="geometry">
<mxPoint x="233.5" y="526.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,586 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-a-n",
"type": "arrow",
"x": 209.5,
"y": 144.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1392959887,
"version": 1,
"versionNonce": 960034335,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-attacker",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-nginx",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-a-n",
"type": "text",
"x": 188.5,
"y": 180.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 683347805,
"version": 1,
"versionNonce": 1056915663,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "위조 헤더",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "위조 헤더",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-n-p",
"type": "arrow",
"x": 209.5,
"y": 311.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1877236961,
"version": 1,
"versionNonce": 266888902,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-nginx",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-proxy",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-n-p",
"type": "text",
"x": 173.5,
"y": 347.0,
"width": 120,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1431696520,
"version": 1,
"versionNonce": 51503328,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "지우지 않으면 그대로 지난다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "지우지 않으면 그대로 지난다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-p-a",
"type": "arrow",
"x": 209.5,
"y": 478.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 579930019,
"version": 1,
"versionNonce": 141204879,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-proxy",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-app",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-p-a",
"type": "text",
"x": 188.5,
"y": 514.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1931736220,
"version": 1,
"versionNonce": 15055194,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "인가 헤더",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "인가 헤더",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-attacker",
"type": "rectangle",
"x": 124.5,
"y": 60.0,
"width": 170.0,
"height": 84.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 145023296,
"version": 1,
"versionNonce": 365379687,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-attacker",
"type": "text",
"x": 134.5,
"y": 70.0,
"width": 150.0,
"height": 64.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 539786699,
"version": 1,
"versionNonce": 180744341,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "밖에서 보낸 위조 헤더\nX-Auth-Request-Roles",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "밖에서 보낸 위조 헤더\nX-Auth-Request-Roles",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-nginx",
"type": "rectangle",
"x": 70.0,
"y": 240.0,
"width": 279.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#fff4e6",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 721013300,
"version": 1,
"versionNonce": 1520646945,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-nginx",
"type": "text",
"x": 80.0,
"y": 250.0,
"width": 259.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 117243520,
"version": 1,
"versionNonce": 936748041,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "nginx\nproxy_set_header ... \"\" 로 먼저 지워야 한다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "nginx\nproxy_set_header ... \"\" 로 먼저 지워야 한다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-proxy",
"type": "rectangle",
"x": 129.5,
"y": 407.0,
"width": 160.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#fff4e6",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 254112210,
"version": 1,
"versionNonce": 42167050,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-proxy",
"type": "text",
"x": 139.5,
"y": 417.0,
"width": 140.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 418528669,
"version": 1,
"versionNonce": 1299542283,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "oauth2-proxy\n--set-xauthrequest",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "oauth2-proxy\n--set-xauthrequest",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-app",
"type": "rectangle",
"x": 134.5,
"y": 574.0,
"width": 150.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 594277328,
"version": 1,
"versionNonce": 293358427,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-app",
"type": "text",
"x": 144.5,
"y": 584.0,
"width": 130.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1055843055,
"version": 1,
"versionNonce": 1025494149,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "앱\n세션은 로그인 시점의 스냅샷",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "앱\n세션은 로그인 시점의 스냅샷",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "b4-header-trust-boundary",
"spec_version": "1.1",
"spec_sha256": "9ae5cf39dfc20e31d81b892ae7483d2b9ccf94fa2c91a03837200b463bfc1103",
"source_context": {
"document": "docs/keycloak-session-store/final/document.md",
"document_sha256": "1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe",
"anchor": {
"kind": "heading",
"value": "B-4 · Edge 인가의 범위 (Q4)",
"line": 401
}
},
"outputs": [
"b4-header-trust-boundary.svg",
"b4-header-trust-boundary.mmd",
"b4-header-trust-boundary.d2",
"b4-header-trust-boundary.dot",
"b4-header-trust-boundary.drawio",
"b4-header-trust-boundary.excalidraw",
"b4-header-trust-boundary.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "component-flow",
"reference_ids": [
"payment-event-flow"
],
"diagram_only": true
}
@@ -0,0 +1,12 @@
%% 지우지 않으면 통과한다
%% question: Edge 가 넣어주는 인가 헤더를 앱이 믿어도 되는가
flowchart TB
n0(["밖에서 보낸 위조 헤더"])
n1{"nginx"}
n2{"oauth2-proxy"}
n3["앱"]
n0 -->|"위조 헤더"| n1
n1 -->|"지우지 않으면 그대로 지난다"| n2
n2 -->|"인가 헤더"| n3
classDef external stroke-dasharray: 6 4
class n0 external
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="680" height="690" viewBox="0 0 680 690" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">지우지 않으면 통과한다</title>
<desc id="diagram-description">nginx 는 자기가 proxy_set_header 로 설정한 헤더만 덮어쓴다. 설정하지 않은 이름은 클라이언트가 보낸 값이 그대로 지나간다. 그래서 X-Auth-Request-Roles 같은 헤더를 앱이 믿으려면 프록시가 그 이름을 먼저 빈 값으로 지워야 한다. 그리고 IdP 에서 클레임을 바꿔도 반영되지 않는다. 12회 요청 6초 동안 옛 값이 갔고 세션을 지워 재인증한 뒤에야 새 값이 왔다. 세션은 로그인 시점의 스냅샷이다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;b4-header-trust-boundary&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;docs/keycloak-session-store/final/document.md&quot;,&quot;document_sha256&quot;:&quot;1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;heading&quot;,&quot;value&quot;:&quot;B-4 · Edge 인가의 범위 (Q4)&quot;,&quot;line&quot;:401}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="680" height="690" />
<polyline class="edge kind-request style-solid emphasis-normal" points="209.5,144.0 209.5,192.0 209.5,192.0 209.5,240.0" data-evidence="393-400" />
<rect class="edge-label-bg" x="207.8" y="178.0" width="51.5" height="22" rx="3" />
<text class="edge-label" x="233.5" y="193.0">위조 헤더</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="209.5,311.0 209.5,359.0 209.5,359.0 209.5,407.0" data-evidence="393-400" />
<rect class="edge-label-bg" x="174.2" y="345.0" width="118.5" height="22" rx="3" />
<text class="edge-label" x="233.5" y="360.0">지우지 않으면 그대로 지난다</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="209.5,478.0 209.5,526.0 209.5,526.0 209.5,574.0" data-evidence="393-408" />
<rect class="edge-label-bg" x="207.8" y="512.0" width="51.5" height="22" rx="3" />
<text class="edge-label" x="233.5" y="527.0">인가 헤더</text>
<g id="node-attacker">
<g class="actor-symbol emphasis-warning" data-evidence="393-400"><circle cx="209.5" cy="80.0" r="11.0" /><line x1="209.5" y1="96.0" x2="209.5" y2="105.0" /><line x1="191.5" y1="106.0" x2="227.5" y2="106.0" /><line x1="209.5" y1="105.0" x2="194.5" y2="122.0" /><line x1="209.5" y1="105.0" x2="224.5" y2="122.0" /></g>
<text class="node-label" x="209.5" y="137.0">밖에서 보낸 위조 헤더</text>
</g>
<g id="node-nginx">
<polygon class="node-shape kind-gateway emphasis-warning role-control" data-evidence="393-400" points="209.5,240.0 349.0,275.5 209.5,311.0 70.0,275.5" />
<text class="node-label" x="209.5" y="267.0">nginx</text>
<line class="node-detail-divider" x1="84.0" y1="288.0" x2="335.0" y2="288.0" />
<text class="node-detail" x="86.0" y="305.0">proxy_set_header ... &quot;&quot; 로 먼저 지워야 한다</text>
</g>
<g id="node-proxy">
<polygon class="node-shape kind-gateway emphasis-normal role-control" data-evidence="393-400" points="209.5,407.0 289.5,442.5 209.5,478.0 129.5,442.5" />
<text class="node-label" x="209.5" y="434.0">oauth2-proxy</text>
<line class="node-detail-divider" x1="143.5" y1="455.0" x2="275.5" y2="455.0" />
<text class="node-detail" x="145.5" y="472.0">--set-xauthrequest</text>
</g>
<g id="node-app">
<rect class="node-shape kind-service emphasis-primary role-target" data-evidence="401-408" x="134.5" y="574.0" width="150.0" height="71.0" rx="7" />
<text class="node-label" x="209.5" y="601.0"></text>
<line class="node-detail-divider" x1="148.5" y1="622.0" x2="270.5" y2="622.0" />
<text class="node-detail" x="150.5" y="639.0">세션은 로그인 시점의 스냅샷</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.0 KiB

@@ -0,0 +1,22 @@
# 볼륨 없는 영속화와 유예 없는 회전
## Alternative text
Redis 의 데이터 디렉터리가 컨테이너 파일시스템일 때 영속화 설정이 무의미해지는 구성과, 새 kid 를 만난 검증기가 JWKS 를 재조회하는 구성.
## Long description
redis-cli config set appendonly yes 를 켜도 아무것도 달라지지 않았다. /data 가 컨테이너 파일시스템이라 컨테이너와 함께 죽기 때문이다. appendonlydir 이 만들어졌다가 그대로 버려진다. 볼륨 없는 영속화 설정은 장식이다. realm 키를 회전했을 때는 JWKS 캐시의 유예 구간을 기대했는데 없었다. NimbusJwtDecoder 는 모르는 kid 를 만나면 JWKS 를 다시 가져온다.
## Elements and evidence
- **appendonly yes** (process): 설정은 적용된다. Evidence: L406L412.
- **/data** (datastore): 컨테이너 파일시스템이다. Evidence: L406L412.
- **컨테이너** (component): 죽으면 /data 도 같이 사라진다. Evidence: L406L412.
- **PersistentVolume** (datastore): 여기 있어야 설정이 뜻을 갖는다. Evidence: L406L412.
## Relationships
- **appendonly yes → /data:** AOF 파일을 쓴다. Evidence: L406L412.
- **/data → 컨테이너:** 컨테이너와 함께 죽는다. Evidence: L406L412.
- **/data → PersistentVolume:** 볼륨을 붙여야 남는다. Evidence: L406L412.
@@ -0,0 +1,18 @@
# 볼륨 없는 영속화와 유예 없는 회전
# Question: 설정만으로 영속화와 무중단 키 교체가 되는가
direction: down
n0: "appendonly yes" {
shape: rectangle
}
n1: "/data" {
shape: cylinder
}
n2: "컨테이너" {
shape: rectangle
}
n3: "PersistentVolume" {
shape: cylinder
}
n0 -> n1: "AOF 파일을 쓴다"
n1 -> n2: "컨테이너와 함께 죽는다"
n1 -> n3: "볼륨을 붙여야 남는다"
@@ -0,0 +1,12 @@
digraph techviz {
graph [rankdir=TB, splines=ortho, nodesep=0.55, ranksep=0.85];
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
n0 [label="appendonly yes", shape=box, style="rounded,filled"];
n1 [label="/data", shape=cylinder, style="rounded,filled"];
n2 [label="컨테이너", shape=box, style="rounded,filled"];
n3 [label="PersistentVolume", shape=cylinder, style="rounded,filled"];
n0 -> n1 [label="AOF 파일을 쓴다", style=solid];
n1 -> n2 [label="컨테이너와 함께 죽는다", style=solid];
n1 -> n3 [label="볼륨을 붙여야 남는다", style=solid];
}
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="b5-b6-storage-and-keys" name="볼륨 없는 영속화와 유예 없는 회전">
<mxGraphModel dx="680" dy="555" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_config" value="appendonly yes&lt;br/&gt;redis-cli config set" tooltip="설정은 적용된다. | Evidence: L406-L412" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="163.0" y="60.0" width="174.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_datadir" value="/data&lt;br/&gt;appendonlydir 이 만들어졌다 버려진다" tooltip="컨테이너 파일시스템이다. | Evidence: L406-L412" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;shape=cylinder3;boundedLbl=1;backgroundOutline=1;fillColor=#eef6fb;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="142.0" y="227.0" width="216.0" height="97.0" as="geometry"/>
</mxCell>
<mxCell id="n_container" value="컨테이너&lt;br/&gt;볼륨이 없으면 여기까지다" tooltip="죽으면 /data 도 같이 사라진다. | Evidence: L406-L412" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="70.0" y="429.5" width="150.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_volume" value="PersistentVolume" tooltip="여기 있어야 설정이 뜻을 갖는다. | Evidence: L406-L412" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;shape=cylinder3;boundedLbl=1;backgroundOutline=1;fillColor=#eef6fb;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="280.0" y="420.0" width="150.0" height="90.0" as="geometry"/>
</mxCell>
<mxCell id="e_c-d" value="AOF 파일을 쓴다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_config" target="n_datadir">
<mxGeometry relative="1" as="geometry">
<mxPoint x="274.0" y="179.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_d-c" value="컨테이너와 함께 죽는다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_datadir" target="n_container">
<mxGeometry relative="1" as="geometry">
<mxPoint x="193.0" y="348.8" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_d-v" value="볼륨을 붙여야 남는다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_datadir" target="n_volume">
<mxGeometry relative="1" as="geometry">
<mxPoint x="307.0" y="344.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,586 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-c-d",
"type": "arrow",
"x": 250.0,
"y": 131.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1041793716,
"version": 1,
"versionNonce": 152807890,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-config",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-datadir",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-c-d",
"type": "text",
"x": 229.0,
"y": 167.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1623889644,
"version": 1,
"versionNonce": 941350353,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "AOF 파일을 쓴다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "AOF 파일을 쓴다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-d-c",
"type": "arrow",
"x": 145.0,
"y": 324.0,
"width": 96.0,
"height": 105.5,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1710772655,
"version": 1,
"versionNonce": 825890023,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
96.0,
0.0
],
[
96.0,
52.75
],
[
0.0,
52.75
],
[
0.0,
105.5
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-datadir",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-container",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-d-c",
"type": "text",
"x": 145.0,
"y": 336.75,
"width": 96,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1795748316,
"version": 1,
"versionNonce": 1772431002,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "컨테이너와 함께 죽는다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "컨테이너와 함께 죽는다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-d-v",
"type": "arrow",
"x": 259.0,
"y": 324.0,
"width": 96.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1778945409,
"version": 1,
"versionNonce": 128234071,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
96.0,
48.0
],
[
96.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-datadir",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-volume",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-d-v",
"type": "text",
"x": 262.0,
"y": 332.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1825891785,
"version": 1,
"versionNonce": 120899807,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "볼륨을 붙여야 남는다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "볼륨을 붙여야 남는다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-config",
"type": "rectangle",
"x": 163.0,
"y": 60.0,
"width": 174.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 736776605,
"version": 1,
"versionNonce": 1424542118,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-config",
"type": "text",
"x": 173.0,
"y": 70.0,
"width": 154.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 802906507,
"version": 1,
"versionNonce": 72227637,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "appendonly yes\nredis-cli config set",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "appendonly yes\nredis-cli config set",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-datadir",
"type": "rectangle",
"x": 142.0,
"y": 227.0,
"width": 216.0,
"height": 97.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#e7f5ff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 253670220,
"version": 1,
"versionNonce": 694258328,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-datadir",
"type": "text",
"x": 152.0,
"y": 237.0,
"width": 196.0,
"height": 77.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1101224859,
"version": 1,
"versionNonce": 1801986568,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "/data\nappendonlydir 이 만들어졌다 버려진다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "/data\nappendonlydir 이 만들어졌다 버려진다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-container",
"type": "rectangle",
"x": 70.0,
"y": 429.5,
"width": 150.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 899340409,
"version": 1,
"versionNonce": 719103033,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-container",
"type": "text",
"x": 80.0,
"y": 439.5,
"width": 130.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 625860718,
"version": 1,
"versionNonce": 292100911,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "컨테이너\n볼륨이 없으면 여기까지다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "컨테이너\n볼륨이 없으면 여기까지다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-volume",
"type": "rectangle",
"x": 280.0,
"y": 420.0,
"width": 150.0,
"height": 90.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#e7f5ff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 690472711,
"version": 1,
"versionNonce": 62320937,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-volume",
"type": "text",
"x": 290.0,
"y": 430.0,
"width": 130.0,
"height": 70.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 964975471,
"version": 1,
"versionNonce": 313361807,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "PersistentVolume",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "PersistentVolume",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "b5-b6-storage-and-keys",
"spec_version": "1.1",
"spec_sha256": "51b618881bf3ef23a734b2beb319e756c12a9a7ff1ae578b7b5ef78a1461b0e9",
"source_context": {
"document": "docs/keycloak-session-store/final/document.md",
"document_sha256": "1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe",
"anchor": {
"kind": "heading",
"value": "B-5 · B-6 — 저장소 상실과 키 회전",
"line": 415
}
},
"outputs": [
"b5-b6-storage-and-keys.svg",
"b5-b6-storage-and-keys.mmd",
"b5-b6-storage-and-keys.d2",
"b5-b6-storage-and-keys.dot",
"b5-b6-storage-and-keys.drawio",
"b5-b6-storage-and-keys.excalidraw",
"b5-b6-storage-and-keys.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "component-flow",
"reference_ids": [
"payment-event-flow"
],
"diagram_only": true
}
@@ -0,0 +1,10 @@
%% 볼륨 없는 영속화와 유예 없는 회전
%% question: 설정만으로 영속화와 무중단 키 교체가 되는가
flowchart TB
n0["appendonly yes"]
n1[("/data")]
n2["컨테이너"]
n3[("PersistentVolume")]
n0 -->|"AOF 파일을 쓴다"| n1
n1 -->|"컨테이너와 함께 죽는다"| n2
n1 -->|"볼륨을 붙여야 남는다"| n3
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="680" height="555" viewBox="0 0 680 555" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">볼륨 없는 영속화와 유예 없는 회전</title>
<desc id="diagram-description">redis-cli config set appendonly yes 를 켜도 아무것도 달라지지 않았다. /data 가 컨테이너 파일시스템이라 컨테이너와 함께 죽기 때문이다. appendonlydir 이 만들어졌다가 그대로 버려진다. 볼륨 없는 영속화 설정은 장식이다. realm 키를 회전했을 때는 JWKS 캐시의 유예 구간을 기대했는데 없었다. NimbusJwtDecoder 는 모르는 kid 를 만나면 JWKS 를 다시 가져온다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;b5-b6-storage-and-keys&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;docs/keycloak-session-store/final/document.md&quot;,&quot;document_sha256&quot;:&quot;1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;heading&quot;,&quot;value&quot;:&quot;B-5 · B-6 — 저장소 상실과 키 회전&quot;,&quot;line&quot;:415}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="680" height="555" />
<polyline class="edge kind-write style-solid emphasis-normal" points="250.0,131.0 250.0,179.0 250.0,179.0 250.0,227.0" data-evidence="406-412" />
<rect class="edge-label-bg" x="231.5" y="165.0" width="85.0" height="22" rx="3" />
<text class="edge-label" x="274.0" y="180.0">AOF 파일을 쓴다</text>
<polyline class="edge kind-blocked style-solid emphasis-normal" points="241.0,324.0 241.0,376.8 145.0,376.8 145.0,429.5" data-evidence="406-412" />
<rect class="edge-label-bg" x="143.8" y="334.8" width="98.4" height="22" rx="3" />
<text class="edge-label" x="193.0" y="349.8">컨테이너와 함께 죽는다</text>
<polyline class="edge kind-write style-solid emphasis-normal" points="259.0,324.0 259.0,372.0 355.0,372.0 355.0,420.0" data-evidence="406-412" />
<rect class="edge-label-bg" x="261.1" y="330.0" width="91.7" height="22" rx="3" />
<text class="edge-label" x="307.0" y="345.0">볼륨을 붙여야 남는다</text>
<g id="node-config">
<rect class="node-shape kind-process emphasis-warning role-source" data-evidence="406-412" x="163.0" y="60.0" width="174.0" height="71.0" rx="7" />
<text class="node-label" x="250.0" y="87.0">appendonly yes</text>
<line class="node-detail-divider" x1="177.0" y1="108.0" x2="323.0" y2="108.0" />
<text class="node-detail" x="179.0" y="125.0">redis-cli config set</text>
</g>
<g id="node-datadir">
<rect class="node-shape kind-datastore emphasis-warning role-control" data-evidence="406-412" x="142.0" y="240.0" width="216.0" height="71.0" /><ellipse class="node-shape kind-datastore emphasis-warning role-control" cx="250.0" cy="240.0" rx="108.0" ry="13.0" /><path class="storage-bottom" d="M 142.0 311.0 A 108.0 13.0 0 0 0 358.0 311.0" />
<text class="node-label" x="250.0" y="267.0">/data</text>
<line class="node-detail-divider" x1="156.0" y1="288.0" x2="344.0" y2="288.0" />
<text class="node-detail" x="158.0" y="305.0">appendonlydir 이 만들어졌다 버려진다</text>
</g>
<g id="node-container">
<rect class="node-shape kind-component emphasis-warning role-target" data-evidence="406-412" x="70.0" y="429.5" width="150.0" height="71.0" rx="7" />
<text class="node-label" x="145.0" y="456.5">컨테이너</text>
<line class="node-detail-divider" x1="84.0" y1="477.5" x2="206.0" y2="477.5" />
<text class="node-detail" x="86.0" y="494.5">볼륨이 없으면 여기까지다</text>
</g>
<g id="node-volume">
<rect class="node-shape kind-datastore emphasis-primary role-target" data-evidence="406-412" x="280.0" y="433.0" width="150.0" height="64.0" /><ellipse class="node-shape kind-datastore emphasis-primary role-target" cx="355.0" cy="433.0" rx="75.0" ry="13.0" /><path class="storage-bottom" d="M 280.0 497.0 A 75.0 13.0 0 0 0 430.0 497.0" />
<text class="node-label" x="355.0" y="463.0">PersistentVolume</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.2 KiB

@@ -0,0 +1,22 @@
# 쿠키에 담으면 공유할 것이 없다
## Alternative text
세션이 쿠키에 담기고 replica 는 같은 Secret 만 읽는 구성. Redis 저장소를 켜면 쿠키에 티켓만 남고 서버에 세션이 생긴다.
## Long description
oauth2-proxy 는 세션 전체를 쿠키에 담고 replica 는 같은 k8s Secret 을 읽을 뿐이다. 공유할 서버 상태가 없으니 콜백이 다른 replica 로 가도 된다. 대신 --cookie-secret 이 단수라 옛 secret 도 당분간 받아준다가 불가능하고 교체하는 순간 모든 쿠키가 한꺼번에 무효가 된다. Redis 저장소를 켜면 쿠키에는 티켓만 남는데, 티켓 안에 세션 id 가 있으므로 secret 을 바꾸면 어느 Redis 키를 지울지도 모르게 된다.
## Elements and evidence
- **k8s Secret** (datastore): 모든 replica 가 같은 값을 읽는다. Evidence: L419L428.
- **쿠키의 티켓** (component): 세션 id 와 복호화 키가 함께 암호화되어 있다. Evidence: L429L438.
- **Redis 의 세션** (datastore): 티켓을 못 풀면 어느 키인지도 모른다. Evidence: L429L440.
- **TTL** (component): 요청으로 갱신되지 않아 생성 시각의 함수다. Evidence: L441L452.
## Relationships
- **Redis 의 세션 → TTL:** 생성 시각이 여기 남는다. Evidence: L441L452.
- **k8s Secret → 쿠키의 티켓:** 티켓을 암호화한다. Evidence: L419L432.
- **쿠키의 티켓 → Redis 의 세션:** 키 이름을 만든다. Evidence: L429L438.
@@ -0,0 +1,18 @@
# 쿠키에 담으면 공유할 것이 없다
# Question: oauth2-proxy 의 secret 회전은 무엇을 남기는가
direction: down
n0: "k8s Secret" {
shape: cylinder
}
n1: "쿠키의 티켓" {
shape: rectangle
}
n2: "Redis 의 세션" {
shape: cylinder
}
n3: "TTL" {
shape: rectangle
}
n0 -> n1: "티켓을 암호화한다"
n1 -> n2: "키 이름을 만든다"
n2 -> n3: "생성 시각이 여기 남는다"
@@ -0,0 +1,12 @@
digraph techviz {
graph [rankdir=TB, splines=ortho, nodesep=0.55, ranksep=0.85];
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
n0 [label="k8s Secret", shape=cylinder, style="rounded,filled"];
n1 [label="쿠키의 티켓", shape=box, style="rounded,filled"];
n2 [label="Redis 의 세션", shape=cylinder, style="rounded,filled"];
n3 [label="TTL", shape=box, style="rounded,filled"];
n0 -> n1 [label="티켓을 암호화한다", style=solid];
n1 -> n2 [label="키 이름을 만든다", style=solid];
n2 -> n3 [label="생성 시각이 여기 남는다", style=solid];
}
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="b7-cookie-session-tradeoff" name="쿠키에 담으면 공유할 것이 없다">
<mxGraphModel dx="680" dy="729" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_secret" value="k8s Secret&lt;br/&gt;--cookie-secret 은 단수" tooltip="모든 replica 가 같은 값을 읽는다. | Evidence: L419-L428" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;shape=cylinder3;boundedLbl=1;backgroundOutline=1;fillColor=#eef6fb;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="70.0" y="60.0" width="174.0" height="97.0" as="geometry"/>
</mxCell>
<mxCell id="n_ticket" value="쿠키의 티켓&lt;br/&gt;secret 을 바꾸면 못 푼다" tooltip="세션 id 와 복호화 키가 함께 암호화되어 있다. | Evidence: L429-L438" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="80.5" y="253.0" width="153.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_redis-session" value="Redis 의 세션&lt;br/&gt;고아로 남는다" tooltip="티켓을 못 풀면 어느 키인지도 모른다. | Evidence: L429-L440" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;shape=cylinder3;boundedLbl=1;backgroundOutline=1;fillColor=#eef6fb;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="82.0" y="420.0" width="150.0" height="97.0" as="geometry"/>
</mxCell>
<mxCell id="n_ttl" value="TTL&lt;br/&gt;역산으로 고아를 고른다" tooltip="요청으로 갱신되지 않아 생성 시각의 함수다. | Evidence: L441-L452" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="82.0" y="613.0" width="150.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="e_r-ttl" value="생성 시각이 여기 남는다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_redis-session" target="n_ttl">
<mxGeometry relative="1" as="geometry">
<mxPoint x="181.0" y="565.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_s-t" value="티켓을 암호화한다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_secret" target="n_ticket">
<mxGeometry relative="1" as="geometry">
<mxPoint x="181.0" y="205.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_t-r" value="키 이름을 만든다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_ticket" target="n_redis-session">
<mxGeometry relative="1" as="geometry">
<mxPoint x="181.0" y="372.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,586 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-r-ttl",
"type": "arrow",
"x": 157.0,
"y": 517.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1135934964,
"version": 1,
"versionNonce": 1808446587,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-redis-session",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-ttl",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-r-ttl",
"type": "text",
"x": 129.0,
"y": 553.0,
"width": 104,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1479126096,
"version": 1,
"versionNonce": 701602824,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "생성 시각이 여기 남는다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "생성 시각이 여기 남는다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-s-t",
"type": "arrow",
"x": 157.0,
"y": 157.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1862434854,
"version": 1,
"versionNonce": 618488197,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-secret",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-ticket",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-s-t",
"type": "text",
"x": 136.0,
"y": 193.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1484782167,
"version": 1,
"versionNonce": 493222229,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "티켓을 암호화한다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "티켓을 암호화한다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-t-r",
"type": "arrow",
"x": 157.0,
"y": 324.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 927026216,
"version": 1,
"versionNonce": 260065228,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-ticket",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-redis-session",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-t-r",
"type": "text",
"x": 136.0,
"y": 360.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 576053733,
"version": 1,
"versionNonce": 1463594537,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "키 이름을 만든다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "키 이름을 만든다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-secret",
"type": "rectangle",
"x": 70.0,
"y": 60.0,
"width": 174.0,
"height": 97.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#e7f5ff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1236896219,
"version": 1,
"versionNonce": 1269619969,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-secret",
"type": "text",
"x": 80.0,
"y": 70.0,
"width": 154.0,
"height": 77.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 809034914,
"version": 1,
"versionNonce": 1728073668,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "k8s Secret\n--cookie-secret 은 단수",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "k8s Secret\n--cookie-secret 은 단수",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-ticket",
"type": "rectangle",
"x": 80.5,
"y": 253.0,
"width": 153.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 36669833,
"version": 1,
"versionNonce": 185547631,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-ticket",
"type": "text",
"x": 90.5,
"y": 263.0,
"width": 133.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1697363880,
"version": 1,
"versionNonce": 82751645,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "쿠키의 티켓\nsecret 을 바꾸면 못 푼다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "쿠키의 티켓\nsecret 을 바꾸면 못 푼다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-redis-session",
"type": "rectangle",
"x": 82.0,
"y": 420.0,
"width": 150.0,
"height": 97.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#e7f5ff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 23377508,
"version": 1,
"versionNonce": 61209674,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-redis-session",
"type": "text",
"x": 92.0,
"y": 430.0,
"width": 130.0,
"height": 77.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 162518557,
"version": 1,
"versionNonce": 332434866,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Redis 의 세션\n고아로 남는다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Redis 의 세션\n고아로 남는다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-ttl",
"type": "rectangle",
"x": 82.0,
"y": 613.0,
"width": 150.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1465514607,
"version": 1,
"versionNonce": 1144908308,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-ttl",
"type": "text",
"x": 92.0,
"y": 623.0,
"width": 130.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 56475114,
"version": 1,
"versionNonce": 531370675,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "TTL\n역산으로 고아를 고른다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "TTL\n역산으로 고아를 고른다",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "b7-cookie-session-tradeoff",
"spec_version": "1.1",
"spec_sha256": "ae4774e0d8dfda387902c00a5d3c933672d4adf799cd6774760a74b8a1ad376a",
"source_context": {
"document": "docs/keycloak-session-store/final/document.md",
"document_sha256": "1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe",
"anchor": {
"kind": "heading",
"value": "B-7 · B-7a — 쿠키에 담는 세션, 그리고 그 대가",
"line": 424
}
},
"outputs": [
"b7-cookie-session-tradeoff.svg",
"b7-cookie-session-tradeoff.mmd",
"b7-cookie-session-tradeoff.d2",
"b7-cookie-session-tradeoff.dot",
"b7-cookie-session-tradeoff.drawio",
"b7-cookie-session-tradeoff.excalidraw",
"b7-cookie-session-tradeoff.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "component-flow",
"reference_ids": [
"payment-event-flow"
],
"diagram_only": true
}
@@ -0,0 +1,10 @@
%% 쿠키에 담으면 공유할 것이 없다
%% question: oauth2-proxy 의 secret 회전은 무엇을 남기는가
flowchart TB
n0[("k8s Secret")]
n1["쿠키의 티켓"]
n2[("Redis 의 세션")]
n3["TTL"]
n0 -->|"티켓을 암호화한다"| n1
n1 -->|"키 이름을 만든다"| n2
n2 -->|"생성 시각이 여기 남는다"| n3
@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="680" height="729" viewBox="0 0 680 729" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">쿠키에 담으면 공유할 것이 없다</title>
<desc id="diagram-description">oauth2-proxy 는 세션 전체를 쿠키에 담고 replica 는 같은 k8s Secret 을 읽을 뿐이다. 공유할 서버 상태가 없으니 콜백이 다른 replica 로 가도 된다. 대신 --cookie-secret 이 단수라 옛 secret 도 당분간 받아준다가 불가능하고 교체하는 순간 모든 쿠키가 한꺼번에 무효가 된다. Redis 저장소를 켜면 쿠키에는 티켓만 남는데, 티켓 안에 세션 id 가 있으므로 secret 을 바꾸면 어느 Redis 키를 지울지도 모르게 된다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;b7-cookie-session-tradeoff&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;docs/keycloak-session-store/final/document.md&quot;,&quot;document_sha256&quot;:&quot;1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;heading&quot;,&quot;value&quot;:&quot;B-7 · B-7a — 쿠키에 담는 세션, 그리고 그 대가&quot;,&quot;line&quot;:424}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="680" height="729" />
<polyline class="edge kind-read style-solid emphasis-normal" points="157.0,517.0 157.0,565.0 157.0,565.0 157.0,613.0" data-evidence="441-452" />
<rect class="edge-label-bg" x="128.4" y="551.0" width="105.1" height="22" rx="3" />
<text class="edge-label" x="181.0" y="566.0">생성 시각이 여기 남는다</text>
<polyline class="edge kind-write style-solid emphasis-normal" points="157.0,157.0 157.0,205.0 157.0,205.0 157.0,253.0" data-evidence="419-432" />
<rect class="edge-label-bg" x="141.8" y="191.0" width="78.3" height="22" rx="3" />
<text class="edge-label" x="181.0" y="206.0">티켓을 암호화한다</text>
<polyline class="edge kind-read style-solid emphasis-normal" points="157.0,324.0 157.0,372.0 157.0,372.0 157.0,420.0" data-evidence="429-438" />
<rect class="edge-label-bg" x="141.8" y="358.0" width="78.3" height="22" rx="3" />
<text class="edge-label" x="181.0" y="373.0">키 이름을 만든다</text>
<g id="node-secret">
<rect class="node-shape kind-datastore emphasis-primary role-source" data-evidence="419-428" x="70.0" y="73.0" width="174.0" height="71.0" /><ellipse class="node-shape kind-datastore emphasis-primary role-source" cx="157.0" cy="73.0" rx="87.0" ry="13.0" /><path class="storage-bottom" d="M 70.0 144.0 A 87.0 13.0 0 0 0 244.0 144.0" />
<text class="node-label" x="157.0" y="100.0">k8s Secret</text>
<line class="node-detail-divider" x1="84.0" y1="121.0" x2="230.0" y2="121.0" />
<text class="node-detail" x="86.0" y="138.0">--cookie-secret 은 단수</text>
</g>
<g id="node-ticket">
<rect class="node-shape kind-component emphasis-warning role-control" data-evidence="429-438" x="80.5" y="253.0" width="153.0" height="71.0" rx="7" />
<text class="node-label" x="157.0" y="280.0">쿠키의 티켓</text>
<line class="node-detail-divider" x1="94.5" y1="301.0" x2="219.5" y2="301.0" />
<text class="node-detail" x="96.5" y="318.0">secret 을 바꾸면 못 푼다</text>
</g>
<g id="node-redis-session">
<rect class="node-shape kind-datastore emphasis-warning role-target" data-evidence="429-440" x="82.0" y="433.0" width="150.0" height="71.0" /><ellipse class="node-shape kind-datastore emphasis-warning role-target" cx="157.0" cy="433.0" rx="75.0" ry="13.0" /><path class="storage-bottom" d="M 82.0 504.0 A 75.0 13.0 0 0 0 232.0 504.0" />
<text class="node-label" x="157.0" y="460.0">Redis 의 세션</text>
<line class="node-detail-divider" x1="96.0" y1="481.0" x2="218.0" y2="481.0" />
<text class="node-detail" x="98.0" y="498.0">고아로 남는다</text>
</g>
<g id="node-ttl">
<rect class="node-shape kind-component emphasis-primary role-target" data-evidence="441-452" x="82.0" y="613.0" width="150.0" height="71.0" rx="7" />
<text class="node-label" x="157.0" y="640.0">TTL</text>
<line class="node-detail-divider" x1="96.0" y1="661.0" x2="218.0" y2="661.0" />
<text class="node-detail" x="98.0" y="678.0">역산으로 고아를 고른다</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.4 KiB

@@ -0,0 +1,22 @@
# 백채널 로그아웃은 양쪽이 있어야 한다
## Alternative text
Keycloak 이 부를 주소와 앱이 받을 엔드포인트가 각각 비어 있어 로그아웃 통지가 어느 쪽에서도 성립하지 않는 구성.
## Long description
C-1 에서 로그아웃이 다른 앱으로 퍼지지 않는 것을 관측했고 원인은 단순했다. 두 클라이언트 모두 backchannelLogoutUrl 이 비어 있어 Keycloak 은 부를 주소를 몰랐고, BFF 소스에는 oidcLogout 설정이 없어 주소를 알려줘도 받을 곳이 없었다. Keycloak 파드에서 앱 URL 로 요청하면 200 이 돌아오므로 네트워크 문제도 아니다. 설정이 빠졌다와 기능이 없다는 다르게 고쳐야 하는데 여기는 둘 다였다.
## Elements and evidence
- **한 앱에서 로그아웃** (actor): Keycloak 세션이 끝난다. Evidence: L458L465.
- **backchannelLogoutUrl** (component): 클라이언트 설정. 비어 있었다. Evidence: L458L466.
- **앱의 수신 엔드포인트** (component): 소스에 없었다. Evidence: L458L466.
- **다른 앱의 세션** (component): 그대로 남는다. Evidence: L458L466.
## Relationships
- **backchannelLogoutUrl → 앱의 수신 엔드포인트:** 주소가 있어야 부른다. Evidence: L458L466.
- **앱의 수신 엔드포인트 → 다른 앱의 세션:** 받을 곳이 있어야 지운다. Evidence: L458L466.
- **한 앱에서 로그아웃 → backchannelLogoutUrl:** 통지할 주소를 찾는다. Evidence: L458L466.
@@ -0,0 +1,18 @@
# 백채널 로그아웃은 양쪽이 있어야 한다
# Question: IdP 쪽만 설정하면 로그아웃이 퍼지는가
direction: down
n0: "한 앱에서 로그아웃" {
shape: person
}
n1: "backchannelLogoutUrl" {
shape: rectangle
}
n2: "앱의 수신 엔드포인트" {
shape: rectangle
}
n3: "다른 앱의 세션" {
shape: rectangle
}
n0 -> n1: "통지할 주소를 찾는다"
n1 -> n2: "주소가 있어야 부른다"
n2 -> n3: "받을 곳이 있어야 지운다"
@@ -0,0 +1,12 @@
digraph techviz {
graph [rankdir=TB, splines=ortho, nodesep=0.55, ranksep=0.85];
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
n0 [label="한 앱에서 로그아웃", shape=box, style="rounded,dashed,filled"];
n1 [label="backchannelLogoutUrl", shape=box, style="rounded,filled"];
n2 [label="앱의 수신 엔드포인트", shape=box, style="rounded,filled"];
n3 [label="다른 앱의 세션", shape=box, style="rounded,filled"];
n0 -> n1 [label="통지할 주소를 찾는다", style=solid];
n1 -> n2 [label="주소가 있어야 부른다", style=solid];
n2 -> n3 [label="받을 곳이 있어야 지운다", style=solid];
}
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="c2-backchannel-both-sides" name="백채널 로그아웃은 양쪽이 있어야 한다">
<mxGraphModel dx="680" dy="690" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_logout" value="한 앱에서 로그아웃" tooltip="Keycloak 세션이 끝난다. | Evidence: L458-L465" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;dashed=1;fillColor=#f5f7fa;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="85.5" y="60.0" width="150.0" height="84.0" as="geometry"/>
</mxCell>
<mxCell id="n_kc-config" value="backchannelLogoutUrl&lt;br/&gt;Keycloak 은 부를 주소를 모른다" tooltip="클라이언트 설정. 비어 있었다. | Evidence: L458-L466" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="70.0" y="240.0" width="181.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_app-endpoint" value="앱의 수신 엔드포인트&lt;br/&gt;oidcLogout 설정 없음" tooltip="소스에 없었다. | Evidence: L458-L466" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="85.5" y="407.0" width="150.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_app-session" value="다른 앱의 세션&lt;br/&gt;네트워크는 200 으로 닿는다" tooltip="그대로 남는다. | Evidence: L458-L466" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="85.5" y="574.0" width="150.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="e_c-e" value="주소가 있어야 부른다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_kc-config" target="n_app-endpoint">
<mxGeometry relative="1" as="geometry">
<mxPoint x="184.5" y="359.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_e-s" value="받을 곳이 있어야 지운다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_app-endpoint" target="n_app-session">
<mxGeometry relative="1" as="geometry">
<mxPoint x="184.5" y="526.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_l-c" value="통지할 주소를 찾는다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_logout" target="n_kc-config">
<mxGeometry relative="1" as="geometry">
<mxPoint x="184.5" y="192.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,586 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-c-e",
"type": "arrow",
"x": 160.5,
"y": 311.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1513220568,
"version": 1,
"versionNonce": 255748168,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-kc-config",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-app-endpoint",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-c-e",
"type": "text",
"x": 139.5,
"y": 347.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 809267409,
"version": 1,
"versionNonce": 1101078427,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "주소가 있어야 부른다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "주소가 있어야 부른다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-e-s",
"type": "arrow",
"x": 160.5,
"y": 478.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1793841938,
"version": 1,
"versionNonce": 1078828844,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-app-endpoint",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-app-session",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-e-s",
"type": "text",
"x": 132.5,
"y": 514.0,
"width": 104,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 588860991,
"version": 1,
"versionNonce": 220268688,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "받을 곳이 있어야 지운다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "받을 곳이 있어야 지운다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-l-c",
"type": "arrow",
"x": 160.5,
"y": 144.0,
"width": 0.0,
"height": 96.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 281015540,
"version": 1,
"versionNonce": 30036555,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
0.0,
48.0
],
[
0.0,
48.0
],
[
0.0,
96.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-logout",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-kc-config",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-l-c",
"type": "text",
"x": 139.5,
"y": 180.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1891696490,
"version": 1,
"versionNonce": 410528482,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "통지할 주소를 찾는다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "통지할 주소를 찾는다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-logout",
"type": "rectangle",
"x": 85.5,
"y": 60.0,
"width": 150.0,
"height": 84.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 135793232,
"version": 1,
"versionNonce": 1679436838,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-logout",
"type": "text",
"x": 95.5,
"y": 70.0,
"width": 130.0,
"height": 64.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1938124211,
"version": 1,
"versionNonce": 1421660240,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "한 앱에서 로그아웃",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "한 앱에서 로그아웃",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-kc-config",
"type": "rectangle",
"x": 70.0,
"y": 240.0,
"width": 181.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 514639084,
"version": 1,
"versionNonce": 1749573177,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-kc-config",
"type": "text",
"x": 80.0,
"y": 250.0,
"width": 161.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 230474799,
"version": 1,
"versionNonce": 1719931632,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "backchannelLogoutUrl\nKeycloak 은 부를 주소를 모른다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "backchannelLogoutUrl\nKeycloak 은 부를 주소를 모른다",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-app-endpoint",
"type": "rectangle",
"x": 85.5,
"y": 407.0,
"width": 150.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 153582198,
"version": 1,
"versionNonce": 1995435187,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-app-endpoint",
"type": "text",
"x": 95.5,
"y": 417.0,
"width": 130.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 268772163,
"version": 1,
"versionNonce": 304002942,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "앱의 수신 엔드포인트\noidcLogout 설정 없음",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "앱의 수신 엔드포인트\noidcLogout 설정 없음",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-app-session",
"type": "rectangle",
"x": 85.5,
"y": 574.0,
"width": 150.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1710553737,
"version": 1,
"versionNonce": 1839332537,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-app-session",
"type": "text",
"x": 95.5,
"y": 584.0,
"width": 130.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 573778378,
"version": 1,
"versionNonce": 1579160282,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "다른 앱의 세션\n네트워크는 200 으로 닿는다",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "다른 앱의 세션\n네트워크는 200 으로 닿는다",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "c2-backchannel-both-sides",
"spec_version": "1.1",
"spec_sha256": "43396622cbf01d243efac1f48396163cc64738ef902bddda0ded0d9fb6467eb1",
"source_context": {
"document": "docs/keycloak-session-store/final/document.md",
"document_sha256": "1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe",
"anchor": {
"kind": "heading",
"value": "C층 — SSO 와 로그아웃 전파",
"line": 464
}
},
"outputs": [
"c2-backchannel-both-sides.svg",
"c2-backchannel-both-sides.mmd",
"c2-backchannel-both-sides.d2",
"c2-backchannel-both-sides.dot",
"c2-backchannel-both-sides.drawio",
"c2-backchannel-both-sides.excalidraw",
"c2-backchannel-both-sides.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "component-flow",
"reference_ids": [
"payment-event-flow"
],
"diagram_only": true
}
@@ -0,0 +1,12 @@
%% 백채널 로그아웃은 양쪽이 있어야 한다
%% question: IdP 쪽만 설정하면 로그아웃이 퍼지는가
flowchart TB
n0(["한 앱에서 로그아웃"])
n1["backchannelLogoutUrl"]
n2["앱의 수신 엔드포인트"]
n3["다른 앱의 세션"]
n0 -->|"통지할 주소를 찾는다"| n1
n1 -->|"주소가 있어야 부른다"| n2
n2 -->|"받을 곳이 있어야 지운다"| n3
classDef external stroke-dasharray: 6 4
class n0 external
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="680" height="690" viewBox="0 0 680 690" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">백채널 로그아웃은 양쪽이 있어야 한다</title>
<desc id="diagram-description">C-1 에서 로그아웃이 다른 앱으로 퍼지지 않는 것을 관측했고 원인은 단순했다. 두 클라이언트 모두 backchannelLogoutUrl 이 비어 있어 Keycloak 은 부를 주소를 몰랐고, BFF 소스에는 oidcLogout 설정이 없어 주소를 알려줘도 받을 곳이 없었다. Keycloak 파드에서 앱 URL 로 요청하면 200 이 돌아오므로 네트워크 문제도 아니다. 설정이 빠졌다와 기능이 없다는 다르게 고쳐야 하는데 여기는 둘 다였다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;c2-backchannel-both-sides&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;docs/keycloak-session-store/final/document.md&quot;,&quot;document_sha256&quot;:&quot;1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;heading&quot;,&quot;value&quot;:&quot;C층 — SSO 와 로그아웃 전파&quot;,&quot;line&quot;:464}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="680" height="690" />
<polyline class="edge kind-blocked style-solid emphasis-normal" points="160.5,311.0 160.5,359.0 160.5,359.0 160.5,407.0" data-evidence="458-466" />
<rect class="edge-label-bg" x="138.7" y="345.0" width="91.7" height="22" rx="3" />
<text class="edge-label" x="184.5" y="360.0">주소가 있어야 부른다</text>
<polyline class="edge kind-blocked style-solid emphasis-normal" points="160.5,478.0 160.5,526.0 160.5,526.0 160.5,574.0" data-evidence="458-466" />
<rect class="edge-label-bg" x="131.9" y="512.0" width="105.1" height="22" rx="3" />
<text class="edge-label" x="184.5" y="527.0">받을 곳이 있어야 지운다</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="160.5,144.0 160.5,192.0 160.5,192.0 160.5,240.0" data-evidence="458-466" />
<rect class="edge-label-bg" x="138.7" y="178.0" width="91.7" height="22" rx="3" />
<text class="edge-label" x="184.5" y="193.0">통지할 주소를 찾는다</text>
<g id="node-logout">
<g class="actor-symbol emphasis-primary" data-evidence="458-465"><circle cx="160.5" cy="80.0" r="11.0" /><line x1="160.5" y1="96.0" x2="160.5" y2="105.0" /><line x1="142.5" y1="106.0" x2="178.5" y2="106.0" /><line x1="160.5" y1="105.0" x2="145.5" y2="122.0" /><line x1="160.5" y1="105.0" x2="175.5" y2="122.0" /></g>
<text class="node-label" x="160.5" y="137.0">한 앱에서 로그아웃</text>
</g>
<g id="node-kc-config">
<rect class="node-shape kind-component emphasis-warning role-control" data-evidence="458-466" x="70.0" y="240.0" width="181.0" height="71.0" rx="7" />
<text class="node-label" x="160.5" y="267.0">backchannelLogoutUrl</text>
<line class="node-detail-divider" x1="84.0" y1="288.0" x2="237.0" y2="288.0" />
<text class="node-detail" x="86.0" y="305.0">Keycloak 은 부를 주소를 모른다</text>
</g>
<g id="node-app-endpoint">
<rect class="node-shape kind-component emphasis-warning role-control" data-evidence="458-466" x="85.5" y="407.0" width="150.0" height="71.0" rx="7" />
<text class="node-label" x="160.5" y="434.0">앱의 수신 엔드포인트</text>
<line class="node-detail-divider" x1="99.5" y1="455.0" x2="221.5" y2="455.0" />
<text class="node-detail" x="101.5" y="472.0">oidcLogout 설정 없음</text>
</g>
<g id="node-app-session">
<rect class="node-shape kind-component emphasis-warning role-target" data-evidence="458-466" x="85.5" y="574.0" width="150.0" height="71.0" rx="7" />
<text class="node-label" x="160.5" y="601.0">다른 앱의 세션</text>
<line class="node-detail-divider" x1="99.5" y1="622.0" x2="221.5" y2="622.0" />
<text class="node-detail" x="101.5" y="639.0">네트워크는 200 으로 닿는다</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

@@ -0,0 +1,22 @@
# 캐시 온도가 결과를 가른다
## Alternative text
냉시동에서는 클라이언트 조회가, 반쯤 더운 상태에서는 스코프 조회가 데이터베이스에 닿아 실패하고, 완전히 더운 상태에서는 어느 쪽도 닿지 않는 구성.
## Long description
volatile 모드에서 로그인은 SQL 을 0개 쏜다. refresh 는 딱 한 문장을 쏘는데 CLIENT_SCOPE_CLIENT 의 선택적 스코프 조회이며, 그것도 첫 번째만 쏘고 이후 캐시된다. 그래서 DB 를 세웠을 때 완전 냉시동이면 클라이언트 조회부터 실패해 로그인이 400 이고, CLIENT 캐시만 더우면 refresh 가 500 이며, 완전히 더우면 둘 다 200 이다. A-7 이 표에 적은 것은 그 사이의 한 상태였다.
## Elements and evidence
- **로그인 · refresh 요청** (actor): 같은 명령이 캐시 상태에 따라 다른 답을 받는다. Evidence: L284L296.
- **CLIENT 조회** (process): 냉시동에서 여기서 실패한다. Evidence: L306L312.
- **CLIENT_SCOPE_CLIENT 조회** (process): refresh 만 쏘고 첫 번째만 쏜다. Evidence: L284L292.
- **PostgreSQL** (datastore): 세운 상태다. 여기 닿는 조회만 실패한다. Evidence: L296L302.
## Relationships
- **CLIENT 조회 → CLIENT_SCOPE_CLIENT 조회:** refresh 는 스코프도 다시 계산한다. Evidence: L284L292.
- **로그인 · refresh 요청 → CLIENT 조회:** 클라이언트 확인. Evidence: L306L312.
- **CLIENT_SCOPE_CLIENT 조회 → PostgreSQL:** 캐시에 없으면 여기까지 간다. Evidence: L296L302.
@@ -0,0 +1,18 @@
# 캐시 온도가 결과를 가른다
# Question: volatile 에서 DB 를 세우면 로그인과 refresh 는 어떻게 되는가
direction: down
n0: "로그인 · refresh 요청" {
shape: person
}
n1: "CLIENT 조회" {
shape: rectangle
}
n2: "CLIENT_SCOPE_CLIENT 조회" {
shape: rectangle
}
n3: "PostgreSQL" {
shape: cylinder
}
n0 -> n1: "클라이언트 확인"
n1 -> n2: "refresh 는 스코프도 다시 계산한다"
n2 -> n3: "캐시에 없으면 여기까지 간다"
@@ -0,0 +1,12 @@
digraph techviz {
graph [rankdir=TB, splines=ortho, nodesep=0.55, ranksep=0.85];
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
n0 [label="로그인 · refresh 요청", shape=box, style="rounded,dashed,filled"];
n1 [label="CLIENT 조회", shape=box, style="rounded,filled"];
n2 [label="CLIENT_SCOPE_CLIENT 조회", shape=box, style="rounded,filled"];
n3 [label="PostgreSQL", shape=cylinder, style="rounded,filled"];
n0 -> n1 [label="클라이언트 확인", style=solid];
n1 -> n2 [label="refresh 는 스코프도 다시 계산한다", style=solid];
n2 -> n3 [label="캐시에 없으면 여기까지 간다", style=solid];
}
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="cache-temperature-outcomes" name="캐시 온도가 결과를 가른다">
<mxGraphModel dx="680" dy="716" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_request" value="로그인 · refresh 요청" tooltip="같은 명령이 캐시 상태에 따라 다른 답을 받는다. | Evidence: L284-L296" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;dashed=1;fillColor=#f5f7fa;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="106.5" y="60.0" width="150.0" height="84.0" as="geometry"/>
</mxCell>
<mxCell id="n_client-lookup" value="CLIENT 조회&lt;br/&gt;select ce1_0.ID from CLIENT" tooltip="냉시동에서 여기서 실패한다. | Evidence: L306-L312" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="70.0" y="240.0" width="223.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_scope-lookup" value="CLIENT_SCOPE_CLIENT 조회&lt;br/&gt;DEFAULT_SCOPE=&#x27;f&#x27; — 선택적 스코프" tooltip="refresh 만 쏘고 첫 번째만 쏜다. | Evidence: L284-L292" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="70.0" y="407.0" width="223.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_db" value="PostgreSQL&lt;br/&gt;캐시에 있으면 닿지 않는다" tooltip="세운 상태다. 여기 닿는 조회만 실패한다. | Evidence: L296-L302" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;shape=cylinder3;boundedLbl=1;backgroundOutline=1;fillColor=#eef6fb;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="106.5" y="574.0" width="150.0" height="97.0" as="geometry"/>
</mxCell>
<mxCell id="e_c-s" value="refresh 는 스코프도 다시 계산한다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_client-lookup" target="n_scope-lookup">
<mxGeometry relative="1" as="geometry">
<mxPoint x="205.5" y="359.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_r-c" value="클라이언트 확인" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_request" target="n_client-lookup">
<mxGeometry relative="1" as="geometry">
<mxPoint x="205.5" y="192.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_s-db" value="캐시에 없으면 여기까지 간다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_scope-lookup" target="n_db">
<mxGeometry relative="1" as="geometry">
<mxPoint x="205.5" y="526.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

Some files were not shown because too many files have changed in this diff Show More