# 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) --- ## 구조 ![B-1 구조 — 세션만 Redis 로, 토큰은 프로세스 메모리에](diagrams/b1-store-split.svg) > 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md). > 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg). --- ## 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 $(kubectl -n keycloak-lab get pod -l app=bff --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') -- 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 에서 이 선택지를 비교한다.** --- --- ## 증거 파일 **증거 수집 시각: 2026-09-04 13:59 – 14:03 KST** (파일 mtime 기준. 문서 상단의 시각 표기는 작성 시점이라 다를 수 있다.) | 파일 | 종류 | |---|---| | [`01-servicelinks-trap.txt`](evidence/b1-redis-session-store/01-servicelinks-trap.txt) | 터미널 원문 | | [`02-autoconfig-after.txt`](evidence/b1-redis-session-store/02-autoconfig-after.txt) | 터미널 원문 | | [`03-redis-contents.txt`](evidence/b1-redis-session-store/03-redis-contents.txt) | 터미널 원문 | | [`b1-login-works-two-replicas.png`](evidence/b1-redis-session-store/b1-login-works-two-replicas.png) | 스크린샷 | | [`b1-token-boundary-after-redis.png`](evidence/b1-redis-session-store/b1-token-boundary-after-redis.png) | 스크린샷 | 파일별 상세는 [`evidence/b1-redis-session-store/README.md`](evidence/b1-redis-session-store/README.md). ## 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 $(kubectl -n keycloak-lab get pod -l app=bff --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') -- 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 --scan --pattern 'bff:session:sessions:*' | grep -v expires | head -1 | sed 's/.*://')" kubectl -n keycloak-lab exec deploy/redis -- redis-cli ttl "bff:session:sessions:$(kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:sessions:*' | grep -v expires | head -1 | sed 's/.*://')" # 6. 사용자 관점 확인 # 브라우저로 https://app1.hyeonworks.com/bff/token-boundary ``` --- ## 8. 다음 실험에 남기는 것 | 실험 | 이 실험이 준 것 | |---|---| | **B-2** 다중 인스턴스 | **authorized client 를 어디로 옮길지**가 남았다. 세 후보를 비교한다 | | **B-3** refresh 경쟁 | 토큰이 공유되어야 경쟁이 재현된다 — **아직 공유되지 않았다** | | **D-2** 업그레이드 | **Java 직렬화된 세션**이 버전 변경에 견디는가 | | 운영 | `enableServiceLinks: false` — Service 이름과 환경변수 충돌 |