Files
document-haness/docs/keycloak-session-store/tech-log-studio/session-custody-across-nodes/setup/setup-reproduce-a7a-volatile-cause.md
T
DongHyeonkaandClaude Opus 5 024362d096 fix(setup): 실험대를 새로 세워 setup 35편을 밟고 어긋난 명령과 결과를 고친다
기반 가이드 7단계로 실험대를 철거하고 다시 세운 뒤 virtualization setup 9편과
keycloak-session-store 26편을 순서대로 밟았다. 24편은 끝까지, 11편은 되는 데까지
밟았고 밟은 범위를 편마다 적었다.

명령이 못 도는 것을 고쳤다.

- kubectl 을 `kc-lab-1` 에서 치라고 적었는데 그 기계에 kubeconfig 가 없다.
  라벨 639개와 각 편의 「어디서 치는가」를 `[lab host]` 로 옮겼다
- `-o custom-columns=…[0]…` 이 zsh 에서 글로브로 읽혀 안 돈다. 28곳에 따옴표
- busybox `sed` 가 끝 개행을 안 붙여 A-3 의 측정이 언제나 0 이었다
- `--token-file ~/node-token` 뒤에 그 파일을 지우면 k3s agent 가 재부팅을
  못 견딘다. `/etc/rancher/node-token` 으로 옮기는 처방을 재서 넣었다
- 게스트에 없는 도구를 전제로 한 명령 넷 — `conntrack`·`dig`·`strings`·`nginx -v`
- `echo` 와 JWT 헤더가 `"이름" : [ 값 ]` 으로 찍는데 문서는 공백 없이 옮겨 적어
  그 실측으로 만든 grep·sed 가 한 줄도 못 잡는다
- B-0 이 `directAccessGrantsEnabled` 와 계정 완성을 빠뜨려 B-3 이 못 돈다
- D-4·D-4a 가 `test-server` 와 `certbot-renew.*` 를 가리키는데 실제로는
  `kc-lab-edge` 의 `certbot.service` 다
- `virsh setmaxmem --config` 를 `dominfo` 로 판정하면 틀린다. `--inactive` 로
- `LIBVIRT_DEFAULT_URI` 를 rc 에만 넣으면 `ssh host '명령'` 에서 안 먹는다

결과가 조건부인 것을 갈랐다.

- readiness 는 즉시 안 뒤집힌다. A-1·A-2 의 60초 창을 적었다
- 03 의 층 ②③ `301` 은 04 이후의 값이고 그 단계에서는 `404` 다
- A-0 의 로그 필터를 요청 직후에 치면 정반대 결론이 나온다
- A-5 의 한 방향 차단은 잠깐 `1` 이었다 `2` 로 돌아온다

증거는 두 프로젝트의 `evidence/raw/` 에 99벌을 README 와 함께 남겼다. 비밀은
길이만 적었고 화면에 찍힌 토큰은 가렸다.

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

46 KiB
Raw Blame History

id, kind, slug, title, topic, topicName, project, status, studio, pinnedVersions, source, sourceRevision
id kind slug title topic topicName project status studio pinnedVersions source sourceRevision
21dce25a-a165-47dc-bb40-2ed9f6f9efea SETUP reproduce-a7a-volatile-cause 문장 로깅으로 그 500 을 낸 SQL 을 확정하고 캐시 온도 셋을 재현한다 session-custody-across-nodes Keycloak 두 노드가 같은 세션을 읽는 경로 keycloak-session-store 게시 전 https://hyeonworks.com/studio/documents/21dce25a-a165-47dc-bb40-2ed9f6f9efea/edit
name version
Keycloak 26.7.0
name version
curlimages/curl 8.11.1
name version
persistent-user-sessions v1
final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-7a
cdac9b8178391311d8eca1ebc6cac15bb62d79af

문장 로깅으로 그 500 을 낸 SQL 을 확정하고 캐시 온도 셋을 재현한다

PostgreSQL 문장 로깅을 켜고 volatile 상태의 로그인과 refresh 가 각각 SQL 을 몇 개 쏘는지 화면에서 직접 보는 절차다. 이어서 재현 셋을 rollout restart 로 갈라 치면 같은 설정에서 400500200 이 차례로 나온다. 약 40분.

관계

  • 같은 설정이 캐시 온도만으로 세 가지 답을 냈다 이 절차가 재현 A·B·C 로 갈라 잰 것을 그 기록이 결론으로 적는다.
  • persistent-user-sessions 가 세션의 거처를 정한다 여기서 끄는 그 기능이 무엇을 바꾸는지는 그 기록이 설명한다.
  • persistent-user-sessions 를 끄고 A층 결론 넷을 다시 잰다 이 절차가 확정하는 500 이 거기서 나왔다. 그쪽은 주입이 args 와 iptables 와 DB 정지이고 계기가 교차 노드 응답 코드이며, 여기는 주입이 문장 로깅과 args 와 DB 정지이고 계기가 표식과 문장 로그다.
  • 예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다 주입이 셋이라 검증도 셋이고, 표식이 로그에 들어갔는지를 확인하지 않으면 뒤의 구간 자르기가 통째로 헛돈다.

본문

읽기 전에 — 어디서 치는가

명령은 전부 [lab host] 에서 kubectl 로 친다. 이 절차에는 노드 자체를 건드리는 명령이 없어서 게스트에 들어갈 일이 없다. kubectlsudo 를 붙이지 않는다 — root 홈에는 ~/.kube/config 가 없어서 localhost:8080 으로 붙으려다 connection refused 로 끝난다.

