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>
323 lines
13 KiB
Markdown
323 lines
13 KiB
Markdown
# 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)
|
||
|
||
---
|
||
|
||
## 구조
|
||
|
||

|
||
|
||
> 다이어그램 규약은 [`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. 사용자에게 보이는 결과 — 가장 중요한 부분
|
||
|
||

|
||
|
||
```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 이름과 환경변수 충돌 |
|