The forward upgrade to 26.7.3 was zero downtime across 87 samples, and since databasechangelog stayed at 210 the rollback to 26.7.0 also succeeded, which narrows D-2's conclusion: rolling back fails when the schema moved, not because of the version number. The row count is the check. Role changes never reach the upstream through request repetition; the session is a snapshot taken at login and only a new session picks up the new claim. Auditing the docs also surfaced that Prometheus scrapes only keycloak, kubelet, node-exporter and itself, so the B-layer experiments have no metrics to screenshot rather than missing screenshots. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 KiB
B-1 — Redis 를 붙이면 무엇이 옮겨지고 무엇이 안 옮겨지는가 → Q3
브랜치 feature/keycloak-b1-redis-session-store ·
증거 docs/evidence/b1-redis-session-store/ ·
2026-09-04 14:50–15:05 KST
선행: B-0
대응 질문 — Q3 · BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가
구조
다이어그램 규약은
diagrams/_style.md. 실험대 전체 구조는diagrams/lab-topology.svg.
0. 결론부터
| before | after | ||
|---|---|---|---|
sessionRepository |
(없음, Tomcat 기본) | RedisSessionRepository |
옮겨졌다 |
authorizedClientService |
InMemoryOAuth2AuthorizedClientService |
InMemoryOAuth2AuthorizedClientService |
그대로다 |
authorizedClientRepository |
AuthenticatedPrincipalOAuth2AuthorizedClientRepository |
동일 | 그대로다 |
그 결과 사용자에게는 이렇게 보인다.
{"principal":"labuser", ← 로그인은 되어 있다
"accessTokenStoredOnServer":false, ← 그런데 토큰이 없다
"refreshTokenStoredOnServer":false,
"browserTokenCount":0}
"로그인은 되어 있는데 아무것도 못 하는" 상태가 만들어진다. Q1 이 "Session Store 를 공유 저장소로 변경하는 것만으로는 충분하지 않다" 고 쓴 것의 실물이다.
1. 문제 ① — 쿠버네티스가 내 환경변수를 덮어썼다
배포하자마자 파드가 안 떴다.
Failed to bind properties under 'spring.data.redis.port' to int:
Property: spring.data.redis.port
Value: "${REDIS_PORT:6379}"
Reason: failed to convert java.lang.String to int
(caused by NumberFormatException: For input string: "tcp://10.43.57.116:6379")
쿠버네티스가 REDIS_PORT=tcp://10.43.57.116:6379 를 주입했다.
개념 — Service Links
쿠버네티스는 같은 네임스페이스의 모든 Service 마다 Docker link 시절의 환경변수를 파드에 자동으로 넣는다.
Service 이름이 redis 이면
REDIS_SERVICE_HOST=10.43.57.116
REDIS_SERVICE_PORT=6379
REDIS_PORT=tcp://10.43.57.116:6379 ← 이게 문제
REDIS_PORT_6379_TCP=tcp://10.43.57.116:6379
REDIS_PORT_6379_TCP_ADDR=10.43.57.116
...
<SVCNAME>_PORT 는 포트 번호가 아니라 URL 형태다. 이름이 겹치면
애플리케이션 설정이 조용히 오염된다.
spec:
enableServiceLinks: false # 근본 처방
환경변수 이름을 바꿔 피할 수도 있다. 그러면 다음 사람이 같은 함정에 다시 빠진다. 주입 자체를 끄는 쪽을 골랐다.
이 함정은 Service 이름과 환경변수 이름이 겹칠 때만 나타나므로,
REDIS,POSTGRES,MYSQL처럼 흔한 이름일수록 위험하다.
문제 ② — 테스트가 Redis 를 찾다가 죽었다
spring-session-data-redis 를 넣으면 컨텍스트 기동 시 Redis 에 붙으려 한다.
테스트에는 Redis 가 없다.
@SpringBootTest(properties = {
"KEYCLOAK_CLIENT_SECRET=test-only-secret",
// 테스트는 Redis 를 띄우지 않는다
"spring.session.store-type=none",
})
문제 ③ — 리소스 서버가 아예 없었다
API 호출이 500 이었다. 원인은 토큰이 아니었다.
java.nio.channels.UnresolvedAddressException
RESOURCE_API_BASE_URL=http://echo.keycloak-lab.svc:8080 인데 echo 는
header-lab 네임스페이스의 8081 이었다. 배포조차 되어 있지 않았다.
500 을 보고 "토큰이 없어서"라고 읽을 뻔했다. 로그를 보니 DNS 였다. A층에서 반복해서 배운 것 — 증상과 원인을 붙이기 전에 로그를 본다.
# 다른 네임스페이스의 서비스는 <svc>.<ns>.svc 로 부른다
value: http://echo.header-lab.svc:8081
2. 자동구성이 실제로 바뀌었는가 — B-0 의 방법을 다시 쓴다
kubectl -n keycloak-lab exec <bff-pod> -- wget -qO- http://localhost:8083/actuator/beans
빈 수: 321 → 402 (+81)
--- 세션 저장소 (새로 생긴 것) ---
★ sessionRepository -> RedisSessionRepository
★ springSessionRepositoryFilter -> SessionRepositoryFilter
★ RedisHttpSessionConfiguration
★ cookieSerializer -> DefaultCookieSerializer
--- OAuth2 authorized client ---
authorizedClientService
before: InMemoryOAuth2AuthorizedClientService
after : InMemoryOAuth2AuthorizedClientService 그대로 — Redis 로 안 옮겨졌다
authorizedClientRepository
before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
after : AuthenticatedPrincipalOAuth2AuthorizedClientRepository 그대로
빈 81개가 늘었는데 authorized client 는 하나도 안 바뀌었다.
"Redis 를 붙였다"가 "상태가 공유된다"를 뜻하지 않는다. 무엇이 옮겨졌는지 찍어서 확인해야 한다. B-0 을 실험으로 만든 이유다.
3. Redis 안에 무엇이 들어갔는가 → Q3 검증 2번
=== Redis 키 ===
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
dbsize: 1
=== 필드 ===
sessionAttr:SPRING_SECURITY_CONTEXT
sessionAttr:SPRING_SECURITY_SAVED_REQUEST
sessionAttr:SPRING_SECURITY_LAST_EXCEPTION
sessionAttr:...HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST
lastAccessedTime / maxInactiveInterval / creationTime
=== TTL ===
1772 초 ← spring.session.timeout=30m 과 일치
refresh token 은 Redis 에 없다
Q3 는 "저장소를 직접 열어 refresh token 이 평문으로 남는지 확인한다" 를 검증 항목으로 두었다. 답은 더 앞에 있었다 — 애초에 들어가지 않는다.
Application Session ──▶ Redis (인증 상태, principal, 인가 요청)
OAuth2AuthorizedClient ─▶ 프로세스 메모리 (access token, refresh token)
"토큰 암호화를 어떻게 할까"를 고민하기 전에, 토큰이 그 저장소에 가지도 않는다는 것을 먼저 알아야 한다.
직렬화는 Java 네이티브다
\xac\xed\x00\x05sr\x00=org.springframework.security.core.context.SecurityContextImpl
\xac\xed 는 Java 직렬화 매직 넘버다. JSON 이 아니다.
| 결과 | |
|---|---|
| 사람이 못 읽는다 | 운영 중 디버깅이 어렵다 |
| 클래스 버전에 묶인다 | 애플리케이션을 올리면 기존 세션이 역직렬화에 실패할 수 있다 |
| 역직렬화 취약점 | 신뢰할 수 없는 데이터가 들어오면 위험한 형식이다 |
D-2(버전 업그레이드)에서 이것이 다시 나온다 — Spring Security 버전이 바뀌면 Redis 에 남은 세션이 깨질 수 있다.
4. 사용자에게 보이는 결과 — 가장 중요한 부분
{"pattern":"AP3-backend-for-frontend",
"principal":"labuser", ← 세션은 Redis 에서 복원되었다
"accessTokenStoredOnServer":false, ← 토큰은 사라졌다
"refreshTokenStoredOnServer":false,
"browserTokenCount":0,
"csrfProtectionEnabled":true}
파드가 전부 교체됐는데 로그인 상태는 살아남았다. Redis 덕분이다. 그런데 토큰은 같이 살아남지 못했다. 인스턴스 메모리에 있었으니까.
사용자 관점: 로그인되어 있다고 나온다
실제: BFF 가 사용자를 대신해 아무것도 못 한다
이것이 "부분적으로만 공유했을 때"의 실패 모양이다. 완전히 로그아웃되는 편이 차라리 낫다 — 적어도 사용자가 다시 로그인한다.
B-0 과 나란히 놓으면
| B-0 (Redis 없음, replica 1) | B-1 (Redis 세션, replica 2) | |
|---|---|---|
principal |
labuser | labuser |
accessTokenStoredOnServer |
true | false |
| 파드 재시작 후 | 로그아웃 | 로그인 상태만 남고 토큰은 소실 |
5. Q3 검증 항목 대조
| # | Q3 의 검증 | 결과 |
|---|---|---|
| 1 | 인스턴스 두 대에서 로그인 유지·재시작 복구 | 세션은 유지, 토큰은 소실 |
| 2 | 저장소를 열어 refresh token 이 평문인지 | 평문 이전에 존재하지 않는다 |
| 3 | session TTL 과 token 만료 어긋남 | TTL 1772초 관측. 토큰 만료(60초)와 처음부터 어긋나 있다 |
| 4 | logout 뒤 두 store 잔여 항목 | B-2 에서 이어서 |
| 5 | 저장소를 끊었을 때 오류 | B-5 에서 |
| 6 | 같은 store vs 분리 | 분리가 기본값이었다 — 고르는 것이 아니라 이미 그렇다 |
| 7 | 저장소 지연이 화면 지연으로 | B-2 이후 |
6번의 답이 이 실험의 요지다. "두 상태를 같은 저장소에 둘지 나눌지"는 선택지가 아니라 이미 나뉘어 있고, 나뉜 채로 두면 깨진다.
6. 그래서 무엇을 해야 하는가
OAuth2AuthorizedClientService 를 공유 저장소로 옮기는 구현이 따로 필요하다.
| 후보 | |
|---|---|
JdbcOAuth2AuthorizedClientService |
Spring Security 기본 제공. PostgreSQL 이 이미 있다 |
| 직접 구현 (Redis) | OAuth2AuthorizedClientService 인터페이스를 Redis 로 구현 |
| 세션 안에 넣기 | HttpSessionOAuth2AuthorizedClientRepository 를 쓰면 세션과 함께 Redis 로 간다 |
세 번째가 흥미롭다 — 조회 키 문제(principal 기준)까지 같이 해결된다. 세션 단위로 저장되므로 같은 사용자의 다른 브라우저가 서로를 덮어쓰지 않는다. 대신 세션이 커진다.
B-2 에서 이 선택지를 비교한다.
7. 재현 절차 (명령어)
# 1. 의존성 두 개를 함께 넣는다 (하나만 넣으면 조용히 in-memory 로 남는다)
# spring-session-data-redis + spring-boot-starter-data-redis
# 2. 테스트는 Redis 를 안 띄우므로 store-type=none 을 준다
# 3. 배포 — enableServiceLinks: false 를 잊지 말 것
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
# 4. 자동구성이 실제로 바뀌었는지 확인 (B-0 의 방법)
kubectl -n keycloak-lab exec <bff-pod> -- wget -qO- http://localhost:8083/actuator/beans > after.json
# sessionRepository 가 RedisSessionRepository 인가
# authorizedClientService 는 여전히 InMemory 인가 ← 이쪽이 핵심
# 5. Redis 를 직접 연다
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
kubectl -n keycloak-lab exec deploy/redis -- redis-cli hkeys "bff:session:sessions:<id>"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli ttl "bff:session:sessions:<id>"
# 6. 사용자 관점 확인
# 브라우저로 https://app1.hyeonworks.com/bff/token-boundary
8. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| B-2 다중 인스턴스 | authorized client 를 어디로 옮길지가 남았다. 세 후보를 비교한다 |
| B-3 refresh 경쟁 | 토큰이 공유되어야 경쟁이 재현된다 — 아직 공유되지 않았다 |
| D-2 업그레이드 | Java 직렬화된 세션이 버전 변경에 견디는가 |
| 운영 | enableServiceLinks: false — Service 이름과 환경변수 충돌 |