원 가이드는 이 명령들을 kc-lab-1 에서 치라고 적었다. 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 sudo 없는 kubectlpermission denied 로 막힌다 — kubeconfig 는 lab host 의 ~/.kube/config 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 kubectl 블록의 기계 이름을 [lab host] 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다.

이 편의 시각은 UTC 다. 증거의 11:18:49 는 KST 로 20:18 이고 같은 순간이다. PostgreSQL 컨테이너가 UTC 로 로그를 찍기 때문이고, 로그 시각과 date 를 견줄 때 이걸 잊으면 9시간을 헤맨다.

무엇
네임스페이스 keycloak-lab
대상 StatefulSet keycloak 파드 둘 · Deployment postgres 하나
탐침 파드 a7a-probecurlimages/curl:8.11.1, sleep 7200, --restart=Never
켜는 것 log_statement = 'all' · 반드시 pg_reload_conf() 까지
끄는 기능 --features-disabled=persistent-user-sessions
표식 MARK_TEST · MARK_LOGIN_START · MARK_LOGIN_END · MARK_REFRESH_START · MARK_REFRESH_END · MARK_R1~MARK_R_END
소음 JGROUPS_PING 폴링이 5초마다 로그를 채운다

터미널은 둘을 연다. 하나는 표식과 요청용, 하나는 로그 관찰용이다.

이 실험이 가르는 것

A-7 은 이렇게 끝났다.

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

그럴듯하고, 틀렸다.

   가설을 세우는 것       →  괜찮다
   가설을 표에 적는 것    →  다음 사람이 사실로 읽는다
   확정하는 방법이 있는데 안 하는 것  →  이 실험이 고치는 것

「refresh 가 어느 테이블 때문에 실패하는가」는 Keycloak 소스를 읽지 않고도 답할 수 있다. DB 가 실제로 받은 문장을 보면 된다. 확정해 보니 원인만 틀린 게 아니었다 — 같은 설정에서 캐시 온도만으로 답이 셋으로 갈린다.

이 절차를 끝까지 치면 여섯을 손으로 보게 된다. 로그인이 SQL 을 0개 쏘는 것, refresh 가 쏘는 딱 한 문장의 이름이 CLIENT_SCOPE_CLIENT 인 것, 그 문장이 첫 refresh 에만 나오는 것, REVOKED_TOKEN 이 한 번도 안 나오는 것, 같은 설정에서 400500200 이 전부 나오는 것, 그리고 실패한 SQL 을 Keycloak 로그가 직접 지목하는 것.

전제와 되돌리기

앞선 구축 단계 05-keycloak 이 끝나 있어야 한다. A-7 을 먼저 한다 — 이 절차는 A-7 이 남긴 가설을 확정하는 것이고, 거기서 본 500 에서 출발한다. A-3 에서 문장 로깅을 해 봤으면 같은 기법이다.

주입이 셋이고 복구도 셋이다.

  • PostgreSQL 문장 로깅을 켠다 → 끄지 않으면 다음 실험의 로그가 폭주한다
  • Keycloak 을 volatile 로 바꾼다 → 되돌리지 않으면 A층 결론이 오염된다
  • PostgreSQL 을 여러 번 내렸다 올린다 → 마지막에 올라와 있어야 한다

실험대에서만 한다. 중간에 그만두려면 복구 절을 위에서부터 그대로 친다.

표식을 넣는 방식에서 이 절차가 원 실행과 갈라진다. 원 실행은 표식을 셸 함수로 감쌌다.

m() { kubectl -n keycloak-lab exec deploy/postgres -- \
        psql -U keycloak -d keycloak -tAc "select 'MARK_$1'" >/dev/null; }

짧고 편한데 출력을 /dev/null 로 버린다. 표식이 실제로 로그에 들어갔는지 확인하지 않고 다음 명령으로 넘어간다는 뜻이고, 로깅이 안 켜져 있었다면 표식 없는 로그를 한참 뒤에 awk 로 자르다가 알게 된다.

따라 하는 사람은 표식을 한 줄씩 손으로 넣는다. 느리지만 그 즉시 보이고, 안 보이면 그 즉시 안다. 아래 절차가 전부 그 형태다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -tAc "select 'MARK_TEST'"

주입 전에 같은 명령으로 먼저 본다

파드 → 문장 로깅이 꺼져 있나 → args → 탐침 파드 → 로그가 지금 무엇으로 차 있나

1. 파드 셋이 전부 떠 있는지 본다

목적 — 이 절차가 내렸다 올릴 postgres 가 지금 있는지 확인한다.

kubectl -n keycloak-lab get pods -o wide

예상 결과 — 모양은 이렇고 값은 환경마다 다르다.

NAME                        READY  STATUS   RESTARTS  AGE  IP           NODE
keycloak-0                  1/1    Running  0         2d   10.42.1.94   kc-lab-2
keycloak-1                  1/1    Running  0         2d   10.42.0.45   kc-lab-1
postgres-7b474b88c8-t6rrf   1/1    Running  0         5d   10.42.0.22   kc-lab-1

왜 필요한가 — 셋 다 Running 이어야 하고 postgres 가 특히 그렇다. 이 절차는 그것을 세 번 내렸다 올린다.

문제가 생기면postgres 가 없으면 scale deployment/postgres --replicas=1 부터 친다.

2. 문장 로깅이 지금 꺼져 있는지 본다

