기록 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>
9.5 KiB
kind, slug, title, topic, topicName, project, status, lastVerifiedOn, sourceRevision, source, evidence
| kind | slug | title | topic | topicName | project | status | lastVerifiedOn | sourceRevision | source | evidence | ||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CASE | moving-the-session-left-the-tokens-behind | 세션만 Redis 로 옮기자 토큰이 따라오지 않았다 | where-application-state-lives | 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기 | keycloak-session-store | 게시 전 | 2026-09-04 | cdac9b8178391311d8eca1ebc6cac15bb62d79af |
|
|
세션만 Redis 로 옮기자 토큰이 따라오지 않았다
세션 저장소만 Redis 로 바꿨더니 파드를 재시작해도 로그인은 유지됐는데 토큰은 살아남지 못했다. 세션은 세션 id 로 찾고 토큰은 principal 이름으로 찾으므로, 세션 저장소를 바꾸는 설정은 토큰 쪽 경로를 건드리지 않는다.
관계
- 세션과 인가된 클라이언트는 조회 키가 다르다 이 실험이 관측한 결과를 만든 구조를 그 기록이 설명한다.
- 기본키에 세션 id 가 없어서 두 번째 로그인이 첫 토큰을 덮어썼다 세션과 따로 옮긴 토큰 저장소에서 무엇이 남았는지를 그 실험이 이어서 쟀다.
- 세션과 토큰의 저장소를 나눠 각각 설계한다 이 관측이 그 결정의 근거다.
문제
BFF 인스턴스가 둘이고 요청이 어느 쪽으로 갈지 모르면 로그인 상태가 인스턴스 안에 있어서는 안 된다. 그래서 세션 저장소를 Redis 로 옮기는 것이 첫 수였다.
문제는 무엇이 옮겨지는지가 코드에 안 적혀 있다는 것이었다. 이 BFF 에는 세션 저장소나 토큰 저장소를 직접 만드는 빈이 없고 전부 자동구성이 고른다. 무엇이 실제로 쓰이는지 모르는 채로 저장소를 붙이면 옮겨진 것과 안 옮겨진 것을 가를 수 없다.
결론
자동구성이 고른 것 세션 저장소 : 없음. 서블릿 컨테이너 메모리 토큰 저장소 : InMemoryOAuth2AuthorizedClientService 토큰 조회를 맡는 것 : AuthenticatedPrincipalOAuth2AuthorizedClientRepository Redis 와 Spring Session : x
세션만 Redis 로 옮긴 뒤 파드 재시작 후 로그인 유지 : o 파드 재시작 후 토큰 생존 : x Redis 에 들어간 키 : 1개. 타입은 hash 그 해시에 인가된 클라이언트 필드 : x
세션은 세션 id 로 찾고 토큰은 principal 이름으로 찾는다. 조회 경로가 갈라져 있어서 세션 저장소를 무엇으로 바꾸든 토큰은 따라오지 않는다.
검증 환경
실험대 : 베어메탈 한 대(test-server, Arch Linux, 12GB, WiFi only) 위의 VM 두 대 kc-lab-1 : k3s server (컨트롤 플레인) · keycloak-1 kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis 애플리케이션 : BFF(Spring Boot) 두 인스턴스 세션 저장소 전환 : 환경변수 SPRING_SESSION_STORE_TYPE 을 redis 로 빈 목록 조회 : BFF 의 actuator beans 엔드포인트 Keycloak 패치 버전 : 이 측정 기록에 적혀 있지 않다 Spring Boot 버전 : 이 측정 기록에 적혀 있지 않다
재현 조건
-
저장소를 아무것도 붙이지 않은 상태로 BFF 를 띄우고 로그인을 한 번 한다.
-
빈 목록을 조회해 세션과 토큰에 관계된 구현이 무엇인지 확인한다. authorizedClientService · authorizedClientRepository · SessionRepository 세 이름을 본다.
-
환경변수 SPRING_SESSION_STORE_TYPE 을 redis 로 두고 다시 배포한 뒤 로그인한다.
-
Redis 의 키를 세고 그 키의 타입과 필드 이름을 확인한다.
-
BFF 파드를 재시작하고 같은 브라우저로 요청한다. 로그인이 유지되는지와 토큰이 필요한 요청이 되는지를 따로 본다.
본문
저장소를 붙이기 전에 무엇이 골라져 있었나
이 BFF 의 코드에는 세션 저장소도 토큰 저장소도 직접 만드는 빈이 없다. 전부 자동구성이 고르므로 Redis 를 붙이기 전에 실행 중인 애플리케이션의 빈 목록을 먼저 읽었다. 전체 빈은 321개였고 그중 세션·토큰 저장소에 관계된 빈은 다섯 줄이었다. 그 다섯 줄에서 토큰을 담는 구현과 토큰을 조회하는 구현을 찾았고, 세션 쪽과 Redis 쪽에는 해당하는 빈이 없었다.
authorizedClientService → InMemoryOAuth2AuthorizedClientService
authorizedClientRepository → AuthenticatedPrincipalOAuth2AuthorizedClientRepository
SessionRepository → 없음 (서블릿 컨테이너 in-memory)
Redis / Spring Session → 없음
InMemoryOAuth2AuthorizedClientService 는 발급받은 access token 과 refresh token 을 JVM 메모리에 담아 두는 기본 구현이다. SessionRepository 가 하나도 없다는 것은 Spring Session 이 구성되지 않았다는 뜻이므로, 로그인 상태는 서블릿 컨테이너가 자기 메모리에 들고 있었다. 두 가지 모두 인스턴스 안에 있었다.
빈 목록은 이렇게 뽑는다.
kubectl exec <bff-pod> -- wget -qO- localhost:8083/actuator/beans \
| grep -o '"[a-zA-Z]*OAuth2AuthorizedClient[a-zA-Z]*"' | sort -u
이름 하나가 조회 키를 말해 준다
AuthenticatedPrincipalOAuth2AuthorizedClientRepository 는 요청에 딸린 인증 주체(principal)의 이름으로 인가된 클라이언트를 찾아 주는 구현이다. 이름에 적힌 그대로 principal 을 기준으로 찾으므로 조회 키에 세션 id 가 들어가지 않는다.
| 무엇을 담나 | 무엇으로 찾나 | |
|---|---|---|
| Application Session | 누가 로그인했는지 | 세션 id |
| OAuth2AuthorizedClient | access · refresh token | principal 이름 |
같은 요청 하나가 세션은 세션 id 로, 토큰은 principal 이름으로 두 갈래로 조회된다. 이름이 비슷해서 하나로 읽히지만 저장되는 것도 다르고 찾아오는 경로도 다르다.
세션만 옮기고 재시작했을 때
배포하자마자 파드가 안 떴다. 쿠버네티스는 같은 네임스페이스의 Service 마다 환경변수를 자동으로 넣는데, 그중 REDIS_PORT=tcp://10.43.57.116:6379 가 application.yml 의 ${REDIS_PORT:6379} 를 덮어써 spring.data.redis.port 를 int 로 바꾸지 못했다. 환경변수 이름을 바꿔 피할 수도 있었지만 그러면 다음 사람이 같은 함정에 다시 빠지므로, 주입 자체를 끄는 enableServiceLinks: false 를 골랐다.
환경변수 SPRING_SESSION_STORE_TYPE 을 redis 로 두어 Application Session 을 Redis 로 옮겼다. 파드를 재시작해도 로그인은 유지됐다. 브라우저는 다시 로그인 화면을 보지 않았고 같은 세션으로 요청이 이어졌다.
그런데 같은 재시작에서 토큰은 살아남지 못했다. OAuth2AuthorizedClient 를 찾는 경로가 principal 이름을 쓰는 인메모리 구현 그대로였기 때문이다. 세션 저장소를 Redis 로 바꾸는 설정은 세션 쪽 경로만 갈아 끼우므로 토큰 쪽은 건드리지 않는다.
Redis 안에 들어간 것
옮긴 뒤 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 초
필드 이름에 토큰이 안 보이는 것과 값 안에 토큰이 없는 것은 다른 말이라, 필드 값도 직접 꺼내 토큰 문자열이 섞여 있는지 봤다. sessionAttr:SPRING_SECURITY_CONTEXT 의 값은 \xac\xed 두 바이트로 시작한다. Java 기본 직렬화의 매직 넘버라, Redis 에 들어간 값은 같은 클래스패스의 JVM 이 있어야 읽힌다. 그 안에 든 것은 누가 로그인했는지를 말하는 인증 객체다. 일곱 필드 어느 이름도 access token 이나 refresh token 을 가리키지 않는다.
값을 꺼내 본 것은 sessionAttr:SPRING_SECURITY_CONTEXT 하나뿐이다. 이름에 OAuth2 가 들어간 …HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST 는 이름만 보고 넘어갔고 값 안을 열어 보지 않았다.
로그인 상태가 Redis 로 나간 것은 맞지만 토큰은 아직 인스턴스 메모리에 있었다. 여기서 「무상태가 됐다」고 판단하면 다른 인스턴스로 간 요청이 토큰을 못 찾는 이유를 설명할 수 없게 된다.
확인하지 않은 것
Redis 를 붙인 상태에서 토큰 저장소만 따로 두는 조합은 B-2 에서 다시 쟀다. 여기서는 세션 쪽만 확인했다. 그 실험이 확인한 네 가지 가운데 둘은 저장소를 밖으로 빼는 것으로 풀렸고 둘은 풀리지 않았는데, 남은 둘의 원인은 저장소가 아니라 토큰 테이블의 기본키였다.
파드 재시작 한 번으로 본 결과이고, 두 인스턴스 사이로 요청을 번갈아 보내 토큰이 어느 쪽에서 사라지는지까지는 이 실험에서 재지 않았다.