Files
document-haness/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b3-refresh-contention.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

762 lines
44 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.
---
id: 405c4206-9b59-491f-aed1-8b97cfd9f584
kind: SETUP
slug: reproduce-b3-refresh-contention
title: 같은 refresh token 다섯 개를 동시에 던지고 client session 을 센다
topic: where-application-state-lives
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/405c4206-9b59-491f-aed1-8b97cfd9f584/edit"
pinnedVersions:
- name: curlimages/curl
version: 8.11.1
source:
- final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-3
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# 같은 refresh token 다섯 개를 동시에 던지고 client session 을 센다
같은 refresh token 다섯 개를 동시에 던져 회전 경쟁을 만들고, 이긴 요청이 받은 토큰과 그 세션의 client session 을 세는 절차다. BFF 를 거치지 않고 토큰 엔드포인트를 직접 치며, 되돌리기는 realm 설정 한 줄이다.
## 관계
- **회전 경쟁에서 이긴 요청의 토큰도 쓸 수 없었다**
이 절차가 만드는 상태에서 나온 판정이다. 여기는 순서만 적고 무엇이 부서졌는지는 그쪽이 적는다.
- **기본키에 세션 id 가 없어서 두 번째 로그인이 첫 토큰을 덮어썼다**
두 replica 가 같은 행을 본다는 것이 이 경쟁의 전제다. 그 전제를 만든 편이다.
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
다섯이 전부 `200` 일 때 그것이 「경쟁이 없었다」인지 「주입이 안 걸렸다」인지를 가르는 기준이다.
- **토큰을 PostgreSQL 로 옮기고 기본키와 로그아웃 정리를 확인한다**
먼저 해 둬야 하는 편이다. 토큰이 공유되어야 경쟁이 성립한다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령은 `[lab host]` 에서 `kubectl` 로 친다. 토큰을 주고받는 `curl` 만 탐침 파드 안에서 치는데, Keycloak 이미지에 `curl``wget` 도 없기 때문이다(`exit 127`). 브라우저는 필요 없다 — direct grant(`grant_type=password`)로 토큰을 만들므로 전 구간이 터미널에서 끝난다.
**원 가이드는 이 명령들을 `kc-lab-1` 에서 치라고 적었다.** 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 `sudo` 없는 `kubectl``permission denied` 로 막힌다 — kubeconfig 는 lab host 의 `~/.kube/config` 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 `kubectl` 블록의 기계 이름을 `[lab host]` 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다.
터미널은 둘을 연다. 하나는 탐침 파드 셸을 붙잡고 있고, 다른 하나로 데이터베이스를 뒤진다. 파드 안에서 잡은 `RT``SID` 는 파드 밖으로 따라가지 않는다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` |
| realm · 사용자 · 클라이언트 | `keycloak-patterns` · `labuser` / `labpass` · `bff-confidential` |
| 주입 수단 | `revokeRefreshToken=true` — realm 전체에 걸린다 |
| 탐침 파드 | `b3-probe``curlimages/curl:8.11.1`, `sleep 7200`, `--restart=Never` |
| 치는 곳 | 토큰 엔드포인트를 직접. BFF 를 거치지 않는다 |
| 동시성 | 다섯. 셸의 `&``wait` 으로 만든다 |
| 전 구간 | 약 20분 |
| 도구 | `jq` 가 이 실험대에 없다. JSON 은 `sed` 로 자른다 |
## 이 실험이 가르는 것
B-2 가 토큰을 PostgreSQL 로 옮겼고 두 replica 가 같은 행을 본다. 조회 키에 세션 id 가 없으니 같은 사용자의 두 브라우저도 같은 행을 본다. 그 행에는 refresh token 이 하나 들어 있다. 둘이 동시에 그 하나를 갱신하면 무슨 일이 일어나는가.
통념은 하나가 성공하고 하나가 실패하며, 실패한 쪽은 새 토큰을 다시 읽어 재시도하면 된다고 본다. 이 절차는 진짜 그런지와, 이긴 쪽은 멀쩡한지를 잰다.
```text
실패가 사용자에게 안 보인다 → 재시도로 덮으면 된다
실패가 사용자에게 보인다 → 애초에 겹치지 않게 lock 을 걸어야 한다
```
그래서 재야 할 것은 몇 개가 성공했나가 아니라 **이긴 요청의 토큰을 다시 쓸 수 있나**다.
재사용 탐지(reuse detection)가 배경에 있다. 회전이 켜져 있으면 새 refresh token 을 줄 때 옛 것을 무효화하는데, 무효화된 옛 토큰이 다시 들어오면 두 가지 중 하나다.
```text
① 정상 클라이언트가 응답을 못 받아 재시도했다 (무해)
② 토큰이 유출되어 공격자가 쓰고 있다 (치명)
```
서버는 둘을 구별할 수 없다. 그래서 OAuth 2.0 보안 권고는 안전한 쪽으로 가정하고 세션 전체를 무효화하라고 말한다. 이 절차가 보는 파괴는 버그가 아니라 규격이 시키는 대로 동작한 결과이고, 그래서 답이 「고쳐 달라」가 아니라 「겹치지 않게 하라」가 된다.
끝까지 밟으면 다섯 중 하나만 `200` 이고 나머지가 `400` 인 것, 오류 문구가 두 종류인 것, 이긴 요청이 받은 토큰조차 못 쓰는 것, user session 은 남고 client session 만 사라진 것, `refreshTokenMaxReuse` 를 올려도 안 되는 것을 자기 화면에서 보게 된다.
## 전제와 되돌리기
- `05-keycloak` · `06-observability` 가 끝나 있다.
- **B-2 가 끝나 있다.** 토큰이 공유되어야 경쟁이 성립한다. 다만 이 절차는 Keycloak 쪽 동작만 갈라 보려고 BFF 를 거치지 않고 토큰 엔드포인트를 직접 친다.
**realm 설정을 바꾸는 실험이다.** `revokeRefreshToken` 을 켜면 realm 전체에 걸리고, 같은 realm 을 쓰는 다른 작업이 영향을 받는다. B-2 의 BFF 로그인도 그 안에 든다. 실험대에서만 하고, 중간에 그만두려면 아래 한 줄이면 된다.
```bash label="[lab host] 중간에 그만둘 때 치는 한 줄"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=false -s refreshTokenMaxReuse=0
```
## 주입 전에 같은 명령으로 먼저 본다
시험군만 재는 측정은 측정이 아니다. 회전이 꺼진 상태에서 같은 명령을 먼저 돌려 두어야, 나중에 나오는 `400` 이 원래 그런 것인지 내가 켠 것 때문인지 갈린다.
```text
파드 → realm 설정 → 탐침 파드 → 토큰 하나 → 대조군(순차) → 대조군(정상 세션)
```
### 1. Keycloak 이 둘 다 Ready 인가
**무엇을 보는가** — 파드 셋의 상태와 배치.
```bash label="[lab host] 파드 배치를 본다"
kubectl -n keycloak-lab get pods -o wide
```
**어디를 보나** — 모양은 이렇다(observed).
```text
NAME READY STATUS RESTARTS AGE IP NODE
keycloak-0 1/1 Running 0 2d 10.42.1.43 kc-lab-2
keycloak-1 1/1 Running 0 2d 10.42.0.35 kc-lab-1
postgres-... 1/1 Running 0 5d ... kc-lab-2
```
**이 값이 뜻하는 것** — Keycloak 이 둘 다 `1/1` 이어야 한다. 하나가 NotReady 면 Service 가 요청을 전부 한쪽으로 보내고, 그러면 동시성이 한 노드 안에서만 생긴다. 재현은 되지만 replica 를 넘는 경쟁이라고 말할 수 없게 된다.
### 2. kcadm 에 로그인해 둔다
**목적** — realm 설정을 읽고 바꾸는 명령을 쓸 수 있게 한다.
① 파드 안에서 관리 세션을 만든다. 비밀번호는 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 안 남는다.
```bash label="[lab host] kcadm 관리 세션을 만든다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
config credentials --server http://localhost:8080 --realm master --user admin \
--password "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
```
**예상 결과** — 성공하면 아무것도 안 나온다.
**왜 필요한가** — 한 번 하면 파드 안에 세션이 남아 뒤의 `get` · `update` 가 전부 그것을 쓴다.
**문제가 생기면** — 비밀번호가 실제로 있는지는 값이 아니라 길이로 본다.
```bash label="[lab host] 비밀번호의 길이만 센다"
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
```
### 3. realm 의 세 값을 읽는다
**무엇을 보는가** — 주입이 건드릴 스위치와 건드리지 않을 값.
```bash label="[lab host] realm 의 세 값을 읽는다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get realms/keycloak-patterns \
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
```
**어디를 보나** — 실측은 이렇다(observed).
```json
{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
```
| 값 | 뜻 | 지금 |
|---|---|---|
| `revokeRefreshToken` | 회전 스위치 | `false` — 꺼져 있다 |
| `refreshTokenMaxReuse` | 회전이 켜졌을 때 몇 번까지 봐줄 것인가 | `0` |
| `accessTokenLifespan` | access token 수명(초) | `60` |
**이 값이 뜻하는 것** — 기본값은 회전이 꺼져 있다. 「회전과 재사용 허용 0회를 쓰는 realm」이 재려는 상태이므로, 그 상태를 만드는 것이 이 절차의 주입이다. 지금 그대로 재면 다른 것을 재게 된다. `accessTokenLifespan=60` 은 B-0 이 이 실험을 위해 넣어 둔 값이고, 만료를 기다리는 시간이 짧아야 재현이 된다. 이 값은 주입이 끝난 뒤에도 `60` 이어야 한다.
### 4. 상주 탐침 파드를 띄운다
**목적** — 발급받은 토큰을 다음 단계로 넘길 수 있는 셸을 만든다.
`--rm` 임시 파드는 매번 만들고 지우므로 토큰을 단계 사이로 못 넘긴다. 이 실험은 앞 단계에서 받은 토큰을 뒤 단계에서 써야 하므로 파드를 하나 띄워 두고 `exec` 로 이어간다.
**중간에 그만뒀다가 다시 시작하는 것이면 먼저 지운다.** `b3-probe` 라는 이름이 이미 있으면 아래 `run` 은 그 이름이 이미 있다며 거절하고, 남아 있는 파드가 들고 있는 `KC` 와 `CS` 는 지난번에 넣은 값이다. 지우는 명령은 이 절 끝 「문제가 생기면」에 있다.
① 파드를 띄우고 Ready 까지 기다린다.
```bash label="[lab host] ① 상주 탐침 파드를 띄운다"
kubectl -n keycloak-lab run b3-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--env="KC=http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns/protocol/openid-connect/token" \
--env="CS=$(kubectl -n keycloak-lab get secret bff-secrets \
-o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d)" \
--command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/b3-probe --timeout=120s
```
② 환경변수가 들어갔는지 값이 아니라 길이로 본다.
```bash label="[lab host] ② 넘어간 값의 길이를 센다"
kubectl -n keycloak-lab exec b3-probe -- sh -c 'echo "KC=$KC CS길이=${#CS}"'
```
**그 길이를 `15` 와 맞추려 하지 않는다.** 아래 실측의 `15` 는 그 실험대의 값이고, B-0 이 적은 `-s secret=bff-lab-secret` 을 쓰면 `14` 다(2026-09-17, observed). 볼 것은 숫자가 아니라 **매니페스트 쪽과 Keycloak 쪽이 같은가**이고, B-0 이 「이 절차에는 둘을 견주는 단계가 없다」고 적어 둔 그 대조가 이것이다. 값을 안 찍고 견주는 형태는 이렇다.
```bash label="[lab host] 두 값이 같은지 값을 안 찍고 본다"
A=$(kubectl -n keycloak-lab get secret bff-secrets \
-o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d)
C=$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get clients -r keycloak-patterns -q clientId=bff-confidential \
--fields id --format csv --noquotes | tr -d '\r')
S=$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get clients/$C/client-secret -r keycloak-patterns \
--fields value --format csv --noquotes | tr -d '\r')
echo "매니페스트 ${#A}자 · Keycloak ${#S}자"
[ "$A" = "$S" ] && echo "두 값이 같다" || echo "두 값이 다르다"
```
2026-09-17 실측은 `매니페스트 14자 · Keycloak 14자` 와 `두 값이 같다` 였다(observed).
**여기서 한 번에 막히는 것이 둘 더 있다.** 둘 다 원인이 B-0 에 있고 B-0 을 끝까지 밟아도 안 드러난다 — B-0 의 로그인은 인가 코드 흐름이라 둘 다 필요 없기 때문이다(2026-09-17, observed).
| 5번에서 나오는 것 | 무엇이 빠졌나 | 어디서 고치나 |
|---|---|---|
| `unauthorized_client` · `Client not allowed for direct access grants` | 클라이언트의 `directAccessGrantsEnabled` 가 `false` | B-0 의 클라이언트 생성에 `-s directAccessGrantsEnabled=true` |
| `invalid_grant` · `Account is not fully set up` | `labuser` 의 `emailVerified` 가 `false` 이고 이메일·이름 칸이 비었다 | B-0 의 사용자 생성 뒤 `-s emailVerified=true -s email=… -s firstName=… -s lastName=…` |
둘을 고친 뒤 같은 요청이 토큰을 돌려줬다(observed).
③ 파드 셸로 들어간다. 프롬프트가 `/ $` 로 바뀐다.
```bash label="[lab host] ③ 파드 셸로 들어간다"
kubectl -n keycloak-lab exec -it b3-probe -- sh
```
**예상 결과** — ①은 `pod/b3-probe condition met`, ②는 아래 모양이다(observed).
```text
KC=http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns/protocol/openid-connect/token CS길이=15
```
**왜 필요한가** — 요청이 Service 로 간다. A-1·A-2 는 어느 노드가 답했나가 질문이라 파드 IP 로 직접 쳤지만, 여기는 replica 를 넘는 경쟁이 질문이므로 Service 가 요청을 흩는 것이 오히려 필요한 조건이다. `--rm` 이 없으므로 `exit` 해도 파드는 안 지워지고, 지우는 명령은 복구 절에 있다.
**문제가 생기면** — `CS길이=0` 이면 `--env` 가 빈 값을 넘겼다. 파드를 지우고 다시 띄운다.
```bash label="[lab host] 탐침 파드를 지운다"
kubectl -n keycloak-lab delete pod b3-probe --ignore-not-found
```
### 5. 토큰을 하나 받는다
**무엇을 보는가** — 응답에 무엇이 들어 있는지. 나중에 걸러 보려면 먼저 통째로 봐야 한다.
```sh label="[탐침 파드] ① 응답을 통째로 본다"
curl -s -X POST "$KC" \
-d grant_type=password -d client_id=bff-confidential \
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid
```
**어디를 보나** — 한 줄 JSON 이 나온다(모양은 observed).
```json
{"access_token":"eyJhbGciOi...","expires_in":60,"refresh_expires_in":1800,
"refresh_token":"eyJhbGciOi...","token_type":"Bearer","scope":"openid profile email"}
```
`expires_in` 이 60 이다 — 앞에서 읽은 `accessTokenLifespan` 그대로다. 여기가 `{"error":"unauthorized_client"}` 면 클라이언트에 direct grant 가 꺼진 것이고, `{"error":"invalid_grant"}` 면 사용자 이름이나 비밀번호다.
**다음에 쓸 값을 변수에 담는다.** 원래 실행도 이 형태였다(observed).
```sh label="[탐침 파드] ② 변수에 담고 sid 를 뽑는다"
R=$(curl -s -X POST "$KC" \
-d grant_type=password -d client_id=bff-confidential \
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
SID=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' | cut -d. -f2 \
| sed 's/$/==/' | base64 -d 2>/dev/null | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
echo "refresh=${#RT}자 SID=$SID"
```
실측은 토큰 길이 `811`, jti `8e7e3ee2-0dc8-573d-58ec-d12651a50b9c`, sid `BvFiB01Rntz1FcLdf7zG4BNt` 다(observed).
**`SID` 를 종이에 적어 둔다.** 관찰 절에서 데이터베이스를 뒤질 때 이 값이 필요하고, 그때는 파드 밖이라 변수가 안 넘어간다.
`sid` 가 빈 줄로 나오면 base64 패딩이나 base64url 문자(`-` `_`) 때문이다. 위 ②는 패딩만 채우고 아래 줄은 base64url 문자만 바꾸므로, 둘 중 하나씩만 고치는 셈이다. 둘을 한 줄에 같이 넣은 형태는 원본 가이드에 없다(unknown). 아래 형태로 페이로드 전체를 찍고 그 안에서 `"sid"` 를 눈으로 찾아 손으로 옮기는 것이 이 문서에 있는 방법이다. 가이드가 이 줄을 미검증으로 표시했다(unknown).
```sh label="[탐침 파드] sid 가 안 나올 때 페이로드를 통째로 찍는다"
echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' \
| cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
```
### 6. 대조군 하나 — 순차로 다섯 번 갱신한다
**무엇을 보는가** — 겹치지 않으면 무슨 일이 일어나는지. 파드 안에서 `&` 없이 친다.
```sh label="[탐침 파드] 순차로 다섯 번 갱신한다"
for i in 1 2 3 4 5; do
R=$(curl -s -w '\n%{http_code}' -X POST "$KC" \
-d grant_type=refresh_token -d client_id=bff-confidential \
-d "client_secret=$CS" -d "refresh_token=$RT")
echo "순차 $i: $(echo "$R" | tail -1)"
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
done
```
**어디를 보나** — 다섯 줄 전부 `200` 이어야 한다. 증거 파일에는 순차 실행 기록이 없고(unknown), 해설 문서가 순차 실행이면 재현되지 않는다고 말한다. 따라 하는 사람이 자기 손으로 확인하는 순서다.
**이 값이 뜻하는 것** — 루프가 갱신마다 `RT` 를 다시 담는다. 회전이 켜지면 옛 것을 계속 쓸 수 없고, 그대로 두면 뒤에 나오는 `400` 이 경쟁 때문인지 옛 토큰을 썼기 때문인지 갈리지 않는다. 이 실험에서 가장 흔한 자기오염이다.
### 7. 대조군 둘 — 경쟁을 겪지 않은 세션의 모양
**무엇을 보는가** — 정상 세션의 `client_sessions` 가 몇인가. 파드 밖에서 친다.
```bash label="[lab host] 정상 세션의 client session 을 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select us.user_session_id, us.offline_flag,
(select count(*) from offline_client_session cs
where cs.user_session_id = us.user_session_id) as client_sessions
from offline_user_session us
where us.user_session_id = 'JT-XuepgutWcE273QwAnIXta'"
```
**어디를 보나** — 실측은 이렇다(observed, `03-client-session-removed.txt`).
```text
=== 대조: 정상 세션 하나를 새로 만들어 비교 ===
새 sid: JT-XuepgutWcE273QwAnIXta
user_session_id | client_sessions
--------------------------+-----------------
JT-XuepgutWcE273QwAnIXta | 1
(1 row)
```
**이 값이 뜻하는 것** — `client_sessions = 1` 이 정상 세션의 모양이다. 위 질의의 sid 는 원래 실행의 값이므로 따라 하는 사람은 자기 `SID` 를 넣는다. 안 바꾸고 치면 질의는 오류 없이 성공하고 `(0 rows)` 만 돌아온다. 이 대조군이 없으면 나중에 나오는 `0` 이 경쟁 때문인지 원래 그런 표인지 모른다.
user session 과 client session 은 서로 다르다.
```text
user session "이 브라우저는 labuser 로 로그인함"
├─ client session : bff-confidential
└─ client session : oauth2-proxy
```
사용자가 한 번 로그인하고 여러 애플리케이션에 들어가면 user session 하나 아래에 client session 이 여럿 달린다. 그게 SSO 다. 재사용 탐지는 이 중 client session 만 제거한다. 온라인 세션인데 표 이름이 `offline_user_session` 인 것이 헷갈리는데, `offline_flag` 열이 그것을 가른다 — 위 출력의 `offline_flag = 0` 이 온라인 세션이다.
## 주입
**목적** — 회전과 재사용 허용 0회를 켠다.
`revokeRefreshToken` 이 회전 스위치다. 이름이 회전(rotation)이 아니라 취소(revoke)인데, 켜면 새 토큰을 줄 때 옛 토큰을 무효화하고 그 결과가 회전이다.
| 설정 | 뜻 |
|---|---|
| `revokeRefreshToken` | 회전 스위치. 켜면 새 토큰 발급 시 옛 토큰을 무효화 |
| `refreshTokenMaxReuse` | 그 위에서 몇 번까지 봐줄 것인가 |
`refreshTokenMaxReuse` 는 `revokeRefreshToken` 이 켜져야 의미가 있다. 꺼진 상태에서 이 값만 올리면 아무 일도 안 일어난다 — 무효화 자체가 없으니 봐줄 횟수를 셀 대상이 없다. 관리 콘솔에서 이 항목이 회색으로 보이는 까닭도 거기 있다.
① 회전을 켜고 시각을 남긴다.
```bash label="[lab host] 회전을 켜고 시각을 남긴다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=0
date '+%H:%M:%S 회전 켬'
```
**예상 결과** — 성공하면 아무 말도 안 하고 시각만 찍힌다(모양은 observed).
```text
14:16:12 회전 켬
```
**왜 필요한가** — 관찰 절의 결과를 이 시각 이후에 만든 토큰으로 재야 한다. 켜기 전에 발급한 토큰으로 재면 발급 시점의 정책이 아니라 검증 시점의 정책이 적용되어 섞이고, 그러면 해석이 안 된다.
**문제가 생기면** — 주입 검증으로 넘어가 설정을 다시 읽는다.
## 주입 검증
결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 본다. 설정은 똑같은 명령으로 다시 읽는다.
```bash label="[lab host] ① realm 을 똑같은 명령으로 다시 읽는다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get realms/keycloak-patterns \
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
```
모양은 이렇다(observed).
```json
{ "revokeRefreshToken" : true, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
```
`revokeRefreshToken` 이 `true` 여야 한다. `false` 그대로면 `update` 가 다른 realm 에 갔거나 kcadm 세션이 만료됐다. kcadm 은 실패해도 조용할 때가 있어 반드시 다시 읽어서 확인한다.
```bash label="[lab host] ② 재시작이 올랐는지 본다"
kubectl -n keycloak-lab get pods -l app=keycloak
```
`RESTARTS` 가 여전히 0 이어야 한다. realm 설정 변경은 재시작을 일으키지 않으므로, 여기서 재시작이 올랐다면 다른 것을 건드렸다. 그 상태로 재면 경쟁이 아니라 재시작을 재게 된다.
**동시성을 넣기 전에 회전 자체가 도는지 확인한다.** 새 토큰을 하나 받고 한 번 갱신한 뒤 옛 것을 다시 쓴다. 가이드는 이 단계를 증거 파일에 없는 사전 확인이라고 적는다(unknown).
```sh label="[탐침 파드] ③ 회전이 실제로 도는지 두 번 쳐서 본다"
R=$(curl -s -X POST "$KC" \
-d grant_type=password -d client_id=bff-confidential \
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
OLD=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
curl -s -o /dev/null -w '1회차(옛 토큰): %{http_code}\n' -X POST "$KC" \
-d grant_type=refresh_token -d client_id=bff-confidential \
-d "client_secret=$CS" -d "refresh_token=$OLD"
curl -s -o /dev/null -w '2회차(같은 옛 토큰 재사용): %{http_code}\n' -X POST "$KC" \
-d grant_type=refresh_token -d client_id=bff-confidential \
-d "client_secret=$CS" -d "refresh_token=$OLD"
```
1회차 `200`, 2회차 `400` 이다. 옛 토큰이 무효화된다는 것이 회전이 켜졌다는 뜻이다. 2회차도 `200` 이면 회전이 안 켜진 것이고, 그 상태로 관찰 절을 돌리면 다섯 개가 전부 `200` 으로 나온다 — 그건 경쟁이 없었다는 뜻이 아니라 주입이 안 걸렸다는 뜻이다.
## 관찰
사전 확인에서 쓴 토큰은 이미 무효다. 깨끗한 토큰을 하나 새로 받고 `SID` 를 다시 적어 둔다.
```sh label="[탐침 파드] ① 깨끗한 토큰을 새로 받는다"
R=$(curl -s -X POST "$KC" \
-d grant_type=password -d client_id=bff-confidential \
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
SID=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' | cut -d. -f2 \
| sed 's/$/==/' | base64 -d 2>/dev/null | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
echo "refresh=${#RT}자 SID=$SID"
```
**동시에 다섯 개를 던진다.** 원래 실행은 스크립트였고, 아래는 가이드가 손으로 치기 좋게 고친 형태다. **2026-09-17 에 그대로 쳐서 돌았다**(observed). 본문과 응답 코드를 파일로 갈라 순서대로 다시 읽게 했다.
```sh label="[탐침 파드] ② 같은 토큰으로 동시에 다섯 번 갱신한다"
i=1
while [ $i -le 5 ]; do
( curl -s -o /tmp/b$i -w '%{http_code}' -X POST "$KC" \
-d grant_type=refresh_token -d client_id=bff-confidential \
-d "client_secret=$CS" -d "refresh_token=$RT" > /tmp/c$i ) &
i=$((i+1))
done
wait
for i in 1 2 3 4 5; do
echo "요청 $i: HTTP $(cat /tmp/c$i) $(head -c 100 /tmp/b$i)"
done
```
셸 문법 세 조각이 전부다.
```text
( ... ) & 서브셸을 백그라운드로 띄운다 → 다섯 개가 동시에 난다
wait 띄운 것이 전부 끝날 때까지 기다린다
> /tmp/c$i 각자 자기 파일에 쓴다 → 출력이 안 섞인다
```
`&` 를 빼면 while 루프가 하나씩 기다리고, 그러면 이 실험은 재현되지 않는다. `wait` 을 빼면 결과 파일을 읽을 때 아직 안 끝난 것이 있어 빈 줄이 나온다. 다섯 개가 같은 터미널에 동시에 쓰면 어느 줄이 어느 요청인지 알 수 없어서 파일로 받고 `wait` 뒤에 순서대로 읽는다.
실측은 이렇다(observed, `01-concurrent-refresh.txt`).
```text
=== [2] 같은 refresh token 으로 동시에 5회 갱신 ===
요청 1: HTTP 400 {"error":"invalid_grant","error_description":"Maximum allowed refresh token reuse exceeded"}
요청 2: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
요청 3: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
요청 4: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
요청 5: HTTP 200 {"access_token":"...(발급됨)
```
성공 개수가 아니라 오류 메시지가 두 종류인 것을 본다.
| 메시지 | 뜻 |
|---|---|
| `Maximum allowed refresh token reuse exceeded` | 재사용 탐지가 발동 |
| `Session doesn't have required client` | 그 여파 — client session 이 이미 없다 |
**2026-09-17 실측**(observed) — 같은 모양이 다시 나왔다.
```text
요청 1: HTTP 400 Session doesn't have required client
요청 2: HTTP 400 Session doesn't have required client
요청 3: HTTP 400 Maximum allowed refresh token reuse exceeded
요청 4: HTTP 200 (발급됨)
요청 5: HTTP 400 Session doesn't have required client
이긴 요청의 새 refresh 길이: 810
그 토큰을 다시 쓰면: 400 Session doesn't have required client
```
**몇 번 요청이 이기는지는 실행마다 다르다.** 위 실측에서는 5번이었고 2026-09-17 에는 4번이었다. 판정에 쓰는 것은 번호가 아니라 **`200` 이 하나이고 그 하나가 받은 토큰도 안 통한다**는 두 가지다.
하나만 이기고 나머지가 진 것이라면 지는 쪽 메시지가 전부 같아야 한다. 두 종류라는 것은 중간에 상태가 바뀌었다는 뜻이다. 성공한 번호는 환경마다 다르고 증거에서는 5번이었지만 순서는 스케줄링이 정한다 — 몇 번이 이겼는가는 아무 의미가 없다.
**이긴 요청의 토큰을 다시 써 본다.** 여기서 진짜 답이 나온다.
```sh label="[탐침 파드] ③ 이긴 요청이 받은 토큰을 꺼내 다시 쓴다"
NEW=$(cat /tmp/b1 /tmp/b2 /tmp/b3 /tmp/b4 /tmp/b5 \
| sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p' | head -1)
echo "새 refresh token 길이: ${#NEW}"
curl -s -w '\n%{http_code}\n' -X POST "$KC" \
-d grant_type=refresh_token -d client_id=bff-confidential \
-d "client_secret=$CS" -d "refresh_token=$NEW"
```
실측은 이렇다(observed, `02-session-impact.txt`).
```text
=== [3] 이긴 요청이 받은 새 토큰은 쓸 수 있는가 ===
새 refresh token 길이: 810
그 토큰으로 다시 갱신: HTTP 400
{"error":"invalid_grant","error_description":"Session doesn't have required client"}
```
이긴 요청조차 쓸 수 없는 토큰을 받았다.
```text
애플리케이션이 본 것 : HTTP 200 + 새 토큰 → "성공했다"
실제 상태 : 세션이 이미 없다 → 다음 요청에서 끊긴다
```
오류가 지연되어 나타난다. `200` 을 받은 코드는 성공했다고 믿고 토큰을 저장하고, 끊긴 것은 그다음 요청에서 안다. 로그를 볼 때 원인 시각과 증상 시각이 어긋나 보이는 까닭도 여기 있다. 재시도하면 되지 않나가 여기서 무너진다 — 새 토큰을 다시 읽어 재시도해도 그 토큰이 이미 무효라 재시도할 대상이 없다.
**무엇이 사라졌는지는 데이터베이스가 말한다.** 파드 밖에서 치고, sid 는 앞에서 적어 둔 값을 넣는다.
**아래 두 블록에 박힌 `'BvFiB01Rntz1FcLdf7zG4BNt'` 를 자기 `SID` 로 바꾼다.** 그것은 원래 실행의 sid 라, 그대로 붙여넣으면 질의는 오류 없이 성공하고 `(0 rows)` 만 돌아온다. 두 블록 모두 바꿔야 한다 — 한쪽만 바꾸면 두 출력이 서로 다른 세션을 말한다. 출력은 마지막 줄부터 읽는다. `(1 row)` 면 그 sid 의 세션을 찾았고, `(0 rows)` 면 sid 를 안 바꿨거나 다른 값을 넣었다.
```bash label="[lab host] ④ 그 sid 의 세션이 남아 있는가"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select us.user_session_id, us.offline_flag, us.last_session_refresh
from offline_user_session us
where us.user_session_id = 'BvFiB01Rntz1FcLdf7zG4BNt'"
```
실측은 이렇다(observed, `02-session-impact.txt`).
```text
=== [4] 그 sid 의 세션이 DB 에 남아 있는가 ===
user_session_id | offline_flag | last_session_refresh
--------------------------+--------------+----------------------
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 1788498996
(1 row)
```
행이 있다. 세션이 통째로 지워진 것이 아니다. 그러면 왜 `Session doesn't have required client` 인가 — client session 을 센다.
```bash label="[lab host] ⑤ 같은 sid 의 client session 을 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select us.user_session_id, us.offline_flag,
(select count(*) from offline_client_session cs
where cs.user_session_id = us.user_session_id) as client_sessions
from offline_user_session us
where us.user_session_id = 'BvFiB01Rntz1FcLdf7zG4BNt'"
```
실측은 이렇다(observed, `03-client-session-removed.txt`).
```text
=== user session 과 client session 을 나눠서 본다 ===
user_session_id | offline_flag | client_sessions
--------------------------+--------------+-----------------
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 0
(1 row)
```
**`(0 rows)` 와 위 출력의 `client_sessions 0` 은 다른 답이다.** 앞은 그 sid 의 행을 아예 못 찾았다는 뜻이고, 뒤는 행을 찾았는데 그 안의 개수가 0 이다. 화면에서 `0` 두 개가 비슷해 보이지만 판정은 뒤에서만 나온다.
`client_sessions = 0` 이고 대조군은 `1` 이었다. 같은 명령에 다른 결과가 나온 것이 이 실험의 판정이다.
```text
user session "이 브라우저는 labuser 로 로그인함" ← 남는다
└─ client session "그중 bff-confidential 에 대한 상태" ← 지워졌다
```
오류 문구가 정확히 그 말을 한다 — 세션은 있는데 그 클라이언트 몫이 없다. 메시지를 오해해서 세션이 만료됐다로 읽으면 엉뚱한 곳을 고치게 된다.
폐기 목록에 실린 것도 아니다.
```bash label="[lab host] ⑥ 폐기 목록을 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select count(*) as revoked_count from revoked_token"
```
실측은 이렇다(observed, `02-session-impact.txt`).
```text
=== [5] revoked_token 테이블 ===
revoked_count
---------------
0
(1 row)
```
`0` 이다. 토큰을 블랙리스트에 올려서 막은 것이 아니라 client session 이 사라져서 검증할 대상이 없어졌다. 토큰을 지우는 방식이었다면 다른 토큰은 살아 있어야 하는데, 여기서는 그 client 에 대한 모든 토큰이 한꺼번에 죽는다.
왜 이긴 쪽도 죽는지는 시간선이 말한다.
```text
t0 5개가 동시에 도착
t1 하나가 처리를 시작 → 새 토큰 발급 준비
t2 다른 것들이 같은 옛 토큰으로 들어옴 → 재사용 탐지 발동
t3 ★ client session 제거
t4 t1 의 응답이 나간다 → HTTP 200, 새 토큰
t5 그 토큰을 쓰면 → client session 이 없다 → 400
```
t3 와 t4 의 순서가 전부다. 응답을 만들던 요청은 이미 성공이 확정된 상태로 나가고, 그 사이 바닥이 빠진다.
**정책을 바꿔 두 번 더 잰다.** 한 번 더 재기 전에 세션을 새로 만든다 — 파괴된 세션으로 재면 전부 `400` 이다.
```bash label="[lab host] ⑦ 구성 B — 회전을 끈다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=false
```
관찰 절의 ① 새 토큰 발급 → ② 동시 다섯 개 → ③ 이긴 토큰 재사용 → ⑤ client session 세기를 그 순서대로 다시 친다. ①·②·③ 은 탐침 파드 셸 안에서 치고 ⑤ 는 파드 밖에서 친다. 스크롤백을 되돌려 치는 것으로는 안 된다 — ①이 새 `SID` 를 만들고, ⑤의 질의에는 방금 만든 그 값을 넣어야 한다.
```text
=== 구성 B: rotation OFF (revokeRefreshToken=false) ===
sid=iW1CGyO7COdyJLryIrCt3njk
1: 200
2: 200
3: 200
4: 200
5: 200
성공 5 / 5
이긴 토큰 재사용: HTTP 200
남은 client_session: 1
```
전부 `200` 이고 세션도 멀쩡하다(observed, `04-policy-comparison.txt`). 같은 refresh token 을 계속 쓸 수 있으므로 경쟁 자체가 성립하지 않는다. 대신 잃는 것이 있다 — 토큰이 유출되면 만료까지 계속 쓸 수 있고, 회전의 목적이 그 창을 좁히는 것이었다.
```bash label="[lab host] ⑧ 구성 C — 회전을 켜고 재사용 1회를 허용한다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=1
```
```text
=== 구성 C: rotation ON + 재사용 1회 허용 (maxReuse=1) ===
sid=72c04JCdr0NpCHGQmXWW2wM8
1: 200
2: 400 "error_description":"Session doesn't have required client"
3: 200
4: 400 "error_description":"Maximum allowed refresh token reuse exceeded"
5: 400 "error_description":"Session doesn't have required client"
성공 2 / 5
이긴 토큰 재사용: HTTP 400
남은 client_session: 0
```
성공이 1에서 2로 늘었지만 `남은 client_session: 0` 은 그대로다(observed, 같은 파일).
| 구성 | 성공 | 이긴 토큰 재사용 | client_session |
|---|---|---|---|
| A 회전 ON · maxReuse=0 | 1 / 5 | 400 | 0 — 파괴 |
| B 회전 OFF | 5 / 5 | 200 | 1 — 생존 |
| C 회전 ON · maxReuse=1 | 2 / 5 | 400 | 0 — 파괴 |
`refreshTokenMaxReuse` 를 올리는 것은 해법이 아니다. 동시 요청이 N 개면 `maxReuse ≥ N-1` 이어야 하는데, 그러면 회전의 보안 목적이 사라진다. 값을 올려 버티려는 시도는 몇 개까지 동시에 올 것인가를 맞춰야 하는 문제로 바뀔 뿐이고, 그 답은 아무도 모른다.
그래서 답은 잠금이고, 잠금은 저장소 쪽에 있어야 한다 — 프로세스 안의 `synchronized` 는 replica 를 넘지 못한다.
| 후보 | |
|---|---|
| PostgreSQL 행 잠금 | `SELECT ... FOR UPDATE` — A-0 에서 Keycloak 자신이 쓰는 방식 |
| Redis 분산 잠금 | `SET NX PX` — TTL 로 스스로 풀린다 |
| 갱신 전용 인스턴스 | 단일 지점. 그 인스턴스가 죽으면? |
데이터베이스 잠금은 잠금의 수명이 연결의 수명과 묶인다. 프로세스가 죽으면 연결이 끊기고 잠금은 자동으로 풀린다. Redis 잠금은 TTL 이 짧으면 중복 갱신, 길면 정지이고, 그 약점은 B-5 에서 다시 만난다.
## 복구와 원상복구 확인표
### 1. realm 설정을 되돌린다
**목적** — 같은 realm 을 쓰는 다른 작업이 회전을 물려받지 않게 한다.
① 두 값을 한 번에 되돌리고 시각을 남긴다.
```bash label="[lab host] ① 회전을 끄고 시각을 남긴다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=false -s refreshTokenMaxReuse=0
date '+%H:%M:%S 회전 끔'
```
② 똑같은 명령으로 다시 읽는다.
```bash label="[lab host] ② 되돌아갔는지 다시 읽는다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get realms/keycloak-patterns \
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
```
**예상 결과** — 주입 전에 읽은 세 값과 전부 같아야 한다(모양은 observed).
```json
{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
```
**왜 필요한가** — 구성 C 에서 `refreshTokenMaxReuse` 를 `1` 로 올렸으므로 그것까지 같이 되돌려야 한다. `accessTokenLifespan` 이 60 이 아니면 다른 것도 건드렸다.
**문제가 생기면** — kcadm 세션이 만료됐을 수 있다. `config credentials` 를 다시 친다.
### 2. 탐침 파드를 지우고 세션을 정리한다
**목적** — 실험 도구를 치우고 파괴된 세션을 남기지 않는다.
① 파드를 직접 지운다. `--rm` 이 없으므로 자동으로 사라지지 않는다.
```bash label="[lab host] ① 탐침 파드를 지운다"
kubectl -n keycloak-lab delete pod b3-probe --ignore-not-found
```
② 파괴된 세션의 행은 TTL 로 스스로 사라진다. 바로 치우고 싶으면 브라우저에서 아래를 연다. 이 실험은 여기까지 재지 않았다(unknown).
```text
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/logout
```
③ 세션 수를 센다.
```bash label="[lab host] ③ 세션 수를 센다"
kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c 'select offline_flag, count(*) from offline_user_session group by 1'
```
**예상 결과** — 관리 API 호출도 세션을 만들기 때문에 개수에는 노이즈가 있다. `0` 이 안 되어도 놀랄 일이 아니다.
**왜 필요한가** — 다음 실험에서 `b3-probe` 이름이 이미 있다고 거절당하는 것을 막는다.
**문제가 생기면** — 파드가 `Completed` 로 남아 있으면 같은 `delete` 를 다시 친다.
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| realm | 위 `get realms/...` | `revokeRefreshToken : false` |
| 파드 | `kubectl -n keycloak-lab get pods -l app=keycloak` | 둘 다 `1/1 Running`, `RESTARTS 0` |
| 탐침 | `kubectl -n keycloak-lab get pod b3-probe` | `NotFound` (없어야 정상) |
| BFF 로그인 | 브라우저에서 `https://app1.hyeonworks.com/` | 로그인이 되고 `token 경계 확인` 이 답한다 |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/keycloak-patterns` | `200` |
## 막히면
가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이거나 그 기록에서 곧바로 따라 나오는 것이라고 적는다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 다섯 개가 전부 `200` | **`&` 를 빼서 순차로 돌았다** — 경합이 안 생긴다 | 루프에 `( ... ) &` 와 `wait` 이 있는지 |
| 다섯 개가 전부 `200` (`&` 는 있는데) | **회전이 안 켜졌다** | 주입 검증을 다시. 사전 확인이 `200/400` 이어야 한다 |
| 결과 파일이 비어 있다 | **`wait` 이 없다.** 아직 안 끝난 요청을 읽었다 | `wait` 뒤에 `cat` |
| 출력이 뒤섞여 어느 줄이 어느 요청인지 모른다 | 다섯 개가 같은 터미널에 동시에 쓴다 | 파일로 받고 나중에 읽는다 |
| 전부 `400 invalid_grant` 인데 메시지가 한 종류 | **옛 `RT` 를 계속 썼다** (자기오염) | 갱신마다 `RT` 를 다시 담는다 |
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드를 쓴다 |
| `CS길이=0` | secret 이름이나 키가 틀렸다 | `get secret bff-secrets -o jsonpath='{.data}'` 로 키 이름만 본다 |
| `{"error":"unauthorized_client"}` | 클라이언트에 direct grant 가 꺼져 있다 | kcadm 으로 `directAccessGrantsEnabled` 확인 |
| kcadm 이 `401` / 아무 말 없이 실패 | 로그인 세션이 만료됐다 | `config credentials` 를 다시 |
| `sid` 가 빈 줄 | base64 패딩 또는 base64url 문자 | `tr '_-' '/+'` 를 넣어 다시 |
| 데이터베이스 질의에서 행 자체가 없다 | 다른 `SID` 를 넣었다 | 파드 안에서 `echo "$SID"` 를 다시 본다 |
| 파드 셸에 다시 들어갔더니 변수가 없다 | 그 `exec` 세션이 끝나면 셸 변수는 사라진다 | 셸을 붙잡고 있는다. 터미널 두 개 |
| 회전을 켠 뒤 브라우저 로그인이 이상하다 | **realm 전체에 걸린 설정이다.** BFF 도 영향받는다 | 실험이 끝나면 반드시 realm 을 되돌린다 |
부하 도구가 없는 것도 설계다. 동시성 5는 `ab` 도 `k6` 도 필요 없고 셸의 `&` 와 `wait` 이면 충분하며, 그 편이 무엇이 일어났는지 더 잘 보인다 — 요청 다섯 개의 본문을 전부 파일로 갖고 있으니 나중에 다시 읽는다. 부하 도구는 개수를 늘려야 할 때 쓴다. 이 절차가 묻는 것은 개수가 아니라 겹치면 무엇이 부서지는가이고, 그건 둘만 겹쳐도 답이 나온다. 다섯 개를 쓴 것은 오류 메시지 두 종류가 한 화면에 같이 보이기 때문이지 다섯이 필요해서가 아니다.
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 14:1614:17 KST` 에 돈 한 번의 실행에서 나왔다(observed).
- (observed) 주입 전 realm 의 세 값 `{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }`, 토큰 길이 `811` 과 jti `8e7e3ee2-0dc8-573d-58ec-d12651a50b9c` 와 sid `BvFiB01Rntz1FcLdf7zG4BNt`, 동시 다섯 요청의 상태 코드와 오류 문구 두 종류, 이긴 토큰의 길이 `810` 과 그 토큰으로 다시 갱신했을 때의 `HTTP 400`, 그 sid 의 `offline_flag 0` · `last_session_refresh 1788498996` · `client_sessions 0`, 대조군 세션 `JT-XuepgutWcE273QwAnIXta` 의 `client_sessions 1`, `revoked_count 0`, 구성 B·C 의 sid 와 다섯 코드와 `남은 client_session` 값, `CS길이=15`.
- (unknown) `&` 와 `wait` 으로 다섯을 동시에 띄우는 while 루프, sid 를 base64url 로 다시 푸는 줄, 순차 다섯 번 갱신 루프, 회전이 도는지 보는 사전 확인 두 줄. 가이드가 전부 미검증으로 표시했고 원래 실행은 스크립트로 했다. 순차 실행은 증거 파일에 기록 자체가 없다.
- 이 실험이 재지 않은 것 — BFF 를 거쳐 같은 경쟁이 나는지는 재지 않았다. 여기서는 Keycloak 쪽 동작만 갈라 보려고 토큰 엔드포인트를 직접 쳤다. RP-initiated logout 으로 파괴된 세션을 치우는 것도, 동시성을 5보다 늘리면 어떻게 되는지도 재지 않았다.
- 추론이지 측정이 아닌 것 — `refreshTokenMaxReuse ≥ N-1` 이어야 한다는 것은 A·C 두 구성에서 관측한 결과에서 따라 나온 것이고, N 을 바꿔 가며 재 보지는 않았다. 잠금 후보 셋도 어느 것을 넣어 재현이 사라지는지 재지 않았다 — 이 실험은 무엇이 부서지는가까지다.
<!-- body:end -->