Files
keycloak-pattern/docs/guides/experiments/b1-redis-session-store.md
T
DongHyeonkaandClaude Opus 5 6f6ab86345 docs(guides): reproduction guides for all 26 experiments
Written by subagents running under the writing-practitioner-guides skill,
one guide per experiment, 22,566 lines. Each walks a reader from baseline
capture through injection, injection verification, observation and recovery.

Section 3 carries the weight in most of them. Injection failed silently nine
times in this lab, and a failed injection looks exactly like no effect — so
the guides verify the target is actually in the intended state before
reading any result. A-4 makes virsh list the only proof because the node
reads Ready for 40 seconds after the machine is off; A-5 makes the packet
counter the sole go/no-go because a rule on the wrong node produces an empty
result that reads like a finding; A-6 quotes the run where 적용완료 was
printed between four Cannot find device "eth0" lines.

The traps the guides are built around are ones that invert a conclusion
rather than merely annoy:

  A-0   emptying the session table without a restart leaves cache entries
        that get counted as replication arriving
  A-2   dropping -o /dev/null fuses body and status into one string
  A-3   presence of "ready to accept connections" instead of its timestamp
  B-2   row count alone reads an UPDATE as nothing having happened
  B-4   tr ',' '\n' splits ["admin","editor"] so only admin is seen
  B-7   no login screen means the cookie died and SSO re-authenticated
  C-1   counting sessions without joining realm counts your own kcadm one
  D-1   kubectl exec without -i restores nothing and still exits 0
  D-4a  "ran with error output" is what success looks like

Every quoted block is copied from docs/evidence/ and marked 실측; reshaped
commands are marked 미검증 rather than passed off as measured. Where a source
document carries a ★ correction the guides follow the corrected claim — A-7's
REVOKED_TOKEN hypothesis, C-1's session count, B-2's schema attribution.

Two hazards are stated rather than smoothed over: B-6 deletes a key that
cannot be recreated, and D-1/D-4 need host sudo, which asks for a password,
so those steps say a person must type them.

Audit over all 26: 672 interpretation pairs, 486 evidence citations, 117
undo sections, and zero occurrences of the patterns the skill forbids —
no python data processing, no deprecated kubectl get endpoints, no
placeholders, no bare kcadm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:29:00 +09:00

