Files
keycloak-pattern/docs/experiment-a7-volatile-comparison.md
DongHyeonkaandClaude Opus 5 b9f4ef7bc2 docs(a7a): the volatile refresh failure is a client-scope lookup, not REVOKED_TOKEN
A-7 left the cause as a hypothesis and said to confirm it with the statement
logging A-3 used. Doing that shows the hypothesis was wrong.

Login issues no SQL at all in volatile mode. Refresh issues exactly one
statement, and it is not the one A-7 guessed:

  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-...', $2 = 'f'

REVOKED_TOKEN never appears. DEFAULT_SCOPE='f' means this is the optional
client scope list, which refresh needs because it recomputes which scopes go
into the new access token.

The larger finding is that A-7's table was conditional and did not say so.
The single statement is issued once and then cached, so the same
configuration produces three different outcomes depending only on cache
warmth — all three reproduced:

  fully cold      login 400  (select ce1_0.ID from CLIENT ...)
  CLIENT warm     login 200, refresh 500  <- what A-7 measured
  fully warm      login 200, refresh 200

So "volatile means you can log in without a database" is also conditional:
on a cold start the client lookup itself fails. In each case the Keycloak
log names the failing SQL directly, so this is determined rather than
inferred.

A-7 keeps its original wording with a correction banner above it, so what
was mis-guessed and why stays visible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 20:27:23 +09:00

11 KiB
Raw Permalink Blame History

A-7 — 옛 방식(volatile)이었다면 무엇이 달라지는가

브랜치 feature/keycloak-a7-volatile-comparison · 증거 docs/evidence/a7-volatile-comparison/ · 2026-09-04 13:4514:15 KST

A층의 결론 전체가 "Keycloak 26 기본값"이라는 전제 위에 있다. 전제를 뒤집어 같은 실험을 반복한 것이 이 실험이다.


구조

A-7 구조 — 두 모드의 데이터 흐름과 뒤집힌 결과

다이어그램 규약은 diagrams/_style.md.


0. 결론부터 — 비교표

실험 persistent (KC 26 기본) volatile (KC 24 이전 방식)
A-0 세션 저장 위치 DB 에 행이 생긴다 DB 0건. 메모리에만
A-1 7800 차단 후 교차 노드 refresh 200 — 안 깨진다 400 Session not active — 깨진다
A-8 롤링 재시작 후 refresh 200 — 세션 생존 400 Session not active — 전원 로그아웃
A-2 DB 정지 중 새 로그인 500 200 — 된다
A-2 DB 정지 중 refresh 500 500

세 개가 정반대로 뒤집혔다. 예측한 그대로다.

★ 이 표에는 조건이 빠져 있다 (A-7a). volatile 행의 A-2 결과는 캐시 온도에 따라 달라진다. 완전 냉시동이면 로그인도 400 이고, 캐시가 완전히 더우면 refresh 도 200 이다. 여기 적힌 값은 그 사이의 한 상태다 — 셋 다 A-7a 에서 재현했다.

"세션 공유는 7800 을 안 탄다"는 A-1 의 결론은 버전에 달린 사실이다. 인터넷 자료 대부분이 24 이전 기준이므로 거기서는 통념이 맞다. 틀린 것은 자료가 아니라 버전을 확인하지 않고 적용하는 것이다.


1. 전환 방법

# 이 버전에서 끌 수 있는지부터 확인한다
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kc.sh build --help-all \
  | tr ',' '\n' | grep -i persistent
#   persistent-user-sessions[:v1]      ← 목록에 있다
# deploy/lab/k8s/keycloak-cluster.yaml
args: ["start", "--features-disabled=persistent-user-sessions"]
kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s

빌드 옵션이므로 기동 시 재빌드가 일어나 평소보다 오래 걸린다.


2. A-0 재실행 — 세션이 DB 에 안 들어간다

=== keycloak-0 에만 로그인 5회 ===
  keycloak-0  sessions 캐시 5.0 건
  keycloak-1  sessions 캐시 0.0 건

=== DB 에는 들어갔는가 (persistent 였을 때는 5건) ===
 offline_flag | count
--------------+-------
(0 rows)                    ← 0 건

