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>
40 KiB
B-2 재현 가이드 — 저장소를 옮겨도 안 고쳐지는 것을 직접 본다
해설 문서: docs/experiment-b2-multi-instance-session.md ·
증거 원문: docs/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·06-observability가 끝나 있다.B-0·B-1이 끝나 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:09–14: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. 파드와 노드
확인
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 의 증거
에 남은 실제 BFF 파드 IP 다. Redis 와 PostgreSQL 은 매니페스트가
nodeSelector 로 kc-lab-2 에 고정해 둔다.
어디를 봐야 하는가
bff가 두 개이고READY가 둘 다1/1NODE가 서로 다르다 — 같은 노드에 몰려 있으면 「다른 인스턴스」가 같은 커널 위의 다른 프로세스일 뿐이다. 매니페스트의topologySpreadConstraints가 이걸 벌려 놓는다RESTARTS가0— 뒤에서 이 값이 오르면 내가 건드린 것이 엉뚱한 데 닿은 것이다
이 결과가 의미하는 것 — 이 실험의 질문(Q1)은 요청이 로그인을 처리하지 않은 인스턴스에 떨어질 수 있어서 생긴다. replica 가 하나면 질문 자체가 성립하지 않는다.
파드 이름은 자주 바뀌므로 이름 대신 라벨로 부른다.
확인
sudo kubectl -n keycloak-lab get pods -l app=bff
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 도 붙었는데 테이블이 없었다. 그리고 아무도 그것을 신고하지 않았다.
확인
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'
=== 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.sqlaccess_token_value blob NOT NULLoauth2-client-schema-postgres.sqlaccess_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-clientjar 가 번들한 파일이다. Liquibase 는 Keycloak 이 자기 스키마에 쓰며 D-2 의 주제다.
없으면 만든다 — 이건 명령이 아니라 파일이다
DDL 은 여러 줄이고 나중에 다시 쓸 것이므로 파일로 만든다. 터미널에 붙여 넣는 명령과 프로그램 원문을 섞지 않는다.
하기
vim /tmp/oauth2-pg.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
의 === PostgreSQL 전용 스키마 === 절 원문이다.
하기 — 태운다
sudo kubectl -n keycloak-lab exec -i deploy/postgres -- \
psql -U keycloak -d keycloak < /tmp/oauth2-pg.sql
실측 — 02-schema.txt
=== 적용 ===
CREATE TABLE
되돌리기 — 이 표는 B-3 이후로도 계속 쓰므로 평소에는 지우지 않는다. 정말 처음 상태로 되돌리려면:
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. 기본키를 눈으로 본다 — 이 실험의 답이 여기 박혀 있다
확인
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'
실측 — 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 세션
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
형태 — 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
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 에서 확인된 성질이다)
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 로 보려면:
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
비밀번호를 화면에 찍지 않는다. 명령 치환으로 넘기므로 값은 터미널에도 셸 히스토리에도 남지 않는다. 존재와 길이만 보고 싶으면:
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절
{"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가 정확히 그 상태다. 로그아웃하고 다시 로그인한다.
확인 — 지금 행을 잡아 둔다. 이 md5 와 issued_at 이 대조군이다
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"
=== [현재] 같은 사용자의 항목 ===
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 는 「같은가 다른가」만 답하고 그게 이 절이 물어보는 전부다.
크기도 같이 봐 둔다.
확인
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
에는 이렇게 적혀 있다.
실측
=== [모의 두 번째 브라우저] 세션만 지우고 같은 사용자로 다시 로그인시킨다 ===
(브라우저가 달라도 principal 은 같으므로 조회 키가 같다)
Redis 세션 삭제 완료 — 다음 요청이 새 로그인을 만든다
「두 브라우저에서」가 아니라 「세션을 지우고 같은 사용자로 다시 로그인」
이었다. 조회 키가 (clientRegistrationId, principalName) 이므로
브라우저가 둘이든 하나든 같은 행을 쓴다는 점에서 등가다.
다만 등가인 이유를 알고 쓰는 것과 모르고 쓰는 것은 다르다. 해설 문서는 처음에 "두 브라우저에서" 라고 적었다가, 측정하지 않은 것을 측정한 것처럼 적었다고 정정했다. 진짜로 두 브라우저를 쓰고 싶으면 시크릿 창을 하나 더 열어 같은 계정으로 로그인하면 된다 — 결과는 같아야 하고, 다르면 그게 더 중요한 발견이다.
2-2. Redis 의 BFF 세션만 지운다
지우기 전에 무엇을 지울지 눈으로 본다. 이 Redis 는 BFF 혼자 쓰는 것이 아니다.
확인
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 세션까지 날아가
그쪽 실험이 오염된다. 접두어로 골라 지운다.
하기
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 이 oauth2-proxy 세션을 지울 때 쓴 것과 같은 모양이다.
형태
(integer) 2
16:21:03 세션 삭제
시각을 적어 둔다. 뒤에서 access_token_issued_at 이 이 시각 뒤인지로
「새 로그인이 실제로 일어났는가」를 판정한다.
3. 주입이 실제로 걸렸는지 확인한다
결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.
3-1. Redis 에서 BFF 세션만 사라졌나
확인
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. 파드를 죽이지 않았나
확인
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 와 똑같은 명령을 친다
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"
=== [재로그인 후] 행이 늘었는가, 덮어써졌는가 ===
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 |
| 스코프가 다른 로그인이면 | 나중 것이 이긴다 |
4-2. 저장소를 바꾸면 고쳐지나 — 안 고쳐진다
1-3 의 PRIMARY KEY 줄을 다시 본다. 그 줄이 답이다.
InMemory → PostgreSQL → Redis → 직접 구현
└────────── 전부 (clientRegistrationId, principalName) 로 찾는다 ──────────┘
고치려면 조회 키에 session 을 넣어야 하고, 그것은 저장소가 아니라
OAuth2AuthorizedClientRepository 쪽 이야기다.
| 후보 | 컨트롤러 변경 | 조회 키 문제 |
|---|---|---|
JdbcOAuth2AuthorizedClientService |
불필요 (같은 인터페이스) | 안 고쳐짐 |
| Redis 직접 구현 | 불필요 | 안 고쳐짐 |
HttpSessionOAuth2AuthorizedClientRepository |
필요 (Repository 로 바꿔야) | 고쳐짐 |
이 실험이 두 번째를 고르지 않은 이유는 Q3 가 "Redis 와 JDBC 중 무엇"을 물었기 때문이고, 그 대가로 조회 키 문제가 남았다. 선택이 남긴 자국을 측정한 것이지 실수가 아니다.
4-3. 저장된 것이 평문인가
먼저 길이만 본다. 값을 찍기 전에 「무엇을 찍게 될지」를 알아야 한다.
확인
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 바이트다. 암호화된 덩어리라면 여기서 알 수 없다. 앞 몇 글자만 본다.
확인
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
의 앞부분(원래 실행이 찍은 길이 그대로)
=== Q3 검증 2번 — 저장소를 직접 열어 refresh token 이 평문인가 ===
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlMmUzZDZkMy0yNzQyLTRhYWItYjk4Ni02ZDU2ZDM5MDk1ZDEifQ.eyJleHAiOjE3ODg1MDA0NDYsImlhdCI6MTc4ODQ5ODY0NiwianRpIjoiNTQwOTZmYTQtZWRjNi1iZjZkLWE4OGMtZDJhNjEzOGJjNmVlIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLmh5ZW9ud29ya3MuY29tL3JlYWxtcy9rZXljbG9hay1wYXR0ZXJucyIsImF1ZCI6I
어디를 봐야 하는가 — eyJ 로 시작한다. 그것이 {" 의 base64 다.
JWT 는 예외 없이 이렇게 시작한다.
convert_from이 성공한다는 것 자체가 답이다. 암호화된 바이트라면 UTF-8 로 디코드되지 않고 오류가 난다. 읽힌다 = 텍스트다.
정말 JWT 인지 헤더를 풀어 본다.
확인 — 미검증
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
=== 저장된 바이트를 그대로 디코드한 결과 ===
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 에서 정한 명령 그대로다.
확인
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'
=== Q1 검증 ④ — 로그아웃하면 두 저장소가 다 정리되는가 ===
로그아웃 전
Redis: 1 키
PostgreSQL: 1 행
하기 — 로그아웃한다. 화면에 로그아웃 버튼이 없다 —
index.html 에는 로그인·조회 버튼만 있다. Spring Security 의 로그아웃은
CSRF 토큰이 붙은 POST /logout 이므로 브라우저 콘솔에서 친다
(F12 → Console, 로그인된 app1 탭에서).
미검증
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가 받아 준다.
되돌리기 — 브라우저에서 다시 로그인한다.
확인 — 로그아웃 후, 같은 세 명령
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'
=== 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. 남은 행을 지운다
하기
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
미검증 — 이 실험은 여기까지 재지 않았다. 확인 화면이 뜨면 승인한다.
확인
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 가 그 상태다 |
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) |
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
는 B-0 의 b0-bff-token-boundary.png 와 동일 파일이다(md5 9ed00537…).
두 시점 모두 accessTokenStoredOnServer: true 인 같은 화면이라 바이트가 같다.
그래서 그 png 는 「JDBC 전환으로 토큰이 공유된다」를 단독으로 증명하지 못한다. 증명은 테이블이 생겼다는 것과 행에 토큰이 들어 있다는 것이 한다 — 이 가이드가 1-2·1-5 에서 재는 것이 그것이다.
같은 화면은 같은 증거가 아니다. 화면이 같아도 그 아래 상태는 다를 수 있고, 다를 수 있다는 것이 곧 「그 화면으로는 판정 못 한다」는 뜻이다.
다음
| 실험 | B-2 가 남긴 질문 |
|---|---|
| B-3 refresh 경쟁 | 이제 토큰이 공유된다 — 경쟁이 재현될 조건이 갖춰졌다. 그리고 덮어쓰기 때문에 두 브라우저가 같은 refresh token 을 다툰다 |
| B-4 Edge 인가 | 헤더로 신원을 넘기는 구조에서는 이 문제가 다른 얼굴로 나온다 |
| B-5 Redis 상실 | 세션(Redis)과 토큰(PostgreSQL)이 나뉘어 있으므로 각각 죽여볼 수 있다 |
| 코드 | 평문 refresh token 과 로그아웃 후 잔존 — 둘 다 코드로 막아야 한다 |