docs(guides): reproduction guides for all 26 experiments

Written by subagents running under the writing-practitioner-guides skill,
one guide per experiment, 22,566 lines. Each walks a reader from baseline
capture through injection, injection verification, observation and recovery.

Section 3 carries the weight in most of them. Injection failed silently nine
times in this lab, and a failed injection looks exactly like no effect — so
the guides verify the target is actually in the intended state before
reading any result. A-4 makes virsh list the only proof because the node
reads Ready for 40 seconds after the machine is off; A-5 makes the packet
counter the sole go/no-go because a rule on the wrong node produces an empty
result that reads like a finding; A-6 quotes the run where 적용완료 was
printed between four Cannot find device "eth0" lines.

The traps the guides are built around are ones that invert a conclusion
rather than merely annoy:

  A-0   emptying the session table without a restart leaves cache entries
        that get counted as replication arriving
  A-2   dropping -o /dev/null fuses body and status into one string
  A-3   presence of "ready to accept connections" instead of its timestamp
  B-2   row count alone reads an UPDATE as nothing having happened
  B-4   tr ',' '\n' splits ["admin","editor"] so only admin is seen
  B-7   no login screen means the cookie died and SSO re-authenticated
  C-1   counting sessions without joining realm counts your own kcadm one
  D-1   kubectl exec without -i restores nothing and still exits 0
  D-4a  "ran with error output" is what success looks like

Every quoted block is copied from docs/evidence/ and marked 실측; reshaped
commands are marked 미검증 rather than passed off as measured. Where a source
document carries a ★ correction the guides follow the corrected claim — A-7's
REVOKED_TOKEN hypothesis, C-1's session count, B-2's schema attribution.

Two hazards are stated rather than smoothed over: B-6 deletes a key that
cannot be recreated, and D-1/D-4 need host sudo, which asks for a password,
so those steps say a person must type them.