목적 — 지금 쌓이는 로그가 이 실험 것인지 앞 실험 것인지 가른다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "show log_statement"

예상 결과

 log_statement
---------------
 none

왜 필요한가all 이면 앞 실험이 켜 둔 채 끝낸 것이고, 지금 쌓인 로그가 어느 실험 것인지 구별할 수 없다.

문제가 생기면all 이 나오면 먼저 끄고 로그가 한 바퀴 돌 때까지 기다린 뒤에 시작한다.

3. 지금 args 를 적어 둔다

목적 — 복구에서 되돌릴 문자열을 확보한다.

kubectl -n keycloak-lab get statefulset keycloak \
  -o jsonpath='{.spec.template.spec.containers[0].args}' ; echo

예상 결과

["start"]

왜 필요한가 — 복구 단계가 이 값 그대로 되돌린다.

문제가 생기면 — 이미 --features-disabled=persistent-user-sessions 가 붙어 있으면 앞 실험이 원복하지 않고 끝냈다. 그것부터 되돌린다.

4. 탐침 파드를 StatefulSet 밖에 띄운다

목적 — Keycloak 을 여러 번 재시작해도 죽지 않는 요청 장치를 세운다.

Keycloak 컨테이너에는 curlwget 도 없어서 kubectl exec keycloak-0 -- curlexit 127 로 끝난다.

K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
  --restart=Never \
  --env="K0=$K0" \
  --env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
              -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
  --command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s

비밀번호는 명령 치환으로 넘어가므로 터미널에도 셸 히스토리에도 값이 남지 않는다. 길이만 본다.

kubectl -n keycloak-lab get secret keycloak-lab-secrets \
  -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
19
kubectl -n keycloak-lab exec a7a-probe -- sh -c 'echo "K0=$K0 PW길이=${#PW}"'

예상 결과 — IP 가 보이고 PW길이 가 0 이 아니다.

왜 필요한가 — 명령줄에 평문 비밀번호를 쓰면 파드 안 ps 에도 셸 히스토리에도 남는다. 원래 실험의 재현 절차에 그 형태가 그대로 적혀 있었다.

문제가 생기면PW길이=0 이면 --env 가 빈 값을 받았다. 파드를 지우고 다시 띄운다.

5. 로그가 지금 무엇으로 차 있는지 본다

목적 — 켜기 전의 로그를 한 번 봐 두고, 켠 뒤의 소음과 견준다.

kubectl -n keycloak-lab logs deploy/postgres --tail=20

예상 결과 — 조용하다. 여기까지는 에러만 찍힌다.

왜 필요한가 — 다음 절에서 로깅을 켜면 JGroups 가 5초마다 하는 JGROUPS_PING 폴링이 로그를 계속 채운다. 그 소음을 먼저 봐 두면 나중에 grep -v JGROUPS_PING 으로 거르는 까닭을 안다.

문제가 생기면 — 지금 SQL 이 줄줄이 나오면 로깅이 이미 켜져 있다. 2번으로 돌아간다.

주입

주입 셋을 차례로 넣는다. 셋 다 되돌리는 명령을 먼저 읽어 둔다.

6. PostgreSQL 문장 로깅을 켠다

목적 — 서버가 받은 모든 SQL 을 로그에 찍게 한다.

되돌리는 명령을 먼저 읽어 둔다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "alter system reset log_statement" -c "select pg_reload_conf()"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "alter system set log_statement='all'" -c "select pg_reload_conf()"

예상 결과ALTER SYSTEMpg_reload_conf 가 차례로 돌고, 곧 로그가 차기 시작한다.

왜 필요한가 — 애플리케이션을 고치지 않고 「이 요청이 DB 를 어떻게 쓰는지」를 밖에서 볼 수 있다. 이것 없이 하면 정확히 A-7 이 겪은 일이 벌어진다 — 그럴듯한 테이블 이름을 골라 가설로 적게 되고, 그게 틀려도 아무도 모른다.

문제가 생기면 — 로그가 안 차면 pg_reload_conf() 가 안 돌았다. alter systempostgresql.auto.conf 에 쓸 뿐이고 reload 를 해야 적용된다.

7. Keycloak 을 volatile 로 바꾼다

목적 — 세션을 메모리로 옮겨 A-7 이 본 조건을 만든다.

되돌리는 명령을 먼저 읽어 둔다.

kubectl -n keycloak-lab patch statefulset keycloak --type=json \
  -p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
kubectl -n keycloak-lab patch statefulset keycloak --type=json \
  -p '[{"op":"replace","path":"/spec/template/spec/containers/0/args",
        "value":["start","--features-disabled=persistent-user-sessions"]}]'
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s

파드 IP 가 바뀌었으므로 탐침 파드를 다시 띄운다.

kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
  --restart=Never --env="K0=$K0" \
  --env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
              -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
  --command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s

예상 결과 — 롤아웃이 partitioned roll out complete 로 끝나고 탐침이 Ready 가 된다.

왜 필요한가 — 빌드 옵션이라 기동할 때 재빌드가 일어나 오래 걸린다. 그래서 --timeout=500s 를 준다.

문제가 생기면 — 타임아웃이 나면 같은 명령을 다시 치고 logs keycloak-0 에 빌드 진행이 보이는지 본다.

8. PostgreSQL 을 내린다

목적 — DB 가 없는 구간을 만들어 캐시가 무엇을 대신하는지 본다.

kubectl -n keycloak-lab scale deployment/postgres --replicas=0
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s

