Files
document-haness/docs/keycloak-session-store/tech-log-studio/session-custody-across-nodes/case/case-cache-temperature-decides-the-outcome.md
T
DongHyeonkaandClaude Opus 5 2109f726fe feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다
기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만
있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문
종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다.

SSOT 결함 둘을 고쳤다.

- kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이
  「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽
  `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다.
- virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데
  원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다.

기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은
둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더
든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다).

계약을 셋 고쳤다.

- kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/**
  28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는
  것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온
  것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수
  대조했을 때 가장 가까운 커밋도 28개가 어긋났다).
- virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고
  재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다.
- kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고
  넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다.

style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져
(실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고,
engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고
있었다(Question 기록에서 11.94 → 3.86).

verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:01:55 +09:00

135 lines
9.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
kind: CASE
slug: cache-temperature-decides-the-outcome
title: 같은 설정이 캐시 온도만으로 세 가지 답을 냈다
topic: session-custody-across-nodes
topicName: Keycloak 두 노드가 같은 세션을 읽는 경로
project: keycloak-session-store
status: 게시 전
lastVerifiedOn: 2026-09-04
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택의-이유와-지킨-경계-a7-a7a
assets:
- key: cache-temperature-outcomes
file: ../../../final/assets/cache-temperature-outcomes/cache-temperature-outcomes.svg
evidence:
- ../../../final/evidence/raw/a7a-volatile-cause__01-cause-determined.txt
- ../../../final/evidence/raw/a7-volatile-comparison__05-a2-rerun-db-loss.txt
---
# 같은 설정이 캐시 온도만으로 세 가지 답을 냈다
캐시 온도만 달라도 같은 명령이 400, 500, 200 세 가지로 갈렸다. refresh 가 쏘는 SQL 은 가설로 둔 REVOKED_TOKEN 이 아니라 CLIENT_SCOPE_CLIENT 조회 한 문장이었고, 그것도 첫 refresh 한 번뿐이라 그 뒤로는 캐시에서 답한다.
## 관계
- **persistent-user-sessions 가 세션의 거처를 정한다**
이 측정은 그 설정을 끈 쪽에서 잰 것이고, 껐을 때 무슨 일이 일어나는지를 숫자로 대는 사례다.
- **버전과 설정을 결과와 함께 적는다**
한 번 재고 표로 적으면 안 되는 종류가 있다는 것을 이 세 결과가 보였고, 그 규칙을 편 기록이다.
## 문제
persistent-user-sessions 를 끄고 A층을 다시 돌렸더니 세 결과가 뒤집혔고, 그 실험이 표 하나를 남겼다. 표에는 데이터베이스를 세운 동안 로그인은 200 이고 refresh 는 500 이라고 적혀 있었다.
500 의 원인은 확정하지 못했고 REVOKED_TOKEN 테이블일 것이라는 가설만 붙어 있었다. 세션을 메모리에 두면 데이터베이스를 안 볼 텐데 왜 refresh 만 실패하는지가 설명되지 않았기 때문이다.
가설이 맞는지 재지 않은 채로 두면 「volatile 이면 이렇다」는 표가 조건 없이 유통된다.
## 결론
원인은 REVOKED_TOKEN 이 아니라 선택적 클라이언트 스코프 조회였다.
로그인이 쏘는 SQL : 0건
refresh 가 쏘는 SQL : CLIENT_SCOPE_CLIENT 한 문장
그 조회의 조건 : DEFAULT_SCOPE = 'f'
REVOKED_TOKEN 이 나온 횟수 : 0
그 조회가 일어나는 때 : 첫 refresh 한 번. 이후로는 캐시에서 답한다
캐시가 그 조회를 삼키는 순간 결과가 바뀌므로, 같은 설정에서 캐시 온도만으로 답이 셋으로 갈린다. 완전 냉시동이면 로그인부터 400 이고, CLIENT 만 더우면 로그인 200 에 refresh 500 이며, 완전히 더우면 둘 다 200 이다.
그래서 원래 표가 적은 「volatile 이면 데이터베이스 없이 로그인된다」도 조건부였다. 냉시동에서는 클라이언트 조회조차 캐시에 없어 로그인이 400 이 된다.
## 검증 환경
Keycloak : 26.7.0 · 2노드
기능 플래그 : --features-disabled=persistent-user-sessions
세션 위치 : 메모리. offline_user_session 행 수 0 으로 확인
데이터베이스 : PostgreSQL
SQL 관측 : PostgreSQL 문장 로깅
수집 기록 : 2026-09-04 11:1811:24 UTC. PostgreSQL 컨테이너가 UTC 로 로그를 찍는다
구간 표시 : 로그인과 refresh 앞뒤에 표식 SELECT 를 넣어 어느 SQL 이 어느 요청 것인지 가른다
## 재현 조건
1. Keycloak 을 --features-disabled=persistent-user-sessions 로 띄운다.
2. 로그인한 뒤 offline_user_session 행 수가 0 인지 확인한다. 0 이어야 세션이 메모리에 있는 상태다.
3. PostgreSQL 문장 로깅을 켠다.
4. 로그인과 refresh 를 각각 표식 SELECT 로 감싸 그 사이에 나오는 SQL 을 가른다.
5. 냉시동을 만든다. Keycloak 을 재시작하고 PostgreSQL 을 내린 다음 로그인한다.
6. 중간 상태를 만든다. Keycloak 을 재시작하고 데이터베이스가 살아 있을 때 로그인을 한 번 한 다음 PostgreSQL 을 내리고 refresh 한다.
7. 완전히 더운 상태를 만든다. refresh 를 세 번 미리 돌려 캐시를 채운 다음 PostgreSQL 을 내리고 로그인과 refresh 를 각각 보낸다.
8. 세 경우의 상태 코드와, 실패한 경우 로그가 지목한 SQL 문장을 함께 적는다.
## 본문
<!-- body:start -->
## 가설은 REVOKED_TOKEN 이었다
`--features-disabled=persistent-user-sessions` 는 세션을 데이터베이스에 쓰지 않고 메모리에 두게 하는 설정이다. 이 상태로 A층을 다시 돌리자 세 실험의 결과가 뒤집혔고, 그중 하나가 데이터베이스를 세운 동안 로그인은 `200` 인데 refresh 는 `500` 이 되는 것이었다.
세션이 메모리에 있으면 refresh 도 데이터베이스를 볼 이유가 없다. 그 실험은 「측정은 확실하지만 원인은 확정하지 못했다」고 적고 유력한 후보로 `REVOKED_TOKEN` 테이블을 남겼다. refresh token 회전은 이미 쓴 토큰이 다시 왔는지 확인해야 하고, 그 확인은 발급 이력을 담은 테이블을 읽어야 하니 그 경로가 캐시를 못 쓸 것이라는 추측이었다.
## 문장 로깅으로 실제 SQL 을 잡았다
PostgreSQL 의 `log_statement``all` 로 올려 오가는 문장을 전부 남기고, 로그인과 refresh 앞뒤에 표식 SELECT 를 하나씩 넣어 어느 문장이 어느 요청 것인지 갈랐다.
로그인은 SQL 을 0개 쏜다. realm 과 사용자, 클라이언트가 전부 Infinispan 캐시에 있어서 데이터베이스를 보지 않는다. refresh 는 딱 한 문장을 쏜다. 그 문장이 이것이다.
```text label="refresh 가 쏜 단 한 문장"
select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
where cscme1_0.CLIENT_ID=$1 and cscme1_0.DEFAULT_SCOPE=$2
parameters: $1 = '131a9912-b578-4b9c-b16a-97518704077e', $2 = 'f'
```
`REVOKED_TOKEN` 은 한 번도 나오지 않았다. `DEFAULT_SCOPE='f'` 이므로 이것은 선택적 클라이언트 스코프 조회다. 클라이언트 스코프는 그 클라이언트에게 발급할 토큰에 어떤 권한 범위를 담을지를 묶어 둔 설정이고, 기본 스코프는 항상 들어가지만 선택적 스코프는 요청이 달라고 해야 들어간다. refresh 는 새 access token 에 무엇을 담을지 다시 계산하므로 그 목록을 읽어야 하고, 목록이 이 테이블에 있다.
## 그 조회는 첫 refresh 한 번뿐이다
refresh 를 연속 세 번 돌리고 표식 사이의 SQL 을 다시 셌더니 0건이었다. 첫 refresh 가 캐시를 채우고 그다음부터는 데이터베이스를 보지 않는다.
그러면 데이터베이스를 세웠을 때 무엇이 실패하는지는 그 순간 캐시가 무엇을 이미 갖고 있느냐로 정해진다. 셋을 각각 만들어 재 봤다.
| 캐시 상태 | 로그인 · refresh 가 받는 것 | 어느 SQL 이 실패했나 |
|---|---|---|
| 완전 냉시동 | `400` · `400` | `select ce1_0.ID from CLIENT ...` |
| CLIENT 만 더움 | `200` · `500` | `CLIENT_SCOPE_CLIENT ...` |
| 완전히 더움 | `200` · `200` | 없음 (SQL 0건) |
![냉시동에서는 클라이언트 조회가, 반쯤 더운 상태에서는 스코프 조회가 데이터베이스에 닿아 실패하고, 완전히 더운 상태에서는 어느 쪽도 닿지 않는 구성.](../../../final/assets/cache-temperature-outcomes/cache-temperature-outcomes.svg)
세 결과를 만드는 것은 조회 두 개다. 요청이 먼저 클라이언트를 확인하고, 그다음 스코프를 다시 계산한다. 캐시가 둘 다 못 삼킨 상태면 앞의 조회에서 400 이 나고, 앞은 삼켰는데 뒤는 못 삼킨 상태면 500 이 나며, 둘 다 삼킨 뒤에는 데이터베이스에 닿는 조회 자체가 없다.
## 원래 표가 본 것은 그 사이의 한 상태였다
앞의 실험은 Keycloak 을 재시작하고 로그인을 한 번 한 다음 데이터베이스를 내렸다. 로그인이 CLIENT 캐시를 채웠고 refresh 는 한 번도 돌지 않아 스코프 캐시는 비어 있었으므로, 표에 적힌 「로그인 200, refresh 500」은 그 중간 상태에서 나온 값이었다.
같은 표의 다른 줄도 마찬가지다. 「volatile 이면 데이터베이스 없이 로그인된다」는 냉시동에서 성립하지 않는다. 재시작 직후에는 클라이언트 조회조차 캐시에 없어서 로그인이 `400` 으로 떨어지고, 로그는 `select ce1_0.ID from CLIENT` 가 실패했다고 지목한다.
세션을 메모리에 두는 구성에서 데이터베이스가 멈췄을 때의 동작은 무엇을 하느냐가 아니라 그 경로가 이미 캐시를 채웠느냐로 결정된다. 그래서 같은 명령이 재시작 직후와 얼마 쓴 뒤에 다른 답을 낸다. 이런 종류의 결과는 한 번 재보고 표로 적으면 안 되는데, 앞 실험이 그렇게 했다.
## 확인하지 않은 것
캐시가 식는 시간을 재지 않았다. 냉시동과 중간, 완전히 더운 세 상태를 만들어 확인했을 뿐 그 사이의 전이는 관측하지 않았다.
세 상태는 재시작과 요청 횟수로 만든 것이라, 운영에서 얼마나 오래 쓰지 않으면 어느 상태로 돌아가는지는 이 측정으로 답할 수 없다.
<!-- body:end -->