Files
document-haness/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b2-jdbc-token-store.md
T
DongHyeonkaandClaude Opus 5 32e39e20aa fix(setup): 실험대에서 35편을 끝까지 밟고 어긋난 명령·결과 31건을 고친다
test-server 를 비우고 다시 세운 뒤 Setup 기록 35편(virtualization 9 ·
keycloak-session-store 26)을 문서에 적힌 명령 그대로 쳤다. 어긋난 자리를
기록과 SSOT 양쪽에 실측과 함께 넣었다.

막히던 것
- 04 의 인증서 경로가 live/hyeonworks.com 이라 nginx 가 [emerg] 로 안 떴다.
  실제 계보는 live/auth.hyeonworks.com 이고 「문제가 생기면」은 진단이 거꾸로였다
- 인증서가 와일드카드가 아니다. SAN 이 auth·app1·app2 셋뿐이라 그 밖의 이름은
  TLS 에서 끊기고 curl 이 exit 60 · %{http_code} 000 을 낸다. SSOT 안에서
  두 문단이 서로 어긋나 있었다
- A-7 14번 ①이 kc-lab-1 에서 여섯 줄 다 실패하는데 마지막 date 만 「차단」을 찍는다

검사가 실패할 수 없던 자리
- B-1 의 세션 키 고르기는 앞 단계가 $KEY 를 채워 둬서 루프가 한 건도 못 맞혀도
  통과한다. KEY= 로 비우고 키마다 1/0 을 찍게 바꿨다
- k3s-agent 유닛의 sed -i 는 패턴에 $HOME 이 들어 있어 아무 줄도 안 바꾼 채 성공한다

certbot
- renew --dry-run 의 종료 코드는 성공도 0, 실패도 0, 다른 사유의 실패는 1 이다.
  본문의 renew failure(s) 로만 판정할 수 있다