예상 결과postgres 파드가 목록에서 사라진다.

왜 필요한가 — 세 재현마다 한 번씩, 모두 세 번 내린다. 각 재현에서 내리는 시점이 다르고 그 시점이 곧 캐시 온도를 정한다.

문제가 생기면 — 파드가 안 사라지면 --timeout 을 늘려서 다시 기다린다. delete pod 은 쓰지 않는다 — Deployment 가 곧바로 새로 만든다.

주입 검증

주입이 셋이라 검증도 셋이다. 로깅이 켜졌는지, 표식이 로그에 들어가는지, volatile 전환이 동작으로도 바뀌었는지를 따로 본다.

9. 로깅이 실제로 켜졌고 로그가 차기 시작했는지 본다

목적 — 설정값과 실제 출력을 둘 다 확인한다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "show log_statement"
 log_statement
---------------
 all
kubectl -n keycloak-lab logs deploy/postgres --tail=10

예상 결과

2026-09-04 11:17:40.112 UTC [214] LOG:  execute <unnamed>: select ... from JGROUPS_PING ...

왜 필요한가JGROUPS_PING 이 계속 나오는 것이 앞에서 예고한 소음이고, 이게 안 보이면 로깅이 안 켜졌다.

문제가 생기면none 이 나오면 pg_reload_conf() 를 다시 친다.

10. 표식이 로그에 들어가는지 본다

목적 — 구간을 자를 수 있는 상태인지 확인한다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -tAc "select 'MARK_TEST'"
kubectl -n keycloak-lab logs deploy/postgres --tail=5 | grep MARK_TEST

예상 결과

2026-09-04 11:18:40.102 UTC [301] LOG:  statement: select 'MARK_TEST'

왜 필요한가statement: select 'MARK_TEST' 가 보이면 이제 표식과 표식 사이만 잘라 볼 수 있다.

문제가 생기면 — 안 보이면 9번의 로깅 확인으로 돌아간다.

11. volatile 전환을 args 와 동작으로 둘 다 본다

목적 — 선언과 동작이 같이 바뀌었는지 확인한다.

kubectl -n keycloak-lab get statefulset keycloak \
  -o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
kubectl -n keycloak-lab exec a7a-probe -- sh -c \
  'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
     "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=password -d client_id=admin-cli \
     -d username=admin -d "password=$PW"'
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -tAc "select count(*) from offline_user_session where offline_flag='0'"

예상 결과

volatile 전환 확인
  args: ["start","--features-disabled=persistent-user-sessions"]
  로그인 200 · offline_user_session 행수 = 0        ← volatile 맞다

왜 필요한가 — 세 가지가 다 맞아야 한다. 로그인이 200 인데 행이 안 생기는 것으로 판정한다.

문제가 생기면 — 행 수가 0 이 아니면 옛 행이 남아 있다. A-7 처럼 delete from offline_user_session 을 먼저 하고 다시 잰다.

문장 로그가 지금 요청을 잡고 있는지도 본다.

kubectl -n keycloak-lab logs deploy/postgres --since=60s | tail -20

이 시점에서는 거의 JGROUPS_PING 뿐일 텐데, 그게 이 실험의 첫 발견이다. 지금은 「내 요청이 어디 있는지 모르겠다」로만 보이고, 구간을 나눠야 보인다.

관찰

표식 → 요청 → 표식 순으로 치고 awk 로 그 사이를 자른다.

12. 로그인이 무슨 SQL 을 쏘는지 본다

목적 — 로그인 한 번이 DB 에 무엇을 보내는지 센다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -tAc "select 'MARK_LOGIN_START'"
kubectl -n keycloak-lab exec a7a-probe -- sh -c \
  'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=password -d client_id=admin-cli \
     -d username=admin -d "password=$PW" > /tmp/tok
   sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
   echo "rt $(wc -c < /tmp/rt) bytes"'
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -tAc "select 'MARK_LOGIN_END'"
rt 1188 bytes
kubectl -n keycloak-lab logs deploy/postgres --tail=4000 > /tmp/pg.log
awk '/MARK_LOGIN_START/,/MARK_LOGIN_END/' /tmp/pg.log | grep -v JGROUPS_PING

예상 결과

  11:18:49.461 statement: select 'MARK_LOGIN_START'
  11:18:49.743 statement: select 'MARK_LOGIN_END'
                                   ↑ 사이에 아무것도 없다

왜 필요한가 — 두 줄뿐이고 로그인은 SQL 을 0개 쏜다. realm 과 사용자와 클라이언트가 전부 Infinispan 캐시에 있고 volatile 이라 세션 쓰기도 없다. awk '/A/,/B/' 는 A 가 나온 줄부터 B 가 나온 줄까지 출력한다. 로그를 파일로 먼저 받는 까닭은 같은 로그를 여러 구간으로 반복해서 잘라 볼 것이기 때문이다.

문제가 생기면rt 1 bytes 면 파싱이 실패했고, 그 상태로 다음을 하면 빈 토큰을 보내고 엉뚱한 오류를 보게 된다. cat /tmp/tok 으로 본문을 본다.

13. refresh 가 쏘는 한 문장의 이름을 읽는다

