diff --git a/.playwright-mcp/console-2026-09-04T05-00-51-720Z.log b/.playwright-mcp/console-2026-09-04T05-00-51-720Z.log
new file mode 100644
index 0000000..2f36cc5
--- /dev/null
+++ b/.playwright-mcp/console-2026-09-04T05-00-51-720Z.log
@@ -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
diff --git a/.playwright-mcp/console-2026-09-04T05-02-32-624Z.log b/.playwright-mcp/console-2026-09-04T05-02-32-624Z.log
new file mode 100644
index 0000000..9b14373
--- /dev/null
+++ b/.playwright-mcp/console-2026-09-04T05-02-32-624Z.log
@@ -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
diff --git a/.playwright-mcp/console-2026-09-04T05-02-45-161Z.log b/.playwright-mcp/console-2026-09-04T05-02-45-161Z.log
new file mode 100644
index 0000000..4317e3d
--- /dev/null
+++ b/.playwright-mcp/console-2026-09-04T05-02-45-161Z.log
@@ -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
diff --git a/.playwright-mcp/page-2026-09-04T05-00-02-742Z.yml b/.playwright-mcp/page-2026-09-04T05-00-02-742Z.yml
new file mode 100644
index 0000000..e300da4
--- /dev/null
+++ b/.playwright-mcp/page-2026-09-04T05-00-02-742Z.yml
@@ -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]
\ No newline at end of file
diff --git a/.playwright-mcp/page-2026-09-04T05-00-09-772Z.yml b/.playwright-mcp/page-2026-09-04T05-00-09-772Z.yml
new file mode 100644
index 0000000..5330104
--- /dev/null
+++ b/.playwright-mcp/page-2026-09-04T05-00-09-772Z.yml
@@ -0,0 +1 @@
+- generic [active] [ref=f30e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":false,\"refreshTokenStoredOnServer\":false,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
\ No newline at end of file
diff --git a/.playwright-mcp/page-2026-09-04T05-00-52-003Z.yml b/.playwright-mcp/page-2026-09-04T05-00-52-003Z.yml
new file mode 100644
index 0000000..65ef96e
--- /dev/null
+++ b/.playwright-mcp/page-2026-09-04T05-00-52-003Z.yml
@@ -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).
\ No newline at end of file
diff --git a/.playwright-mcp/page-2026-09-04T05-02-33-239Z.yml b/.playwright-mcp/page-2026-09-04T05-02-33-239Z.yml
new file mode 100644
index 0000000..0046daf
--- /dev/null
+++ b/.playwright-mcp/page-2026-09-04T05-02-33-239Z.yml
@@ -0,0 +1 @@
+- generic [active] [ref=f32e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":false,\"refreshTokenStoredOnServer\":false,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
\ No newline at end of file
diff --git a/.playwright-mcp/page-2026-09-04T05-02-45-383Z.yml b/.playwright-mcp/page-2026-09-04T05-02-45-383Z.yml
new file mode 100644
index 0000000..b1ee469
--- /dev/null
+++ b/.playwright-mcp/page-2026-09-04T05-02-45-383Z.yml
@@ -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]
\ No newline at end of file
diff --git a/.playwright-mcp/page-2026-09-04T05-03-13-061Z.yml b/.playwright-mcp/page-2026-09-04T05-03-13-061Z.yml
new file mode 100644
index 0000000..f892328
--- /dev/null
+++ b/.playwright-mcp/page-2026-09-04T05-03-13-061Z.yml
@@ -0,0 +1 @@
+- generic [active] [ref=f34e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":false,\"refreshTokenStoredOnServer\":false,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
\ No newline at end of file
diff --git a/bff/pom.xml b/bff/pom.xml
index 093b952..bdb5ec7 100644
--- a/bff/pom.xml
+++ b/bff/pom.xml
@@ -29,6 +29,19 @@
org.springframework.boot
spring-boot-starter-oauth2-client
+
+
+
+ org.springframework.session
+ spring-session-data-redis
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis
+
org.springframework.boot
spring-boot-starter-web
diff --git a/bff/src/main/resources/application.yml b/bff/src/main/resources/application.yml
index b2d21e4..e830a64 100644
--- a/bff/src/main/resources/application.yml
+++ b/bff/src/main/resources/application.yml
@@ -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:
diff --git a/bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java b/bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java
index 6cd928e..a2ff6d0 100644
--- a/bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java
+++ b/bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java
@@ -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
diff --git a/deploy/lab/k8s/bff-redis.yaml b/deploy/lab/k8s/bff-redis.yaml
index 6974f98..3adbdb5 100644
--- a/deploy/lab/k8s/bff-redis.yaml
+++ b/deploy/lab/k8s/bff-redis.yaml
@@ -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 로 부른다. 이름을 틀리면 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:
diff --git a/docs/evidence/b1-redis-session-store/01-servicelinks-trap.txt b/docs/evidence/b1-redis-session-store/01-servicelinks-trap.txt
new file mode 100644
index 0000000..9ab6e15
--- /dev/null
+++ b/docs/evidence/b1-redis-session-store/01-servicelinks-trap.txt
@@ -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
diff --git a/docs/evidence/b1-redis-session-store/02-autoconfig-after.txt b/docs/evidence/b1-redis-session-store/02-autoconfig-after.txt
new file mode 100644
index 0000000..d805fa2
--- /dev/null
+++ b/docs/evidence/b1-redis-session-store/02-autoconfig-after.txt
@@ -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
diff --git a/docs/evidence/b1-redis-session-store/03-redis-contents.txt b/docs/evidence/b1-redis-session-store/03-redis-contents.txt
new file mode 100644
index 0000000..a75383e
--- /dev/null
+++ b/docs/evidence/b1-redis-session-store/03-redis-contents.txt
@@ -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 초
diff --git a/docs/evidence/b1-redis-session-store/README.md b/docs/evidence/b1-redis-session-store/README.md
new file mode 100644
index 0000000..902109e
--- /dev/null
+++ b/docs/evidence/b1-redis-session-store/README.md
@@ -0,0 +1,18 @@
+# B-1 — Redis 세션 저장소 전환 증거
+
+2026-09-04 14:50–15: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. **"로그인은 되어 있는데 아무것도 못 하는" 상태가 만들어진다** — 완전 로그아웃보다 나쁘다.
diff --git a/docs/evidence/b1-redis-session-store/b1-login-works-two-replicas.png b/docs/evidence/b1-redis-session-store/b1-login-works-two-replicas.png
new file mode 100644
index 0000000..8cafd43
Binary files /dev/null and b/docs/evidence/b1-redis-session-store/b1-login-works-two-replicas.png differ
diff --git a/docs/evidence/b1-redis-session-store/b1-token-boundary-after-redis.png b/docs/evidence/b1-redis-session-store/b1-token-boundary-after-redis.png
new file mode 100644
index 0000000..8cafd43
Binary files /dev/null and b/docs/evidence/b1-redis-session-store/b1-token-boundary-after-redis.png differ
diff --git a/docs/experiment-b1-redis-session-store.md b/docs/experiment-b1-redis-session-store.md
new file mode 100644
index 0000000..2040926
--- /dev/null
+++ b/docs/experiment-b1-redis-session-store.md
@@ -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:50–15: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
+ ...
+```
+
+**`_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 로 부른다
+value: http://echo.header-lab.svc:8081
+```
+
+---
+
+## 2. 자동구성이 실제로 바뀌었는가 — B-0 의 방법을 다시 쓴다
+
+```bash
+kubectl -n keycloak-lab exec -- 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. 사용자에게 보이는 결과 — 가장 중요한 부분
+
+
+
+```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 -- 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:"
+kubectl -n keycloak-lab exec deploy/redis -- redis-cli ttl "bff:session:sessions:"
+
+# 6. 사용자 관점 확인
+# 브라우저로 https://app1.hyeonworks.com/bff/token-boundary
+```
+
+---
+
+## 8. 다음 실험에 남기는 것
+
+| 실험 | 이 실험이 준 것 |
+|---|---|
+| **B-2** 다중 인스턴스 | **authorized client 를 어디로 옮길지**가 남았다. 세 후보를 비교한다 |
+| **B-3** refresh 경쟁 | 토큰이 공유되어야 경쟁이 재현된다 — **아직 공유되지 않았다** |
+| **D-2** 업그레이드 | **Java 직렬화된 세션**이 버전 변경에 견디는가 |
+| 운영 | `enableServiceLinks: false` — Service 이름과 환경변수 충돌 |