Audit over all 26: 672 interpretation pairs, 486 evidence citations, 117
undo sections, and zero occurrences of the patterns the skill forbids —
no python data processing, no deprecated kubectl get endpoints, no
placeholders, no bare kcadm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-07 18:29:00 +09:00
co-authored by Claude Opus 5
parent 8062cc9a19
commit 6f6ab86345
27 changed files with 22678 additions and 0 deletions
@@ -0,0 +1,869 @@
# B-2 재현 가이드 — 저장소를 옮겨도 안 고쳐지는 것을 직접 본다
해설 문서: [`docs/experiment-b2-multi-instance-session.md`](../../experiment-b2-multi-instance-session.md) ·
증거 원문: [`docs/evidence/b2-multi-instance-session/`](../../evidence/b2-multi-instance-session/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 파드는 `1/1 Running` 인데 테이블이 없는 상태 | `psql``Did not find any relation` |
| 조회 키에 session id 가 **없다**는 것 | `\d oauth2_authorized_client``PRIMARY KEY` |
| `bytea` 안에 든 **평문 JWT** | `convert_from(refresh_token_value,'UTF8')` |
| 두 번째 로그인이 같은 행을 덮어쓰는 것 | 행 수 1 그대로 · `md5` 만 바뀜 |
| 로그아웃이 **셋 중 하나만** 지우는 것 | Redis 0 키 · PostgreSQL 1 행 · Keycloak 2 세션 |
## 전제
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
- [`B-0`](b0-bff-redis-deploy.md) · [`B-1`](b1-redis-session-store.md)
이 끝나 **BFF 가 replica 2개**로 떠 있고 Redis 가 세션 저장소로 붙어 있다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다
(kubeconfig 를 사용자 홈에 복사해 뒀다면 `sudo` 는 빼도 된다).
- **브라우저가 필요하다.** BFF 는 authorization code 흐름이라 로그인을
`curl` 로 만들 수 없다. `https://app1.hyeonworks.com/` 에 붙어
`labuser` / `labpass` 로 들어간다. realm 은 `keycloak-patterns`.
- 터미널 하나와 브라우저 창 하나를 나란히 둔다. 브라우저에서 버튼을 누르고
터미널에서 저장소를 세는 왕복이 이 실험의 전부다.
## 주의 — 이건 상태를 바꾸는 실험이다
DDL 을 태우고, Redis 세션을 지우고, 로그아웃한다. **실험대에서만 한다.**
전 구간 약 25분이고, 되돌리는 방법은 매 단계에 적어 두었다.
중간에 그만두려면 브라우저에서 다시 로그인하면 원래 상태로 돌아온다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 14:0914:13 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 UUID·해시는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트였다 |
세션 UUID·md5·타임스탬프는 **당신 환경에서 다르다.** 이 문서는 자리표시자
(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은
전부 위 실행 기록의 실제 값이다.
---
# 0. 왜 이 실험을 하는가
B-1 이 **Application Session 만** Redis 로 옮겼다. 그러자 사용자는 로그인
상태로 보이는데 **BFF 에는 access token 이 없는** 상태가 만들어졌다.
세션과 토큰이 서로 다른 것에 들어 있고, 한쪽만 옮겼기 때문이다.
토큰도 공유 저장소로 옮기면 그건 고쳐진다. 문제는 **무엇이 같이 고쳐지고
무엇이 안 고쳐지는가**다.
| | 예측 |
|---|---|
| 통념 | 공유 저장소로 옮기면 **다중 인스턴스 문제가 해결된다** |
| B-2 모델 | 인스턴스 간 공유만 해결되고 **브라우저 간 격리와 로그아웃 정리는 그대로** |
> **개념 — 「어디에 두는가」와 「어떻게 찾는가」는 독립이다.**
>
> ```
> 저장소 (where) 메모리 → PostgreSQL → Redis … ← 옮기면 인스턴스 간 공유가 된다
> 조회 키 (how) (clientRegistrationId, principalName) ← 옮겨도 그대로다
> ```
>
> 이 실험이 판정하는 것은 두 번째다. 그리고 **키는 코드가 아니라 스키마에
> 박혀 있다** — 그래서 「구현을 바꾸면 되겠지」로 넘어갈 수 없다.
> 1-3 에서 그 줄을 직접 본다.
같은 성질이 로그아웃에서도 나온다. 지워야 하는 것이 셋인데
**셋이 서로 다른 시스템에 있다.**
```
① HttpSession Redis Spring Security 가 지운다
② OAuth2AuthorizedClient PostgreSQL ★ 아무도 안 지운다
③ IdP SSO 세션 Keycloak ★ RP-initiated logout 을 보내야 한다
```
---
# 1. 기준선 — 두 번째 로그인을 만들기 전에
**시험군만 재는 측정은 측정이 아니다.** 덮어쓰기를 보려면 **덮어쓰이기 전의
행**이 있어야 하고, 로그아웃 정리를 보려면 **로그아웃 전의 세 숫자**가 있어야
한다. 넓은 것부터 좁혀 간다.
```
파드 → 테이블 존재 → 스키마(키) → 세 저장소 세기 → 브라우저 로그인 → 대조군 행
```
## 1-1. 파드와 노드
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide
```
**형태** — IP 와 해시는 당신 것과 다르다
```
NAME READY STATUS RESTARTS AGE IP NODE
bff-555df79c97-6j86w 1/1 Running 0 44s 10.42.0.52 kc-lab-1
bff-555df79c97-vgg6g 1/1 Running 0 22s 10.42.1.124 kc-lab-2
postgres-... 1/1 Running 0 5d ... kc-lab-2
redis-... 1/1 Running 0 3d ... kc-lab-2
```
`10.42.0.52``10.42.1.124` 는 [B-5 의 증거](../../evidence/b5-redis-loss/03-health-groups.txt)
에 남은 실제 BFF 파드 IP 다. Redis 와 PostgreSQL 은 매니페스트가
`nodeSelector`**`kc-lab-2` 에 고정**해 둔다.
**어디를 봐야 하는가**
- `bff` 가 **두 개**이고 `READY` 가 둘 다 `1/1`
- **`NODE` 가 서로 다르다** — 같은 노드에 몰려 있으면 「다른 인스턴스」가
같은 커널 위의 다른 프로세스일 뿐이다. 매니페스트의
`topologySpreadConstraints` 가 이걸 벌려 놓는다
- `RESTARTS``0` — 뒤에서 이 값이 오르면 내가 건드린 것이 엉뚱한 데 닿은 것이다
**이 결과가 의미하는 것** — 이 실험의 질문(Q1)은 **요청이 로그인을 처리하지
않은 인스턴스에 떨어질 수 있어서** 생긴다. replica 가 하나면 질문 자체가
성립하지 않는다.
파드 이름은 자주 바뀌므로 이름 대신 라벨로 부른다.
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -l app=bff
```
**실측** — [`01-jdbc-store-deploy.txt`](../../evidence/b2-multi-instance-session/01-jdbc-store-deploy.txt)
```
deployment.apps/bff configured
deployment "bff" successfully rolled out
bff-555df79c97-6j86w 1/1 Running 0 44s
bff-555df79c97-vgg6g 1/1 Running 0 22s
```
위 두 줄은 배포 명령이 같이 찍은 것이다. 파드 줄만 나오면 정상이다.
## 1-2. 테이블이 실제로 있는가 — 없으면 여기서 멈춘다
**이 실험은 원래 여기서 한 번 넘어졌다.** 파드는 떴고 Hikari 도 붙었는데
테이블이 없었다. 그리고 **아무도 그것을 신고하지 않았다.**
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'
```
**실측** — [`01-jdbc-store-deploy.txt`](../../evidence/b2-multi-instance-session/01-jdbc-store-deploy.txt)
```
=== oauth2_authorized_client 테이블이 생겼는가 ===
Did not find any relation named "oauth2_authorized_client".
command terminated with exit code 1
```
**어디를 봐야 하는가** — 이 두 줄이 나오면 **아직 아무것도 저장되지 않는
상태**다. 테이블이 있으면 컬럼 표가 나온다(1-3).
**이 결과가 의미하는 것** — 스키마 초기화가 **조용히 실패**했다.
> **개념 — `blob` 은 PostgreSQL 에 없는 타입이다.**
>
> Spring Security 는 DDL 을 **두 벌** 번들한다.
>
> | 파일 | 토큰 컬럼 타입 |
> |---|---|
> | `oauth2-client-schema.sql` | `access_token_value blob NOT NULL` |
> | `oauth2-client-schema-postgres.sql` | `access_token_value bytea NOT NULL` |
>
> 기본 판본을 그대로 태우면 `blob` 에서 문법 오류가 난다. 그리고
> `spring.sql.init.continue-on-error: true` 가 켜져 있으면 **그 실패가
> 삼켜지고 파드는 정상으로 보인다.**
>
> `continue-on-error` 는 **「없어도 되는 초기화」에만** 쓴다. 여기서는
> 없으면 안 되는 초기화였다.
>
> **정정 노트** — 해설 문서의 이 절 제목은 처음에 "Liquibase 스키마의 방언
> 차이" 였다가 정정됐다. **Liquibase 가 아니다.** 여기서 스키마를 태우는
> 것은 Spring Boot 의 `spring.sql.init` 이고, DDL 은
> `spring-security-oauth2-client` jar 가 번들한 파일이다. Liquibase 는
> Keycloak 이 자기 스키마에 쓰며 D-2 의 주제다.
### 없으면 만든다 — 이건 명령이 아니라 파일이다
DDL 은 여러 줄이고 나중에 다시 쓸 것이므로 **파일로 만든다.** 터미널에
붙여 넣는 명령과 프로그램 원문을 섞지 않는다.
**하기**
```bash
vim /tmp/oauth2-pg.sql
```
```sql
-- file: /tmp/oauth2-pg.sql
-- spring-security-oauth2-client jar 의 oauth2-client-schema-postgres.sql 과 같다.
CREATE TABLE oauth2_authorized_client (
client_registration_id varchar(100) NOT NULL,
principal_name varchar(200) NOT NULL,
access_token_type varchar(100) NOT NULL,
access_token_value bytea NOT NULL,
access_token_issued_at timestamp NOT NULL,
access_token_expires_at timestamp NOT NULL,
access_token_scopes varchar(1000) DEFAULT NULL,
refresh_token_value bytea DEFAULT NULL,
refresh_token_issued_at timestamp DEFAULT NULL,
created_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
PRIMARY KEY (client_registration_id, principal_name)
);
```
**실측** — 위 DDL 은 [`02-schema.txt`](../../evidence/b2-multi-instance-session/02-schema.txt)
`=== PostgreSQL 전용 스키마 ===` 절 원문이다.
**하기** — 태운다
```bash
sudo kubectl -n keycloak-lab exec -i deploy/postgres -- \
psql -U keycloak -d keycloak < /tmp/oauth2-pg.sql
```
**실측** — [`02-schema.txt`](../../evidence/b2-multi-instance-session/02-schema.txt)
```
=== 적용 ===
CREATE TABLE
```
**되돌리기****이 표는 B-3 이후로도 계속 쓰므로 평소에는 지우지 않는다.**
정말 처음 상태로 되돌리려면:
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c 'drop table oauth2_authorized_client'
```
> `-i` 를 빼면 `<` 로 넘긴 파일이 파드 안으로 안 들어간다. 아무 일도 안
> 일어나고 오류도 안 난다 — `kubectl exec` 는 stdin 을 기본으로 연결하지
> 않는다.
## 1-3. 기본키를 눈으로 본다 — 이 실험의 답이 여기 박혀 있다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'
```
**실측** — [`02-schema.txt`](../../evidence/b2-multi-instance-session/02-schema.txt)
```
Table "public.oauth2_authorized_client"
Column | Type | Collation | Nullable | Default
-------------------------+-----------------------------+-----------+----------+-------------------------
client_registration_id | character varying(100) | | not null |
principal_name | character varying(200) | | not null |
access_token_type | character varying(100) | | not null |
access_token_value | bytea | | not null |
access_token_issued_at | timestamp without time zone | | not null |
access_token_expires_at | timestamp without time zone | | not null |
access_token_scopes | character varying(1000) | | | NULL::character varying
refresh_token_value | bytea | | |
refresh_token_issued_at | timestamp without time zone | | |
created_at | timestamp without time zone | | not null | CURRENT_TIMESTAMP
Indexes:
"oauth2_authorized_client_pkey" PRIMARY KEY, btree (client_registration_id, principal_name)
```
**어디를 봐야 하는가****맨 아래 `Indexes:` 줄** 하나다.
```
PRIMARY KEY, btree (client_registration_id, principal_name)
└── "keycloak" ──┘ └── "labuser" ──┘
세션 id 가 없다
```
**이 결과가 의미하는 것** — 같은 사용자가 어떤 브라우저에서 로그인하든
`(keycloak, labuser)` 라는 **한 행**을 쓴다. B-0 에서 빈 이름
(`AuthenticatedPrincipalOAuth2AuthorizedClientRepository`)으로 짐작했던 것이
**테이블 정의로 확정된다.**
**저장소를 Redis 로 바꿔도, 직접 구현해도 이 키를 그대로 쓰는 한 결과는 같다.**
4-1 에서 그것을 눈으로 확인한다.
## 1-4. 세 저장소를 세는 명령을 정해 둔다
4절에서 이 세 숫자를 **로그아웃 전후로** 비교한다. 지금 형태를 확정해 두고,
매번 같은 명령을 친다. 다른 명령으로 재면 비교가 아니다.
**확인 ①** — Redis 의 BFF 세션
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
```
**형태** — [`03-redis-contents.txt`](../../evidence/b1-redis-session-store/03-redis-contents.txt)
의 B-1 측정과 같은 모양이다
```
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
```
> **`KEYS *` 대신 `--scan` 을 쓴다.** `KEYS` 는 Redis 를 잡아 두고 전 키를
> 훑는다. `--scan` 은 커서로 나눠 돌아 블로킹하지 않는다.
>
> **`dbsize` 는 이 실험에서 부정확하다.** Redis 하나를 BFF 와 oauth2-proxy
> (B-7)가 나눠 쓰므로 `dbsize` 에는 `_oauth2_proxy-…` 키도 섞인다.
> **접두어로 걸러 세는 것**이 맞다.
**확인 ②** — PostgreSQL 의 authorized client
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c 'select count(*) from oauth2_authorized_client'
```
**확인 ③** — Keycloak 의 SSO 세션. 온라인 세션도 `offline_user_session`
`offline_flag = 0` 으로 들어 있다(B-3 에서 확인된 성질이다)
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak \
-c 'select offline_flag, count(*) from offline_user_session group by 1'
```
**미검증** — 원래 실행은 Keycloak 관리 API 로 셌고 증거에는 숫자만 남아 있다.
같은 숫자를 DB 쪽에서 보는 형태다. 관리 API 로 보려면:
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
config credentials --server http://localhost:8080 --realm master --user admin \
--password "$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get client-session-stats -r keycloak-patterns
```
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
> 셸 히스토리에도 남지 않는다. 존재와 길이만 보고 싶으면:
> ```bash
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
> ```
## 1-5. 브라우저로 로그인하고 대조군 행을 잡는다
**하기** — 브라우저에서 `https://app1.hyeonworks.com/` 를 열고
**Keycloak 로그인** 을 눌러 `labuser` / `labpass` 로 들어간다. 그다음
**token 경계 확인** 을 누른다.
**실측** — 해설 문서 3절
```json
{"principal":"labuser",
"accessTokenStoredOnServer":true, B-1 false
"refreshTokenStoredOnServer":true,
"browserTokenCount":0}
```
**어디를 봐야 하는가**`accessTokenStoredOnServer``true`.
`browserTokenCount``0` 인 것이 BFF 패턴의 정의다 — **토큰이 브라우저에
없다.**
**이 결과가 의미하는 것** — B-1 에서는 이 값이 `false` 로 나올 수 있었다.
authorized client 가 프로세스 메모리에 있어 **로그인을 처리하지 않은 replica**
가 답하면 아무것도 못 찾았기 때문이다. 지금은 두 replica 가 같은 PostgreSQL
행을 본다.
> 이 값이 지금도 `false` 로 나온다면 **테이블은 만들었는데 옛 세션을 쓰고
> 있는 것**이다. 증거의 [`b2-before-relogin.png`](../../evidence/b2-multi-instance-session/b2-before-relogin.png)
> 가 정확히 그 상태다. 로그아웃하고 다시 로그인한다.
**확인** — 지금 행을 잡아 둔다. **이 md5 와 `issued_at` 이 대조군이다**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select client_registration_id, principal_name, access_token_issued_at,
md5(access_token_value) as at_md5
from oauth2_authorized_client"
```
**실측** — [`04-overwrite-test.txt`](../../evidence/b2-multi-instance-session/04-overwrite-test.txt)
```
=== [현재] 같은 사용자의 항목 ===
client_registration_id | principal_name | access_token_issued_at | at_md5
------------------------+----------------+----------------------------+----------------------------------
keycloak | labuser | 2026-09-04 05:10:46.927192 | 675af2286bfc2fd9d2bab7bc8f391df7
(1 row)
행 수: 1
```
**어디를 봐야 하는가**`(1 row)``at_md5`. **둘 다 종이에 적어 둔다.**
2절 뒤에 이 두 값을 다시 본다.
> **왜 토큰 값이 아니라 md5 인가.** 값 자체는 **지금 쓸 수 있는 자격증명**
> 이라 터미널 스크롤백에 남기면 안 된다. md5 는 「같은가 다른가」만 답하고
> 그게 이 절이 물어보는 전부다.
크기도 같이 봐 둔다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select client_registration_id, principal_name, access_token_type,
length(access_token_value) as at_len, length(refresh_token_value) as rt_len
from oauth2_authorized_client"
```
**실측** — 해설 문서 3절. (이 표는 `.txt` 증거에는 없고 문서에만 남아 있다)
```
client_registration_id | principal_name | access_token_type | at_len | rt_len
------------------------+----------------+-------------------+--------+--------
keycloak | labuser | Bearer | 1431 | 744
```
---
# 2. 주입 — 「두 번째 브라우저」를 만든다
여기부터 상태가 바뀐다. **되돌리는 방법을 먼저 읽어 둔다.**
**되돌리기** — 지운 세션은 되살릴 수 없다. 브라우저에서 다시 로그인하면
새 세션이 만들어지고 원래 상태로 돌아온다.
## 2-1. 실제로는 브라우저를 두 개 쓰지 않는다 — 왜 등가인가
증거 [`04-overwrite-test.txt`](../../evidence/b2-multi-instance-session/04-overwrite-test.txt)
에는 이렇게 적혀 있다.
**실측**
```
=== [모의 두 번째 브라우저] 세션만 지우고 같은 사용자로 다시 로그인시킨다 ===
(브라우저가 달라도 principal 은 같으므로 조회 키가 같다)
Redis 세션 삭제 완료 — 다음 요청이 새 로그인을 만든다
```
**「두 브라우저에서」가 아니라 「세션을 지우고 같은 사용자로 다시 로그인」
이었다.** 조회 키가 `(clientRegistrationId, principalName)` 이므로
**브라우저가 둘이든 하나든 같은 행을 쓴다는 점에서 등가**다.
> **다만 등가인 이유를 알고 쓰는 것과 모르고 쓰는 것은 다르다.**
> 해설 문서는 처음에 "두 브라우저에서" 라고 적었다가, **측정하지 않은 것을
> 측정한 것처럼 적었다**고 정정했다. 진짜로 두 브라우저를 쓰고 싶으면
> 시크릿 창을 하나 더 열어 같은 계정으로 로그인하면 된다 — 결과는 같아야
> 하고, 다르면 그게 더 중요한 발견이다.
## 2-2. Redis 의 BFF 세션만 지운다
**지우기 전에 무엇을 지울지 눈으로 본다.** 이 Redis 는 BFF 혼자 쓰는 것이
아니다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
```
**형태**
```
bff:session:sessions:c63c39ee-...
bff:session:expires:c63c39ee-...
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
```
**어디를 봐야 하는가**`_oauth2_proxy-` 로 시작하는 키가 섞여 있는지.
있으면 **`FLUSHALL` 을 치면 안 된다** — B-7 의 oauth2-proxy 세션까지 날아가
그쪽 실험이 오염된다. 접두어로 골라 지운다.
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*' \
| xargs -r sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli del
date '+%H:%M:%S 세션 삭제'
```
**미검증** — 원래 실행은 스크립트였다. 이 형태는
[후속 문서 §3](../../experiment-followup-untested-items.md) 이 oauth2-proxy
세션을 지울 때 쓴 것과 같은 모양이다.
**형태**
```
(integer) 2
16:21:03 세션 삭제
```
**시각을 적어 둔다.** 뒤에서 `access_token_issued_at` 이 이 시각 뒤인지로
「새 로그인이 실제로 일어났는가」를 판정한다.
---
# 3. 주입이 실제로 걸렸는지 확인한다
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
## 3-1. Redis 에서 BFF 세션만 사라졌나
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
```
**어디를 봐야 하는가** — 첫 명령은 **아무것도 안 나와야** 하고, 두 번째는
**아까와 같아야** 한다. 두 번째까지 비었으면 `FLUSHALL` 을 친 것이고,
B-7 세션을 날린 것이다.
## 3-2. 파드를 죽이지 않았나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -l app=bff
```
**어디를 봐야 하는가****`RESTARTS` 가 여전히 0.** 세션을 지우는 것은
BFF 를 건드리지 않는다. 여기서 재시작이 올랐다면 Redis 쪽을 잘못 만진 것이고,
그 상태로 재면 「덮어쓰기」가 아니라 「파드 재시작」을 재게 된다.
## 3-3. 다음 요청이 정말 새 로그인을 만드는가
**하기** — 브라우저에서 `https://app1.hyeonworks.com/` 를 새로고침하고
**token 경계 확인** 을 누른다.
**어디를 봐야 하는가****로그인 화면이 뜨지 않고 그냥 들어가진다.**
**이 결과가 의미하는 것** — Redis 세션은 지워졌지만 **Keycloak SSO 세션은
살아 있다.** 그래서 BFF 가 `/oauth2/authorization/keycloak` 으로 보내면
Keycloak 이 화면 없이 즉시 코드를 돌려주고, **새 로그인 한 벌이 조용히
만들어진다.** 이것이 「모의 두 번째 브라우저」다.
> **이 조용한 재인증이 6절에서 다시 나온다.** 여기서는 편리하지만
> 로그아웃 뒤에는 「로그아웃했는데 다시 들어가진다」로 보인다.
> **같은 성질의 양면**이다.
---
# 4. 효과를 관찰한다
## 4-1. 행이 늘었는가, 덮어써졌는가
**확인** — 1-5 와 **똑같은 명령**을 친다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select client_registration_id, principal_name, access_token_issued_at,
md5(access_token_value) as at_md5
from oauth2_authorized_client"
```
**실측** — [`04-overwrite-test.txt`](../../evidence/b2-multi-instance-session/04-overwrite-test.txt)
```
=== [재로그인 후] 행이 늘었는가, 덮어써졌는가 ===
client_registration_id | principal_name | access_token_issued_at | at_md5
------------------------+----------------+----------------------------+----------------------------------
keycloak | labuser | 2026-09-04 05:12:13.018828 | e19a63fc5aa18bd0a68b3e19dff16b3b
(1 row)
행 수: 1
★ 행 수가 1 그대로이고 md5 가 바뀌었으면 → 덮어쓰기다
```
**어디를 봐야 하는가** — 세 가지를 **한꺼번에** 본다.
| 값 | 대조군(1-5) | 지금 | 읽는 법 |
|---|---|---|---|
| 행 수 | `(1 row)` | `(1 row)` | **INSERT 가 아니다** |
| `at_md5` | `675af228…` | `e19a63fc…` | **내용은 바뀌었다** |
| `issued_at` | `05:10:46` | `05:12:13` | 2-2 의 삭제 시각 뒤 = 새 로그인 맞다 |
셋 중 하나만 보면 틀린다. 행 수만 보면 「아무 일도 없었다」로, md5 만 보면
「새 행이 생겼나?」로 읽힌다.
**이 결과가 의미하는 것****UPDATE 다.**
```
브라우저 A 로그인 → (keycloak, labuser) 행 생성
브라우저 B 로그인 → 같은 행을 덮어쓴다
└─ A 의 토큰은 사라진다
```
**A 쪽에서 다음 요청을 하면 B 의 토큰을 쓰게 된다.** 같은 사용자이므로
당장은 아무 증상이 없다. 증상은 나중에 나온다.
| 언제 문제가 되는가 | |
|---|---|
| B 가 로그아웃하면 | **A 도 같이 끊긴다** (행이 지워지므로) |
| refresh 회전이 켜져 있으면 | **A 와 B 가 같은 refresh token 을 다툰다** → [B-3](b3-refresh-token-contention.md) |
| 스코프가 다른 로그인이면 | 나중 것이 이긴다 |
## 4-2. 저장소를 바꾸면 고쳐지나 — 안 고쳐진다
**1-3 의 `PRIMARY KEY` 줄을 다시 본다.** 그 줄이 답이다.
```
InMemory → PostgreSQL → Redis → 직접 구현
└────────── 전부 (clientRegistrationId, principalName) 로 찾는다 ──────────┘
```
**고치려면 조회 키에 session 을 넣어야 하고, 그것은 저장소가 아니라
`OAuth2AuthorizedClientRepository` 쪽 이야기다.**
| 후보 | 컨트롤러 변경 | 조회 키 문제 |
|---|---|---|
| `JdbcOAuth2AuthorizedClientService` | **불필요** (같은 인터페이스) | 안 고쳐짐 |
| Redis 직접 구현 | 불필요 | 안 고쳐짐 |
| `HttpSessionOAuth2AuthorizedClientRepository` | **필요** (Repository 로 바꿔야) | **고쳐짐** |
**이 실험이 두 번째를 고르지 않은 이유**는 Q3 가 "Redis 와 JDBC 중 무엇"을
물었기 때문이고, 그 대가로 조회 키 문제가 남았다. **선택이 남긴 자국을
측정한 것**이지 실수가 아니다.
## 4-3. 저장된 것이 평문인가
**먼저 길이만 본다.** 값을 찍기 전에 「무엇을 찍게 될지」를 알아야 한다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select length(refresh_token_value) from oauth2_authorized_client"
```
**실측** — 해설 문서 3절의 `rt_len`
```
744
```
**744 바이트다.** 암호화된 덩어리라면 여기서 알 수 없다. 앞 몇 글자만 본다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select left(convert_from(refresh_token_value,'UTF8'), 40) from oauth2_authorized_client"
```
**미검증** — 원래 실행은 앞 200자 남짓을 통째로 찍었다. 이 형태는 **화면에
남는 양을 줄인** 것이다.
**실측** — [`03-plaintext-tokens.txt`](../../evidence/b2-multi-instance-session/03-plaintext-tokens.txt)
의 앞부분(원래 실행이 찍은 길이 그대로)
```
=== Q3 검증 2번 — 저장소를 직접 열어 refresh token 이 평문인가 ===
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlMmUzZDZkMy0yNzQyLTRhYWItYjk4Ni02ZDU2ZDM5MDk1ZDEifQ.eyJleHAiOjE3ODg1MDA0NDYsImlhdCI6MTc4ODQ5ODY0NiwianRpIjoiNTQwOTZmYTQtZWRjNi1iZjZkLWE4OGMtZDJhNjEzOGJjNmVlIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLmh5ZW9ud29ya3MuY29tL3JlYWxtcy9rZXljbG9hay1wYXR0ZXJucyIsImF1ZCI6I
```
**어디를 봐야 하는가****`eyJ` 로 시작한다.** 그것이 `{"` 의 base64 다.
JWT 는 예외 없이 이렇게 시작한다.
> **`convert_from` 이 성공한다는 것 자체가 답이다.** 암호화된 바이트라면
> UTF-8 로 디코드되지 않고 오류가 난다. **읽힌다 = 텍스트다.**
정말 JWT 인지 헤더를 풀어 본다.
**확인****미검증**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select convert_from(refresh_token_value,'UTF8') from oauth2_authorized_client limit 1" \
| cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
```
**실측** — [`03-plaintext-tokens.txt`](../../evidence/b2-multi-instance-session/03-plaintext-tokens.txt)
```
=== 저장된 바이트를 그대로 디코드한 결과 ===
refresh_token 헤더 : {"alg":"HS512","typ" : "JWT","kid" : "e2e3d6d3-2742-4aab-b986-6d56d39095d1"}
refresh_token 페이로드(앞부분):
{"exp":1788500446,"iat":1788498646,"jti":"54096fa4-edc6-bf6d-a88c-d2a6138bc6ee","iss":"https://auth.hyeonworks.com/realms/keycloak-patterns"
access_token 헤더 : {"alg":"RS256","typ" : "JWT","kid" : "OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}
→ bytea 에 들어 있는 것은 암호화된 덩어리가 아니라 JWT 문자열 그대로다.
DB 읽기 권한만 있으면 그 자리에서 쓸 수 있는 토큰을 얻는다.
```
**이 결과가 의미하는 것** — **DB 읽기 권한만 있으면 그 자리에서 쓸 수 있는
토큰을 얻는다.** 백업 파일, 읽기 전용 복제본, 덤프, 로그 — 어디로 새든
그대로 쓸 수 있다. `Spring Security 기본 구현은 저장 시 암호화하지 않는다.`
암호화하려면 `JdbcOAuth2AuthorizedClientService` 를 감싸거나 직접 구현해야 한다.
> **원래 실행은 여기서 한 번 넘어졌다.** 증거 파일에 그 실패가 그대로 있다.
> ```
> === 그 문자열이 실제 JWT 인지 — 헤더를 디코드 ===
> File "<string>", line 3
> h=open(/tmp/hdr.txt).read().strip()
> ^
> SyntaxError: invalid syntax
> ```
> **파이썬 한 줄짜리로 디코드하려다 따옴표를 빠뜨린 것**이다. 셸 안에
> 프로그램을 밀어 넣으면 이렇게 된다 — 문법 오류가 측정 결과 자리에
> 남는다. `cut` 과 `base64 -d` 로 충분하고, 그건 문법이 틀릴 자리가 없다.
## 4-4. 로그아웃 — 세 저장소를 한 번에 센다
**로그아웃 전에 세 숫자를 먼저 잡는다.** 1-4 에서 정한 명령 그대로다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -tAc 'select count(*) from oauth2_authorized_client'
```
**실측** — [`04-overwrite-test.txt`](../../evidence/b2-multi-instance-session/04-overwrite-test.txt)
```
=== Q1 검증 ④ — 로그아웃하면 두 저장소가 다 정리되는가 ===
로그아웃 전
Redis: 1 키
PostgreSQL: 1 행
```
**하기** — 로그아웃한다. **화면에 로그아웃 버튼이 없다**
`index.html` 에는 로그인·조회 버튼만 있다. Spring Security 의 로그아웃은
CSRF 토큰이 붙은 `POST /logout` 이므로 브라우저 콘솔에서 친다
(`F12` → Console, 로그인된 app1 탭에서).
**미검증**
```js
const csrf = await (await fetch('/bff/csrf')).json();
const token = decodeURIComponent(
document.cookie.split('; ').find(c => c.startsWith('XSRF-TOKEN=')).split('=')[1]);
const r = await fetch('/logout', { method: 'POST', headers: { [csrf.headerName]: token } });
console.log(r.status, r.url);
```
> **왜 셸이 아니라 브라우저인가** — 세션 쿠키가 `HttpOnly` 라 `curl` 로
> 로그인 상태를 재현할 수 없다. `XSRF-TOKEN` 쿠키만 JS 가 읽을 수 있게
> 되어 있고(`CookieCsrfTokenRepository.withHttpOnlyFalse()`), 그래서 이
> 조각이 성립한다. 해설 문서 8절은 같은 일을 **form 파라미터 `_csrf`** 로
> 적었다 — 어느 쪽이든 `SpaCsrfTokenRequestHandler` 가 받아 준다.
**되돌리기** — 브라우저에서 다시 로그인한다.
**확인** — 로그아웃 후, **같은 세 명령**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select principal_name, access_token_issued_at, access_token_expires_at
from oauth2_authorized_client"
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c 'select offline_flag, count(*) from offline_user_session group by 1'
```
**실측** — [`05-logout-cleanup.txt`](../../evidence/b2-multi-instance-session/05-logout-cleanup.txt)
```
=== Q1 검증 ④ — 로그아웃 후 두 저장소 상태 ===
Redis 세션 : 0 키
PostgreSQL 토큰 : 1 행
principal_name | access_token_issued_at | access_token_expires_at
----------------+----------------------------+----------------------------
labuser | 2026-09-04 05:12:13.018828 | 2026-09-04 05:13:13.018828
(1 row)
★ Redis 는 비었는데 PostgreSQL 에 행이 남아 있으면 → 한쪽만 정리된 것
=== Keycloak 쪽 SSO 세션은? ===
Keycloak 온라인 세션: 2
```
**어디를 봐야 하는가** — 세 숫자를 나란히 놓는다.
```
로그아웃 후:
Redis 세션 : 0 키 ← 정리됨
PostgreSQL 토큰 : 1 행 ← 평문 refresh token 이 그대로 남는다
Keycloak SSO : 2 세션 ← 남아 있다
```
**이 결과가 의미하는 것****셋 중 하나만 지워졌다.**
```
로그아웃
├─▶ HttpSession 무효화 ✔ Redis 키 삭제됨
├─▶ authorized client 삭제 ✗ 아무도 안 지운다
└─▶ Keycloak SSO 종료 ✗ RP-initiated logout 을 안 보낸다
```
**남은 행의 `access_token_expires_at``issued_at` 의 60초 뒤**인 것도 같이
본다(`accessTokenLifespan=60`). **access token 은 이미 만료됐지만 같은 행의
refresh token 은 아직 살아 있다** — 그리고 그건 4-3 에서 본 대로 평문이다.
## 4-5. 「로그아웃했는데 다시 들어가진다」
**하기** — 브라우저에서 `https://app1.hyeonworks.com/` 를 다시 연다.
**어디를 봐야 하는가****로그인 화면이 안 뜨고 그냥 들어가진다.**
3-3 에서 본 것과 같은 조용한 재인증이다.
**이 결과가 의미하는 것** — 애플리케이션 세션은 지웠는데 **IdP 세션은 그대로**
이므로 IdP 가 화면 없이 새 세션을 만들어 준다. 사용자 입장에서는
**로그아웃이 안 된 것**이다.
| 필요한 것 | 방법 |
|---|---|
| authorized client 삭제 | `LogoutSuccessHandler` 에서 `removeAuthorizedClient` 호출 |
| Keycloak 세션 종료 | **RP-initiated logout**`OidcClientInitiatedLogoutSuccessHandler` |
| 두 곳을 원자적으로 | 한쪽이 실패하면? — **정리 순서와 실패 처리를 정해야 한다** |
**Q3 의 미지수 5번("두 store 를 logout 에서 어떻게 한 번에 지우게 되는가")이
바로 이 지점이며, 답은 「지금은 하나도 안 지운다」이다.**
---
# 5. 복구
## 5-1. 남은 행을 지운다
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"delete from oauth2_authorized_client where principal_name = 'labuser'"
```
**형태**
```
DELETE 1
```
**되돌리기** — 브라우저에서 다시 로그인하면 행이 다시 만들어진다.
**표 자체는 지우지 않는다** — B-3 이 이 표를 쓴다.
## 5-2. Keycloak SSO 세션을 끊는다
**하기** — 브라우저에서 아래 주소를 연다. RP 가 안 보내 주니 사람이 직접 간다.
```
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/logout
```
**미검증** — 이 실험은 여기까지 재지 않았다. 확인 화면이 뜨면 승인한다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c 'select offline_flag, count(*) from offline_user_session group by 1'
```
**어디를 봐야 하는가**`offline_flag = 0` 의 개수가 줄어드는지.
**관리 API 호출도 세션을 만들기 때문에 개수에는 노이즈가 있다.**
0 이 안 되어도 놀랄 일이 아니다.
## 5-3. Redis 세션을 되돌린다
지운 세션은 되돌아오지 않는다. **브라우저에서 다시 로그인하는 것이 복구다.**
## 5-4. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 파드 | `sudo kubectl -n keycloak-lab get pods -l app=bff` | 둘 다 `1/1 Running`, `RESTARTS 0` |
| 표 | `sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'` | 컬럼 표가 나온다 (지우면 안 된다) |
| BFF 세션 | `… redis-cli --scan --pattern 'bff:session:*'` | 다시 로그인했으면 키가 있다 |
| **B-7 세션** | `… redis-cli --scan --pattern '_oauth2_proxy-*'` | **2절 전과 같아야 한다** |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/` | `200` |
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| `Did not find any relation named "oauth2_authorized_client"` | 스키마 초기화가 **조용히 실패**했다. 기본 DDL 의 `blob` 은 PostgreSQL 에 없는 타입 | `-postgres.sql` 판본을 태운다 — 1-2 |
| 파드는 정상인데 토큰이 저장되지 않는다 | 같은 원인. `continue-on-error: true` 가 실패를 삼켰다 | 파드 로그에서 `Did not find any relation` 을 찾는다 |
| `token-boundary` 가 계속 `false` | 테이블은 만들었는데 **옛 세션**을 쓰고 있다 | 로그아웃 후 재로그인 — [`b2-before-relogin.png`](../../evidence/b2-multi-instance-session/b2-before-relogin.png) 가 그 상태다 |
| `psql ... < file` 이 아무 일도 안 한다 | `kubectl exec`**`-i` 가 없다** | `exec -i deploy/postgres` |
| 행 수가 2 로 늘었다 | principal 이 다르다(다른 사용자로 로그인) | `select principal_name from oauth2_authorized_client` |
| md5 가 안 바뀌었다 | 재로그인이 안 일어났다. 세션이 안 지워졌거나 요청을 안 보냈다 | `access_token_issued_at` 이 삭제 시각 뒤인지 |
| B-7 실험이 갑자기 깨진다 | **`FLUSHALL` 을 쳤다.** 같은 Redis 를 나눠 쓴다 | 접두어로만 지운다 — 2-2 |
| 파이썬 한 줄로 디코드하다 `SyntaxError` | **원래 실행이 이 실수를 했다** ([`03`](../../evidence/b2-multi-instance-session/03-plaintext-tokens.txt)) | `cut -d. -f1 \| base64 -d` 로 충분하다 |
| 로그아웃 POST 가 `403` | CSRF 토큰이 없거나 이름이 틀렸다 | `/bff/csrf``headerName` 을 그대로 쓴다 |
| 로그아웃했는데 다시 들어가진다 | **버그가 아니다.** Keycloak SSO 세션이 살아 있다 | 4-5 · 5-2 |
| `dbsize` 와 세어 본 키 수가 다르다 | oauth2-proxy 키가 섞여 있다 | `--scan --pattern` 으로 나눠 센다 |
---
# 왜 이 가이드에는 스크린샷 대신 숫자가 있나
증거의 [`b2-tokens-shared-across-instances.png`](../../evidence/b2-multi-instance-session/b2-tokens-shared-across-instances.png)
는 **B-0 의 `b0-bff-token-boundary.png` 와 동일 파일**이다(md5 `9ed00537…`).
두 시점 모두 `accessTokenStoredOnServer: true` 인 같은 화면이라 바이트가 같다.
**그래서 그 png 는 「JDBC 전환으로 토큰이 공유된다」를 단독으로 증명하지
못한다.** 증명은 테이블이 생겼다는 것과 행에 토큰이 들어 있다는 것이 한다 —
이 가이드가 1-2·1-5 에서 재는 것이 그것이다.
> **같은 화면은 같은 증거가 아니다.** 화면이 같아도 그 아래 상태는 다를 수
> 있고, 다를 수 있다는 것이 곧 「그 화면으로는 판정 못 한다」는 뜻이다.
---
# 다음
| 실험 | B-2 가 남긴 질문 |
|---|---|
| [B-3](b3-refresh-token-contention.md) refresh 경쟁 | **이제 토큰이 공유된다** — 경쟁이 재현될 조건이 갖춰졌다. 그리고 덮어쓰기 때문에 **두 브라우저가 같은 refresh token 을 다툰다** |
| [B-4](b4-edge-authorization-scope.md) Edge 인가 | 헤더로 신원을 넘기는 구조에서는 이 문제가 **다른 얼굴**로 나온다 |
| [B-5](b5-redis-loss-persistence.md) Redis 상실 | 세션(Redis)과 토큰(PostgreSQL)이 나뉘어 있으므로 **각각 죽여볼 수 있다** |
| 코드 | **평문 refresh token****로그아웃 후 잔존** — 둘 다 코드로 막아야 한다 |