목적 — A-7 의 가설이 지목한 테이블이 실제로 나오는지 본다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -tAc "select 'MARK_REFRESH_START'"
kubectl -n keycloak-lab exec a7a-probe -- sh -c \
  'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
     "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=refresh_token -d client_id=admin-cli \
     -d "refresh_token=$(cat /tmp/rt)"'
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -tAc "select 'MARK_REFRESH_END'"
kubectl -n keycloak-lab logs deploy/postgres --tail=4000 > /tmp/pg.log
awk '/MARK_REFRESH_START/,/MARK_REFRESH_END/' /tmp/pg.log | grep -v JGROUPS_PING

예상 결과

  11:18:52.009 statement: select 'MARK_REFRESH_START'
  11:18:52.137 statement: BEGIN
  11:18:52.137 execute <unnamed>/C_107:
       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'
  11:18:52.148 execute S_2: COMMIT
  11:18:52.253 statement: select 'MARK_REFRESH_END'

세 가지를 본다 — BEGINCOMMIT 사이에 select 가 하나뿐인 것, 테이블 이름이 CLIENT_SCOPE_CLIENT 인 것, parameters 줄의 $2 = 'f'.

가설이 지목한 테이블이 정말 없는지 직접 센다.

awk '/MARK_REFRESH_START/,/MARK_REFRESH_END/' /tmp/pg.log | grep -ci revoked_token
REVOKED_TOKEN 은 **한 번도 나오지 않는다.**

왜 필요한가DEFAULT_SCOPE='f' 가 그 문장을 읽는 열쇠다. Keycloak 의 클라이언트는 스코프를 두 종류로 갖는다.

DEFAULT_SCOPE
default scope 항상 붙는다 t
optional scope 요청이 scope= 로 달라고 해야 붙는다 f

refresh 는 새 access token 을 만든다. 그 토큰에 어떤 스코프를 담을지 정하려면 이 클라이언트가 요청할 수 있는 optional 스코프가 무엇인지 알아야 하고, 그 목록이 CLIENT_SCOPE_CLIENT 에 있다. 로그인 때는 이미 결정된 것을 쓰지만 refresh 는 다시 계산한다. 이 조회가 실패하면 토큰을 만들 수 없어 500 이 된다. 400 Session not active 와 달리 세션 문제가 아니어서, A-7 이 세션 계열 테이블을 의심한 것이 자연스러웠지만 빗나갔다.

그 UUID 가 어느 클라이언트인지 궁금하면 물어본다. UUID 는 렐름을 만들 때 정해지므로 실험대마다 다르다. ②가 자른 구간의 parameters 줄에 있는 $1 값을 그대로 옮겨 넣는다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select id, client_id from client where id='{{CLIENT_UUID}}'"

이 실험대의 값은 131a9912-b578-4b9c-b16a-97518704077e 였다(observed).

admin-cli 가 나오면 방금 친 요청의 클라이언트가 맞다.

문제가 생기면 — 구간에 표식이 두 번 나오면 로그를 여러 번 받아 구간이 겹쳤다. --tail 을 줄이거나 새 표식 이름을 쓴다.

14. 그 조회가 한 번뿐인 것을 본다

목적 — 첫 refresh 가 캐시를 채우고 이후로는 DB 를 보지 않는다는 것을 확인한다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -tAc "select 'MARK_R1'"
kubectl -n keycloak-lab exec a7a-probe -- sh -c \
  'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=refresh_token -d client_id=admin-cli \
     -d "refresh_token=$(cat /tmp/rt)" > /tmp/tok
   sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
   echo "rt $(wc -c < /tmp/rt) bytes"'
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -tAc "select 'MARK_R2'"

같은 모양으로 MARK_R3MARK_R_END 까지 두 번 더 한다. 매번 /tmp/rt 를 다시 채운다 — refresh token 은 회전하고, 옛것을 계속 쓰면 나오는 오류가 무효화 때문인지 재사용 때문인지 구별되지 않는다.

kubectl -n keycloak-lab logs deploy/postgres --tail=4000 > /tmp/pg.log
awk '/MARK_R1/,/MARK_R_END/' /tmp/pg.log | grep -v JGROUPS_PING

예상 결과

연속 refresh 3회, 전부 200. 표식 사이 SQL:
  statement: select 'MARK_R1'
  statement: select 'MARK_R2'
  statement: select 'MARK_R3'
  statement: select 'MARK_R_END'
                          ↑ SQL 0건

왜 필요한가 — 표식 네 줄만 있고 그 사이에 아무것도 없다. 첫 refresh 가 캐시를 채우고 이후로는 DB 를 보지 않으므로, DB 를 언제 내리느냐에 따라 답이 달라진다.

문제가 생기면400 Session not active 가 나오면 옛 refresh token 을 재사용했다. 매번 /tmp/rt 를 갱신한다.

세 재현의 2026-09-17 실측(observed) — 같은 설정에서 캐시 온도만 달리해 셋을 이어서 쳤다.

재현 만든 상태 쳤을 때
A 재시작 직후, 로그인 0회, DB 정지 로그인이 400 unauthorized_client
B 재시작 뒤 로그인 1회로 캐시를 덥히고 DB 정지 refresh 가 500 unknown_error
C DB 를 다시 켜고 같은 refresh 200

A 의 400invalid_grant 가 아니라 unauthorized_client 다 — 세션이 아니라 클라이언트를 못 찾은 것이고, 그래서 이 셋을 안 갈라 놓고 재면 A-7 의 결론을 잘못 읽는다.

15. 재현 A — 완전 냉시동이면 로그인부터 400 이다

목적 — 캐시가 전부 빈 상태에서 DB 를 내렸을 때의 답을 잰다.