- --dry-run 은 staging 서버를 쓰는데 renewal/*.conf 의 account= 는 운영 계정을
  가리킨다. 실패한 dry-run 이 staging 계정을 하나 더 만들어 다음 실행이 계속 멎는다
- 훅을 755 로 놓고 시뮬레이션이 성공해도 Running deploy-hook command 는 안 나온다.
  certbot 2.1.0 에는 --run-deploy-hooks 도 없다
- 강제 갱신은 실제로 쳤고 서빙까지 닿았다. serial 06F3E0EF…1373 → 065547…3DF1,
  notAfter Dec 3 → Dec 16, nginx worker 2629 4712 → 4745 4754

독자가 칠 수 있는 형태로
- 안 되는 형태가 번호 붙은 단계에 앉아 있던 8곳을 뒤집고, 되는 형태를 ①로 올렸다
- 랩 안에서 공개 이름을 치는 curl 65줄에 --resolve 를 붙였다. 붙인 형태를 실제로
  쳐서 문서가 적은 값과 같은지 확인했다
- 힙독·sed -i·echo >>·&&·|| 를 편집기 + 파일 리스팅 + 분할 형태로 바꿨다
- 닫는 코드펜스가 빠져 뒤 200여 줄의 블록 종류가 뒤집혀 있던 곳을 포함해 3곳을 고쳤다

관문: check_body PASS · check_prose error 0 · check_evidence 두 프로젝트 문제 없음 ·
verify-tech-log-tree error 0 · verify-project-layout error 0 · 코드펜스 전수 0건

남은 것: B-0 주입은 keycloak-pattern 저장소의 소스를 고치고 이미지를 다시 구워야
해서 안 했다(unknown).

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

53 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
df2ee798-7f31-4570-a355-11f96c0eea84 SETUP reproduce-b2-jdbc-token-store 토큰을 PostgreSQL 로 옮기고 기본키와 로그아웃 정리를 확인한다 where-application-state-lives 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기 keycloak-session-store 게시 전 https://hyeonworks.com/studio/documents/df2ee798-7f31-4570-a355-11f96c0eea84/edit
name version
keycloak-pattern-bff lab
name version
Redis 7.4.x
final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-2
cdac9b8178391311d8eca1ebc6cac15bb62d79af

토큰을 PostgreSQL 로 옮기고 기본키와 로그아웃 정리를 확인한다

B-1 이 Redis 로 안 옮긴 토큰을 PostgreSQL 로 옮겨 보는 절차다. JdbcOAuth2AuthorizedClientService 를 걸고 기본키 한 줄과 로그아웃 뒤 세 저장소의 숫자를 읽는다. 전 구간 약 25분이고 브라우저와 터미널을 나란히 둔다.

관계

  • 기본키에 세션 id 가 없어서 두 번째 로그인이 첫 토큰을 덮어썼다 이 절차가 낸 결론을 담은 기록이다. 무엇을 발견했는지는 그쪽에 있고 여기에는 치는 순서만 있다.
  • 주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다 스키마 초기화가 조용히 실패하고 파드는 정상으로 보이는 것이 그 아홉 건 가운데 하나다.
  • 저장소를 옮기기 전에 조회 키를 본다 \d oauth2_authorized_client 를 주입 전에 치는 순서를 규칙으로 굳힌 기록이다.
  • 세션과 토큰의 저장소를 나눠 각각 설계한다 여기서 나온 덮어쓰기와 한쪽만 정리되는 로그아웃이 그 결정의 근거가 된다.
  • Redis 를 붙이고 무엇이 옮겨졌는지 빈 목록으로 견준다 먼저 해 둬야 하는 편이다. 그 편이 만든 구성 위에서 이 절차가 시작한다.
  • 같은 refresh token 다섯 개를 동시에 던지고 client session 을 센다 다음 편이다. 여기서 만든 oauth2_authorized_client 표를 그 편이 그대로 쓰므로 표를 지우지 않는다.

본문

먼저 읽는다 — 이 편만 따라 쳐서는 주입이 재현되지 않는다

이 절차에는 JDBC 토큰 저장소 배선을 넣는 단계가 없다. 배선을 넣는 편집 명령도 그때 친 빌드 명령도 B-2 의 원 가이드에 없고(unknown), 없는 명령을 지어내지 않았다. 아래 절차는 그 배선이 이미 들어간 BFF 가 떠 있는 상태에서 시작한다 — 주입 전 1번이 인용하는 deployment "bff" successfully rolled out 두 줄이 그 배포가 남긴 출력이다.

B-0 과 B-1 을 글자 그대로 따라 친 사람은 그 상태가 아니다. B-0 의 주입 절이 spring-boot-starter-jdbc · postgresql · h2SecurityConfig 의 명시 빈 둘과 spring.datasourceBFF_DB_*지우고, B-1 은 그것을 되살리지 않는다. 그래서 B-0 → B-1 → B-2 순으로 온 BFF 에는 JdbcOAuth2AuthorizedClientService 가 없다.

2026-09-17 에 그 상태를 실제로 봤다(observed). 저장소의 매니페스트를 그대로 배포하면 표는 생기고 행은 0 이다.

                 Table "public.oauth2_authorized_client"
 client_registration_id  | character varying(100)  | not null
 principal_name          | character varying(200)  | not null
 access_token_value      | bytea                   | not null
 …
select count(*) from oauth2_authorized_client  →  0

\d 가 정의를 돌려주므로 「배선이 됐다」로 읽기 쉬운데, 행은 브라우저 로그인이 한 번 끝나야 생긴다.

그 상태로 쳐도 화면은 정상으로 보인다. 주입 전 3번이 표를 만들고 \d oauth2_authorized_client 도 정의를 돌려주는데, 행만 한 번도 안 생긴다. 그러면 주입 전 6번의 「이 값이 아직 false 로 나오면 … 로그아웃하고 다시 로그인한다」가 끝나지 않는 고리가 되어 로그인만 반복하게 된다. 두 번 재로그인해도 accessTokenStoredOnServerfalse 이고 표의 행이 0이면 배선이 안 들어간 상태이므로 이 절차를 멈춘다.

무엇을 넣어야 하는지는 B-0 의 주입 절이 지울 목록으로 적어 두었고, 바로 아래 「전제와 되돌리기」가 그 넷을 표로 옮겨 두었다. 그 넷을 되돌려 넣는 순서와 그때 친 빌드 명령은 원 가이드에 없다(unknown).

읽기 전에 — 어디서 치는가

명령은 [lab host] 에서 kubectl 로 친다. kubectlsudo 를 붙이지 않는다. 브라우저에서 버튼을 누르고 터미널에서 저장소를 세는 왕복이 이 절차의 대부분이라 터미널 하나와 브라우저 창 하나를 나란히 둔다. 로그아웃 한 단계만 브라우저 개발자 도구의 콘솔에서 친다.

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

예외는 아래 「전제와 되돌리기」의 두 블록뿐이다. 거기서만 워크스테이션의 저장소를 고치고 이미지를 다시 만든다. 그 기계로 건너가는 명령은 이 절차에 없다 — 경로는 이미지를 밀어 넣는 줄 하나에만 드러난다(ssh test-server "ssh kc-lab-1 '...'", 워크스테이션에서 test-server 를 거쳐 kc-lab-1 로 간다). 그 블록의 bff/pom.xmldeploy/lab/k8s/bff-redis.yaml 도 저장소 상대경로라 어느 디렉터리에서 치는지 적힌 줄이 없으므로, 두 기계 각각에서 저장소 루트로 옮겨 두고 시작한다. 이 셋의 명령이 원 가이드에 없다(unknown).

무엇
네임스페이스 keycloak-lab
시작 상태 B-0 과 B-1 이 끝나 BFF 가 replica 2 이고 Redis 가 세션 저장소다
주입 수단 Redis 세션을 지우고 같은 사용자로 다시 로그인시킨다
읽어야 하는 한 줄 \d oauth2_authorized_client 의 맨 아래 Indexes:
브라우저 필요하다. 인가 코드 흐름을 curl 로 만들 수 없다
세는 저장소 셋 — Redis 의 bff:session:*, PostgreSQL 의 oauth2_authorized_client, Keycloak 세션
걸리는 시간 약 25분

이 실험이 가르는 것

B-1 이 Application Session 만 Redis 로 옮겼고, 그러자 사용자는 로그인 상태로 보이는데 BFF 에는 access token 이 없는 상태가 만들어졌다. 세션과 토큰이 서로 다른 것에 들어 있고 한쪽만 옮겼기 때문이다. 토큰도 공유 저장소로 옮기면 그건 고쳐진다. 이 실험이 묻는 것은 무엇이 같이 고쳐지고 무엇이 안 고쳐지는가다.

예측
통념 공유 저장소로 옮기면 다중 인스턴스 문제가 해결된다
B-2 모델 인스턴스 간 공유만 해결되고 브라우저 간 격리와 로그아웃 정리는 안 바뀐다

어디에 두는가와 어떻게 찾는가는 서로 독립이다.

   저장소 (where)   메모리 → PostgreSQL → Redis …   ← 옮기면 인스턴스 간 공유가 된다
   조회 키 (how)    (clientRegistrationId, principalName)  ← 옮겨도 그대로다

이 실험이 판정하는 것은 두 번째이고, 키는 코드가 아니라 스키마에 박혀 있다. 그래서 구현을 바꾸면 되겠지로 넘어갈 수 없고, 주입하기 전에 그 줄을 직접 읽는다.

같은 성질이 로그아웃에서도 나온다. 지워야 하는 것이 셋인데 셋이 서로 다른 시스템에 있다.

   ① HttpSession           Redis          Spring Security 가 지운다
   ② OAuth2AuthorizedClient PostgreSQL     ★ 아무도 안 지운다
   ③ IdP SSO 세션           Keycloak       ★ RP-initiated logout 을 보내야 한다

전제와 되돌리기

  • 05-keycloak06-observability 가 끝나 있다.
  • B-0 과 B-1 이 끝나 BFF 가 replica 2개로 떠 있고 Redis 가 세션 저장소로 붙어 있다.
  • 명령은 kc-lab-1 에서 친다.
  • 브라우저가 필요하다. https://app1.hyeonworks.com/ 에 붙어 realm keycloak-patternslabuser 로 들어간다.
  • 터미널 하나와 브라우저 창 하나를 나란히 둔다.

상태를 바꾸는 실험이다. DDL 을 태우고 Redis 세션을 지우고 로그아웃한다. 실험대에서만 한다. 중간에 그만두려면 브라우저에서 다시 로그인하면 원래 상태로 돌아온다.

되돌리기가 어디까지 가는지는 무엇을 되돌리느냐로 갈린다.

무엇을 되돌리나 어디까지 가나
지운 Redis 세션 브라우저에서 다시 로그인한다. 지운 세션 자체는 되살아나지 않는다
덮어쓴 oauth2_authorized_client 다시 로그인하면 새 행이 만들어진다. 덮이기 전 토큰은 돌아오지 않는다
끊은 Keycloak SSO 세션 브라우저에서 다시 로그인한다
oauth2_authorized_client drop table 이 있지만 B-3 이 이 표를 쓰므로 평소에는 치지 않는다
JDBC 토큰 저장소 배선 자체 소스를 되돌리고 다시 빌드해 두 노드에 다시 import 해야 한다

마지막 줄이 B층과 A층이 갈리는 곳이다. 이 편의 절차 안에는 소스를 고치는 단계가 없고 B-1 이 만든 구성 위에서 시작하는데, JDBC 배선을 걷어내려면 애플리케이션을 다시 빌드해야 한다. B-2 가 소스에 넣은 것이 무엇인지는 B-0 의 주입 절이 지울 목록으로 적어 두었다.

B-2 가 넣은 것 어느 파일
spring-boot-starter-jdbc · postgresql · h2 bff/pom.xml
OAuth2AuthorizedClientServiceOAuth2AuthorizedClientManager 명시 빈 SecurityConfig.java
spring.datasource · spring.sql.init application.yml
BFF_DB_URL · BFF_DB_USER · BFF_DB_PASSWORD deploy/lab/k8s/bff-redis.yaml

그 넷을 되돌리는 명령은 B-0 의 되돌리기와 같은 한 줄이고, 소스만 되돌리면 클러스터에는 여전히 옛 이미지가 도므로 다시 빌드해 두 노드에 다시 밀어 넣는 데까지 가야 한다.

git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
  bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java \
  deploy/lab/k8s/bff-redis.yaml
docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab rollout restart deployment/bff
kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s

위 블록은 한 블록인데 기계가 둘이다. docker 로 시작하는 앞의 세 줄은 워크스테이션에서 치고, kubectl 로 시작하는 뒤의 세 줄은 kc-lab-1 에서 친다. 워크스테이션에는 kubeconfig 가 없어 거기서 kubectl 을 치면 클러스터에 못 붙고 끝난다. kubectl apply 가 읽는 deploy/lab/k8s/bff-redis.yaml 은 바로 위에서 git checkout 으로 되돌린 그 파일이므로 kc-lab-1 쪽 체크아웃에도 같은 내용이 있어야 하는데, 옮기는 명령은 원 가이드에 없다(unknown).

B-2 자신의 가이드에는 JDBC 배선을 넣는 편집 명령도 그때 친 빌드 명령도 없다(unknown). 증거에 남은 것은 배포 결과 두 줄뿐이고, 위 되돌리기는 B-0 이 적어 둔 목록과 B-1 이 적어 둔 재빌드 순서를 그대로 옮겼다.

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

시험군만 재는 측정은 측정이 아니다. 덮어쓰기를 보려면 덮어쓰이기 전의 행이 있어야 하고, 로그아웃 정리를 보려면 로그아웃 전의 세 숫자가 있어야 한다.

파드 → 테이블 존재 → 스키마(키) → 세 저장소 세기 → 브라우저 로그인 → 대조군 행

1. BFF 두 개가 다른 노드에 있는가

무엇을 보는가 — 파드 배치와 재시작 횟수.

kubectl -n keycloak-lab get pods -o wide

어디를 보나 — 모양은 이렇고 주소와 해시는 환경마다 다르다(observed).

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.5210.42.1.124 는 B-5 의 증거에 남은 실제 BFF 파드 주소다. Redis 와 PostgreSQL 은 매니페스트가 nodeSelectorkc-lab-2 에 고정해 둔다.

파드 이름은 자주 바뀌므로 이름 대신 라벨로 부른다.

kubectl -n keycloak-lab get pods -l app=bff

실측은 이렇다(observed, 01-jdbc-store-deploy.txt). 위 두 줄은 JDBC 토큰 저장소를 올린 배포 명령이 같이 찍은 것이고, 이 절차는 그 배포가 끝난 뒤부터 시작한다.

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

이 값이 뜻하는 것bff 가 두 개이고 READY 가 둘 다 1/1 이어야 하며 NODE 가 서로 달라야 한다. 같은 노드에 몰려 있으면 다른 인스턴스가 같은 커널 위의 다른 프로세스일 뿐이고, topologySpreadConstraints 가 이걸 벌려 놓는다. replica 가 하나면 이 실험의 질문이 성립하지 않는다. RESTARTS0 인 것도 적어 둔다. 뒤에서 이 값이 오르면 건드린 것이 엉뚱한 데 닿았다.

2. 토큰이 들어갈 테이블이 실제로 있는가

무엇을 보는가oauth2_authorized_client 가 있는지. 원래 실행은 여기서 한 번 넘어졌다. 파드는 떴고 Hikari 도 붙었는데 테이블이 없었고 아무도 그것을 신고하지 않았다.

kubectl -n keycloak-lab exec deploy/postgres -- \
  psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'

어디를 보나 — 실측은 이렇다(observed, 01-jdbc-store-deploy.txt).

=== oauth2_authorized_client 테이블이 생겼는가 ===
Did not find any relation named "oauth2_authorized_client".
command terminated with exit code 1

이 값이 뜻하는 것 — 이 두 줄이 나오면 아직 아무것도 저장되지 않는 상태다. 스키마 초기화가 조용히 실패했고 원인은 타입 이름 하나다. 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 의 주제다.

3. PostgreSQL 전용 DDL 을 파일로 만들어 태운다

목적 — 토큰이 들어갈 표를 만든다.

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 전용 스키마 === 절 원문이다(observed).

kubectl -n keycloak-lab exec -i deploy/postgres -- \
  psql -U keycloak -d keycloak < /tmp/oauth2-pg.sql

예상 결과 — 실측은 이렇다(observed, 02-schema.txt).

=== 적용 ===
CREATE TABLE

왜 필요한가-i 를 빼면 < 로 넘긴 파일이 파드 안으로 안 들어간다. 아무 일도 안 일어나고 오류도 안 난다. kubectl exec 는 stdin 을 기본으로 연결하지 않는다.

문제가 생기면 — 되돌리는 명령은 있지만 평소에는 치지 않는다. B-3 이후로도 이 표를 계속 쓴다.

kubectl -n keycloak-lab exec deploy/postgres -- \
  psql -U keycloak -d keycloak -c 'drop table oauth2_authorized_client'

4. 기본키를 눈으로 읽는다

무엇을 보는가 — 이 편의 답이 박혀 있는 한 줄. 이 줄을 보기 전에는 다음으로 넘어가지 않는다.

kubectl -n keycloak-lab exec deploy/postgres -- \
  psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'

어디를 보나 — 실측은 이렇다(observed, 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 로 바꿔도 직접 구현해도 이 키를 그대로 쓰는 한 결과는 같다.

5. 세 저장소를 세는 명령을 확정한다

무엇을 보는가 — 관찰 절에서 로그아웃 전후로 견줄 숫자 셋. 다른 명령으로 재면 비교가 아니다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'

모양은 B-1 측정과 같다(observed).

bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae

KEYS * 대신 --scan 을 쓰는 것은 KEYS 가 Redis 를 잡아 두고 전 키를 훑기 때문이다. 그리고 dbsize 는 이 실험에서 부정확하다. Redis 하나를 BFF 와 B-7 의 oauth2-proxy 가 나눠 쓰므로 dbsize 에는 _oauth2_proxy-… 키도 섞인다. 접두어로 걸러 세는 쪽이 맞다.

kubectl -n keycloak-lab exec deploy/postgres -- \
  psql -U keycloak -d keycloak -c 'select count(*) from oauth2_authorized_client'
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_user_sessionoffline_flag = 0 으로 들어 있다. B-3 에서 확인된 성질이다. 원래 실행은 Keycloak 관리 API 로 셌고 증거에는 숫자만 남아 있다. ③ 은 같은 숫자를 DB 쪽에서 보는 형태이고 오래 미검증이었는데, 2026-09-17 에 ②③④ 를 나란히 쳤다(observed).

 count
-------
     0
 offline_flag | count
--------------+-------
 0            |     5
[ {
  "offline" : "0",
  "clientId" : "bff-confidential",
  "active" : "4",
  "id" : "7ae3362a-2bed-4902-b6af-d7307ea4ad0b"
} ]

③ 과 ④ 는 같은 숫자가 아니다. DB 는 5 인데 관리 API 는 4 라고 답했다. 둘이 세는 단위가 다르기 때문이다 — offline_user_session사용자 세션의 행이고, client-session-stats클라이언트 세션을 클라이언트마다 센다. 사용자 세션 하나에 클라이언트 세션이 0개일 수도 여럿일 수도 있으므로 두 수는 맞을 이유가 없다. 「③ 은 같은 숫자를 DB 쪽에서 보는 형태」라는 위 문장은 그래서 정확하지 않다. 대조군으로 쓸 때는 둘 중 하나를 골라 끝까지 그것만 쓴다. 관리 API 로 보려면 이쪽이다.

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)"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get client-session-stats -r keycloak-patterns

비밀번호를 화면에 찍지 않는다. 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 남지 않고, 존재와 길이만 보려면 base64 -d | wc -c 로 센다.

6. 브라우저로 로그인하고 토큰 경계를 읽는다

무엇을 보는가 — 토큰이 이제 서버에 있는지.

브라우저에서 https://app1.hyeonworks.com/ 를 열고 Keycloak 로그인을 눌러 labuser 로 들어간 뒤 token 경계 확인을 누른다. realm 은 keycloak-patterns 이고 비밀번호는 B-0 에서 그 사용자를 만들 때 정한 값이다.

어디를 보나 — 실측은 이렇다(observed, 해설 문서 3절).

{"principal":"labuser",
 "accessTokenStoredOnServer":true,       B-1 에서는 false 였다
 "refreshTokenStoredOnServer":true,
 "browserTokenCount":0}

이 값이 뜻하는 것accessTokenStoredOnServertrue 다. B-1 에서는 인가된 클라이언트가 프로세스 메모리에 있어 로그인을 처리하지 않은 replica 가 답하면 아무것도 못 찾았고, 지금은 두 replica 가 같은 PostgreSQL 행을 본다. 이 값이 아직 false 로 나오면 표는 만들었는데 옛 세션을 쓰고 있는 것이므로 로그아웃하고 다시 로그인한다. 증거의 b2-before-relogin.png 가 정확히 그 상태다.

7. 대조군 행을 잡는다

무엇을 보는가 — 덮어쓰이기 전의 행 수와 토큰 해시와 발급 시각.

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"

어디를 보나 — 실측은 이렇다(observed, 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 둘 다 적어 둔다. 토큰 값이 아니라 md5 를 보는 까닭은, 값 자체가 지금 쓸 수 있는 자격증명이라 터미널 스크롤백에 남기면 안 되기 때문이다. md5 는 같은가 다른가만 답하고 그것이 이 단계가 묻는 전부다.

크기도 같이 본다.

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"

실측은 이렇다(observed, 해설 문서 3절. 이 표는 .txt 증거에는 없고 문서에만 있다).

 client_registration_id | principal_name | access_token_type | at_len | rt_len
------------------------+----------------+-------------------+--------+--------
 keycloak               | labuser        | Bearer            |   1431 |    744

주입

1. 세션을 지우고 같은 사용자로 다시 로그인시킨다

목적 — 두 번째 브라우저에 해당하는 상태를 만든다. 조회 키가 (clientRegistrationId, principalName) 이므로 브라우저가 둘이든 하나든 같은 행을 쓴다는 점에서 등가다.

증거 04-overwrite-test.txt 는 실제로 한 일을 이렇게 적었다(observed).

=== [모의 두 번째 브라우저] 세션만 지우고 같은 사용자로 다시 로그인시킨다 ===
  (브라우저가 달라도 principal 은 같으므로 조회 키가 같다)
  Redis 세션 삭제 완료 — 다음 요청이 새 로그인을 만든다

해설 문서는 처음에 두 브라우저에서라고 적었다가 측정하지 않은 것을 측정한 것처럼 적었다고 정정했다. 진짜로 두 브라우저를 쓰려면 시크릿 창을 하나 더 열어 같은 계정으로 로그인하면 되고, 결과는 같아야 하며 다르면 그게 더 중요한 발견이다.

지우기 전에 무엇을 지울지 눈으로 본다. 이 Redis 는 BFF 혼자 쓰는 것이 아니다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan

모양은 이렇다(observed).

bff:session:sessions:c63c39ee-...
bff:session:expires:c63c39ee-...
_oauth2_proxy-f6a9201fd534a047998278452001ccbf

_oauth2_proxy- 로 시작하는 키가 섞여 있으면 FLUSHALL 을 치면 안 된다. B-7 의 oauth2-proxy 세션까지 날아가 그쪽 실험이 오염된다. 접두어로 골라 지운다.

원래 실행은 스크립트를 돌렸고 아래 형태는 원 가이드가 손으로 치기 좋게 고쳐 미검증으로 표시한 것이다(unknown). 후속 문서 3절이 oauth2-proxy 세션을 지울 때 쓴 것과 같은 모양이다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*' \
  | xargs -r kubectl -n keycloak-lab exec deploy/redis -- redis-cli del
date '+%H:%M:%S 세션 삭제'

이 줄에는 B-1 이 같은 출력에 붙였던 tr -d '\r' 이 없다. 그것이 문제가 되는지를 오래 미검증으로 두었는데, 2026-09-17 에 가렸다 — 문제가 안 된다(observed).

시험용 키 둘을 넣고 위 블록을 한 글자도 안 바꾸고 쳤더니 del5 를 돌려주고 스캔이 빈손이 됐다. 키가 실제로 지워졌다. 출력에 CR 이 붙는지를 od -c 로 직접 봤더니 줄 끝이 \n 하나다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*' | od -c | head -3
0000000   b   f   f   :   s   e   s   s   i   o   n   :   p   r   o   b
0000020   e   3  \n

갈리는 것은 redis-cli 가 아니라 kubectl exec 에 tty 가 붙었는지다. 위 블록은 -it 없이 파이프로 받으므로 tty 가 없고 CR 도 없다. B-1 은 tty 가 붙는 형태로 쳤고 그쪽에서는 tr -d '\r' 이 필요하다. 그러니 이 줄은 그대로 두는 것이 맞다.

예상 결과 — 모양은 이렇다(observed).

(integer) 2
16:21:03 세션 삭제

(integer) 0 이 나왔으면 지워진 키가 없다. 그대로 다음으로 가지 말고 주입 검증 1번을 먼저 친다 — 첫 명령에 bff:session:* 키가 남아 있으면 세션이 안 지워진 상태이고, 그 위에서 관찰 절을 재면 덮어쓰기가 아니라 아무 일도 안 일어난 것을 재게 된다.

왜 필요한가 — 시각을 적어 둔다. 뒤에서 access_token_issued_at 이 이 시각 뒤인지로 새 로그인이 실제로 일어났는가를 판정한다.

문제가 생기면_oauth2_proxy-* 키까지 사라졌으면 FLUSHALL 을 쳐서 B-7 세션까지 지웠다. 그 상태는 이 절차로 되돌릴 수 없고 B-7 쪽에서 다시 로그인해야 한다.

주입 검증

결과를 읽기 전에 주입이 의도한 것만 건드렸는지 먼저 본다.

1. BFF 세션만 사라지고 B-7 키는 그대로인가

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'

첫 명령은 아무것도 안 나와야 하고 두 번째는 주입 전과 같아야 한다. 두 번째까지 비었으면 FLUSHALL 을 쳤다.

2. BFF 가 재시작되지 않았는가

kubectl -n keycloak-lab get pods -l app=bff

RESTARTS 가 여전히 0 이어야 한다. 세션을 지우는 것은 BFF 를 건드리지 않는다. 여기서 재시작이 올랐다면 Redis 쪽을 잘못 만진 것이고, 그 상태로 재면 덮어쓰기가 아니라 파드 재시작을 재게 된다.

3. 조용한 재인증이 실제로 일어났는가

브라우저에서 https://app1.hyeonworks.com/ 를 새로고침하고 token 경계 확인을 누른다. 로그인 화면이 뜨지 않고 그냥 들어가진다. Redis 세션은 지워졌지만 Keycloak SSO 세션은 살아 있어서, BFF 가 /oauth2/authorization/keycloak 으로 보내면 Keycloak 이 화면 없이 즉시 코드를 돌려주고 새 로그인 한 벌이 조용히 만들어진다. 이것이 모의 두 번째 브라우저다. 같은 조용한 재인증이 로그아웃 뒤에는 로그아웃했는데 다시 들어가진다로 보인다. 같은 성질의 양면이다.

관찰

1. 행이 늘었는가 덮어써졌는가

무엇을 보는가 — 주입 전에 친 것과 똑같은 명령의 결과.

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"

어디를 보나 — 실측은 이렇다(observed, 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 row) (1 row) INSERT 가 아니다
at_md5 675af228… e19a63fc… 내용은 바뀌었다
issued_at 05:10:46 05:12:13 삭제 시각 뒤 = 새 로그인 맞다

이 값이 뜻하는 것 — 셋 중 하나만 보면 틀린다. 행 수만 보면 아무 일도 없었다로, md5 만 보면 새 행이 생겼나로 읽힌다. UPDATE 다.

   브라우저 A 로그인  →  (keycloak, labuser) 행 생성
   브라우저 B 로그인  →  같은 행을 덮어쓴다
                          └─ A 의 토큰은 사라진다

A 쪽에서 다음 요청을 하면 B 의 토큰을 쓰게 된다. 같은 사용자이므로 당장은 아무 증상이 없고 증상은 나중에 나온다.

언제 문제가 되는가
B 가 로그아웃하면 A 도 같이 끊긴다. 행이 지워지므로
refresh 회전이 켜져 있으면 A 와 B 가 같은 refresh token 을 다툰다 → B-3
스코프가 다른 로그인이면 나중 것이 이긴다

저장소를 바꾸면 고쳐지는가 — 안 고쳐진다. PRIMARY KEY 줄이 답이다.

   InMemory   →  PostgreSQL  →  Redis  →  직접 구현
        └────────── 전부 (clientRegistrationId, principalName) 로 찾는다 ──────────┘

고치려면 조회 키에 세션을 넣어야 하고, 그것은 저장소가 아니라 OAuth2AuthorizedClientRepository 쪽 이야기다.

후보 컨트롤러 변경 조회 키 문제
JdbcOAuth2AuthorizedClientService 불필요 (같은 인터페이스) 안 고쳐짐
Redis 직접 구현 불필요 안 고쳐짐
HttpSessionOAuth2AuthorizedClientRepository 필요 (Repository 로 바꿔야) 고쳐짐

이 실험이 세 번째를 고르지 않은 것은 Q3 가 Redis 와 JDBC 중 무엇을 물었기 때문이고, 그 대가로 조회 키 문제가 풀리지 않았다. 선택이 남긴 자국을 측정한 것이지 실수가 아니다.

2. 저장된 토큰이 평문인가

무엇을 보는가bytea 안에 든 것이 암호화된 덩어리인지 JWT 문자열인지.

값을 찍기 전에 무엇을 찍게 될지 길이로 먼저 안다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
  "select length(refresh_token_value) from oauth2_authorized_client"

실측은 744 다(observed, 해설 문서 3절의 rt_len). 암호화된 덩어리라면 여기서 알 수 없으므로 앞 몇 글자만 본다.

원래 실행은 앞 200자 남짓을 통째로 찍었다. 아래 형태는 화면에 남는 양을 줄인 것이고 원 가이드가 미검증으로 표시했다(unknown).

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"

어디를 보나 — 실측은 03-plaintext-tokens.txt 에 있고, 원래 실행이 찍은 문자열 가운데 앞 36자만 옮긴다(observed). 그 뒤는 지금 쓸 수 있는 자격증명이라 증거 파일에만 둔다.

=== Q3 검증 2번 — 저장소를 직접 열어 refresh token 이 평문인가 ===
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldU

이 값이 뜻하는 것eyJ 로 시작한다. 그것이 {" 의 base64 이고 JWT 는 예외 없이 이렇게 시작한다. convert_from 이 성공하는 것 자체가 답을 준다. 암호화된 바이트라면 UTF-8 로 디코드되지 않고 오류가 나므로, 읽힌다는 것은 텍스트라는 뜻이다.

정말 JWT 인지 헤더를 풀어 본다. 원 가이드는 이 줄도 미검증으로 표시한다(unknown).

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

실측은 이렇다(observed, 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 를 감싸거나 직접 구현해야 한다.

원래 실행은 여기서 한 번 넘어졌고 증거 파일에 그 실패가 그대로 있다(observed, 03-plaintext-tokens.txt).

=== 그 문자열이 실제 JWT 인지 — 헤더를 디코드 ===
  File "<string>", line 3
    h=open(/tmp/hdr.txt).read().strip()
           ^
SyntaxError: invalid syntax

파이썬 한 줄짜리로 디코드하려다 따옴표를 빠뜨렸다. 셸 안에 프로그램을 밀어 넣으면 문법 오류가 측정 결과 칸에 남는다. cutbase64 -d 로 충분하고 그 둘은 문법이 틀릴 곳이 없다.

3. 로그아웃하면 세 저장소가 다 정리되는가

목적 — 로그아웃 전후의 세 숫자를 같은 명령으로 견준다.

로그아웃 전에 세 숫자를 먼저 잡는다. 주입 전에 정해 둔 명령 그대로다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
kubectl -n keycloak-lab exec deploy/postgres -- \
  psql -U keycloak -d keycloak -tAc 'select count(*) from oauth2_authorized_client'

실측은 이렇다(observed, 04-overwrite-test.txt).

=== Q1 검증 ④ — 로그아웃하면 두 저장소가 다 정리되는가 ===
  로그아웃 전
    Redis: 1 키
    PostgreSQL: 1 행

화면에 로그아웃 버튼이 없다. index.html 에는 로그인과 조회 버튼만 있다. Spring Security 의 로그아웃은 CSRF 토큰이 붙은 POST /logout 이므로 브라우저 콘솔에서 친다. 로그인된 app1 탭에서 F12 를 눌러 Console 로 간다. 원 가이드가 미검증으로 표시한 조각이다(unknown).

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);

셸이 아니라 브라우저인 까닭은 세션 쿠키가 HttpOnlycurl 로 로그인 상태를 재현할 수 없기 때문이다. XSRF-TOKEN 쿠키만 JS 가 읽을 수 있게 되어 있고(CookieCsrfTokenRepository.withHttpOnlyFalse()) 그래서 이 조각이 성립한다. 해설 문서 8절은 같은 일을 form 파라미터 _csrf 로 적었는데 어느 쪽이든 SpaCsrfTokenRequestHandler 가 받아 준다.

로그아웃 후 같은 세 명령을 친다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
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"
kubectl -n keycloak-lab exec deploy/postgres -- \
  psql -U keycloak -d keycloak -c 'select offline_flag, count(*) from offline_user_session group by 1'

예상 결과 — 실측은 이렇다(observed, 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_atissued_at 의 60초 뒤인 것도 같이 본다. B-0 에서 accessTokenLifespan=60 으로 잡았기 때문이다. access token 은 이미 만료됐는데 같은 행의 refresh token 은 아직 쓸 수 있고 그것은 평문이다.

브라우저에서 https://app1.hyeonworks.com/ 를 다시 열면 로그인 화면이 안 뜨고 그냥 들어가진다. 주입 검증에서 본 것과 같은 조용한 재인증이다. 애플리케이션 세션은 지웠는데 IdP 세션은 살아 있으므로 IdP 가 화면 없이 새 세션을 만들어 주고, 사용자 입장에서는 로그아웃이 안 됐다.

필요한 것 방법
authorized client 삭제 LogoutSuccessHandler 에서 removeAuthorizedClient 호출
Keycloak 세션 종료 RP-initiated logout — OidcClientInitiatedLogoutSuccessHandler
두 곳을 원자적으로 한쪽이 실패하면 어떻게 할지 — 정리 순서와 실패 처리를 정해야 한다

Q3 는 미지수 5번으로 「두 store 를 logout 에서 어떻게 한 번에 지우게 되는가」를 남겼는데, 이 실험이 그 답을 냈다 — 지금은 하나도 안 지운다.

문제가 생기면 — 로그아웃 POST 가 403 이면 CSRF 토큰이 없거나 헤더 이름이 틀린 것이므로 /bff/csrfheaderName 을 그대로 쓴다. 되돌리기는 브라우저에서 다시 로그인하는 것이다.

복구와 원상복구 확인표

1. 남은 행을 지운다

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
  "delete from oauth2_authorized_client where principal_name = 'labuser'"

모양은 DELETE 1 이다(observed). 브라우저에서 다시 로그인하면 행이 다시 만들어진다. 표 자체는 지우지 않는다. B-3 이 이 표를 쓴다.

2. Keycloak SSO 세션을 사람이 끊는다

RP 가 로그아웃을 안 보내 주므로 사람이 직접 끊는다. 브라우저에서 아래 주소를 열고 확인 화면이 뜨면 승인한다. 이 실험은 여기까지 재지 않았다(unknown).

https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/logout
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 이 안 되어도 놀랄 일이 아니다. 지운 Redis 세션은 되돌아오지 않으며 브라우저에서 다시 로그인하는 것이 복구다.

항목 명령 돌아왔을 때
파드 kubectl -n keycloak-lab get pods -l app=bff 둘 다 1/1 Running, RESTARTS 0
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-*' 주입 전과 같아야 한다
curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/ 200
임시 파일 rm -f /tmp/oauth2-pg.sql

JDBC 배선까지 걷어낼 것이면 전제와 되돌리기 절의 두 블록을 친다. 소스를 되돌리고 다시 빌드해 두 노드에 다시 import 한 뒤 배포해야 클러스터가 소스와 같아진다.

막히면

원 가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다고 적는다.

증상 원인 확인
Did not find any relation named "oauth2_authorized_client" 스키마 초기화가 조용히 실패했다. 기본 DDL 의 blob 은 PostgreSQL 에 없는 타입 -postgres.sql 판본을 태운다
파드는 정상인데 토큰이 저장되지 않는다 같은 원인. 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 를 나눠 쓴다 접두어로만 지운다
파이썬 한 줄로 디코드하다 SyntaxError 원래 실행이 이 실수를 했다 cut -d. -f1 | base64 -d 로 충분하다
로그아웃 POST 가 403 CSRF 토큰이 없거나 이름이 틀렸다 /bff/csrfheaderName 을 그대로 쓴다
로그아웃했는데 다시 들어가진다 버그가 아니다. Keycloak SSO 세션이 살아 있다 RP-initiated logout 을 사람이 연다
dbsize 와 세어 본 키 수가 다르다 oauth2-proxy 키가 섞여 있다 --scan --pattern 으로 나눠 센다

이 실험대에서 아직 못 밟은 단계

2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. 못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.

  • 끝까지 밟았다(2026-09-17). TLS 를 세우고 로그인한 뒤 7 절과 관찰 1·2 를 전부 밟았다. 문서의 값과 거의 그대로 나온다.
 client_registration_id | principal_name | access_token_type | at_len | rt_len
------------------------+----------------+-------------------+--------+--------
 keycloak               | labuser        | Bearer            |   1437 |    744

rt_len744 로 문서와 같고 at_len1431 대신 1437 이다 — access token 은 클레임에 따라 길이가 조금씩 달라진다.

  • 오래 미검증이던 두 줄도 값을 냈다(observed). 관찰 2 의 left(convert_from(...), 40) 형태가 그대로 돌고, 나온 문자열이 문서에 실린 36자와 한 글자도 다르지 않다.
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldU
  • 덮어쓰기도 재현됐다(observed). 같은 사용자로 새 브라우저에서 한 번 더 로그인했더니 행 수는 1 그대로인데 at_md5 가 바뀌었다. 앞 로그인의 토큰은 그 순간 사라졌다.
전   행 1 · at_md5 722769ac041a42164d08060e5446d02b
뒤   행 1 · at_md5 5c38b11f27f490ea1d9ee07e73aef2b2 · 발급 2026-09-17 08:05:52
  • 남은 것 — 관찰 3(로그아웃하면 세 저장소가 다 정리되는가). 브라우저 없이 POST /logout 을 치려면 CSRF 토큰이 필요한데 그 구간은 밟지 않았다.
  • 막는 것https://auth.hyeonworks.com 이 서지 않는다. 남은 것은 인증서 하나다(2026-09-17 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길은 호스트의 libvirt guest_input 구멍과 유닛의 ExecStartPost 가 들어가면서 열렸고(http 가 밖에서 200), 클러스터 안에서 그 이름이 엣지를 안 가리키던 것은 기반 가이드 03 의 CoreDNS 한 단계로 놓았다. 인증서는 Cloudflare API 토큰이 필요한 DNS-01 로만 받을 수 있다 — 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않는다.
  • 그때까지 이 편의 실측 가운데 (observed) 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다. 나머지는 원래 실행의 값이다.

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

이 절차의 숫자는 2026-09-04 14:0914:13 KST 에 돈 한 번의 실행에서 나왔다(observed).

  • (observed) 테이블이 없을 때의 Did not find any relation ...exit code 1, CREATE TABLE, 컬럼 표 전문과 PRIMARY KEY, btree (client_registration_id, principal_name), 대조군 행의 2026-09-04 05:10:46.927192675af2286bfc2fd9d2bab7bc8f391df7, 재로그인 뒤의 2026-09-04 05:12:13.018828e19a63fc5aa18bd0a68b3e19dff16b3b(1 row), at_len 1431rt_len 744, 디코드한 두 JWT 헤더와 페이로드 앞부분, 로그아웃 뒤 Redis 0 키 · PostgreSQL 1 행 · Keycloak 온라인 세션 2, access_token_expires_atissued_at 의 60초 뒤인 것.
  • (unknown) Keycloak 세션을 DB 쪽에서 세는 질의, --scan | xargs ... redis-cli del 로 BFF 세션만 지우는 줄, left(convert_from(...), 40) 으로 앞 40자만 찍는 줄, cuttrbase64 -d 로 헤더를 푸는 줄, 브라우저 콘솔의 로그아웃 조각, RP-initiated logout 주소. 원 가이드가 전부 미검증으로 표시했다.
  • 원래 실행과 다르게 적은 곳 — refresh token 값은 증거 파일에 200자 남짓이 있지만 여기에는 앞 36자만 옮겼다. 나머지는 지금 쓸 수 있는 자격증명이라 옮기지 않는다. at_md5 두 개는 해시라 그대로 적었다.
  • (observed) 파이썬 한 줄로 JWT 헤더를 디코드하려다 난 SyntaxError 도 증거 파일에 있다. 그 시도가 깨진 뒤 cutbase64 -d 로 다시 받았다.
  • 스크린샷으로는 판정하지 못한다 — b2-tokens-shared-across-instances.png 는 B-0 의 b0-bff-token-boundary.png 와 동일 파일이다(md5 9ed00537…). 두 시점 모두 accessTokenStoredOnServer: true 인 같은 화면이라 바이트가 같다. 증명은 표가 생겼다는 것과 행에 토큰이 들어 있다는 것이 한다.
  • 이 실험이 재지 않은 것 — 진짜 두 브라우저를 열어 같은 결과가 나오는지는 재지 않았다. 세션을 지우고 다시 로그인하는 것이 등가인 까닭은 조회 키가 같기 때문이라는 추론이고 측정이 아니다. RP-initiated logout 을 열었을 때 세션 수가 실제로 줄어드는지도 재지 않았다.
  • 이 편의 절차에는 소스를 고치는 단계가 없다(unknown). JDBC 토큰 저장소를 넣은 편집과 빌드는 증거에 배포 결과 두 줄로만 남았고, 되돌리기 절의 파일 목록은 B-0 이 적어 둔 지울 목록에서 가져왔다.