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