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,99 @@
---
kind: REFERENCE
slug: look-at-the-lookup-key-before-moving-the-store
title: 저장소를 옮기기 전에 조회 키를 본다
topic: where-application-state-lives
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
project: keycloak-session-store
status: 게시 전
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-b0
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-열린-질문-네-개에-대한-답
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
- ../../../final/evidence/raw/b2-multi-instance-session__05-logout-cleanup.txt
---
# 저장소를 옮기기 전에 조회 키를 본다
서버가 들고 있던 상태를 외부 저장소로 옮기기 전에 무엇으로 조회하는지부터 읽는다. BFF 에서 세션은 세션 id 로, 인가된 클라이언트는 principal 이름으로 찾았고, 세션만 Redis 로 옮기자 토큰은 따라오지 않았다.
## 관계
- **세션과 인가된 클라이언트는 조회 키가 다르다**
이 기준이 선 근거. 두 저장소가 무엇을 담고 무엇으로 찾는지 설명한다.
- **기본키에 세션 id 가 없어서 두 번째 로그인이 첫 토큰을 덮어썼다**
조회 키를 그대로 둔 채 저장소만 옮겼을 때 무엇이 남았는지 잰 기록이다.
## 목적
BFF(Backend For Frontend, 브라우저 앞에 두는 서버)는 로그인한 사용자가 누구인지와 그 사용자의 access token, refresh token 을 둘 다 서버에 들고 있다. 두 값은 한 요청에서 같이 쓰이지만 조회 키가 다르다. 세션은 세션 id 로 찾고, 인가된 클라이언트(OAuth2AuthorizedClient)는 principal 이름으로 찾는다.
이 기준은 저장소 한쪽만 밖으로 옮겨 놓고 나머지도 따라왔다고 여기는 실수를 막는다. 세션 저장소를 Redis 로 바꾸는 설정은 세션 id 로 찾는 것만 옮기고, principal 이름으로 찾는 인가된 클라이언트는 그 설정에 걸리지 않는다.
옮긴 뒤에 남은 문제도 저장소 종류가 아니라 조회 키에서 나왔다. 토큰을 PostgreSQL 로 옮긴 뒤에도 테이블의 기본키에 세션 id 가 없어서, 같은 사용자의 두 세션이 같은 행을 쓰고 나중 로그인이 앞의 토큰을 덮어썼다.
## 규칙
### 1. 저장소를 붙이기 전에 자동구성이 무엇을 골랐는지 읽는다
설정하지 않은 값에도 구현체가 하나씩 들어가 있다. 저장소를 붙이기 전 BFF 를 들여다보니 인가된 클라이언트 서비스는 InMemoryOAuth2AuthorizedClientService 였고 저장소는 AuthenticatedPrincipalOAuth2AuthorizedClientRepository 였다. 세션 저장소는 고른 것이 없어 서블릿 컨테이너 메모리에서 돌고 있었고, Redis 도 Spring Session 도 구성되지 않은 상태였다.
두 번째 줄이 조회 키를 정한다. AuthenticatedPrincipalOAuth2AuthorizedClientRepository 는 principal 이름으로 찾기 때문에 조회 키에 세션 id 가 없다.
### 2. 상태마다 무엇으로 찾는지 적고, 키가 다르면 저장소도 따로 정한다
BFF 가 서버에 들고 있는 것은 둘이다. Application Session 은 누가 로그인했는지를 담고 세션 id 로 찾는다. OAuth2AuthorizedClient 는 access token 과 refresh token 을 담고 principal 이름으로 찾는다. 이름은 비슷해도 서로 다른 것을 저장하는 두 개가 따로 굴러간다.
키가 다르면 한쪽을 옮기는 설정이 다른 쪽에 닿지 않는다. 저장소를 하나 골라 둘을 함께 옮기는 대신 상태마다 따로 정한다.
### 3. 한쪽을 옮긴 뒤 나머지 빈이 그대로인지 다시 읽는다
SPRING_SESSION_STORE_TYPE=redis 로 세션을 Redis 로 옮긴 뒤 1번과 같은 방법으로 빈을 다시 읽었더니, 인가된 클라이언트를 다루는 서비스와 저장소와 매니저 셋 다 옮기기 전과 같았다. Redis 에 생긴 키는 하나였고 타입은 hash 였으며, 담긴 필드 일곱 개 어느 이름도 access token 이나 refresh token 을 가리키지 않았다.
설정을 넣은 것과 그 설정이 무엇을 옮겼는지는 따로 확인한다.
### 4. 조회 키가 그대로면 저장소를 옮겨도 키 충돌은 남는다
토큰을 JdbcOAuth2AuthorizedClientService 로 PostgreSQL 에 옮긴 뒤, 같은 사용자가 다른 브라우저로 로그인하자 두 번째 로그인이 첫 토큰을 덮어썼다. 만들어진 테이블의 기본키는 client_registration_id 와 principal_name 두 컬럼이고 세션 id 가 없어서, 같은 사용자의 두 세션이 같은 행을 쓴다.
기본키에 세션 id 를 더하면 덮어쓰기가 사라지는지는 이 실험대에서 재지 않았다.
### 5. 저장소가 둘이면 정리 경로도 둘인지 확인한다
로그아웃한 뒤 두 저장소를 열어 보니 Redis 세션은 0 키로 정리됐고 PostgreSQL 토큰은 1 행이 남았다. 남은 행에는 평문 refresh token 이 그대로 있었다.
세어 보니 지울 것이 둘이 아니라 셋이었다. Keycloak 쪽 SSO 세션이 2 로 남아 있었다. HttpSession 은 Spring Security 가 지우고, 인가된 클라이언트는 아무도 안 지우고, IdP 세션은 RP-initiated logout 을 보내야 끊긴다. 그래서 로그아웃한 뒤 같은 주소를 다시 열면 로그인 화면 없이 그냥 들어가진다.
### 6. 이름을 본 것과 값을 연 것을 나눠 적는다
Redis 해시에서 값까지 꺼내 본 필드는 sessionAttr 로 시작하는 SPRING_SECURITY_CONTEXT 하나이고, 그 값은 \xac\xed 두 바이트로 시작한다 — Java 기본 직렬화의 매직 넘버다. 이름에 OAuth2 가 들어간 …AUTHORIZATION_REQUEST 는 값 안을 열어 보지 않았다.
그래서 확인한 범위는 일곱 필드의 이름까지다. 토큰이 저장되지 않았다고 적으려면 값을 열지 않은 필드를 한 번 더 봐야 한다.
## 적용 조건
- 인스턴스가 둘 이상일 때
- 한 요청이 서버에 든 상태를 둘 이상 쓸 때. BFF 에서는 세션과 인가된 클라이언트가 그렇다
- 메모리에서만 돌던 상태를 Redis 나 PostgreSQL 같은 외부 저장소로 옮기려 할 때
- 자동구성이 고른 구현체를 그대로 쓰고 있을 때
## 예외
- 확인한 짝은 세션(세션 id)과 인가된 클라이언트(principal 이름) 하나뿐이다. 다른 상태 짝에서도 조회 키가 이렇게 갈리는지는 재지 않았다. 이 기준은 다른 짝에서 무엇이 나올지 미리 말하지 않고, 옮기기 전에 같은 확인을 한 번 하라고만 한다
- 상태가 하나뿐이고 조회 키도 하나면 이 확인이 필요 없다. 다만 자동구성이 무엇을 골랐는지는 그때도 읽는다
- 단일 인스턴스로만 운영하면 이 기준이 막으려는 문제가 생기지 않는다
- 기본키를 고쳐서 덮어쓰기를 막는 방법은 이 기준에 없다. 원인을 확인한 데까지이고 고친 뒤를 재지 않았다
## 예시
- 저장소를 붙이기 전 BFF 에는 세션 저장소로 고른 것이 없었고 서블릿 컨테이너 메모리에서 돌고 있었다
- 세션을 Redis 로 옮긴 뒤에도 인가된 클라이언트를 다루는 빈 셋은 옮기기 전과 같았다
- Redis 해시의 필드 일곱 개 어느 이름도 access token 이나 refresh token 을 가리키지 않았다
- 토큰 테이블의 기본키가 client_registration_id 와 principal_name 이라 같은 사용자의 두 번째 로그인이 첫 토큰을 덮어썼다
- 로그아웃한 뒤 Redis 세션은 0 키였고 PostgreSQL 토큰은 1 행이 남았다