Compare commits

...
Author SHA1 Message Date
DongHyeonkaandClaude Opus 5 f2595f748f docs: B-1 — Redis moves the session and leaves the tokens behind
Adding Spring Session Redis grows the context by 81 beans and swaps sessionRepository for RedisSessionRepository, while authorizedClientService stays InMemoryOAuth2AuthorizedClientService. The user then reads as logged in with principal labuser while accessTokenStoredOnServer is false, which is worse than being logged out.

Redis holds only the security context, serialized with Java native serialization, and the refresh token is not there to encrypt in the first place. Three problems on the way: Kubernetes service links overwrote REDIS_PORT with a tcp:// URL, the tests tried to reach Redis, and the resource server was never deployed so a DNS failure looked like a token failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:05:02 +09:00
20 changed files with 480 additions and 1 deletions
@@ -0,0 +1 @@
[ 236ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://app1.hyeonworks.com/bff/api/me:0
@@ -0,0 +1,2 @@
[ 7292ms] [ERROR] Access to fetch at 'https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?response_type=code&client_id=bff-confidential&scope=openid%20profile%20email&state=WWc76H7TY73Fbsdc41B2nRD5exkXqHcohLh4WdJB4AA%3D&redirect_uri=https://app1.hyeonworks.com/login/oauth2/code/keycloak&nonce=A4TXweuKS4Y5HdZ63rLJUez1ZOyI2em6zs3OIfTXLFo&code_challenge=wPr8PXG0lcUvie7Wo91YrVMhOUYq0KtEU4PxVJ0_CWA&code_challenge_method=S256' (redirected from 'https://app1.hyeonworks.com/bff/api/me') from origin 'https://app1.hyeonworks.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ https://app1.hyeonworks.com/bff/token-boundary:0
[ 7293ms] [ERROR] Failed to load resource: net::ERR_FAILED @ https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?response_type=code&client_id=bff-confidential&scope=openid%20profile%20email&state=WWc76H7TY73Fbsdc41B2nRD5exkXqHcohLh4WdJB4AA%3D&redirect_uri=https://app1.hyeonworks.com/login/oauth2/code/keycloak&nonce=A4TXweuKS4Y5HdZ63rLJUez1ZOyI2em6zs3OIfTXLFo&code_challenge=wPr8PXG0lcUvie7Wo91YrVMhOUYq0KtEU4PxVJ0_CWA&code_challenge_method=S256:0
@@ -0,0 +1,2 @@
[ 6726ms] [ERROR] Access to fetch at 'https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?response_type=code&client_id=bff-confidential&scope=openid%20profile%20email&state=GGupuPr3ZklKp8ah99r7h7mNHEq9yTsEWZr85WyXevE%3D&redirect_uri=https://app1.hyeonworks.com/login/oauth2/code/keycloak&nonce=rPVvEOvG7rzssAjR7pP67qNvoY2W6ZVpIpKYz1LGoU8&code_challenge=qoKRLRrzB7z9CU_rlaAxJ7UYcRZswyqmDi8PgxmaWM0&code_challenge_method=S256' (redirected from 'https://app1.hyeonworks.com/bff/api/me') from origin 'https://app1.hyeonworks.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ https://app1.hyeonworks.com/:0
[ 6726ms] [ERROR] Failed to load resource: net::ERR_FAILED @ https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?response_type=code&client_id=bff-confidential&scope=openid%20profile%20email&state=GGupuPr3ZklKp8ah99r7h7mNHEq9yTsEWZr85WyXevE%3D&redirect_uri=https://app1.hyeonworks.com/login/oauth2/code/keycloak&nonce=rPVvEOvG7rzssAjR7pP67qNvoY2W6ZVpIpKYz1LGoU8&code_challenge=qoKRLRrzB7z9CU_rlaAxJ7UYcRZswyqmDi8PgxmaWM0&code_challenge_method=S256:0
@@ -0,0 +1,7 @@
- main [ref=f29e2]:
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f29e3]
- paragraph [ref=f29e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
- button "Keycloak 로그인" [ref=f29e5] [cursor=pointer]
- button "token 경계 확인" [ref=f29e6] [cursor=pointer]
- button "BFF 경유 API 호출" [ref=f29e7] [cursor=pointer]
- button "CSRF token으로 상태 변경" [ref=f29e8] [cursor=pointer]
@@ -0,0 +1 @@
- generic [active] [ref=f30e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":false,\"refreshTokenStoredOnServer\":false,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
@@ -0,0 +1,5 @@
- generic [active] [ref=f31e1]:
- heading "Whitelabel Error Page" [level=1] [ref=f31e2]
- paragraph [ref=f31e3]: This application has no explicit mapping for /error, so you are seeing this as a fallback.
- generic [ref=f31e4]: Fri Sep 04 05:00:51 GMT 2026
- generic [ref=f31e5]: There was an unexpected error (type=Internal Server Error, status=500).
@@ -0,0 +1 @@
- generic [active] [ref=f32e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":false,\"refreshTokenStoredOnServer\":false,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
@@ -0,0 +1,7 @@
- main [ref=f33e2]:
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f33e3]
- paragraph [ref=f33e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
- button "Keycloak 로그인" [ref=f33e5] [cursor=pointer]
- button "token 경계 확인" [ref=f33e6] [cursor=pointer]
- button "BFF 경유 API 호출" [ref=f33e7] [cursor=pointer]
- button "CSRF token으로 상태 변경" [ref=f33e8] [cursor=pointer]
@@ -0,0 +1 @@
- generic [active] [ref=f34e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":false,\"refreshTokenStoredOnServer\":false,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
+13
View File
@@ -29,6 +29,19 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<!-- B-1: Application Session 을 Redis 로 옮긴다.
spring-session-data-redis 가 SessionRepository 를 갈아끼우고,
spring-boot-starter-data-redis 가 연결(Lettuce)을 제공한다.
둘 다 있어야 자동구성이 걸린다 — 하나만 넣으면 조용히 in-memory 로 남는다. -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
+11
View File
@@ -10,6 +10,17 @@ server:
spring:
application:
name: keycloak-bff
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
session:
# Application Session 만 Redis 로 간다. OAuth2AuthorizedClient 는
# 이 설정과 무관하며 여전히 InMemory 다 — 조회 키가 다르기 때문이다(B-0).
store-type: ${SPRING_SESSION_STORE_TYPE:redis}
timeout: ${SPRING_SESSION_TIMEOUT:30m}
redis:
namespace: bff:session
security:
oauth2:
client:
@@ -24,6 +24,9 @@ import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(properties = {
"KEYCLOAK_CLIENT_SECRET=test-only-secret",
// 테스트는 Redis 를 띄우지 않는다. store-type=none 이면 자동구성이
// 서블릿 컨테이너 기본 세션으로 되돌아가 컨텍스트가 뜬다.
"spring.session.store-type=none",
"resource-api.base-url=http://127.0.0.1:9"
})
@AutoConfigureMockMvc
+21 -1
View File
@@ -92,6 +92,14 @@ spec:
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels: { app: bff }
# 쿠버네티스는 같은 네임스페이스의 Service 마다 Docker link 시절의
# 환경변수를 자동 주입한다: REDIS_PORT=tcp://10.43.57.116:6379.
# 그것이 application.yml 의 ${REDIS_PORT:6379} 를 덮어써서 기동이 실패했다.
# Failed to bind properties under 'spring.data.redis.port' to int:
# Value: "tcp://10.43.57.116:6379"
# 이 주입 자체를 끄는 것이 근본 처방이다. 이름을 바꿔 피하면 다음 사람이
# 같은 함정에 다시 빠진다.
enableServiceLinks: false
containers:
- name: bff
image: keycloak-pattern-bff:lab
@@ -107,8 +115,11 @@ spec:
value: https://auth.hyeonworks.com/realms/keycloak-patterns
- name: KC_ISSUER_INTERNAL
value: http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns
# echo 는 header-lab 네임스페이스의 8081 이다. 다른 네임스페이스의
# 서비스는 <svc>.<ns>.svc 로 부른다. 이름을 틀리면 500 이 나는데
# 원인은 UnresolvedAddressException 이지 토큰 문제가 아니다.
- name: RESOURCE_API_BASE_URL
value: http://echo.keycloak-lab.svc:8080
value: http://echo.header-lab.svc:8081
- name: KEYCLOAK_CLIENT_SECRET
valueFrom:
secretKeyRef: { name: bff-secrets, key: KEYCLOAK_CLIENT_SECRET }
@@ -117,6 +128,15 @@ spec:
# it builds comes back as http:// and Keycloak rejects it.
- name: SERVER_FORWARD_HEADERS_STRATEGY
value: native
# B-1: Application Session 을 Redis 로 옮긴다.
# OAuth2AuthorizedClient 는 이것으로 옮겨지지 않는다 — 조회 키가
# 다르기 때문이며, B-0 에서 확인한 사실이다.
- name: SPRING_SESSION_STORE_TYPE
value: redis
- name: REDIS_HOST
value: redis.keycloak-lab.svc
- name: REDIS_PORT
value: "6379"
- name: JAVA_TOOL_OPTIONS
value: "-Xms128m -Xmx320m"
readinessProbe:
@@ -0,0 +1,5 @@
deployment.apps/bff configured
deployment "bff" successfully rolled out
bff-576d869c6d-bshvl true kc-lab-2
bff-695646ddb-kzs9k true kc-lab-1
bff-695646ddb-vjqzf true kc-lab-2
@@ -0,0 +1,64 @@
=== B-1 — Redis 를 붙인 뒤 자동구성이 실제로 바뀌었는가 ===
빈 수: 321 → 402 (+81)
--- 세션 저장소 관련 (새로 생긴 것) ---
★ cookieSerializer -> DefaultCookieSerializer
★ org.springframework.boot.autoconfigure.session.RedisSessionConfiguration -> RedisSessionConfiguration
★ org.springframework.boot.autoconfigure.session.RedisSessionConfiguration$DefaultRedisSessionConfiguration -> RedisSessionConfiguration$DefaultRedisSessionConfiguration
★ org.springframework.boot.autoconfigure.session.SessionAutoConfiguration -> SessionAutoConfiguration
★ org.springframework.boot.autoconfigure.session.SessionAutoConfiguration$ServletSessionConfiguration -> SessionAutoConfiguration$ServletSessionConfiguration
★ org.springframework.boot.autoconfigure.session.SessionAutoConfiguration$ServletSessionConfiguration$RememberMeServicesConfiguration -> SessionAutoConfiguration$ServletSessionConfiguration$RememberMeServicesConfiguration
★ org.springframework.boot.autoconfigure.session.SessionAutoConfiguration$ServletSessionConfiguration$ServletSessionRepositoryConfiguration -> SessionAutoConfiguration$ServletSessionConfiguration$ServletSessionRepositoryConfiguration
★ org.springframework.boot.autoconfigure.session.SessionRepositoryFilterConfiguration -> SessionRepositoryFilterConfiguration
★ org.springframework.session.config.annotation.web.http.SpringHttpSessionConfiguration -> SpringHttpSessionConfiguration
★ org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration -> RedisHttpSessionConfiguration
★ rememberMeServicesCookieSerializerCustomizer -> SessionAutoConfiguration$ServletSessionConfiguration$RememberMeServicesConfiguration$$Lambda/0x00007f364e69fa60
★ sessionEventHttpSessionListenerAdapter -> SessionEventHttpSessionListenerAdapter
★ sessionRepository -> RedisSessionRepository
★ sessionRepositoryFilterRegistration -> DelegatingFilterProxyRegistrationBean
★ spring.session-org.springframework.boot.autoconfigure.session.SessionProperties -> SessionProperties
★ spring.session.redis-org.springframework.boot.autoconfigure.session.RedisSessionProperties -> RedisSessionProperties
★ springBootSessionRepositoryCustomizer -> RedisSessionConfiguration$DefaultRedisSessionConfiguration$$Lambda/0x00007f364e6a4a68
★ springSessionRepositoryFilter -> SessionRepositoryFilter
--- OAuth2 authorized client — 바뀌었는가? ---
authorizedClientService
before: InMemoryOAuth2AuthorizedClientService
after : InMemoryOAuth2AuthorizedClientService 그대로 — Redis 로 안 옮겨졌다
authorizedClientRepository
before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
after : AuthenticatedPrincipalOAuth2AuthorizedClientRepository 그대로 — Redis 로 안 옮겨졌다
authorizedClientManager
before: AuthorizedClientServiceOAuth2AuthorizedClientManager
after : AuthorizedClientServiceOAuth2AuthorizedClientManager 그대로 — Redis 로 안 옮겨졌다
--- Redis 연결 빈 (새로 생긴 것) ---
★ keyValueMappingContext -> RedisMappingContext
★ lettuceMetrics -> LettuceMetricsAutoConfiguration$$Lambda/0x00007f364e56f4d0
★ org.springframework.boot.actuate.autoconfigure.data.redis.RedisHealthContributorAutoConfiguration -> RedisHealthContributorAutoConfiguration
★ org.springframework.boot.actuate.autoconfigure.data.redis.RedisReactiveHealthContributorAutoConfiguration -> RedisReactiveHealthContributorAutoConfiguration
★ org.springframework.boot.actuate.autoconfigure.metrics.redis.LettuceMetricsAutoConfiguration -> LettuceMetricsAutoConfiguration
★ org.springframework.boot.autoconfigure.data.redis.LettuceConnectionConfiguration -> LettuceConnectionConfiguration
★ org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration -> RedisAutoConfiguration
★ org.springframework.boot.autoconfigure.data.redis.RedisReactiveAutoConfiguration -> RedisReactiveAutoConfiguration
★ org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration -> RedisRepositoriesAutoConfiguration
★ org.springframework.boot.autoconfigure.session.RedisSessionConfiguration -> RedisSessionConfiguration
★ org.springframework.boot.autoconfigure.session.RedisSessionConfiguration$DefaultRedisSessionConfiguration -> RedisSessionConfiguration$DefaultRedisSessionConfiguration
★ org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration -> RedisHttpSessionConfiguration
★ reactiveRedisTemplate -> ReactiveRedisTemplate
★ reactiveStringRedisTemplate -> ReactiveStringRedisTemplate
★ redisConnectionDetails -> PropertiesRedisConnectionDetails
★ redisConnectionFactory -> LettuceConnectionFactory
★ redisConverter -> MappingRedisConverter
★ redisCustomConversions -> RedisCustomConversions
★ redisHealthContributor -> RedisReactiveHealthIndicator
★ redisKeyValueAdapter -> RedisKeyValueAdapter
★ redisKeyValueTemplate -> RedisKeyValueTemplate
★ redisMappingConfiguration#0 -> MappingConfiguration
★ redisReferenceResolver -> ReferenceResolverImpl
★ redisTemplate -> RedisTemplate
★ sessionRepository -> RedisSessionRepository
★ spring.data.redis-org.springframework.boot.autoconfigure.data.redis.RedisProperties -> RedisProperties
★ spring.session.redis-org.springframework.boot.autoconfigure.session.RedisSessionProperties -> RedisSessionProperties
★ springBootSessionRepositoryCustomizer -> RedisSessionConfiguration$DefaultRedisSessionConfiguration$$Lambda/0x00007f364e6a4a68
★ stringRedisTemplate -> StringRedisTemplate
@@ -0,0 +1,21 @@
=== Redis 에 무엇이 들어 있는가 ===
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
총 키 수: 1
=== 세션 키의 내용 — refresh token 이 있는가 (Q3 검증 2번) ===
키: bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
타입: 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
=== 필드 값에 토큰 문자열이 보이는가 ===
1) "sessionAttr:SPRING_SECURITY_CONTEXT"
2) "\xac\xed\x00\x05sr\x00=org.springframework.security.core.context.SecurityContextImpl\x00\x00\x00\x00\x00\x00\x02l\x02\x00\x01L\x00\x0eauthenticationt\x002Lorg/springframework/security/core/Authentication;xpsr\x00Sorg.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken\x00\x00\x00\x00\x00\x00\x02l\x02\x00\x02L\x00\x1eauthorizedClientRegistrationIdt\x00\x12Ljava/lang/String;L\x00\tprincipalt\x00:Lorg/springframework/security/oauth2/core/user/OAuth2User;xr\x00Gorg.springframework.security.authentication.AbstractAuthenticationToken\xd3\xaa(~nGd\x0e\x02\x00\x03Z\x00\rauthenticatedL\x00\x0bauthoritiest\x00\x16Ljava/util/Collection;L\x00\adetailst\x00\x12Ljava/lang/Object;xp\x01sr\x00&java.util.Collections$UnmodifiableList\xfc\x0f%1\xb5\xec\x8e\x10\x02\x00\x01L\x00\x04listt\x00\x10Ljava/util/List;xr\x00,java.util.Collect
=== TTL (Q3 검증 3번 — session TTL) ===
TTL: 1772 초
@@ -0,0 +1,18 @@
# B-1 — Redis 세션 저장소 전환 증거
2026-09-04 14:5015:05 KST
해설: [`docs/experiment-b1-redis-session-store.md`](../../experiment-b1-redis-session-store.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-servicelinks-trap.txt` | `enableServiceLinks: false` 적용 후 롤아웃 성공 — 쿠버네티스가 주입한 `REDIS_PORT=tcp://...` 가 설정을 덮어쓴 문제 |
| `02-autoconfig-after.txt` | **핵심** — 빈 321→402(+81). `sessionRepository → RedisSessionRepository` 로 바뀌었지만 **`authorizedClientService``InMemory` 그대로** |
| `03-redis-contents.txt` | Redis 키 1개, 필드는 `SPRING_SECURITY_CONTEXT` 뿐. **토큰 없음.** Java 직렬화(`\xac\xed`), TTL 1772초 |
| `b1-login-works-two-replicas.png` | 전환 직후 `accessTokenStoredOnServer: false` |
| `b1-token-boundary-after-redis.png` | 파드 전면 교체 후 — `principal: labuser` 는 살아남고 토큰만 사라진 상태 |
## 핵심 세 줄
1. **세션은 옮겨졌고 토큰은 안 옮겨졌다.** 빈 81개가 늘었는데 authorized client 관련은 하나도 안 바뀌었다.
2. **refresh token 은 Redis 에 평문으로 있는 게 아니라 아예 없다.** 암호화를 고민하기 전에 이걸 알아야 한다.
3. **"로그인은 되어 있는데 아무것도 못 하는" 상태가 만들어진다** — 완전 로그아웃보다 나쁘다.
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+297
View File
@@ -0,0 +1,297 @@
# B-1 — Redis 를 붙이면 무엇이 옮겨지고 무엇이 안 옮겨지는가 → Q3
브랜치 `feature/keycloak-b1-redis-session-store` ·
증거 [`docs/evidence/b1-redis-session-store/`](evidence/b1-redis-session-store/) ·
2026-09-04 14:5015:05 KST
선행: [`B-0`](experiment-b0-bff-redis-deploy.md)
**대응 질문** — [Q3 · BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가](https://hyeonworks.com/questions/bff-session-authorized-client-store)
---
## 0. 결론부터
| | before | after | |
|---|---|---|---|
| `sessionRepository` | (없음, Tomcat 기본) | **`RedisSessionRepository`** | **옮겨졌다** |
| `authorizedClientService` | `InMemoryOAuth2AuthorizedClientService` | **`InMemoryOAuth2AuthorizedClientService`** | **그대로다** |
| `authorizedClientRepository` | `AuthenticatedPrincipalOAuth2AuthorizedClientRepository` | **동일** | **그대로다** |
그 결과 사용자에게는 이렇게 보인다.
```json
{"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 형태다.** 이름이 겹치면
애플리케이션 설정이 조용히 오염된다.
```yaml
spec:
enableServiceLinks: false # 근본 처방
```
> **환경변수 이름을 바꿔 피할 수도 있다.** 그러면 다음 사람이 같은 함정에
> 다시 빠진다. **주입 자체를 끄는 쪽**을 골랐다.
>
> 이 함정은 Service 이름과 환경변수 이름이 겹칠 때만 나타나므로,
> `REDIS`, `POSTGRES`, `MYSQL` 처럼 **흔한 이름일수록 위험하다.**
## 문제 ② — 테스트가 Redis 를 찾다가 죽었다
`spring-session-data-redis` 를 넣으면 컨텍스트 기동 시 Redis 에 붙으려 한다.
테스트에는 Redis 가 없다.
```java
@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층에서 반복해서 배운 것 — **증상과 원인을 붙이기 전에 로그를 본다.**
```yaml
# 다른 네임스페이스의 서비스는 <svc>.<ns>.svc 로 부른다
value: http://echo.header-lab.svc:8081
```
---
## 2. 자동구성이 실제로 바뀌었는가 — B-0 의 방법을 다시 쓴다
```bash
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. 사용자에게 보이는 결과 — 가장 중요한 부분
![Redis 전환 후 token-boundary](evidence/b1-redis-session-store/b1-token-boundary-after-redis.png)
```json
{"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. 재현 절차 (명령어)
```bash
# 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 이름과 환경변수 충돌 |