=== 교차 노드 세션은 되는가 ===
  keycloak-0 로그인 → keycloak-1 에서 refresh  HTTP 200

DB 는 비어 있는데 교차 노드가 된다. persistent 때와 겉보기 결과가 같지만 경로가 완전히 다르다 — 이제는 DB 가 아니라 클러스터를 타고 있다.

캐시 엔트리 수가 5 / 0 인 것은 persistent 때와 같다. approximate_entries_unique그 노드가 소유한 엔트리만 세므로, 백업본을 들고 있어도 0 으로 보인다. 이 지표만으로는 두 모드를 구분할 수 없다 — 구분하려면 7800 을 끊어봐야 한다. 그게 다음 절이다.


3. A-1 재실행 — 정반대다

A-5 에서 확립한 raw 테이블 양방향 차단을 그대로 썼다.

  [대조군] 차단 전 교차 노드 refresh   HTTP 200

  차단 적용 → 분단 성립
    +25초  cluster_size(k0 k1) = [2.0 2.0]
    +50초  cluster_size(k0 k1) = [1.0 ...]     ← 갈라졌다

=== 분단 상태에서 ===
  keycloak-0 로그인 → keycloak-0 에서 refresh  HTTP 200  ← 대조군
  keycloak-0 로그인 → keycloak-1 에서 refresh  HTTP 400  ← 시험군
  {"error":"invalid_grant","error_description":"Session not active"}

대조군이 200 인 것이 중요하다. 차단이 모든 것을 망가뜨린 게 아니라 교차 노드만 끊었다는 증거다.

   persistent :  세션 ── PostgreSQL ──▶ 양쪽이 본다      7800 무관
   volatile   :  세션 ── 클러스터(7800) ─▶ 상대에게 간다   7800 필수

같은 주입, 같은 관측, 정반대 결과. 이 한 쌍이 A층 전체의 근거다.


4. A-8 재실행 — 배포가 곧 로그아웃

=== 재시작 전 로그인 ===
  sid = aVwYnzKZFFvMqD3bpSeiILuM

=== 롤링 재시작 ===
partitioned roll out complete: 2 new pods have been updated...

=== ★ 재시작 전 토큰이 아직 통하는가 (persistent 였을 때는 200) ===
  keycloak-0 에서 refresh  HTTP 400
  {"error":"invalid_grant","error_description":"Session not active"}

배포할 때마다 전원 로그아웃된다.

persistent volatile
배포 자유롭다 모든 사용자가 다시 로그인
파드 재시작 (OOM, 노드 교체) 무해 그 노드가 처리하던 세션 소멸
무중단 여부 무중단 (A-8) 접속은 되지만 로그인 상태가 사라진다

A-8 에서 "이것이 persistent 를 켜는 진짜 이유"라고 썼는데, 여기서 증명된다. 24 이전 버전을 쓰는 곳에서 "배포하면 로그아웃된다"가 당연하게 여겨졌던 이유다.


5. A-2 재실행 — 예상 못 한 비대칭

  ① 캐시를 가진 노드에서 refresh   HTTP 500
  ② 새 로그인                      HTTP 200      ← persistent 에서는 500 이었다

새 로그인은 되는데 refresh 가 안 된다. 순서가 거꾸로다.

왜 새 로그인이 되는가

   로그인에 필요한 것
     ├─ realm 설정   → Infinispan `realms` 캐시에 있다
     ├─ 사용자 자격  → `users` 캐시에 있다
     └─ 세션 저장    → volatile 이므로 메모리
   → DB 없이 완결된다

A-2 에서 persistent 로 했을 때 로그인이 실패한 이유는 "세션을 DB 에 써야 해서"였다. 그 쓰기가 없어지니 로그인이 통과한다.

refresh 가 500 인 이유 — 가설

★ 정정 (A-7a) — 이 가설은 틀렸다. 문장 로깅으로 확정한 결과 원인은 REVOKED_TOKEN 이 아니라 CLIENT_SCOPE_CLIENT 조회였다. 더 중요하게는 위 표 자체가 캐시 온도에 따라 400/500/200 으로 갈린다. A-7a 문서 참조.

아래는 정정 전 원문이다 — 무엇을 어떻게 잘못 짚었는지 남긴다.