캐시는 Keycloak 을 재시작해야만 식는다.

   Infinispan 캐시 = 프로세스 메모리
        │
        └─ 파드가 살아 있는 한 안 식는다
             └─ 그래서 세 재현 사이마다 rollout restart 를 한다

이 재시작을 건너뛰면 세 상태가 하나로 뭉개진다. 이미 더워진 캐시에서 계속 재게 되므로 A 와 B 를 재도 C 의 답이 나오고, 「A-7 이 틀렸다」는 엉뚱한 결론에 이른다.

kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s

재시작과 DB 정지 사이에 아무 요청도 보내지 않는다. 한 번이라도 로그인하면 캐시가 더워져서 이건 재현 B 가 된다. 파드 IP 가 바뀌었으므로 탐침을 다시 띄운 다음 로그인을 본문까지 본다.

탐침의 K0 는 만들 때 고정된 값이라 재시작 뒤에는 낡았다. 지우고 새 IP 로 다시 만든다. 이 블록을 건너뛰면 뒤의 curl 이 없는 주소로 가고, 그 침묵을 「DB 가 없어서 실패」로 읽게 된다.

kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
  --restart=Never --env="K0=$K0" \
  --env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
              -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
  --command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
kubectl -n keycloak-lab exec a7a-probe -- sh -c \
  'curl -s -w "\n%{http_code}\n" -X POST \
     "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=password -d client_id=admin-cli \
     -d username=admin -d "password=$PW"'

예상 결과

    로그인 400 {"error":"unauthorized_client",
                "error_description":"Unexpected error when authenticating client"}

unauthorized_client 이고 invalid_grant 가 아니다. 세션 문제가 아니라 클라이언트를 못 찾았다. 왜인지는 Keycloak 로그가 직접 말한다.

kubectl -n keycloak-lab logs keycloak-0 --tail=400 \
  | grep -oE 'KC-SERVICES0015[^[]*\[[^]]*\]'

이 형태로 넓혔다. 원래 적힌 grep -oE 'JDBC exception executing SQL …'속 예외가 그 문구일 때만 잡는데, 속 예외는 DB 를 언제 끊었느냐에 따라 둘로 갈린다. 2026-09-17 에는 --tail=150 과 그 패턴으로 한 줄도 안 나왔다(observed).

끊는 중에 걸린 요청   GenericJDBCException: JDBC exception executing SQL [FATAL: terminating connection due to administrator command]
이미 사라진 뒤의 요청 JDBCConnectionException: Unable to acquire JDBC Connection [Connection to postgres:5432 refused. …]

둘 다 앞머리가 KC-SERVICES0015: Unexpected error when authenticating client 이므로 그것으로 거는 편이 안전하다. 2026-09-17 실측은 뒤엣것이었다(observed).

KC-SERVICES0015: Unexpected error when authenticating client: org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection [Connection to postgres:5432 refused. Check that the hostname and port are correct and that the postmaster is accepting TCP/IP connections.]

--tail 도 늘렸다. 재시작 직후에는 기동 로그가 길어서 150 줄로는 이 줄까지 못 거슬러 올라간다.

  ERROR [org.keycloak.services] KC-SERVICES0015: Unexpected error when
    authenticating client: org.hibernate.exception.GenericJDBCException:
    JDBC exception executing SQL [FATAL: terminating connection due to
    administrator command]
    [select ce1_0.ID from CLIENT ce1_0 where ce1_0.CLIENT_ID=? and ce1_0.REALM_ID=?]

왜 필요한가 — 대괄호가 두 쌍이다. 앞은 DB 가 준 오류, 뒤는 실패한 SQL 원문이고 grep -oE 가 그 두 쌍만 뽑는다. A-7 은 「volatile 이면 DB 없이 로그인된다」고 적었는데 냉시동에서는 클라이언트 조회조차 캐시에 없어서 로그인부터 실패한다.

문제가 생기면 — 아무것도 안 나오면 --tail 을 늘리거나 grep -i 'JDBC exception' 으로 먼저 넓게 본다. 정규식이 안 맞는 것과 로그에 없는 것은 다르다. 로그인이 200 이 나오면 재시작 후 요청을 한 번이라도 보낸 것이므로 이 재현을 처음부터 다시 한다.

16. 재현 B — 로그인만 한 번 하면 refresh 가 500 이다

목적 — A-7 이 본 그 조건을 그대로 만든다.

kubectl -n keycloak-lab scale deployment/postgres --replicas=1
kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s

탐침을 새 IP 로 다시 띄운 뒤 로그인 한 번만 한다. 15번과 같은 이유로 여기서도 탐침을 다시 만든다.

kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
  --restart=Never --env="K0=$K0" \
  --env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
              -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
  --command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
kubectl -n keycloak-lab exec a7a-probe -- sh -c \
  'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=password -d client_id=admin-cli \
     -d username=admin -d "password=$PW" > /tmp/tok
   sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
   echo "rt $(wc -c < /tmp/rt) bytes"'

여기서 refresh 를 하면 재현 C 가 된다. 8번의 DB 정지를 친 다음에 refresh 한다.

kubectl -n keycloak-lab exec a7a-probe -- sh -c \
  'curl -s -w "\n%{http_code}\n" -X POST \
     "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=refresh_token -d client_id=admin-cli \
     -d "refresh_token=$(cat /tmp/rt)"'

예상 결과

    로그인   200
    refresh  500 {"error":"unknown_error"}

