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>
53 KiB
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 |
|
|
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 · h2 와 SecurityConfig 의 명시 빈 둘과 spring.datasource 와 BFF_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 로 나오면 … 로그아웃하고 다시 로그인한다」가 끝나지 않는 고리가 되어 로그인만 반복하게 된다. 두 번 재로그인해도 accessTokenStoredOnServer 가 false 이고 표의 행이 0이면 배선이 안 들어간 상태이므로 이 절차를 멈춘다.
무엇을 넣어야 하는지는 B-0 의 주입 절이 지울 목록으로 적어 두었고, 바로 아래 「전제와 되돌리기」가 그 넷을 표로 옮겨 두었다. 그 넷을 되돌려 넣는 순서와 그때 친 빌드 명령은 원 가이드에 없다(unknown).
읽기 전에 — 어디서 치는가
명령은 [lab host] 에서 kubectl 로 친다. kubectl 에 sudo 를 붙이지 않는다. 브라우저에서 버튼을 누르고 터미널에서 저장소를 세는 왕복이 이 절차의 대부분이라 터미널 하나와 브라우저 창 하나를 나란히 둔다. 로그아웃 한 단계만 브라우저 개발자 도구의 콘솔에서 친다.
원 가이드는 이 명령들을 kc-lab-1 에서 치라고 적었다. 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 sudo 없는 kubectl 이 permission 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.xml 과 deploy/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-keycloak과06-observability가 끝나 있다.- B-0 과 B-1 이 끝나 BFF 가 replica 2개로 떠 있고 Redis 가 세션 저장소로 붙어 있다.
- 명령은
kc-lab-1에서 친다. - 브라우저가 필요하다.
https://app1.hyeonworks.com/에 붙어 realmkeycloak-patterns의labuser로 들어간다. - 터미널 하나와 브라우저 창 하나를 나란히 둔다.
상태를 바꾸는 실험이다. 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 |
OAuth2AuthorizedClientService 와 OAuth2AuthorizedClientManager 명시 빈 |
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.52 와 10.42.1.124 는 B-5 의 증거에 남은 실제 BFF 파드 주소다. Redis 와 PostgreSQL 은 매니페스트가 nodeSelector 로 kc-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 가 하나면 이 실험의 질문이 성립하지 않는다. RESTARTS 가 0 인 것도 적어 둔다. 뒤에서 이 값이 오르면 건드린 것이 엉뚱한 데 닿았다.
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_session 에 offline_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}
이 값이 뜻하는 것 — accessTokenStoredOnServer 가 true 다. 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).
시험용 키 둘을 넣고 위 블록을 한 글자도 안 바꾸고 쳤더니 del 이 5 를 돌려주고 스캔이 빈손이 됐다. 키가 실제로 지워졌다. 출력에 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
파이썬 한 줄짜리로 디코드하려다 따옴표를 빠뜨렸다. 셸 안에 프로그램을 밀어 넣으면 문법 오류가 측정 결과 칸에 남는다. cut 과 base64 -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);
셸이 아니라 브라우저인 까닭은 세션 쿠키가 HttpOnly 라 curl 로 로그인 상태를 재현할 수 없기 때문이다. 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_at 이 issued_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/csrf 의 headerName 을 그대로 쓴다. 되돌리기는 브라우저에서 다시 로그인하는 것이다.
복구와 원상복구 확인표
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/csrf 의 headerName 을 그대로 쓴다 |
| 로그아웃했는데 다시 들어가진다 | 버그가 아니다. 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_len 이 744 로 문서와 같고 at_len 만 1431 대신 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 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길은 호스트의 libvirtguest_input구멍과 유닛의ExecStartPost가 들어가면서 열렸고(http가 밖에서200), 클러스터 안에서 그 이름이 엣지를 안 가리키던 것은 기반 가이드 03 의 CoreDNS 한 단계로 놓았다. 인증서는 Cloudflare API 토큰이 필요한 DNS-01 로만 받을 수 있다 — 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않는다. - 그때까지 이 편의 실측 가운데
(observed)로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다. 나머지는 원래 실행의 값이다.
무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 2026-09-04 14:09–14: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.927192와675af2286bfc2fd9d2bab7bc8f391df7, 재로그인 뒤의2026-09-04 05:12:13.018828와e19a63fc5aa18bd0a68b3e19dff16b3b와(1 row),at_len 1431과rt_len 744, 디코드한 두 JWT 헤더와 페이로드 앞부분, 로그아웃 뒤Redis 0 키 · PostgreSQL 1 행 · Keycloak 온라인 세션 2,access_token_expires_at이issued_at의 60초 뒤인 것. - (unknown) Keycloak 세션을 DB 쪽에서 세는 질의,
--scan | xargs ... redis-cli del로 BFF 세션만 지우는 줄,left(convert_from(...), 40)으로 앞 40자만 찍는 줄,cut과tr과base64 -d로 헤더를 푸는 줄, 브라우저 콘솔의 로그아웃 조각, RP-initiated logout 주소. 원 가이드가 전부 미검증으로 표시했다. - 원래 실행과 다르게 적은 곳 — refresh token 값은 증거 파일에 200자 남짓이 있지만 여기에는 앞 36자만 옮겼다. 나머지는 지금 쓸 수 있는 자격증명이라 옮기지 않는다.
at_md5두 개는 해시라 그대로 적었다. - (observed) 파이썬 한 줄로 JWT 헤더를 디코드하려다 난
SyntaxError도 증거 파일에 있다. 그 시도가 깨진 뒤cut과base64 -d로 다시 받았다. - 스크린샷으로는 판정하지 못한다 —
b2-tokens-shared-across-instances.png는 B-0 의b0-bff-token-boundary.png와 동일 파일이다(md59ed00537…). 두 시점 모두accessTokenStoredOnServer: true인 같은 화면이라 바이트가 같다. 증명은 표가 생겼다는 것과 행에 토큰이 들어 있다는 것이 한다. - 이 실험이 재지 않은 것 — 진짜 두 브라우저를 열어 같은 결과가 나오는지는 재지 않았다. 세션을 지우고 다시 로그인하는 것이 등가인 까닭은 조회 키가 같기 때문이라는 추론이고 측정이 아니다. RP-initiated logout 을 열었을 때 세션 수가 실제로 줄어드는지도 재지 않았다.
- 이 편의 절차에는 소스를 고치는 단계가 없다(unknown). JDBC 토큰 저장소를 넣은 편집과 빌드는 증거에 배포 결과 두 줄로만 남았고, 되돌리기 절의 파일 목록은 B-0 이 적어 둔 지울 목록에서 가져왔다.