feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다

기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만
있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문
종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다.

SSOT 결함 둘을 고쳤다.

- kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이
  「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽
  `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다.
- virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데
  원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다.

기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은
둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더
든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다).

계약을 셋 고쳤다.

- kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/**
  28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는
  것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온
  것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수
  대조했을 때 가장 가까운 커밋도 28개가 어긋났다).
- virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고
  재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다.
- kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고
  넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다.

style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져
(실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고,
engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고
있었다(Question 기록에서 11.94 → 3.86).

verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-17 11:01:55 +09:00
co-authored by Claude Opus 5
parent d473609e0a
commit 2109f726fe
574 changed files with 159654 additions and 1551 deletions
@@ -0,0 +1,117 @@
---
kind: CONCEPT
slug: two-stores-two-lookup-keys
title: 세션과 인가된 클라이언트는 조회 키가 다르다
topic: where-application-state-lives
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
project: keycloak-session-store
status: 게시 전
basisVersion: Spring Boot 3 · Spring Security OAuth2 Client · Spring Session Redis
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-b0
assets:
- key: bff-store-lookup-keys
file: ../../../final/assets/bff-store-lookup-keys/bff-store-lookup-keys.svg
evidence:
- ../../../final/evidence/raw/b0-bff-redis-deploy__03-beans-analysis.txt
- ../../../final/evidence/raw/b1-redis-session-store__02-autoconfig-after.txt
- ../../../final/evidence/raw/b1-redis-session-store__03-redis-contents.txt
- ../../../final/evidence/raw/b2-multi-instance-session__02-schema.txt
- ../../../final/evidence/raw/b2-multi-instance-session__04-overwrite-test.txt
---
# 세션과 인가된 클라이언트는 조회 키가 다르다
BFF 가 서버에 두는 상태는 둘이고, 세션은 세션 id 로 토큰은 principal 이름으로 찾는다. 조회 키가 이렇게 갈라져 있어서 세션 저장소만 Redis 로 바꿔도 OAuth2AuthorizedClient 는 따라오지 않는다.
## 관계
- **세션만 Redis 로 옮기자 토큰이 따라오지 않았다**
여기서 설명하는 조회 키 차이가 실제 배포에서 어떤 결과를 냈는지 잰 기록이다.
- **기본키에 세션 id 가 없어서 두 번째 로그인이 첫 토큰을 덮어썼다**
principal 이름으로 찾는다는 것이 테이블 기본키 한 줄로 드러난 대목을 이어서 다룬다.
- **세션과 토큰의 저장소를 나눠 각각 설계한다**
두 저장소를 따로 옮긴 뒤 무엇이 풀리고 무엇이 남았는지를 적은 기록이다.
## 본문
<!-- body:start -->
## 자동 구성이 고르는 네 가지
Redis 도 Spring Session 도 붙이지 않은 Spring Boot BFF(Backend for Frontend, 프런트엔드 하나를 위해 두는 백엔드) 에서 자동 구성이 골라 둔 것은 넷이다.
```
authorizedClientService → InMemoryOAuth2AuthorizedClientService
authorizedClientRepository → AuthenticatedPrincipalOAuth2AuthorizedClientRepository
SessionRepository → 없음 (서블릿 컨테이너 in-memory)
Redis / Spring Session → 없음
```
둘째 줄의 `AuthenticatedPrincipalOAuth2AuthorizedClientRepository` 는 인증된 principal 을 기준으로 인가된 클라이언트를 꺼내 오는 구현이고, principal 이름으로 찾기 때문에 조회 키에 세션 id 가 없다. 첫째 줄의 `InMemoryOAuth2AuthorizedClientService` 는 이름 그대로 인스턴스 메모리에 두고, `SessionRepository` 는 아예 없어서 세션도 서블릿 컨테이너 메모리에 남는다.
이 목록이 아무것도 안 줬을 때의 값인 것은 배포 순서를 그렇게 짜 두었기 때문이다. 배포 매니페스트의 머리 주석이 그 순서를 적어 놓았다.
```text
# The BFF is deployed FIRST WITHOUT any session store wiring. That is deliberate:
# B-0 asks what Spring Boot's autoconfiguration actually picks when nothing is
# configured, and the only honest way to answer is to look at a running instance
# that has been given nothing. Redis is deployed alongside but left unused until
# B-1 turns it on.
```
Redis 는 같은 매니페스트로 함께 올라가 있었고 연결만 안 했다.
## 한 요청이 두 갈래로 조회된다
이름은 비슷해도 담는 것이 다른 두 가지가 따로 굴러간다.
| | 무엇을 담나 | 조회 키 |
|---|---|---|
| Application Session | 누가 로그인했는지 | 세션 id |
| OAuth2AuthorizedClient | access · refresh token | principal 이름 |
같은 요청이 세션은 세션 id 로, 토큰은 principal 이름으로 두 갈래로 조회된다.
![세션 id 로 찾는 Application Session 과 principal 이름으로 찾는 OAuth2AuthorizedClient 가 각각 다른 저장소에 놓인 구성](../../../final/assets/bff-store-lookup-keys/bff-store-lookup-keys.svg)
## 세션만 Redis 로 옮겼을 때 남은 것
`SPRING_SESSION_STORE_TYPE=redis` 로 Application Session 을 Redis 로 옮겨도 토큰은 같이 살아남지 못한다. 조회 키가 다르므로 세션 저장소를 바꿔도 `OAuth2AuthorizedClient` 는 따라오지 않고, 옮기기 전과 뒤의 빈 목록에서도 authorizedClientService 와 authorizedClientRepository, authorizedClientManager 셋이 그대로였다.
옮긴 뒤 Redis 에는 키가 하나 있었고 타입은 hash 였다.
```
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
총 키 수: 1
타입: hash
필드: sessionAttr:SPRING_SECURITY_CONTEXT
필드: sessionAttr:SPRING_SECURITY_SAVED_REQUEST
필드: sessionAttr:SPRING_SECURITY_LAST_EXCEPTION
필드: sessionAttr:org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST
필드: lastAccessedTime
필드: maxInactiveInterval
필드: creationTime
TTL: 1772 초
```
일곱 필드 어느 이름도 access token 이나 refresh token 을 가리키지 않는다. 값까지 꺼내 본 필드는 `sessionAttr:SPRING_SECURITY_CONTEXT` 하나이고, 그 값은 `\xac\xed` 두 바이트로 시작한다 — Java 기본 직렬화의 매직 넘버다. 이름에 OAuth2 가 들어간 `…AUTHORIZATION_REQUEST` 는 값 안을 열어 보지 않았으므로, 그 필드에 무엇이 들어 있는지는 이 실험대가 답하지 않는다.
## 조회 키가 기본키로 드러나는 곳
토큰은 `JdbcOAuth2AuthorizedClientService` 로 PostgreSQL 에 옮겼고, 그때 만들어진 테이블의 기본키가 조회 키를 그대로 적어 놓는다.
```sql
PRIMARY KEY (client_registration_id, principal_name)
```
두 컬럼 어디에도 세션 id 가 없다. 그래서 같은 사용자의 두 세션이 같은 행을 쓰게 되고, 나중 로그인이 앞의 토큰을 덮어쓴다.
## 지금 확인한 범위
조회 키가 principal 이름이라는 것은 두 곳에서 나온다. 아무것도 설정하지 않았을 때 자동 구성이 고른 구현체 이름과, 토큰을 PostgreSQL 로 옮겼을 때 만들어진 테이블의 기본키다. Spring Security 소스를 열어 조회 경로를 따라가지는 않았다.
여기 적은 것은 인스턴스가 둘 이상인 구성의 이야기다. 단일 인스턴스에서는 이 질문 자체가 생기지 않는다 — 저장소를 붙이기 전에는 세션도 인가된 클라이언트도 같은 프로세스 메모리에 있다.
<!-- body:end -->