Files
document-haness/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/reference/reference-look-at-the-lookup-key-before-moving-the-store.md
T
DongHyeonkaandClaude Opus 5 2109f726fe 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>
2026-09-17 11:01:55 +09:00

7.9 KiB

kind, slug, title, topic, topicName, project, status, sourceRevision, source, evidence
kind slug title topic topicName project status sourceRevision source evidence
REFERENCE look-at-the-lookup-key-before-moving-the-store 저장소를 옮기기 전에 조회 키를 본다 where-application-state-lives 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기 keycloak-session-store 게시 전 cdac9b8178391311d8eca1ebc6cac15bb62d79af
final/document.md#선택이-코드와-흐름에-반영되는-방식-b0
final/document.md#얻은-것-잃은-것-적용하지-않을-때-열린-질문-네-개에-대한-답
../../../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 행이 남았다