측정은 확실하지만 원인은 확정하지 못했다. 유력한 후보는 REVOKED_TOKEN 테이블이다 — refresh token 회전에서 이미 쓴 토큰인지 확인하려면 그 테이블을 봐야 하고, 그 경로는 캐시되지 않는다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c "\dt" | grep revoked
 public | revoked_token | table | keycloak

확정하려면 A-3 에서 쓴 문장 로깅을 켜고 다시 재현해야 한다. 여기서는 가설로 남긴다.A-7a 에서 그대로 실행해 확정했고, 가설은 틀렸다.

volatile 이 "DB 없이 돌아간다"는 뜻은 아니다. realm·사용자·클라이언트·취소 토큰은 여전히 DB 에 있다. 세션만 메모리로 옮긴 것이다.


6. 원복

# args 를 되돌린다
args: ["start"]
kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml
=== [검증] persistent 로 돌아왔는가 ===
["start"]
  DB 온라인 세션: 1 건  (로그인 1회 후 → persistent 복귀 확인)
  외부 진입점 HTTP 200

전환 자체는 설정 한 줄이고 되돌리기도 한 줄이다. 다만 전환 시점에 기존 세션은 전부 사라진다 (저장 위치가 바뀌므로).


7. 개념

두 모드의 데이터 흐름

   persistent (KC 25+, 26 기본)
      로그인 ──▶ PostgreSQL (진실)  +  로컬 캐시 (사본)
      조회   ──▶ 캐시에 없으면 DB
      공유   ──▶ 같은 DB 를 보는 것

   volatile (KC 24 이전)
      로그인 ──▶ Infinispan (진실)
      조회   ──▶ 클러스터에서 찾는다
      공유   ──▶ 7800 을 통한 복제

무엇을 맞바꾸는가

persistent volatile
재시작 내구성 있다 없다
7800 의존 낮다 (무효화만) 높다 (세션 자체)
DB 부하 로그인·refresh 마다 쓰기 세션 관련 없음
노드 확장 DB 가 병목 복제 트래픽이 N² 로 증가
지연 민감도 DB 왕복에 민감 (A-6) 클러스터 왕복에 민감

26 이 기본을 바꾼 이유가 이 표에 있다 — 운영에서 가장 아픈 것이 "배포하면 로그아웃"이었기 때문이다.



증거 파일

증거 수집 시각: 2026-09-04 13:22 13:32 KST (파일 mtime 기준. 문서 상단의 시각 표기는 작성 시점이라 다를 수 있다.)

파일 종류
01-switch-to-volatile.txt 터미널 원문
02-a0-rerun.txt 터미널 원문
03-a8-rerun-restart.txt 터미널 원문
04-a1-rerun-partition.txt 터미널 원문
05-a2-rerun-db-loss.txt 터미널 원문
06-restore-persistent.txt 터미널 원문

파일별 상세는 evidence/a7-volatile-comparison/README.md.

8. 재현 절차 (명령어)

# 1. 끌 수 있는지 확인
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kc.sh build --help-all \
  | tr ',' '\n' | grep -i persistent

# 2. 세션을 비우고 전환 (비교 기준을 맞추기 위해)
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "delete from offline_user_session"
#   args: ["start", "--features-disabled=persistent-user-sessions"]
kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s

# 3. volatile 확인 — 로그인 후 DB 가 비어 있어야 한다
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select offline_flag, count(*) from offline_user_session group by offline_flag"

# 4. A-1 / A-8 을 그대로 반복한다 (증거 파일 참조)

# 5. 원복
#   args: ["start"]
kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml

9. 이 실험이 A층에 남기는 것

A-0~A-6 의 결론은 모두 "26 기본값" 조건부다 버전이 다르면 답이 다르다
A-1 이 통념과 어긋난 이유가 확정됐다 통념은 24 이전에서 맞다
버전 확인이 1순위 kc.sh --version 을 먼저 본다
volatile 이 DB 독립을 뜻하지 않는다 realm·사용자·취소 토큰은 여전히 DB

미해결로 남긴 것

volatile 에서 refresh 만 500 이 되는 이유REVOKED_TOKEN 조회 가설을 세웠지만 확정하지 못했다. A-3 의 문장 로깅 기법으로 재현하면 답이 나온다.