853 lines
34 KiB
Markdown
Raw 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 를 붙이고, 무엇이 옮겨졌고 무엇이 안 옮겨졌는지 찍어서 확인한다
해설 문서: [`docs/experiment-b1-redis-session-store.md`](../../experiment-b1-redis-session-store.md) ·
증거 원문: [`docs/evidence/b1-redis-session-store/`](../../evidence/b1-redis-session-store/)
## 이 가이드가 끝나면
당신 터미널과 브라우저에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| **쿠버네티스가 넣지도 않은 환경변수로 파드를 죽이는 것** | `logs` · `printenv` |
| 그것이 `enableServiceLinks: false` 로 고쳐지는 것 | 롤아웃 성공 |
| 빈이 **321 → 402 (+81)** 로 늘어나는 것 | `/actuator/beans` |
| **그런데 authorized client 는 하나도 안 바뀐 것** | 같은 곳 |
| Redis 안의 키·필드·TTL, 그리고 **토큰이 없는 것** | `redis-cli` |
| 세션이 **Java 네이티브 직렬화**인 것 | `\xac\xed` |
| **「로그인은 되어 있는데 아무것도 못 하는」 상태** | 브라우저 |
## 전제
- [`B-0`](b0-bff-redis-deploy.md) 이 끝나 있다. **B-0 의 답(빈 세 개의 이름)을
손에 들고 시작한다** — 이 실험은 그 값들이 어떻게 바뀌는지를 재는 것이다.
- **브라우저가 필요하다.** 인가 코드 흐름은 왕복이 두 번이라 `curl` 로 대신할 수 없다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- `jq` 는 이 실험대에 **깔려 있지 않다.** 이 가이드는 `grep``redis-cli` 로 읽는다.
## 주의 — 이건 애플리케이션 구성을 바꾸는 실험이다
의존성과 설정을 바꿔 **다시 빌드하고 다시 배포한다.** 되돌리려면 소스 변경을
되돌리고 다시 빌드해야 하므로, **`git status` 가 깨끗한 상태에서 시작한다.**
전 구간 약 40분(빌드 시간 포함). 되돌리는 방법은 매 단계에 적어 두었다.
**★ 2-3 은 일부러 고장 난 상태로 배포한다.** 함정을 직접 보기 위해서다. 건너뛰고
싶으면 [2-4](#2-4-고침--enableservicelinks-false) 부터 시작해도 결과는 같다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 13:5914:03 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 다른 방법을 썼다 |
파드 이름·세션 ID·Service IP·TTL 은 **당신 환경에서 다르다.** 이 문서는
자리표시자(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
---
# 0. 왜 이 실험을 하는가
B-0 이 답을 냈다. 세션도 토큰도 **인스턴스 메모리**에 있고, 그래서 replica 2 에서는
로그인조차 안 된다.
**처방은 뻔해 보인다 — 공유 저장소를 붙이면 된다.**
```
Redis 를 붙인다 → 상태가 공유된다 → 다중 인스턴스가 된다
정말 그런가?
```
이 실험이 재는 것은 **「붙였다」와 「공유된다」 사이의 거리**다.
| | 물어볼 것 |
|---|---|
| 무엇이 옮겨졌나 | `/actuator/beans` 를 다시 찍는다 |
| **무엇이 안 옮겨졌나** | **같은 곳.** 안 바뀐 것을 확인하는 게 더 중요하다 |
| 옮겨진 것 안에 무엇이 들었나 | Redis 를 직접 연다 |
| 사용자에게는 어떻게 보이나 | 브라우저 |
그리고 배포 첫 시도에서 **쿠버네티스가 내 설정을 덮어쓰는** 함정을 만난다.
그게 1절과 2절의 절반이다.
---
# 1. 기준선 — 붙이기 전에
넓은 것부터 좁혀 간다.
```
BFF 가 돌고 있나 → B-0 의 답 세 개 → Redis 가 비어 있나 → ★ 파드 안 환경변수
```
## 1-1. BFF 가 B-0 구성으로 돌고 있나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide -l app=bff
sudo kubectl -n keycloak-lab get pods -o wide -l app=redis
```
**형태**
```
bff-574c6d658b-8cz4x 1/1 Running 0 20m 10.42.0.51 kc-lab-1
bff-574c6d658b-zpkbp 1/1 Running 0 20m 10.42.1.52 kc-lab-2
redis-568bd7c4-5c5vc 1/1 Running 0 20m 10.42.1.53 kc-lab-2
```
**어디를 봐야 하는가** — BFF 두 개가 **서로 다른 노드**에 있고, Redis 가 떠 있는 것.
**이 결과가 의미하는 것** — Redis 는 **배포만 되어 있고 아직 연결되지 않았다.**
B-0 이 그렇게 만들어 뒀다. 이제 연결한다.
## 1-2. B-0 의 답을 다시 확인한다 — before 값
**하기**
```bash
BFF=$(sudo kubectl -n keycloak-lab get pod -l app=bff \
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
sudo kubectl -n keycloak-lab exec "$BFF" -- \
wget -qO- http://localhost:8083/actuator/beans > /tmp/beans-before.json
wc -c /tmp/beans-before.json
```
**확인** — 빈 수와 관련 빈 세 개. **미검증**
```bash
grep -o '"aliases":\[' /tmp/beans-before.json | wc -l
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-before.json \
| sed 's/{"aliases".*"type":"/ -> /' \
| grep -iE 'authorizedclient|sessionRepository'
```
**실측** — [`02-autoconfig-after.txt`](../../evidence/b1-redis-session-store/02-autoconfig-after.txt)
```
빈 수: 321 → 402 (+81)
```
```
authorizedClientService
before: InMemoryOAuth2AuthorizedClientService
authorizedClientRepository
before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
authorizedClientManager
before: AuthorizedClientServiceOAuth2AuthorizedClientManager
```
**어디를 봐야 하는가** — 빈 수 **321**, `sessionRepository`**아예 없다.**
**★ 이 세 줄과 숫자를 적어 둔다.** 4-1 의 비교 대상이 이것이고, **비교 없이는
「안 바뀌었다」를 말할 수 없다.**
## 1-3. Redis 가 비어 있는지 본다
**확인** — 먼저 살아 있는지
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli info server | head
```
**형태**
```
PONG
# Server
redis_version:7.4.x
...
```
**확인** — 키가 있나
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
```
**형태**
```
(integer) 0
```
**어디를 봐야 하는가****`0`.** 비어 있어야 4-2 에서 「내가 만든 것」이라고
말할 수 있다.
> **`KEYS *` 대신 `--scan` 을 쓴다.** `KEYS` 는 서버를 블로킹한다. 지금은 키가
> 0개라 차이가 없지만, 습관이 되면 운영에서 사고가 난다.
## 1-4. ★ 파드 안 환경변수를 미리 본다 — 함정이 여기 있다
**아직 아무것도 안 바꿨는데** 파드 안에 Redis 관련 환경변수가 이미 있다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec "$BFF" -- printenv | sort
```
한 번은 통째로 본다. 그다음 걸러 본다.
```bash
sudo kubectl -n keycloak-lab exec "$BFF" -- printenv | grep -i 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
REDIS_PORT_6379_TCP_PORT=6379
REDIS_PORT_6379_TCP_PROTO=tcp
```
**어디를 봐야 하는가****`REDIS_PORT` 의 값이 포트 번호가 아니라 URL 이다.**
### 개념 — Service Links
**무엇인가.** 쿠버네티스는 같은 네임스페이스의 **모든 Service 마다** Docker link
시절의 환경변수를 파드에 자동으로 넣는다. 옛 Docker 링크 호환을 위한 기능이고,
**기본값이 켜짐**이다.
**왜 여기 나오나.** Service 이름이 `redis` 이므로 `REDIS_*` 가 들어온다.
그리고 애플리케이션 설정도 `${REDIS_PORT:6379}` 를 읽는다. **이름이 겹친다.**
```
Service 이름이 redis 이면
REDIS_SERVICE_HOST=10.43.57.116
REDIS_SERVICE_PORT=6379
REDIS_PORT=tcp://10.43.57.116:6379 ← 이게 문제
```
**`<SVCNAME>_PORT` 는 포트 번호가 아니라 URL 형태다.**
**없거나 틀리면.** 매니페스트에 `REDIS_PORT: "6379"` 를 명시하면 그게 이긴다 —
**그런데 명시를 안 하면 자동 주입이 이긴다.** 그리고 오류 메시지는 당신이 쓰지도
않은 값을 지목한다.
**이 결과가 의미하는 것****지금은 아무 일도 안 일어난다.** 애플리케이션이
그 변수를 안 읽기 때문이다. **다음 절에서 읽기 시작하는 순간 파드가 죽는다.**
> `REDIS`, `POSTGRES`, `MYSQL` 처럼 **흔한 Service 이름일수록 위험하다.**
---
# 2. 주입 — Redis 를 붙인다
**되돌리기** — 먼저 읽어 둔다
```bash
git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
deploy/lab/k8s/bff-redis.yaml
```
## 2-1. 의존성 **두 개**를 함께 넣는다
```bash
vim bff/pom.xml
```
```xml
<!-- spring-session-data-redis 가 SessionRepository 를 갈아끼우고,
spring-boot-starter-data-redis 가 연결(Lettuce)을 제공한다.
둘 다 있어야 자동구성이 걸린다 -->
<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>
```
**★ 하나만 넣으면 조용히 in-memory 로 남는다.** 오류도 안 난다. 그래서
4-1 에서 **찍어서 확인**하는 절차가 필요하다.
## 2-2. 설정을 넣는다
```bash
vim bff/src/main/resources/application.yml
```
```yaml
spring:
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
session:
store-type: ${SPRING_SESSION_STORE_TYPE:redis}
timeout: ${SPRING_SESSION_TIMEOUT:30m}
redis:
namespace: bff:session
```
### 문제 ② — 테스트가 Redis 를 찾다가 죽는다
`spring-session-data-redis` 를 넣으면 **컨텍스트 기동 시 Redis 에 붙으려 한다.**
테스트에는 Redis 가 없다.
```bash
vim bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java
```
```java
@SpringBootTest(properties = {
"KEYCLOAK_CLIENT_SECRET=test-only-secret",
// 테스트는 Redis 를 띄우지 않는다
"spring.session.store-type=none",
})
```
**이 한 줄이 없으면 빌드가 테스트 단계에서 죽는다.** 그리고 그 실패 메시지는
Redis 연결 오류라서 **「배포 환경 문제」로 읽히기 쉽다.** 실패한 곳은 빌드다.
## 2-3. ★ 일부러 `enableServiceLinks` 없이 배포한다
**함정을 직접 본다.** 이미 아는 함정을 문서에서 읽는 것과, 자기 터미널에서
그 오류 메시지를 만나는 것은 다르다.
```bash
vim deploy/lab/k8s/bff-redis.yaml
```
```yaml
spec:
# enableServiceLinks: false ← 아직 넣지 않는다
containers:
- name: bff
env:
- name: SPRING_SESSION_STORE_TYPE
value: redis
- name: REDIS_HOST
value: redis.keycloak-lab.svc
# REDIS_PORT 를 일부러 안 준다 — 자동 주입이 어떻게 이기는지 본다
```
**하기** — 빌드하고 두 노드에 밀어 넣고 배포한다
```bash
docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
echo "exit=$?"
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml
sudo kubectl -n keycloak-lab rollout restart deployment/bff
```
**되돌리기** — 2-4 가 곧 되돌리기다. 지금 멈추려면:
```bash
sudo kubectl -n keycloak-lab rollout undo deployment/bff
```
### 무엇이 일어나는지 순서대로 본다
**확인 ①** — 넓게
```bash
sudo kubectl -n keycloak-lab get pods -l app=bff
```
**형태**
```
NAME READY STATUS RESTARTS AGE
bff-695646ddb-kzs9k 0/1 CrashLoopBackOff 3 (20s ago) 90s
```
**확인 ②** — 왜인지 물어본다. **로그보다 먼저 이벤트를 본다**
```bash
sudo kubectl -n keycloak-lab describe pod -l app=bff | tail -20
```
**확인 ③** — 로그. 죽은 뒤라면 `--previous`
```bash
sudo kubectl -n keycloak-lab logs -l app=bff --tail=40
sudo kubectl -n keycloak-lab logs -l app=bff --previous --tail=40
```
**실측** — [`experiment-b1-redis-session-store.md`](../../experiment-b1-redis-session-store.md) 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")
```
**어디를 봐야 하는가** — 마지막 줄의 **`"tcp://10.43.57.116:6379"`.**
**이 결과가 의미하는 것****내가 쓴 적 없는 값이 오류에 나온다.**
1-4 에서 미리 본 그 환경변수다. 쿠버네티스가 넣었다.
> **이 오류를 「Redis 가 안 떠서」로 읽기 쉽다.** 실제로 Redis 는 멀쩡하다.
> **파드가 Redis 에 붙어 보지도 못하고 설정 바인딩에서 죽었다.** 메시지가
> `Failed to bind properties` 라고 말하고 있다 — 연결 오류가 아니다.
**확인** — Redis 는 멀쩡한지 확인해 본다
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping
```
**형태**
```
PONG
```
## 2-4. 고침 — `enableServiceLinks: false`
**두 가지 처방이 있다.**
| 처방 | 문제 |
|---|---|
| 환경변수 이름을 바꾼다 (`BFF_REDIS_PORT` 등) | **다음 사람이 같은 함정에 다시 빠진다** |
| **주입 자체를 끈다** | 근본 처방 |
```bash
vim deploy/lab/k8s/bff-redis.yaml
```
```yaml
spec:
enableServiceLinks: false # 근본 처방
containers:
- name: bff
env:
- name: SPRING_SESSION_STORE_TYPE
value: redis
- name: REDIS_HOST
value: redis.keycloak-lab.svc
- name: REDIS_PORT
value: "6379"
```
**하기**
```bash
sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
```
**실측** — [`01-servicelinks-trap.txt`](../../evidence/b1-redis-session-store/01-servicelinks-trap.txt)
```
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
```
**어디를 봐야 하는가****세 줄이다.** replica 는 2인데 파드가 3개 보인다.
**롤아웃 전환 중에 찍은 것**이고, 옛 ReplicaSet 의 파드가 아직 종료 전이다.
잠시 뒤 두 개가 된다.
**확인** — 주입이 정말 사라졌나
```bash
BFF=$(sudo kubectl -n keycloak-lab get pod -l app=bff \
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
sudo kubectl -n keycloak-lab exec "$BFF" -- printenv | grep -i redis
```
**형태**
```
REDIS_HOST=redis.keycloak-lab.svc
REDIS_PORT=6379
```
**어디를 봐야 하는가****`REDIS_SERVICE_HOST` 계열이 전부 사라졌고**, 내가 준
두 개만 남은 것. `REDIS_PORT``6379` 다.
## 2-5. 문제 ③ — 리소스 서버가 아예 없었다
API 호출이 `500` 이었다. **원인은 토큰이 아니었다.**
**확인** — 로그를 본다
```bash
sudo kubectl -n keycloak-lab logs "$BFF" --tail=100 | grep -iE 'exception|error'
```
**실측** — [`experiment-b1-redis-session-store.md`](../../experiment-b1-redis-session-store.md) 1절
```
java.nio.channels.UnresolvedAddressException
```
**어디를 봐야 하는가****`UnresolvedAddressException`.** DNS 다.
`RESOURCE_API_BASE_URL=http://echo.keycloak-lab.svc:8080` 이었는데 `echo`
**`header-lab` 네임스페이스의 8081** 이었다. 배포조차 되어 있지 않았다.
```yaml
# 다른 네임스페이스의 서비스는 <svc>.<ns>.svc 로 부른다
- name: RESOURCE_API_BASE_URL
value: http://echo.header-lab.svc:8081
```
**확인** — 그 서비스가 실제로 있나
```bash
sudo kubectl -n header-lab get svc echo
```
> **`500` 을 보고 「토큰이 없어서」라고 읽을 뻔했다.** 로그를 보니 DNS 였다.
> **증상과 원인을 붙이기 전에 로그를 본다.**
---
# 3. 주입이 실제로 걸렸는지 확인한다
## 3-1. 파드가 떴고 Redis 에 붙었나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide -l app=bff
sudo kubectl -n keycloak-lab exec "$BFF" -- \
wget -qO- http://localhost:8083/actuator/health
```
**형태**
```json
{"status":"UP","components":{"redis":{"status":"UP","details":{"version":"7.4.x"}},...}}
```
**어디를 봐야 하는가****`redis` 컴포넌트가 있고 `UP` 인 것.**
**이 결과가 의미하는 것** — B-0 에서는 이 컴포넌트가 **아예 없었다.**
`spring-boot-starter-data-redis` 가 헬스 인디케이터를 같이 들고 왔다.
**건강 체크에 새 항목이 생긴 것 자체가 자동구성이 걸렸다는 신호다.**
## 3-2. 로그인이 되나 — replica 2 에서
**하기** — 브라우저에서 `https://app1.hyeonworks.com/` 로 로그인한다.
**쿠키를 먼저 지운다.**
**실측** — [`b1-login-works-two-replicas.png`](../../evidence/b1-redis-session-store/b1-login-works-two-replicas.png)
**어디를 봐야 하는가****로그인이 된다.** B-0 에서 `replica 2` 로는 `/login?error`
였던 그 자리다.
**이 결과가 의미하는 것** — 인가 요청(state·PKCE verifier)이 이제 **Redis**
있으므로 콜백이 다른 인스턴스로 가도 찾을 수 있다. **B-0 이 replica 를 1로
줄여야 했던 문제는 고쳐졌다.**
**여기서 멈추면 「Redis 를 붙였더니 다 해결됐다」로 끝난다. 그게 이 실험이
막으려는 결론이다.**
---
# 4. 관찰
## 4-1. ★ 자동구성이 실제로 무엇을 바꿨나 — B-0 의 방법을 그대로
**하기**
```bash
sudo kubectl -n keycloak-lab exec "$BFF" -- \
wget -qO- http://localhost:8083/actuator/beans > /tmp/beans-after.json
grep -o '"aliases":\[' /tmp/beans-after.json | wc -l
```
**실측** — [`02-autoconfig-after.txt`](../../evidence/b1-redis-session-store/02-autoconfig-after.txt)
```
빈 수: 321 → 402 (+81)
```
**확인** — 세션 저장소 계열. **미검증**
```bash
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-after.json \
| sed 's/{"aliases".*"type":"/ -> /' \
| grep -iE 'session|redis'
```
**실측** — 같은 파일
```
--- 세션 저장소 관련 (새로 생긴 것) ---
★ cookieSerializer -> DefaultCookieSerializer
★ org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration -> RedisHttpSessionConfiguration
★ sessionRepository -> RedisSessionRepository
★ springSessionRepositoryFilter -> SessionRepositoryFilter
★ redisConnectionFactory -> LettuceConnectionFactory
★ redisTemplate -> RedisTemplate
```
**확인** — ★ **안 바뀐 것.** 이쪽이 핵심이다. **미검증**
```bash
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-after.json \
| sed 's/{"aliases".*"type":"/ -> /' \
| grep -i authorizedclient
```
**실측** — 같은 파일
```
--- OAuth2 authorized client — 바뀌었는가? ---
authorizedClientService
before: InMemoryOAuth2AuthorizedClientService
after : InMemoryOAuth2AuthorizedClientService 그대로 — Redis 로 안 옮겨졌다
authorizedClientRepository
before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
after : AuthenticatedPrincipalOAuth2AuthorizedClientRepository 그대로 — Redis 로 안 옮겨졌다
authorizedClientManager
before: AuthorizedClientServiceOAuth2AuthorizedClientManager
after : AuthorizedClientServiceOAuth2AuthorizedClientManager 그대로 — Redis 로 안 옮겨졌다
```
**어디를 봐야 하는가****빈 81개가 늘었는데 authorized client 는 하나도 안 바뀌었다.**
**이 결과가 의미하는 것**
```
Application Session ──▶ Redis (인증 상태, principal, 인가 요청)
OAuth2AuthorizedClient ──▶ 프로세스 메모리 (access token, refresh token)
```
**「Redis 를 붙였다」가 「상태가 공유된다」를 뜻하지 않는다.**
`spring.session.store-type`**HttpSession** 을 갈아끼우는 설정이고,
`OAuth2AuthorizedClient` 는 **그 설정과 무관한 다른 저장소**다.
> **찍어서 확인하지 않으면 이 사실을 알 방법이 없다.** 로그인은 되고, 화면도
> 뜨고, 파드도 건강하다. **B-0 을 실험으로 만든 이유가 이것이다** — before 가
> 있어야 after 를 읽는다.
## 4-2. Redis 안에 무엇이 들어갔나
**확인** — 키가 생겼나
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
```
**실측** — [`03-redis-contents.txt`](../../evidence/b1-redis-session-store/03-redis-contents.txt)
```
=== Redis 에 무엇이 들어 있는가 ===
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
총 키 수: 1
```
**어디를 봐야 하는가** — 네임스페이스가 **`bff:session`** 이다. `application.yml`
`spring.session.redis.namespace` 가 그대로 접두어가 됐다.
**하기** — 키 이름을 변수로 잡는다
```bash
KEY=$(sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern 'bff:session:sessions:*' | grep -v expires | head -1 | tr -d '\r')
echo "$KEY"
```
**`grep -v expires` 가 필요한 이유** — Spring Session 은 만료 추적용 키
(`bff:session:expirations:*` · `bff:session:sessions:expires:*`)도 만든다.
그것을 잡으면 다음 명령이 빈 결과를 낸다.
**`tr -d '\r'`** — `redis-cli` 출력이 CR 을 달고 올 수 있다. 그대로 쓰면 키가
안 맞는데 오류는 안 난다.
**확인** — 타입과 필드
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli type "$KEY"
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli hkeys "$KEY"
```
**실측** — [`03-redis-contents.txt`](../../evidence/b1-redis-session-store/03-redis-contents.txt)
```
타입: 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
```
**어디를 봐야 하는가****필드 목록에 토큰이 없다.**
### ★ refresh token 은 Redis 에 **없다**
「저장소를 직접 열어 refresh token 이 평문으로 남는지 확인한다」가 검증 항목이었다.
**답은 더 앞에 있었다 — 애초에 들어가지 않는다.**
**「토큰 암호화를 어떻게 할까」를 고민하기 전에, 토큰이 그 저장소에 가지도
않는다는 것을 먼저 알아야 한다.**
**확인** — TTL
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ttl "$KEY"
```
**실측** — 같은 파일
```
=== TTL (Q3 검증 3번 — session TTL) ===
TTL: 1772 초
```
**어디를 봐야 하는가**`1772`. `spring.session.timeout=30m`(1800초)에서 방금
지난 만큼 줄어든 값이다.
**이 결과가 의미하는 것** — **세션 TTL 1772초와 access token 수명 60초가 처음부터
어긋나 있다.** 어느 쪽에 맞출지는 선택이 아니라 **이미 어긋나 있고 그 간극을
누가 메우는가**의 문제다. B-3 의 주제다.
## 4-3. 직렬화는 Java 네이티브다
**확인** — 값의 바이트를 본다
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --no-raw hgetall "$KEY" | head -4
```
**실측** — [`03-redis-contents.txt`](../../evidence/b1-redis-session-store/03-redis-contents.txt)
```
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...
```
**어디를 봐야 하는가****`\xac\xed` 로 시작한다.**
> **`--no-raw` 를 쓰는 이유** — 바이너리를 이스케이프해서 보여준다. 안 쓰면
> 터미널이 제어문자를 먹고 화면이 깨진다.
**이 결과가 의미하는 것**`\xac\xed` 는 **Java 직렬화 매직 넘버**다. JSON 이 아니다.
| 결과 | |
|---|---|
| 사람이 못 읽는다 | 운영 중 디버깅이 어렵다 |
| **클래스 버전에 묶인다** | 애플리케이션을 올리면 **기존 세션이 역직렬화에 실패**할 수 있다 |
| 역직렬화 취약점 | 신뢰할 수 없는 데이터가 들어오면 위험한 형식이다 |
**D-2(버전 업그레이드)에서 이것이 다시 나온다** — Spring Security 버전이 바뀌면
Redis 에 남은 세션이 깨질 수 있다.
## 4-4. ★ 사용자에게는 어떻게 보이나 — 가장 중요한 부분
**하기** — 파드를 전부 교체한다. Redis 덕을 보는지 확인하는 것이다
```bash
sudo kubectl -n keycloak-lab rollout restart deployment/bff
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
```
**되돌리기** — 롤링 재시작은 정상 작업이라 되돌릴 것이 없다.
**하기****로그인은 그대로 둔 채** 브라우저에서
`https://app1.hyeonworks.com/bff/token-boundary` 를 연다.
**실측** — [`b1-token-boundary-after-redis.png`](../../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}
```
**어디를 봐야 하는가****`principal` 은 살아 있는데 두 토큰이 `false` 다.**
**이 결과가 의미하는 것**
```
사용자 관점: 로그인되어 있다고 나온다
실제: BFF 가 사용자를 대신해 아무것도 못 한다
```
**파드가 전부 교체됐는데 로그인 상태는 살아남았다.** Redis 덕분이다.
**그런데 토큰은 같이 살아남지 못했다.** 인스턴스 메모리에 있었으니까.
> **이것이 「부분적으로만 공유했을 때」의 실패 모양이다.**
> **완전히 로그아웃되는 편이 차라리 낫다** — 적어도 사용자가 다시 로그인한다.
> 지금은 화면상 로그인 상태라 사용자가 아무것도 안 한다.
### B-0 과 나란히 놓으면
| | B-0 (Redis 없음, replica 1) | **B-1 (Redis 세션, replica 2)** |
|---|---|---|
| `principal` | labuser | labuser |
| `accessTokenStoredOnServer` | **true** | **false** |
| 파드 재시작 후 | 로그아웃 | **로그인 상태만 남고 토큰은 소실** |
> **★ 스크린샷으로 시점을 구별하지 않는다.**
> [`README.md`](../../evidence/b1-redis-session-store/README.md) 가 적어 둔 대로,
> `b1-login-works-two-replicas.png` 와 `b1-token-boundary-after-redis.png` 는
> **동일 파일**이다. 세 시점 모두 `accessTokenStoredOnServer: false` 인 같은
> 화면이었기 때문이다. **시점 구별은 터미널 출력과 Redis/DB 조회가 한다.**
## 4-5. 그래서 무엇을 해야 하는가
`OAuth2AuthorizedClientService` 를 공유 저장소로 옮기는 구현이 **따로** 필요하다.
| 후보 | |
|---|---|
| `JdbcOAuth2AuthorizedClientService` | Spring Security 기본 제공. **PostgreSQL 이 이미 있다** |
| 직접 구현 (Redis) | `OAuth2AuthorizedClientService` 인터페이스를 Redis 로 구현 |
| 세션 안에 넣기 | `HttpSessionOAuth2AuthorizedClientRepository` 를 쓰면 세션과 함께 Redis 로 간다 |
**세 번째가 흥미롭다** — 조회 키 문제(principal 기준)까지 같이 해결된다.
세션 단위로 저장되므로 **같은 사용자의 다른 브라우저가 서로를 덮어쓰지 않는다.**
대신 세션이 커진다. **[B-2](b2-multi-instance-session.md) 에서
이 선택지를 비교한다.**
**「두 상태를 같은 저장소에 둘지 나눌지」는 선택지가 아니다 — 이미 나뉘어 있고,
나뉜 채로 두면 깨진다.**
---
# 5. 복구
## 5-1. B-2 로 이어갈 것이면 그대로 둔다
이 구성이 B-2 의 출발점이다. **아무것도 안 되돌린다.**
## 5-2. B-0 상태로 되돌릴 때
**하기**
```bash
git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java \
deploy/lab/k8s/bff-redis.yaml
git status --short
```
**★ 되돌린 뒤에는 다시 빌드해서 다시 밀어 넣어야 한다.** 소스만 되돌리면
클러스터에는 여전히 옛 이미지가 돈다.
```bash
docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml
sudo kubectl -n keycloak-lab rollout restart deployment/bff
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
```
## 5-3. Redis 를 비운다
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushdb
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
```
**되돌리기****없다.** 지운 세션은 돌아오지 않는다. 로그인한 사용자는 전부
로그아웃된다. **실험대라서 하는 일이다.**
세션 하나만 지우고 싶으면:
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli del "$KEY"
```
## 5-4. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 소스 | `git status --short` | 출력 없음 (B-0 로 되돌릴 때) |
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide -l app=bff` | `2/2`, 두 노드에 하나씩 |
| Redis | `sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping` | `PONG` |
| Redis 키 | `... redis-cli dbsize` | 의도한 값 |
| Keycloak | `sudo kubectl -n keycloak-lab get pods \| grep keycloak` | 둘 다 `1/1 Running` |
| 밖 | `curl -I https://app1.hyeonworks.com/` | `200` |
| 임시 파일 | `rm -f /tmp/beans-before.json /tmp/beans-after.json /tmp/build.log` | — |
> **이 실험이 재지 않은 것 셋**
> - **Redis 를 끊었을 때 무엇이 나는지** — B-5 의 주제다
> - **로그아웃 뒤 두 저장소에 무엇이 남는지** — B-2 로 넘긴다
> - **저장소 지연이 화면 지연으로 얼마나 번역되는지** — B-2 이후
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| **파드가 `CrashLoopBackOff`, 오류에 `tcp://...:6379`** | **쿠버네티스가 `REDIS_PORT` 를 주입했다** | `printenv \| grep -i redis` — 1-4 · 2-3 |
| 위 오류를 「Redis 가 죽어서」로 읽었다 | 메시지가 `Failed to bind properties` 다 | `redis-cli ping` 으로 Redis 를 따로 확인 |
| `enableServiceLinks` 를 넣었는데 그대로 | 파드가 아직 옛 것이다 | `rollout restart``printenv` 다시 |
| 빌드가 Redis 연결 오류로 죽는다 | **테스트가 Redis 를 찾는다** | `spring.session.store-type=none` — 2-2 |
| `sessionRepository` 가 안 생긴다 | **의존성을 하나만 넣었다.** 오류 없이 in-memory 로 남는다 | 두 개 다 있는지 `pom.xml` — 2-1 |
| `redis-cli --scan` 이 비어 있다 | 아직 로그인 안 했다 | 브라우저로 로그인 후 다시 |
| `hkeys` 가 빈 결과 | **만료 추적 키를 잡았다** | `grep -v expires` — 4-2 |
| 키가 맞는데 명령이 안 먹는다 | 출력에 CR 이 붙었다 | `tr -d '\r'` — 4-2 |
| 값이 깨져서 터미널이 이상해진다 | 바이너리를 그대로 찍었다 | `--no-raw` — 4-3 |
| API 호출이 `500` 인데 토큰은 멀쩡 | **DNS 다.** 다른 네임스페이스의 서비스 | 로그의 `UnresolvedAddressException` — 2-5 |
| `/actuator/beans``Bad Gateway` | 응답이 커서 프록시가 못 넘긴다 | 파드 안에서 받는다 — 4-1 |
| `jq: command not found` | **이 실험대에 `jq` 가 없다** | `grep` 으로 읽는다 — 4-1 |
| 로그인은 되는데 API 가 전부 실패 | **이게 이 실험의 결론이다** | `token-boundary` 의 두 `false` — 4-4 |
| 스크린샷으로 시점을 구별하려다 헷갈린다 | **두 파일이 동일하다** | 터미널 출력과 Redis 조회로 구별 — 4-4 |
---
# 다음
| 실험 | B-1 이 남긴 것 |
|---|---|
| [B-2](b2-multi-instance-session.md) 다중 인스턴스 | **authorized client 를 어디로 옮길지**가 남았다. 4-5 의 세 후보를 비교한다 |
| [B-3](b3-refresh-token-contention.md) refresh 경쟁 | 토큰이 공유되어야 경쟁이 재현된다 — **아직 공유되지 않았다** |
| [B-5](../../experiment-b5-redis-loss-persistence.md) Redis 소실 | 이제 잃을 것이 생겼다. `/data` 가 볼륨인지부터 본다 |
| [D-2](d2-version-upgrade.md) 업그레이드 | **Java 직렬화된 세션**이 버전 변경에 견디는가 |
| 운영 | `enableServiceLinks: false` — Service 이름과 환경변수 충돌 |