실패한 SQL 을 15번과 같은 grep -oE 로 뽑으면 이렇게 나온다.

  JDBC exception executing SQL [FATAL: terminating connection due to
    administrator command]
    [select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
      where cscme1_0.CLIENT_ID=? and cscme1_0.DEFAULT_SCOPE=?]

왜 필요한가 — 13번에서 문장 로깅이 「이 문장을 쏜다」를 보여 줬고 여기서는 「이 문장이 실패했다」가 나온다. 둘이 만나면 가설이 아니라 확정이 된다. 500 unknown_error 인 까닭도 이제 안다 — 세션은 멀쩡하고, 토큰을 조립하다가 DB 가 없어서 못 만든 것을 Keycloak 이 사용자 오류로 분류할 방법이 없어서 unknown_error 를 준다.

문제가 생기면200 / 200 이 나오면 로그인 뒤 refresh 를 미리 했다. 로그인 한 번만 하고 DB 를 내린다.

17. 재현 C — 미리 세 번 갱신해 두면 둘 다 200 이다

목적 — 캐시가 완전히 더운 상태의 답을 잰다.

DB 를 살리고, 재시작하고, 탐침을 새로 만들고, 로그인하고, refresh 를 3회 미리 돌린 뒤 DB 를 내린다. 앞 절들의 명령을 그대로 다시 친다.

kubectl -n keycloak-lab scale deployment/postgres --replicas=1
kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s

파드가 새로 떴으므로 K0 가 낡았다. 탐침도 그 값을 --env 로 박아 뒀으니 같이 다시 만든다.

kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
  --restart=Never --env="K0=$K0" \
  --env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
              -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
  --command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s

새 탐침에는 /tmp/rt 가 없다. 16번의 ③ 과 같은 명령으로 다시 만든다.

kubectl -n keycloak-lab exec a7a-probe -- sh -c \
  'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=password -d client_id=admin-cli \
     -d username=admin -d "password=$PW" > /tmp/tok
   sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
   echo "rt $(wc -c < /tmp/rt) bytes"'

여기서 재현 C 가 끊긴다. 다음에 와야 할 것은 DB 를 내리기 전에 refresh 를 세 번 돌려 캐시를 마저 채우는 단계인데, 그 세 번을 치는 명령이 원본 가이드에 없다(unknown). 가이드는 「refresh 3회를 미리 돌린 뒤」라고 쓰고 그 세 번의 명령도, 회전하는 refresh token 을 /tmp/rt 에 매번 다시 쓰는 형태도 남기지 않았다. 14번의 ① 이 표식 사이에서 refresh 를 한 번 돌리며 /tmp/rt 를 갱신하는 형태를 갖고 있지만, 그것을 세 번 돌리는 것이 가이드가 말한 그 3회와 같은지는 확인되지 않았다. 이 단계를 채우지 못하면 아래 ④⑤ 를 쳐도 재현 B 와 같은 상태이고 500 이 나온다.

그 세 번을 돌렸다고 보고, 8번과 같은 명령으로 DB 를 내린다.

kubectl -n keycloak-lab scale deployment/postgres --replicas=0
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
kubectl -n keycloak-lab exec a7a-probe -- sh -c \
  'curl -s -o /dev/null -w "login %{http_code}\n" -X POST \
     "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=password -d client_id=admin-cli -d "password=$PW" -d username=admin
   curl -s -o /dev/null -w "refresh %{http_code}\n" -X POST \
     "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=refresh_token -d client_id=admin-cli \
     -d "refresh_token=$(cat /tmp/rt)"'

예상 결과

  refresh 를 3회 미리 돌려 캐시를 채운 뒤 postgres 정지
    로그인   200
    refresh  200        ← A-7 의 표와 정반대다

왜 필요한가 — 같은 설정, 같은 명령, 세 개의 답이 나왔다.

캐시 상태 로그인 refresh 실패한 SQL
완전 냉시동 (재시작 직후) 400 400 select ce1_0.ID from CLIENT where CLIENT_ID=? and REALM_ID=?
CLIENT 만 더움 ← A-7 이 본 것 200 500 select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT …
완전히 더움 200 200 없음 (SQL 0건)

무엇이 다른지는 kubectl get 어디에도 안 나온다. 캐시 온도는 보이지 않는 상태이고, A-1 에서 conntrack 이 「주입했는데 안 걸렸다」를 만든 것과 같은 계열의 함정이다.

   volatile + DB 정지의 결과
     = "무엇을 하느냐"가 아니라
       "그 경로가 이미 캐시를 채웠느냐"

persistent 기본값에는 이 조건부성이 없다. 세션 자체를 DB 에 쓰므로 DB 가 없으면 캐시 온도와 무관하게 실패한다. 이것은 volatile 고유의 성질이고, 옛 방식이 「DB 의존이 적다」고 말할 때 놓치는 부분이다.

문제가 생기면 — 세 재현이 전부 200/200 이면 재시작을 건너뛰어 캐시가 계속 더웠다. 재현마다 rollout restart 를 넣는다.

복구와 원상복구 확인표

셋을 순서대로 되돌린다. DB 가 살아 있어야 나머지가 된다.

kubectl -n keycloak-lab scale deployment/postgres --replicas=1
kubectl -n keycloak-lab wait --for=condition=Ready pod -l app=postgres --timeout=180s

문장 로깅을 끈다. 잊으면 다음 실험이 전부 오염된다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "alter system reset log_statement" -c "select pg_reload_conf()"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "show log_statement"
 log_statement
---------------
 none

