Files
keycloak-pattern/docs/experiment-b1-redis-session-store.md
DongHyeonkaandClaude Opus 5 74c9b3cea7 docs: replace prose placeholders in reproduction steps with executable commands
The audit found ~80 placeholders, and the damaging ones were where the
measuring apparatus itself was prose rather than a command:

  a6  "( curl ... ) & 를 20개 띄우고 wait"  — the 22.2s headline came from this
  a3  "<로그인 반복, sid 를 /tmp/sids 에>"  — the whole RPO measurement
  a3  "<sid 목록>"                          — the control it is compared against
  a5  "<수신 파드IP>"                       — the injection
  a8  writes /tmp/tok, reads /tmp/rt        — self-inconsistent, sent an empty token
  b3  $KC / $RT / $NEW never assigned
  c2  bare kcadm.sh with no kubectl exec
  a1  conntrack tuples written by hand, though the direction flips per restart

Each is now a shell-expandable form: pod IPs from jsonpath, the admin password
from the secret, ids from kcadm --format csv, conntrack tuples derived from
"conntrack -L" with awk rather than transcribed.

Then the rewritten commands were executed against the live cluster, and one
of them failed — the 20-way load generator, written as "kubectl run --rm -i",
lost its output stream twice in a row. That is a trap this series already hit
once, and the rewrite reintroduced it. A-6 now uses a resident probe pod that
collects into a file and is cat-ed once; verified 20/20 lines.

Evidence: docs/evidence/followup/05-command-reproducibility.txt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:01:54 +09:00

323 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
---
## 구조
![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
...
```
**`<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 $(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 이름과 환경변수 충돌 |