diff --git a/.playwright-mcp/console-2026-09-04T02-50-02-217Z.log b/.playwright-mcp/console-2026-09-04T02-50-02-217Z.log
index f0f542f..27a9e46 100644
--- a/.playwright-mcp/console-2026-09-04T02-50-02-217Z.log
+++ b/.playwright-mcp/console-2026-09-04T02-50-02-217Z.log
@@ -19,3 +19,23 @@
[ 137390ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 157071ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 166091ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 182011ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 188613ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 206228ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 218561ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 229607ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 235818ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 237158ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 252095ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 261118ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 273809ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 280227ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 292650ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 306477ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 309957ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 311984ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 325683ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 344586ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 357576ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 359294ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 364740ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
diff --git a/.playwright-mcp/console-2026-09-04T02-56-16-987Z.log b/.playwright-mcp/console-2026-09-04T02-56-16-987Z.log
new file mode 100644
index 0000000..9ad8635
--- /dev/null
+++ b/.playwright-mcp/console-2026-09-04T02-56-16-987Z.log
@@ -0,0 +1,16 @@
+[ 615ms] [WARNING] is deprecated. Please include @ https://app2.hyeonworks.com/explore?schemaVersion=1&panes=%7B%22te0%22%3A%7B%22datasource%22%3A%22PBFA97CFB590B2093%22%2C%22queries%22%3A%5B%7B%22refId%22%3A%22A%22%2C%22expr%22%3A%22up%7Bjob%3D%5C%22keycloak%5C%22%7D%22%2C%22range%22%3Atrue%2C%22instant%22%3Afalse%2C%22editorMode%22%3A%22code%22%2C%22legendFormat%22%3A%22up+%E2%80%94+%7B%7Bpod%7D%7D%22%2C%22datasource%22%3A%7B%22type%22%3A%22prometheus%22%2C%22uid%22%3A%22PBFA97CFB590B2093%22%7D%7D%5D%2C%22range%22%3A%7B%22from%22%3A%22now-20m%22%2C%22to%22%3A%22now%22%7D%7D%7D&orgId=1:0
+[ 929ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 2566ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 5541ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 7990ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 12402ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 17640ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 20285ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 28277ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 34341ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 34985ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 50081ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 65649ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 75046ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 75890ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
+[ 96067ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
diff --git a/.playwright-mcp/page-2026-09-04T02-56-17-648Z.yml b/.playwright-mcp/page-2026-09-04T02-56-17-648Z.yml
new file mode 100644
index 0000000..d577dea
--- /dev/null
+++ b/.playwright-mcp/page-2026-09-04T02-56-17-648Z.yml
@@ -0,0 +1,145 @@
+- generic [active] [ref=f3e1]:
+ - generic [ref=f3e4]:
+ - link "Skip to main content" [ref=f3e5] [cursor=pointer]:
+ - /url: "#pageContent"
+ - banner [ref=f3e7]:
+ - generic [ref=f3e8]:
+ - link [ref=f3e10] [cursor=pointer]:
+ - /url: /
+ - img "Grafana" [ref=f3e11]
+ - generic [ref=f3e14]:
+ - button "Search or jump to..." [ref=f3e18] [cursor=pointer]
+ - generic [ref=f3e19]: ctrl+k
+ - generic [ref=f3e23]:
+ - button "New" [ref=f3e24] [cursor=pointer]
+ - button "Help" [ref=f3e30] [cursor=pointer]
+ - button "News" [ref=f3e33] [cursor=pointer]
+ - button "Profile" [ref=f3e36] [cursor=pointer]:
+ - img "User avatar" [ref=f3e37]
+ - generic [ref=f3e38]:
+ - button "Open menu" [ref=f3e40] [cursor=pointer]
+ - navigation "Breadcrumbs" [ref=f3e43]:
+ - list [ref=f3e44]:
+ - listitem [ref=f3e45]:
+ - link "Home" [ref=f3e46] [cursor=pointer]:
+ - /url: /
+ - listitem [ref=f3e50]:
+ - link "Explore" [ref=f3e51] [cursor=pointer]:
+ - /url: /explore
+ - listitem [ref=f3e55]:
+ - generic "Prometheus" [ref=f3e56]
+ - generic [ref=f3e57]:
+ - generic [ref=f3e60]:
+ - button "Copy shortened URL" [ref=f3e61] [cursor=pointer]
+ - button "Open copy link options" [ref=f3e64] [cursor=pointer]
+ - button "Toggle top search bar" [ref=f3e68] [cursor=pointer]
+ - main [ref=f3e74]:
+ - generic [ref=f3e76]:
+ - heading "Explore" [level=1] [ref=f3e77]
+ - generic [ref=f3e82]:
+ - navigation "Explore toolbar" [ref=f3e84]:
+ - navigation "Search links" [ref=f3e86]:
+ - generic [ref=f3e87]:
+ - button "Content outline" [expanded] [ref=f3e89] [cursor=pointer]:
+ - generic [ref=f3e92]: Outline
+ - generic [ref=f3e97] [cursor=pointer]:
+ - img "Prometheus logo" [ref=f3e99]
+ - textbox "Select a data source" [ref=f3e100]:
+ - /placeholder: ""
+ - generic [ref=f3e104]:
+ - button "Split the pane" [ref=f3e106] [cursor=pointer]:
+ - generic [ref=f3e109]: Split
+ - button "Add" [ref=f3e111] [cursor=pointer]
+ - generic [ref=f3e116]:
+ - 'button "Time range selected: Last 20 minutes" [ref=f3e117] [cursor=pointer]'
+ - button "Zoom out time range" [ref=f3e122] [cursor=pointer]
+ - generic [ref=f3e126]:
+ - button "Run query" [ref=f3e127] [cursor=pointer]
+ - button "Auto refresh turned off. Choose refresh time interval" [ref=f3e131] [cursor=pointer]
+ - generic [ref=f3e135]:
+ - generic [ref=f3e139]:
+ - button "Collapse outline" [expanded] [ref=f3e141] [cursor=pointer]:
+ - img "arrow-from-right" [ref=f3e142]
+ - button "Queries" [ref=f3e145] [cursor=pointer]:
+ - img "arrow" [ref=f3e146]
+ - button "Graph" [ref=f3e150] [cursor=pointer]:
+ - img "graph-bar" [ref=f3e151]
+ - generic [ref=f3e158]:
+ - generic [ref=f3e160]:
+ - generic "Query editor row" [ref=f3e163]:
+ - generic [ref=f3e164]:
+ - generic [ref=f3e166]:
+ - generic [ref=f3e167]:
+ - button "Collapse query row" [expanded] [ref=f3e168] [cursor=pointer]
+ - generic [ref=f3e171]:
+ - button "Query editor row title A" [ref=f3e172] [cursor=pointer]:
+ - generic [ref=f3e173]: A
+ - emphasis [ref=f3e174]: (Prometheus)
+ - generic [ref=f3e175]:
+ - button "Show data source help" [ref=f3e177] [cursor=pointer]
+ - button "Duplicate query" [ref=f3e181] [cursor=pointer]
+ - button "Hide response" [ref=f3e185] [cursor=pointer]
+ - button "Remove query" [ref=f3e189] [cursor=pointer]
+ - button "Drag and drop to reorder" [ref=f3e192]:
+ - img "Drag and drop to reorder" [ref=f3e193]
+ - generic [ref=f3e196]:
+ - generic [ref=f3e197]:
+ - button "Kick start your query" [ref=f3e198] [cursor=pointer]
+ - generic [ref=f3e201]:
+ - generic [ref=f3e202] [cursor=pointer]: Explain
+ - generic [ref=f3e203]:
+ - checkbox "Explain Toggle switch" [ref=f3e204]
+ - generic "Toggle switch" [ref=f3e205] [cursor=pointer]
+ - radiogroup [ref=f3e210]:
+ - generic [ref=f3e211]:
+ - radio "Builder" [ref=f3e212] [cursor=pointer]
+ - generic [ref=f3e213] [cursor=pointer]: Builder
+ - generic [ref=f3e214]:
+ - radio "Code" [checked] [ref=f3e215] [cursor=pointer]
+ - generic [ref=f3e216] [cursor=pointer]: Code
+ - generic [ref=f3e218]:
+ - generic [ref=f3e220]:
+ - button "Loading metrics..." [disabled] [ref=f3e221] [cursor=pointer]
+ - code [ref=f3e228]:
+ - generic [ref=f3e229]:
+ - generic [ref=f3e234]: "up{job=\"keycloak\"}"
+ - textbox "Editor content;Press Alt+F1 for Accessibility Options." [ref=f3e239]: "up{job=\"keycloak\"}"
+ - 'button "Options Legend: up — {{pod}} Format: Time series Step: auto Type: Range Exemplars: false" [ref=f3e245] [cursor=pointer]':
+ - generic [ref=f3e249]:
+ - heading "Options" [level=6] [ref=f3e250]
+ - generic [ref=f3e251]:
+ - generic [ref=f3e252]: "Legend: up — {{pod}}"
+ - generic [ref=f3e253]: "Format: Time series"
+ - generic [ref=f3e254]: "Step: auto"
+ - generic [ref=f3e255]: "Type: Range"
+ - generic [ref=f3e256]: "Exemplars: false"
+ - generic [ref=f3e257]:
+ - button "Add query" [ref=f3e258] [cursor=pointer]
+ - button "Query history" [ref=f3e262] [cursor=pointer]
+ - button "Query inspector" [ref=f3e266] [cursor=pointer]
+ - main [ref=f3e270]:
+ - region [ref=f3e272]:
+ - generic [ref=f3e273]:
+ - heading "Graph" [level=2] [ref=f3e275]
+ - radiogroup [ref=f3e278]:
+ - generic [ref=f3e279]:
+ - radio "Lines" [checked] [ref=f3e280] [cursor=pointer]
+ - generic [ref=f3e281] [cursor=pointer]: Lines
+ - generic [ref=f3e282]:
+ - radio "Bars" [ref=f3e283] [cursor=pointer]
+ - generic [ref=f3e284] [cursor=pointer]: Bars
+ - generic [ref=f3e285]:
+ - radio "Points" [ref=f3e286] [cursor=pointer]
+ - generic [ref=f3e287] [cursor=pointer]: Points
+ - generic [ref=f3e288]:
+ - radio "Stacked lines" [ref=f3e289] [cursor=pointer]
+ - generic [ref=f3e290] [cursor=pointer]: Stacked lines
+ - generic [ref=f3e291]:
+ - radio "Stacked bars" [ref=f3e292] [cursor=pointer]
+ - generic [ref=f3e293] [cursor=pointer]: Stacked bars
+ - generic [ref=f3e294]: Loading plugin panel...
+ - generic [ref=f3e299]:
+ - alert
+ - alert
+ - complementary
+ - complementary
\ No newline at end of file
diff --git a/docs/evidence/a2-database-loss/01-baseline.txt b/docs/evidence/a2-database-loss/01-baseline.txt
new file mode 100644
index 0000000..45b1a21
--- /dev/null
+++ b/docs/evidence/a2-database-loss/01-baseline.txt
@@ -0,0 +1,14 @@
+=== A-2 기준선 — 클러스터가 정상으로 돌아왔는가 ===
+keycloak-0 true 10.42.1.67 kc-lab-2
+keycloak-1 true 10.42.0.35 kc-lab-1
+postgres-7b474b88c8-sn9ff true 10.42.1.24 kc-lab-2
+
+ cluster_size keycloak-1 = 2
+ cluster_size keycloak-0 = 2
+
+=== 노드별 세션 캐시 (실험 설계에 필요) ===
+ keycloak-1 kc-lab-1 = 0 건
+ keycloak-0 kc-lab-2 = 0 건
+
+=== DB 온라인 세션 ===
+ 2
diff --git a/docs/evidence/a2-database-loss/02-setup-sessions.txt b/docs/evidence/a2-database-loss/02-setup-sessions.txt
new file mode 100644
index 0000000..ce5036b
--- /dev/null
+++ b/docs/evidence/a2-database-loss/02-setup-sessions.txt
@@ -0,0 +1,10 @@
+pod/a2-probe condition met
+ keycloak-0=10.42.1.67 keycloak-1=10.42.0.35
+
+=== [준비] 양쪽 노드에 세션을 하나씩 만든다 ===
+ keycloak-0 에서 로그인 sid=EAXV5HcG2J1BZ3vnwONf64AQ 토큰길이=613
+ keycloak-1 에서 로그인 sid=McyTj5lj3n_JqApCXeuAHExc 토큰길이=613
+
+=== [확인] 세션이 각자 노드에만 캐시되었는가 ===
+ keycloak-1 = 0 건
+ keycloak-0 = 1 건
diff --git a/docs/evidence/a2-database-loss/03-four-paths.txt b/docs/evidence/a2-database-loss/03-four-paths.txt
new file mode 100644
index 0000000..69a8a0c
--- /dev/null
+++ b/docs/evidence/a2-database-loss/03-four-paths.txt
@@ -0,0 +1,16 @@
+=== [1] 토큰을 새로 발급 (access 수명 60초) ===
+ 발급 완료 sid=RKXQGAgkuLtouFMVPTFmp_0_
+
+=== [2] PostgreSQL 정지 ===
+ 정지 시각: 11:56:04
+deployment.apps/postgres scaled
+pod/postgres-7b474b88c8-sn9ff condition met
+ 삭제 완료: 11:56:04
+
+=== [3] 네 경로를 즉시 시험 ===
+ ④ 이미 발급된 access token 으로 관리 API HTTP 000000{"error":"HTTP 401 Unauthorized"}401
+ ① 캐시를 가진 노드(keycloak-0)에서 refresh HTTP 500
+ ② 캐시가 없는 노드(keycloak-1)에서 refresh HTTP 500
+ ③ 새 로그인 HTTP 500
+ --- 오류 본문 (새 로그인) ---
+{"error":"unknown_error","error_description":"For more on this error consult the server log."}
diff --git a/docs/evidence/a2-database-loss/04-health-and-service.txt b/docs/evidence/a2-database-loss/04-health-and-service.txt
new file mode 100644
index 0000000..20ac72d
--- /dev/null
+++ b/docs/evidence/a2-database-loss/04-health-and-service.txt
@@ -0,0 +1,29 @@
+=== 파드 Ready 상태 — DB 가 없으면 어떻게 되는가 ===
+keycloak-0 false 0
+keycloak-1 false 0
+
+=== Service 엔드포인트 ===
+Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
+Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
+ notReady: [10.42.0.35 10.42.1.67]
+=== health/ready 상세 ===
+ 전체: DOWN
+ Graceful Shutdown UP
+ Keycloak cluster health check UP
+ Keycloak database connections async health check DOWN
+ Keycloak Initialized UP
+
+=== ④ 다시 — 서명 검증만 필요한 경로는 살아 있는가 ===
+ JWKS 엔드포인트(realm 공개키) HTTP 200
+ realm 메타데이터(.well-known) HTTP 200
+ 관리 API(세션 조회 필요) HTTP 500
+
+=== 외부 진입점 ===
+ https://auth.hyeonworks.com/realms/master HTTP 503
+
+=== Keycloak 로그 — 실제 오류 ===
+ at io.agroal.pool.ConnectionPool$CreateConnectionTask.call(ConnectionPool.java:664)
+ at io.agroal.pool.ConnectionPool$CreateConnectionTask.call(ConnectionPool.java:645)
+Caused by: java.net.ConnectException: Connection refused
+ at org.postgresql.core.v3.ConnectionFactoryImpl.tryConnect(ConnectionFactoryImpl.java:219)
+ at org.postgresql.core.v3.ConnectionFactoryImpl.openConnectionImpl(ConnectionFactoryImpl.java:365)
diff --git a/docs/evidence/a2-database-loss/05-recovery.txt b/docs/evidence/a2-database-loss/05-recovery.txt
new file mode 100644
index 0000000..a9579e8
--- /dev/null
+++ b/docs/evidence/a2-database-loss/05-recovery.txt
@@ -0,0 +1,21 @@
+=== ★ up 지표는 무엇을 말하는가 (프로세스는 살아 있다) ===
+ up{pod=keycloak-1} = 1 ← 1 인데 서비스는 503 이다
+ up{pod=keycloak-0} = 1 ← 1 인데 서비스는 503 이다
+
+=== 복구 — PostgreSQL 재기동 ===
+ 재기동 시각: 11:57:09
+deployment.apps/postgres scaled
+Waiting for deployment "postgres" rollout to finish: 0 out of 1 new replicas have been updated...
+Waiting for deployment "postgres" rollout to finish: 0 of 1 updated replicas are available...
+deployment "postgres" successfully rolled out
+
+=== Keycloak 이 스스로 회복하는가 (재시작 없이) ===
+ +15초 keycloak-0 true keycloak-1 true | 외부 HTTP 200
+ → 서비스 복귀
+
+=== 재시작 횟수 — 파드가 죽었다 살아난 것인가, 그대로 회복한 것인가 ===
+keycloak-0 0
+keycloak-1 0
+
+=== 정지 전 세션이 살아남았는가 ===
+ online 세션 5
diff --git a/docs/evidence/a2-database-loss/README.md b/docs/evidence/a2-database-loss/README.md
new file mode 100644
index 0000000..5c77ee9
--- /dev/null
+++ b/docs/evidence/a2-database-loss/README.md
@@ -0,0 +1,19 @@
+# A-2 — PostgreSQL 정지 증거
+
+2026-09-04 11:56–11:58 KST · Keycloak 26.7.0
+해설: [`docs/experiment-a2-database-loss.md`](../../experiment-a2-database-loss.md)
+
+| 파일 | 무엇을 보여주는가 |
+|---|---|
+| `01-baseline.txt` | 정지 전 — 양쪽 Ready, `cluster_size=2` |
+| `02-setup-sessions.txt` | 양쪽 노드에 세션 하나씩. 캐시는 각자 노드에만 |
+| `03-four-paths.txt` | **네 경로 전부 `500`.** 캐시를 가진 노드도 실패 — refresh 는 쓰기다 |
+| `04-health-and-service.txt` | **전면 장애 증거** — Ready 파드 0개, `ready 주소=[]`, 외부 **503**, `database connections: DOWN`. JWKS·.well-known 은 `200` |
+| `05-recovery.txt` | **`up=1` 인 채로 503.** DB 복귀 15초 후 재시작 0회로 자동 회복, 세션 5건 생존 |
+| `a2-up-stayed-1-during-outage.png` | Grafana — `up{job="keycloak"}` 이 전면 장애 내내 **1에 평평** |
+
+## 핵심 세 줄
+
+1. **DB 는 단일 장애점이다.** Keycloak 을 몇 대로 늘려도 같이 죽는다 — Ready 파드 0개, 외부 503.
+2. **캐시는 읽기를 대신할 뿐 쓰기를 못 한다.** refresh 는 `UPDATE LAST_SESSION_REFRESH` 를 하므로 캐시가 있어도 실패한다.
+3. **`up` 은 이 장애를 못 잡는다.** 알림은 readiness 와 외부 응답 코드에 걸어야 한다.
diff --git a/docs/evidence/a2-database-loss/a2-up-stayed-1-during-outage.png b/docs/evidence/a2-database-loss/a2-up-stayed-1-during-outage.png
new file mode 100644
index 0000000..2cc5f49
Binary files /dev/null and b/docs/evidence/a2-database-loss/a2-up-stayed-1-during-outage.png differ
diff --git a/docs/experiment-a2-database-loss.md b/docs/experiment-a2-database-loss.md
new file mode 100644
index 0000000..37f01d0
--- /dev/null
+++ b/docs/experiment-a2-database-loss.md
@@ -0,0 +1,308 @@
+# A-2 — PostgreSQL 이 죽으면 어떻게 되는가
+
+브랜치 `feature/keycloak-a2-database-loss` ·
+증거 [`docs/evidence/a2-database-loss/`](evidence/a2-database-loss/) ·
+2026-09-04 11:56–11:58 KST · Keycloak 26.7.0
+
+선행: [`A-0`](experiment-00-session-replication.md) ·
+[`A-1`](experiment-a1-jgroups-transport-block.md)
+
+---
+
+## 0. 결론부터
+
+| 예측 | 결과 |
+|---|---|
+| 즉시 전면 장애 | **맞다.** 외부 진입점 **503**, 양쪽 노드 NotReady |
+| 캐시에 있어도 못 쓴다 | **맞다.** 캐시를 가진 노드도 `500` |
+| — | **`up = 1` 인 채로 전면 장애가 났다** (관측의 함정) |
+| — | **DB 복귀 15초 만에 재시작 없이 자동 회복** |
+
+**A-1 과 정반대다.** A-1 은 한쪽만 빠지고 서비스가 계속됐지만,
+A-2 는 **살아남는 노드가 없다.**
+
+---
+
+## 1. 설계 — 네 경로를 구분해서 본다
+
+A-1 에서 **"룩어사이드 캐시는 읽을 때 DB 와 대조하지 않는다"** 를 확인했다.
+그렇다면 캐시를 가진 노드는 DB 없이도 버틸지 모른다. 그 가설을 가른다.
+
+| # | 경로 | 무엇을 보는가 |
+|---|---|---|
+| ① | **캐시를 가진 노드**에서 refresh | 캐시가 DB 를 대신할 수 있는가 |
+| ② | 캐시가 없는 노드에서 refresh | 완전한 DB 의존 |
+| ③ | 새 로그인 | 쓰기 경로 |
+| ④ | 이미 발급된 토큰으로 조회 | 서명만으로 되는 경로 |
+
+**access token 수명이 60초**이므로, 토큰 발급 → DB 정지 → 시험을 그 안에
+끝내야 한다.
+
+### 계측 도구를 바꿨다
+
+A-1 에서 임시 curl 파드가 형편없는 계측 도구임을 확인했다. 여기서는
+**상주 탐침 파드**를 하나 띄우고 `exec` 로 단계를 이어간다. 토큰을 파드 안
+파일에 남겨 **DB 정지 전후로 같은 토큰**을 쓸 수 있다.
+
+```bash
+kubectl -n keycloak-lab run a2-probe --image=curlimages/curl:8.11.1 \
+ --restart=Never --command -- sleep 7200
+kubectl -n keycloak-lab wait --for=condition=Ready pod/a2-probe --timeout=120s
+```
+
+---
+
+## 2. 기준선
+
+```
+keycloak-0 ready=true 10.42.1.67 kc-lab-2
+keycloak-1 ready=true 10.42.0.35 kc-lab-1
+postgres ready=true 10.42.1.24 kc-lab-2
+
+cluster_size keycloak-0 = 2
+cluster_size keycloak-1 = 2
+```
+
+세션을 양쪽에 하나씩 만들고, A-0 대로 **각자 자기 노드에만 캐시**되는 것을
+확인했다.
+
+```
+ keycloak-0 에서 로그인 sid=EAXV5HcG2J1BZ3vnwONf64AQ
+ keycloak-1 에서 로그인 sid=McyTj5lj3n_JqApCXeuAHExc
+ → 캐시 keycloak-0 = 1 건 / keycloak-1 = 0 건 (스크레이프 지연)
+```
+
+---
+
+## 3. 주입
+
+```bash
+kubectl -n keycloak-lab scale deployment/postgres --replicas=0
+kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
+```
+
+```
+정지 시각: 11:56:04
+삭제 완료: 11:56:04 ← 즉시
+```
+
+---
+
+## 4. 결과 — 네 경로
+
+```
+ ① 캐시를 가진 노드(keycloak-0)에서 refresh HTTP 500
+ ② 캐시가 없는 노드(keycloak-1)에서 refresh HTTP 500
+ ③ 새 로그인 HTTP 500
+ ④ 관리 API (세션 조회 필요) HTTP 500
+
+ --- 오류 본문 ---
+ {"error":"unknown_error","error_description":"For more on this error consult the server log."}
+```
+
+### ① 이 500 인 것이 중요하다
+
+**캐시에 세션을 들고 있어도 refresh 는 실패한다.**
+
+A-1 에서는 로그아웃된 세션을 캐시로 `200` 을 줬다. 왜 여기서는 안 되는가.
+
+```
+ refresh 처리
+ ├── 세션이 존재하는가 → 캐시로 답할 수 있다
+ └── LAST_SESSION_REFRESH 갱신 → DB 쓰기가 필요하다 ← 여기서 죽는다
+```
+
+A-0 에서 잡은 SQL 그대로다.
+
+```sql
+update OFFLINE_USER_SESSION set LAST_SESSION_REFRESH=$1, VERSION=$2 where ...
+```
+
+> **캐시는 읽기를 대신할 뿐, 쓰기를 대신하지 못한다.**
+> refresh 는 이름과 달리 **쓰기 연산**이다.
+
+### 로그가 말하는 원인
+
+```
+Caused by: java.net.ConnectException: Connection refused
+ at org.postgresql.core.v3.ConnectionFactoryImpl.tryConnect
+ at io.agroal.pool.ConnectionPool$CreateConnectionTask.call
+```
+
+`agroal` 은 Quarkus 의 커넥션 풀이다. 풀이 새 커넥션을 만들지 못한다.
+
+---
+
+## 5. 살아남은 것 — 상태가 필요 없는 경로
+
+```
+ JWKS 엔드포인트(realm 공개키) HTTP 200
+ realm 메타데이터(.well-known) HTTP 200
+ 관리 API (세션 조회 필요) HTTP 500
+```
+
+**realm 공개키와 메타데이터는 메모리에 있으므로 DB 없이도 응답한다.**
+
+이론적으로는 **이미 JWKS 를 캐시한 리소스 서버는 토큰 검증을 계속할 수 있다**는
+뜻이다. 다만 이 실험대에는 독립 리소스 서버가 아직 없으므로 **여기까지가
+말할 수 있는 범위**다 — B층에서 확인한다.
+
+> **그런데 정문으로는 이것도 못 쓴다.** 아래 6절 때문이다.
+
+---
+
+## 6. 전면 장애 — 살아남는 노드가 없다
+
+```
+=== 파드 Ready ===
+keycloak-0 false restarts=0
+keycloak-1 false restarts=0
+
+=== Service 엔드포인트 ===
+ ready : [] ← 비었다
+ notReady: [10.42.0.35 10.42.1.67]
+
+=== 외부 진입점 ===
+ https://auth.hyeonworks.com/realms/master HTTP 503
+```
+
+```json
+{ "status": "DOWN",
+ "checks": [
+ { "name": "Keycloak cluster health check", "status": "UP" },
+ { "name": "Keycloak database connections async health check", "status": "DOWN" },
+ { "name": "Keycloak Initialized", "status": "UP" } ] }
+```
+
+**`cluster health` 는 UP 인데 `database connections` 가 DOWN 이라 전체가 DOWN 이다.**
+헬스체크는 **모든 항목이 UP 이어야 UP** 이다.
+
+### A-1 과의 대비가 이 실험의 핵심이다
+
+| | A-1 (7800 차단) | **A-2 (DB 정지)** |
+|---|---|---|
+| Ready 인 파드 | keycloak-1 **1개 생존** | **0개** |
+| Service `ready` | `[10.42.0.35]` | **`[]`** |
+| 외부 응답 | **200** | **503** |
+| 성격 | 용량 저하 | **전면 장애** |
+
+**노드를 몇 대로 늘려도 DB 가 죽으면 전부 같이 죽는다.**
+Keycloak 의 대수는 DB 장애에 아무 도움이 되지 않는다.
+
+> 원래 질문 *"Redis 또는 DB가 뒤질 경우 어떻게 복구를 해야 되는지"* 에 대한
+> 첫 번째 답 — **복구 이전에, DB 이중화가 Keycloak 대수보다 우선한다.**
+
+---
+
+## 7. 관측의 함정 — `up = 1` 인 채로 전면 장애
+
+```
+ up{pod=keycloak-1} = 1
+ up{pod=keycloak-0} = 1 ← 서비스는 503 인데
+```
+
+
+
+**전 구간 평평하다.** (11:44 의 짧은 골은 A-1 에서 파드를 교체한 자국이다.)
+
+`up` 은 **Prometheus 가 `/metrics` 를 긁는 데 성공했는가**만 말한다.
+프로세스는 멀쩡히 살아 메트릭을 내놓고 있었다. **기능은 전멸했는데.**
+
+| 지표 | 이 장애에서 |
+|---|---|
+| `up` | **1 — 아무것도 알려주지 않는다** |
+| 파드 `Ready` | **false — 여기서 드러난다** |
+| 외부 HTTP 코드 | **503 — 사용자가 겪는 것** |
+
+> **A-0 에서 나는 `up` 을 "가장 중요한 합성 지표"라고 썼다.**
+> 절반만 맞다. `up` 은 **대상이 사라진 것**을 잡지만 **대상이 살아서 못 쓰는 것**은
+> 못 잡는다. 후자가 운영에서 훨씬 흔하다.
+>
+> **알림은 `up` 이 아니라 readiness 와 외부 응답 코드에 걸어야 한다.**
+
+이 실험대에는 아직 `kube-state-metrics` 가 없어 파드 readiness 가 지표로
+남지 않는다. **관측 스택에 빠진 것을 이 실험이 찾아냈다** — 보완 항목이다.
+
+---
+
+## 8. 복구 — 자동이었다
+
+```bash
+kubectl -n keycloak-lab scale deployment/postgres --replicas=1
+```
+
+```
+ 재기동 시각: 11:57:09
+ +15초 keycloak-0 true keycloak-1 true | 외부 HTTP 200
+ → 서비스 복귀
+
+ 재시작 횟수: keycloak-0 = 0, keycloak-1 = 0
+ 정지 전 세션: online 세션 5 건 살아남음
+```
+
+| | |
+|---|---|
+| 회복 시간 | **약 15초** (DB Ready 이후) |
+| 사람 개입 | **없음** |
+| Keycloak 재시작 | **불필요** — `restarts=0` |
+| 세션 | **살아남음** — DB 에 있으므로 |
+
+**커넥션 풀이 스스로 재연결하고 readiness 가 다시 UP 이 되면서 Service 에
+복귀했다.** `readiness` 를 쓴 설계의 이득이 여기서 나온다 — `liveness` 였다면
+파드가 재시작되어 캐시까지 날아갔을 것이다.
+
+### 개념 — readiness 와 liveness 를 가르는 기준
+
+| | 실패하면 | 언제 쓰나 |
+|---|---|---|
+| **liveness** | **재시작** | 재시작하면 나아지는 문제 (교착, 메모리 누수) |
+| **readiness** | **트래픽에서 격리** | 재시작해도 안 나아지는 문제 (**의존 대상이 죽음**) |
+
+**DB 장애에 liveness 를 걸면 재앙이다.** 모든 파드가 무한 재시작하고,
+DB 가 돌아와도 CrashLoopBackOff 의 백오프 때문에 회복이 늦어진다.
+
+---
+
+## 9. 재현 절차 (명령어)
+
+```bash
+# 0. 상주 탐침 (임시 파드는 계측에 부적합 — A-1 참조)
+kubectl -n keycloak-lab run a2-probe --image=curlimages/curl:8.11.1 \
+ --restart=Never --command -- sleep 7200
+kubectl -n keycloak-lab wait --for=condition=Ready pod/a2-probe --timeout=120s
+
+# 1. 토큰 발급 (access 60초 안에 시험을 끝내야 한다)
+kubectl -n keycloak-lab exec a2-probe -- sh -c \
+ 'curl -s -X POST http://:8080/realms/master/protocol/openid-connect/token \
+ -d grant_type=password -d client_id=admin-cli \
+ -d username=admin -d password= > /tmp/tok.json'
+
+# 2. DB 정지
+kubectl -n keycloak-lab scale deployment/postgres --replicas=0
+kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
+
+# 3. 네 경로
+kubectl -n keycloak-lab exec a2-probe -- curl -s -o /dev/null -w '%{http_code}\n' ...
+
+# 4. 영향 범위
+kubectl -n keycloak-lab get endpoints keycloak \
+ -o jsonpath='{.subsets[*].addresses[*].ip}' # 비어 있으면 전면 장애
+curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
+
+# 5. up 이 거짓말하는 것을 확인
+curl -s "http://localhost:19090/api/v1/query?query=up%7Bjob=%22keycloak%22%7D"
+
+# 6. 복구
+kubectl -n keycloak-lab scale deployment/postgres --replicas=1
+```
+
+---
+
+## 10. 다음 실험에 남기는 것
+
+| 실험 | 이 실험이 준 것 |
+|---|---|
+| **A-3** DB 강제 종료 | 정상 정지는 데이터를 안 잃었다. **강제 종료는?** (`synchronous_commit OFF`) |
+| **A-4** 노드 상실 | postgres 가 kc-lab-2 에 있으므로 그 노드를 죽이면 **A-2 가 함께 일어난다** |
+| **D-1** 백업·복구 | 여기서는 DB 가 되살아났다. **데이터가 사라졌다면?** |
+| 관측 스택 | **`kube-state-metrics` 가 없어 파드 readiness 가 지표로 안 남는다** — 보완 필요 |