--- id: df2ee798-7f31-4570-a355-11f96c0eea84 kind: SETUP slug: reproduce-b2-jdbc-token-store title: 토큰을 PostgreSQL 로 옮기고 기본키와 로그아웃 정리를 확인한다 topic: where-application-state-lives topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기 project: keycloak-session-store status: 게시 전 studio: "https://hyeonworks.com/studio/documents/df2ee798-7f31-4570-a355-11f96c0eea84/edit" pinnedVersions: - name: keycloak-pattern-bff version: lab - name: Redis version: 7.4.x source: - final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-2 sourceRevision: 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` 가 없다. **그 상태로 쳐도 화면은 정상으로 보인다.** 주입 전 3번이 표를 만들고 `\d oauth2_authorized_client` 도 정의를 돌려주는데, 행만 한 번도 안 생긴다. 그러면 주입 전 6번의 「이 값이 아직 `false` 로 나오면 … 로그아웃하고 다시 로그인한다」가 끝나지 않는 고리가 되어 로그인만 반복하게 된다. 두 번 재로그인해도 `accessTokenStoredOnServer` 가 `false` 이고 표의 행이 0이면 배선이 안 들어간 상태이므로 이 절차를 멈춘다. 무엇을 넣어야 하는지는 B-0 의 주입 절이 지울 목록으로 적어 두었고, 바로 아래 「전제와 되돌리기」가 그 넷을 표로 옮겨 두었다. 그 넷을 되돌려 넣는 순서와 그때 친 빌드 명령은 원 가이드에 없다(unknown). ## 읽기 전에 — 어디서 치는가 명령은 `kc-lab-1` 에서 `kubectl` 로 친다. `kubectl` 에 `sudo` 를 붙이지 않는다. 브라우저에서 버튼을 누르고 터미널에서 저장소를 세는 왕복이 이 절차의 대부분이라 터미널 하나와 브라우저 창 하나를 나란히 둔다. 로그아웃 한 단계만 브라우저 개발자 도구의 콘솔에서 친다. 예외는 아래 「전제와 되돌리기」의 두 블록뿐이다. 거기서만 워크스테이션의 저장소를 고치고 이미지를 다시 만든다. **그 기계로 건너가는 명령은 이 절차에 없다** — 경로는 이미지를 밀어 넣는 줄 하나에만 드러난다(`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 모델 | 인스턴스 간 공유만 해결되고 브라우저 간 격리와 로그아웃 정리는 안 바뀐다 | 어디에 두는가와 어떻게 찾는가는 서로 독립이다. ```text 저장소 (where) 메모리 → PostgreSQL → Redis … ← 옮기면 인스턴스 간 공유가 된다 조회 키 (how) (clientRegistrationId, principalName) ← 옮겨도 그대로다 ``` 이 실험이 판정하는 것은 두 번째이고, 키는 코드가 아니라 스키마에 박혀 있다. 그래서 구현을 바꾸면 되겠지로 넘어갈 수 없고, 주입하기 전에 그 줄을 직접 읽는다. 같은 성질이 로그아웃에서도 나온다. 지워야 하는 것이 셋인데 셋이 서로 다른 시스템에 있다. ```text ① 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/` 에 붙어 realm `keycloak-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 의 되돌리기와 같은 한 줄이고, 소스만 되돌리면 클러스터에는 여전히 옛 이미지가 도므로 다시 빌드해 두 노드에 다시 밀어 넣는 데까지 가야 한다. ```bash label="[워크스테이션] 네 파일을 되돌린다" 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 ``` ```bash label="[워크스테이션 → kc-lab-1] 다시 빌드해 두 노드에 다시 넣고 다시 배포한다" 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 이 적어 둔 재빌드 순서를 그대로 옮겼다. ## 주입 전에 같은 명령으로 먼저 본다 시험군만 재는 측정은 측정이 아니다. 덮어쓰기를 보려면 덮어쓰이기 전의 행이 있어야 하고, 로그아웃 정리를 보려면 로그아웃 전의 세 숫자가 있어야 한다. ```text 파드 → 테이블 존재 → 스키마(키) → 세 저장소 세기 → 브라우저 로그인 → 대조군 행 ``` ### 1. BFF 두 개가 다른 노드에 있는가 **무엇을 보는가** — 파드 배치와 재시작 횟수. ```bash label="[kc-lab-1] 네임스페이스 전체를 본다" kubectl -n keycloak-lab get pods -o wide ``` **어디를 보나** — 모양은 이렇고 주소와 해시는 환경마다 다르다(observed). ```text 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` 에 고정해 둔다. 파드 이름은 자주 바뀌므로 이름 대신 라벨로 부른다. ```bash label="[kc-lab-1] 라벨로 BFF 만 본다" kubectl -n keycloak-lab get pods -l app=bff ``` 실측은 이렇다(observed, `01-jdbc-store-deploy.txt`). 위 두 줄은 JDBC 토큰 저장소를 올린 배포 명령이 같이 찍은 것이고, 이 절차는 그 배포가 끝난 뒤부터 시작한다. ```text 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 도 붙었는데 테이블이 없었고 아무도 그것을 신고하지 않았다. ```bash label="[kc-lab-1] 테이블 정의를 물어본다" kubectl -n keycloak-lab exec deploy/postgres -- \ psql -U keycloak -d keycloak -c '\d oauth2_authorized_client' ``` **어디를 보나** — 실측은 이렇다(observed, `01-jdbc-store-deploy.txt`). ```text === 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 은 여러 줄이고 나중에 다시 쓸 것이므로 파일로 만든다. 터미널에 붙여 넣는 명령과 프로그램 원문을 섞지 않는다. ```bash label="[kc-lab-1] ① 편집기로 DDL 파일을 만든다" 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` 의 `=== PostgreSQL 전용 스키마 ===` 절 원문이다(observed). ```bash label="[kc-lab-1] ② 파일을 파드 안으로 넘겨 태운다" kubectl -n keycloak-lab exec -i deploy/postgres -- \ psql -U keycloak -d keycloak < /tmp/oauth2-pg.sql ``` **예상 결과** — 실측은 이렇다(observed, `02-schema.txt`). ```text === 적용 === CREATE TABLE ``` **왜 필요한가** — `-i` 를 빼면 `<` 로 넘긴 파일이 파드 안으로 안 들어간다. 아무 일도 안 일어나고 오류도 안 난다. `kubectl exec` 는 stdin 을 기본으로 연결하지 않는다. **문제가 생기면** — 되돌리는 명령은 있지만 평소에는 치지 않는다. B-3 이후로도 이 표를 계속 쓴다. ```bash label="[kc-lab-1] 표를 지운다. 평소에는 치지 않는다" kubectl -n keycloak-lab exec deploy/postgres -- \ psql -U keycloak -d keycloak -c 'drop table oauth2_authorized_client' ``` ### 4. 기본키를 눈으로 읽는다 **무엇을 보는가** — 이 편의 답이 박혀 있는 한 줄. 이 줄을 보기 전에는 다음으로 넘어가지 않는다. ```bash label="[kc-lab-1] 테이블 정의를 다시 물어본다" kubectl -n keycloak-lab exec deploy/postgres -- \ psql -U keycloak -d keycloak -c '\d oauth2_authorized_client' ``` **어디를 보나** — 실측은 이렇다(observed, `02-schema.txt`). ```text 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:` 줄 하나가 답이다. ```text PRIMARY KEY, btree (client_registration_id, principal_name) └── "keycloak" ──┘ └── "labuser" ──┘ 세션 id 가 없다 ``` **이 값이 뜻하는 것** — 같은 사용자가 어떤 브라우저에서 로그인하든 `(keycloak, labuser)` 라는 한 행을 쓴다. B-0 에서 빈 이름인 `AuthenticatedPrincipalOAuth2AuthorizedClientRepository` 로 짐작했던 것이 테이블 정의로 확정된다. 저장소를 Redis 로 바꿔도 직접 구현해도 이 키를 그대로 쓰는 한 결과는 같다. ### 5. 세 저장소를 세는 명령을 확정한다 **무엇을 보는가** — 관찰 절에서 로그아웃 전후로 견줄 숫자 셋. 다른 명령으로 재면 비교가 아니다. ```bash label="[kc-lab-1] ① BFF 세션 키를 접두어로 골라 본다" kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*' ``` 모양은 B-1 측정과 같다(observed). ```text bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae ``` `KEYS *` 대신 `--scan` 을 쓰는 것은 `KEYS` 가 Redis 를 잡아 두고 전 키를 훑기 때문이다. 그리고 `dbsize` 는 이 실험에서 부정확하다. Redis 하나를 BFF 와 B-7 의 oauth2-proxy 가 나눠 쓰므로 `dbsize` 에는 `_oauth2_proxy-…` 키도 섞인다. 접두어로 걸러 세는 쪽이 맞다. ```bash label="[kc-lab-1] ② 토큰 행 수를 센다" kubectl -n keycloak-lab exec deploy/postgres -- \ psql -U keycloak -d keycloak -c 'select count(*) from oauth2_authorized_client' ``` ```bash label="[kc-lab-1] ③ Keycloak 세션을 DB 쪽에서 센다" 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 쪽에서 보는 형태이고 원 가이드가 미검증으로 표시했다(unknown). 관리 API 로 보려면 이쪽이다. ```bash label="[kc-lab-1] ④ 관리 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절). ```json {"principal":"labuser", "accessTokenStoredOnServer":true, ← B-1 에서는 false 였다 "refreshTokenStoredOnServer":true, "browserTokenCount":0} ``` **이 값이 뜻하는 것** — `accessTokenStoredOnServer` 가 `true` 다. B-1 에서는 인가된 클라이언트가 프로세스 메모리에 있어 로그인을 처리하지 않은 replica 가 답하면 아무것도 못 찾았고, 지금은 두 replica 가 같은 PostgreSQL 행을 본다. 이 값이 아직 `false` 로 나오면 표는 만들었는데 옛 세션을 쓰고 있는 것이므로 로그아웃하고 다시 로그인한다. 증거의 `b2-before-relogin.png` 가 정확히 그 상태다. ### 7. 대조군 행을 잡는다 **무엇을 보는가** — 덮어쓰이기 전의 행 수와 토큰 해시와 발급 시각. ```bash label="[kc-lab-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`). ```text === [현재] 같은 사용자의 항목 === 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 는 같은가 다른가만 답하고 그것이 이 단계가 묻는 전부다. 크기도 같이 본다. ```bash label="[kc-lab-1] ② 두 토큰의 바이트 수를 본다" 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` 증거에는 없고 문서에만 있다). ```text 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). ```text === [모의 두 번째 브라우저] 세션만 지우고 같은 사용자로 다시 로그인시킨다 === (브라우저가 달라도 principal 은 같으므로 조회 키가 같다) Redis 세션 삭제 완료 — 다음 요청이 새 로그인을 만든다 ``` 해설 문서는 처음에 두 브라우저에서라고 적었다가 측정하지 않은 것을 측정한 것처럼 적었다고 정정했다. 진짜로 두 브라우저를 쓰려면 시크릿 창을 하나 더 열어 같은 계정으로 로그인하면 되고, 결과는 같아야 하며 다르면 그게 더 중요한 발견이다. 지우기 전에 무엇을 지울지 눈으로 본다. 이 Redis 는 BFF 혼자 쓰는 것이 아니다. ```bash label="[kc-lab-1] ① 전체 키를 한 번 본다" kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan ``` 모양은 이렇다(observed). ```text bff:session:sessions:c63c39ee-... bff:session:expires:c63c39ee-... _oauth2_proxy-f6a9201fd534a047998278452001ccbf ``` `_oauth2_proxy-` 로 시작하는 키가 섞여 있으면 `FLUSHALL` 을 치면 안 된다. B-7 의 oauth2-proxy 세션까지 날아가 그쪽 실험이 오염된다. 접두어로 골라 지운다. 원래 실행은 스크립트를 돌렸고 아래 형태는 원 가이드가 손으로 치기 좋게 고쳐 미검증으로 표시한 것이다(unknown). 후속 문서 3절이 oauth2-proxy 세션을 지울 때 쓴 것과 같은 모양이다. ```bash label="[kc-lab-1] ② BFF 세션만 골라 지우고 시각을 남긴다" 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'` 이 없다. `redis-cli` 출력은 CR 을 달고 오므로 `xargs` 가 넘기는 키 이름이 실제 키와 안 맞을 수 있고, 그때 `del` 은 오류 없이 `(integer) 0` 을 돌려준 뒤 `date` 줄은 그대로 「세션 삭제」를 찍는다. 원 가이드의 이 줄에 그 조각이 없어 여기에도 안 넣었다(unknown). **예상 결과** — 모양은 이렇다(observed). ```text (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 키는 그대로인가 ```bash label="[kc-lab-1] 두 접두어를 따로 센다" 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 가 재시작되지 않았는가 ```bash label="[kc-lab-1] 재시작 횟수를 본다" 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. 행이 늘었는가 덮어써졌는가 **무엇을 보는가** — 주입 전에 친 것과 똑같은 명령의 결과. ```bash label="[kc-lab-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`). ```text === [재로그인 후] 행이 늘었는가, 덮어써졌는가 === 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 다. ```text 브라우저 A 로그인 → (keycloak, labuser) 행 생성 브라우저 B 로그인 → 같은 행을 덮어쓴다 └─ A 의 토큰은 사라진다 ``` A 쪽에서 다음 요청을 하면 B 의 토큰을 쓰게 된다. 같은 사용자이므로 당장은 아무 증상이 없고 증상은 나중에 나온다. | 언제 문제가 되는가 | | |---|---| | B 가 로그아웃하면 | A 도 같이 끊긴다. 행이 지워지므로 | | refresh 회전이 켜져 있으면 | A 와 B 가 같은 refresh token 을 다툰다 → B-3 | | 스코프가 다른 로그인이면 | 나중 것이 이긴다 | 저장소를 바꾸면 고쳐지는가 — 안 고쳐진다. `PRIMARY KEY` 줄이 답이다. ```text InMemory → PostgreSQL → Redis → 직접 구현 └────────── 전부 (clientRegistrationId, principalName) 로 찾는다 ──────────┘ ``` 고치려면 조회 키에 세션을 넣어야 하고, 그것은 저장소가 아니라 `OAuth2AuthorizedClientRepository` 쪽 이야기다. | 후보 | 컨트롤러 변경 | 조회 키 문제 | |---|---|---| | `JdbcOAuth2AuthorizedClientService` | 불필요 (같은 인터페이스) | 안 고쳐짐 | | Redis 직접 구현 | 불필요 | 안 고쳐짐 | | `HttpSessionOAuth2AuthorizedClientRepository` | 필요 (Repository 로 바꿔야) | 고쳐짐 | 이 실험이 세 번째를 고르지 않은 것은 Q3 가 Redis 와 JDBC 중 무엇을 물었기 때문이고, 그 대가로 조회 키 문제가 풀리지 않았다. 선택이 남긴 자국을 측정한 것이지 실수가 아니다. ### 2. 저장된 토큰이 평문인가 **무엇을 보는가** — `bytea` 안에 든 것이 암호화된 덩어리인지 JWT 문자열인지. 값을 찍기 전에 무엇을 찍게 될지 길이로 먼저 안다. ```bash label="[kc-lab-1] ① refresh token 의 바이트 수만 본다" 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). ```bash label="[kc-lab-1] ② 앞 40자만 텍스트로 디코드해 본다" 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). 그 뒤는 지금 쓸 수 있는 자격증명이라 증거 파일에만 둔다. ```text === Q3 검증 2번 — 저장소를 직접 열어 refresh token 이 평문인가 === eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldU ``` **이 값이 뜻하는 것** — `eyJ` 로 시작한다. 그것이 `{"` 의 base64 이고 JWT 는 예외 없이 이렇게 시작한다. `convert_from` 이 성공하는 것 자체가 답을 준다. 암호화된 바이트라면 UTF-8 로 디코드되지 않고 오류가 나므로, 읽힌다는 것은 텍스트라는 뜻이다. 정말 JWT 인지 헤더를 풀어 본다. 원 가이드는 이 줄도 미검증으로 표시한다(unknown). ```bash label="[kc-lab-1] ③ 첫 조각만 잘라 base64 로 푼다" 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`). ```text === 저장된 바이트를 그대로 디코드한 결과 === 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`). ```text === 그 문자열이 실제 JWT 인지 — 헤더를 디코드 === File "", line 3 h=open(/tmp/hdr.txt).read().strip() ^ SyntaxError: invalid syntax ``` 파이썬 한 줄짜리로 디코드하려다 따옴표를 빠뜨렸다. 셸 안에 프로그램을 밀어 넣으면 문법 오류가 측정 결과 칸에 남는다. `cut` 과 `base64 -d` 로 충분하고 그 둘은 문법이 틀릴 곳이 없다. ### 3. 로그아웃하면 세 저장소가 다 정리되는가 **목적** — 로그아웃 전후의 세 숫자를 같은 명령으로 견준다. 로그아웃 전에 세 숫자를 먼저 잡는다. 주입 전에 정해 둔 명령 그대로다. ```bash label="[kc-lab-1] ① 로그아웃 전 두 숫자를 잡는다" 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`). ```text === Q1 검증 ④ — 로그아웃하면 두 저장소가 다 정리되는가 === 로그아웃 전 Redis: 1 키 PostgreSQL: 1 행 ``` 화면에 로그아웃 버튼이 없다. `index.html` 에는 로그인과 조회 버튼만 있다. Spring Security 의 로그아웃은 CSRF 토큰이 붙은 `POST /logout` 이므로 브라우저 콘솔에서 친다. 로그인된 app1 탭에서 `F12` 를 눌러 Console 로 간다. 원 가이드가 미검증으로 표시한 조각이다(unknown). ```js label="[브라우저 콘솔] 로그아웃을 POST 로 보낸다" 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 label="[kc-lab-1] ② 로그아웃 뒤 세 숫자를 같은 명령으로 잡는다" 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`). ```text === 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 ``` 세 숫자를 나란히 놓으면 하나만 지워졌다. ```text 로그아웃 후: Redis 세션 : 0 키 ← 정리됨 PostgreSQL 토큰 : 1 행 ← 평문 refresh token 이 그대로 남는다 Keycloak SSO : 2 세션 ← 남아 있다 ``` ```text 로그아웃 ├─▶ 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. 남은 행을 지운다 ```bash label="[kc-lab-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). ```text https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/logout ``` ```bash label="[kc-lab-1] 세션 수가 줄었는지 본다" 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-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` 와 동일 파일이다(md5 `9ed00537…`). 두 시점 모두 `accessTokenStoredOnServer: true` 인 같은 화면이라 바이트가 같다. 증명은 표가 생겼다는 것과 행에 토큰이 들어 있다는 것이 한다. - 이 실험이 재지 않은 것 — 진짜 두 브라우저를 열어 같은 결과가 나오는지는 재지 않았다. 세션을 지우고 다시 로그인하는 것이 등가인 까닭은 조회 키가 같기 때문이라는 추론이고 측정이 아니다. RP-initiated logout 을 열었을 때 세션 수가 실제로 줄어드는지도 재지 않았다. - 이 편의 절차에는 소스를 고치는 단계가 없다(unknown). JDBC 토큰 저장소를 넣은 편집과 빌드는 증거에 배포 결과 두 줄로만 남았고, 되돌리기 절의 파일 목록은 B-0 이 적어 둔 지울 목록에서 가져왔다.