왜 급한가 — A-3 은 수백 건의 로그인을 최대한 빨리 돈다. log_statement='all' 이면 로그인 하나에 SQL 열 몇 줄씩 쌓이고, 로그가 폭주하고 디스크 입출력이 늘어 크래시 타이밍 자체가 달라진다. 다음 실험의 측정값이 이 설정 때문에 바뀐다.

kubectl -n keycloak-lab patch statefulset keycloak --type=json \
  -p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s

args 문자열만 보고 끝내지 않는다. 탐침을 새 IP 로 띄우고 로그인을 한 번 한 다음 행을 센다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -tAc "select count(*) from offline_user_session where offline_flag='0'"

0 이 아니어야 한다. 로그인 후 행이 생기면 persistent 로 돌아온 것이고, 원래 재현 절차도 마지막에 이 한 줄을 둔다.

항목 명령 돌아왔을 때
문장 로깅 psql -c "show log_statement" none
args get statefulset keycloak -o jsonpath='{...containers[0].args}' ["start"]
DB kubectl -n keycloak-lab get pods -l app=postgres 1/1 Running
동작 로그인 뒤 select count(*) ... 세션 행이 생긴다
파드 kubectl -n keycloak-lab get pods -o wide keycloak 둘 다 1/1 Running
클러스터 vendor_cluster_size 양쪽 2
탐침 파드 kubectl -n keycloak-lab get pod a7a-probe NotFound
임시 파일 ls /tmp/pg.log 지워도 된다
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master 200
kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
rm -f /tmp/pg.log

막히면

아래는 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다.

증상 원인 확인
표식이 로그에 안 보인다 pg_reload_conf() 를 안 했다 show log_statementall 인지
표식 사이가 JGROUPS_PING 으로 가득하다 정상이다. 5초마다 폴링한다 grep -v JGROUPS_PING
표식이 두 번 나온다 로그를 여러 번 받아 구간이 겹쳤다 --tail 을 줄이거나 새 표식 이름을 쓴다
로그 시각이 9시간 어긋난다 컨테이너 로그가 UTC 다 date -u 와 비교한다
refresh 가 400 Session not active 옛 refresh token 을 재사용했다 매번 /tmp/rt 를 갱신
rt 1 bytes 파싱 실패. 빈 토큰을 보내게 된다 cat /tmp/tok 으로 본문 확인
세 재현이 전부 200/200 재시작을 건너뛰어 캐시가 계속 더웠다 재현마다 rollout restart
재현 A 가 200 이 나온다 재시작 후 요청을 한 번이라도 보냈다 재시작 뒤 바로 DB 정지
재현 B 가 200/200 로그인 뒤 refresh 를 미리 했다 로그인 한 번만 하고 DB 정지
JDBC exception grep 이 빈 출력 --tail 이 짧거나 정규식이 안 맞는다 grep -i 'JDBC exception' 으로 먼저 넓게
재시작 뒤 아무 데도 안 닿는다 파드 IP 가 바뀌었다 탐침을 지우고 새 IP 로 다시 띄운다
kubectl exec keycloak-0 -- curlexit 127 Keycloak 이미지에 curl 도 wget 도 없다 탐침 파드를 쓴다
다음 실험의 postgres 로그가 폭주한다 문장 로깅을 끄지 않았다 show log_statementnone
다음 실험의 세션이 안 살아남는다 volatile 로 둔 채 끝냈다 로그인 뒤 행 수 확인

무엇이 관측이고 무엇이 아닌가

이 실험대가 실제로 본 것(observed)은 volatile 전환 확인의 세 줄(args · 로그인 200 · 행수 0), 로그인 구간의 표식 두 줄 11:18:49.46111:18:49.743 및 그 사이 SQL 0건, refresh 구간의 다섯 줄과 CLIENT_SCOPE_CLIENT 문장 전문과 파라미터 $1 = '131a9912-b578-4b9c-b16a-97518704077e'$2 = 'f', REVOKED_TOKEN 0건, 연속 refresh 3회의 표식 네 줄과 SQL 0건, 재현 A 의 400 unauthorized_clientselect ce1_0.ID from CLIENT ... 실패 SQL, 재현 B 의 200500 unknown_errorCLIENT_SCOPE_CLIENT 실패 SQL, 재현 C 의 200200, 비밀번호 길이 19, 표식 시험의 statement: select 'MARK_TEST' 다.

이 편에는 가이드가 미검증으로 표시한 명령이 하나도 없다(unknown 이 0건이다). 표식을 감싼 셸 함수 m() 은 원 실행이 실제로 썼고(observed), 그것을 한 줄씩 손으로 푸는 형태가 가이드의 권고다.

시각 표기는 UTC 다. 증거 파일과 위 인용이 전부 UTC 이고 KST 로는 20:1820:24 이며, PostgreSQL 컨테이너가 UTC 로 찍기 때문이다.

A-7 에서 틀린 것으로 확정된 것이 둘이다. 원인 테이블을 REVOKED_TOKEN 으로 본 가설, 그리고 「volatile 이면 DB 없이 로그인된다」는 서술이다. 냉시동에서는 로그인부터 실패한다.

이 절차가 재지 않은 것은 캐시가 얼마나 오래 더운지다. CLIENT_SCOPE_CLIENT 결과의 캐시 만료 시간을 모르므로 한참 뒤에 다시 재면 또 다른 답이 나올 수도 있다. 그것까지 확인하려면 재현 C 뒤에 시간을 두고 같은 시험을 반복해야 한다.