---
id: 1e5d05fa-88f4-4ef5-a402-4a520ae4a52d
kind: SETUP
slug: reproduce-b1-redis-session-store
title: Redis 를 붙이고 무엇이 옮겨졌는지 빈 목록으로 견준다
topic: where-application-state-lives
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/1e5d05fa-88f4-4ef5-a402-4a520ae4a52d/edit"
pinnedVersions:
- name: keycloak-pattern-bff
version: lab
- name: Redis
version: 7.4.x
source:
- final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-1
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# Redis 를 붙이고 무엇이 옮겨졌는지 빈 목록으로 견준다
Redis 를 세션 저장소로 붙이고 B-0 에서 찍어 둔 빈 목록과 견주는 절차다. 의존성 둘을 함께 넣어 다시 빌드하고 두 노드에 밀어 넣은 뒤, 무엇이 옮겨졌는지와 무엇이 안 옮겨졌는지를 같은 명령으로 확인한다. 전 구간 약 40분이고 빌드 시간이 들어 있다.
## 관계
- **세션만 Redis 로 옮기자 토큰이 따라오지 않았다**
이 절차가 낸 결과를 담은 기록이다. 무엇을 발견했는지는 그쪽에 있고 여기에는 치는 순서만 있다.
- **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다**
의존성을 하나만 넣으면 오류 없이 메모리에 남는다. 그 아홉 건 가운데 하나가 이 편에서 나왔다.
- **세션과 인가된 클라이언트는 조회 키가 다르다**
빈 81개가 늘었는데 인가된 클라이언트 빈 셋이 그대로인 까닭을 그 기록이 설명한다.
- **아무 저장소도 주지 않고 Spring 이 무엇을 고르는지 찍어서 확인한다**
먼저 해 둬야 하는 편이다. 그 편이 남긴 빈 세 개의 이름과 숫자 `321` 이 여기서 대조군이 된다.
- **토큰을 PostgreSQL 로 옮기고 기본키와 로그아웃 정리를 확인한다**
다음 편이다. 여기서 Redis 로 안 옮겨진 인가된 클라이언트를 그 편이 공유 저장소로 옮긴다.
## 본문
## 읽기 전에 — 어디서 치는가
B-0 과 같다. 소스를 고치고 이미지를 만드는 일은 워크스테이션에서 하고, 클러스터를 보고 배포하는 일은 `[lab host]` 에서 친다. `kubectl` 에 `sudo` 를 붙이지 않는다. 브라우저 창도 하나 열어 둔다.
**원 가이드는 이 명령들을 `kc-lab-1` 에서 치라고 적었다.** 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 `sudo` 없는 `kubectl` 이 `permission denied` 로 막힌다 — kubeconfig 는 lab host 의 `~/.kube/config` 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 `kubectl` 블록의 기계 이름을 `[lab host]` 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다.
**끊기는 곳도 B-0 과 같다. 시작 전에 셋을 스스로 정해 둔다 — 그 명령이 원 가이드에 없다(unknown).**
첫째, 워크스테이션과 `kc-lab-1` 을 여섯 번 오가는데 건너가는 `ssh` 도 `exit` 도 이 절차에 없다. 경로는 이미지를 밀어 넣는 줄 하나에만 드러난다 — `ssh test-server "ssh kc-lab-1 '...'"`.
둘째, `bff/pom.xml` 과 `deploy/lab/k8s/bff-redis.yaml` 이 전부 저장소 상대경로다. 두 기계 각각에서 저장소 루트로 옮겨 두고 시작한다.
셋째, 워크스테이션에서 고친 `deploy/lab/k8s/bff-redis.yaml` 을 `kc-lab-1` 로 넘기는 단계가 없다. 이 편에서는 그 누락이 B-0 보다 더 헷갈리게 나온다 — 주입 3번이 넣는 `enableServiceLinks: false` 가 `kc-lab-1` 쪽 파일에 없으면 `apply` 는 성공하는데 파드가 똑같이 `CrashLoopBackOff` 로 남고, 그때 이 편은 「파드가 아직 안 바뀌었다」를 먼저 의심하라고 적는다. 실제로는 고친 파일이 그 기계에 안 간 것이다.
**아래 코드블록의 라벨은 그 줄을 치는 기계를 가리킨다.** `[워크스테이션 → kc-lab-1]` 이 붙은 블록은 한 블록 안에서 기계가 바뀌므로 어느 줄이 어디인지 블록 뒤에 적어 두었다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` |
| 고치는 파일 | 넷 — `bff/pom.xml` · `application.yml` · `BffControllerTest.java` · `bff-redis.yaml` |
| 주입 수단 | 의존성 둘을 넣고 다시 빌드해 두 노드에 import |
| 무엇을 찍나 | `/actuator/beans` 의 빈 수와 이름, Redis 의 키·필드·TTL |
| 브라우저 | 필요하다. 인가 코드 흐름은 왕복이 두 번이라 `curl` 로 대신할 수 없다 |
| 도구 | `jq` 가 이 실험대에 없다. `grep` 과 `redis-cli` 로 읽는다 |
| 걸리는 시간 | 약 40분. 빌드 시간이 들어 있다 |
## 이 실험이 가르는 것
B-0 이 답을 냈다. 세션도 토큰도 인스턴스 메모리에 있고, 그래서 replica 2 에서는 로그인조차 안 된다. 처방은 뻔해 보인다 — 공유 저장소를 붙인다.
```text
Redis 를 붙인다 → 상태가 공유된다 → 다중 인스턴스가 된다
↑
정말 그런가?
```
이 실험이 재는 것은 붙였다와 공유된다 사이의 거리다. 묻는 것이 넷이고 그중 둘째를 이 실험이 판정한다.
| | 물어볼 것 |
|---|---|
| 무엇이 옮겨졌나 | `/actuator/beans` 를 다시 찍는다 |
| 무엇이 안 옮겨졌나 | 같은 곳. 안 바뀐 것을 확인하는 쪽이 더 중요하다 |
| 옮겨진 것 안에 무엇이 들었나 | Redis 를 직접 연다 |
| 사용자에게는 어떻게 보이나 | 브라우저 |
절차를 끝까지 밟으면 일곱을 자기 화면에서 보게 된다 — 쿠버네티스가 넣지도 않은 환경변수로 파드를 죽이는 것, 그것이 `enableServiceLinks: false` 로 고쳐지는 것, 빈이 321 에서 402 로 81개 늘어나는 것, 그런데 인가된 클라이언트는 하나도 안 바뀐 것, Redis 안의 키와 필드와 TTL 에 토큰이 없는 것, 세션이 Java 네이티브 직렬화인 것, 로그인은 되어 있는데 아무것도 못 하는 상태.
## 전제와 되돌리기
- B-0 이 끝나 있고, B-0 의 답인 빈 세 개의 이름을 손에 들고 시작한다. 이 실험은 그 값들이 어떻게 바뀌는지를 잰다.
- B-0 의 복구 절 §3 「전부 지운다」를 치지 않은 상태여야 한다. 그 절은 `bff` 와 `redis` Deployment 와 realm `keycloak-patterns` 를 함께 없애므로, 쳤으면 아래 첫 확인부터 빈 목록이 나온다. 그 상태라면 B-0 의 주입 절과 realm·사용자 단계를 다시 밟아 배포를 세운 뒤 여기로 온다.
- 브라우저가 필요하다.
- 명령은 `kc-lab-1` 에서 친다.
- `jq` 는 이 실험대에 깔려 있지 않다. 이 절차는 `grep` 과 `redis-cli` 로 읽는다.
애플리케이션 구성을 바꾸는 실험이라 의존성과 설정을 고쳐 다시 빌드하고 다시 배포한다. 되돌리려면 소스를 되돌린 뒤 또 한 번 빌드해 두 노드에 다시 밀어 넣어야 하므로 `git status` 가 깨끗한 상태에서 시작한다.
되돌리기는 먼저 읽어 둔다.
```bash label="[워크스테이션] 소스를 되돌린다"
git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
deploy/lab/k8s/bff-redis.yaml
```
주입 절의 첫 배포는 일부러 고장 난 상태로 한다. 함정을 직접 보기 위해서이고, 건너뛰고 `enableServiceLinks: false` 부터 시작해도 결과는 같다고 원 가이드가 적는다.
## 주입 전에 같은 명령으로 먼저 본다
넓은 것부터 좁혀 간다.
```text
BFF 가 돌고 있나 → B-0 의 답 세 개 → Redis 가 비어 있나 → ★ 파드 안 환경변수
```
### 1. BFF 두 개와 Redis 가 떠 있는가
**무엇을 보는가** — 파드 배치.
```bash label="[lab host] BFF 와 Redis 의 배치를 본다"
kubectl -n keycloak-lab get pods -o wide -l app=bff
kubectl -n keycloak-lab get pods -o wide -l app=redis
```
**어디를 보나** — 모양은 이렇고 주소와 해시는 환경마다 다르다(observed).
```text
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 이 그렇게 만들어 뒀다.
### 2. B-0 의 답을 before 값으로 다시 잡는다
**무엇을 보는가** — 주입 뒤에 견줄 빈 수와 빈 이름 셋.
```bash label="[lab host] ① 빈 목록을 파드 안에서 받는다"
BFF=$(kubectl -n keycloak-lab get pod -l app=bff \
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
kubectl -n keycloak-lab exec "$BFF" -- \
wget -qO- http://localhost:8083/actuator/beans > /tmp/beans-before.json
wc -c /tmp/beans-before.json
```
이 실험대는 `jq` 가 없어 `grep` 으로 덩어리를 뽑았다. 원 가이드가 아래 두 줄을 미검증으로 표시했다(unknown).
```bash label="[lab host] ② 빈을 세고 저장소 계열 이름을 뽑는다"
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'
```
`jq` 가 깔려 있으면 그것을 쓴다고 원 가이드가 적는데 어떤 표현을 쓰라고는 여기서도 적지 않았다(unknown).
**어디를 보나** — 실측은 이렇다(observed, `02-autoconfig-after.txt`).
```text
빈 수: 321 → 402 (+81)
```
```text
authorizedClientService
before: InMemoryOAuth2AuthorizedClientService
authorizedClientRepository
before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
authorizedClientManager
before: AuthorizedClientServiceOAuth2AuthorizedClientManager
```
**이 값이 뜻하는 것** — 지금 빈 수가 `321` 이고 `sessionRepository` 는 아예 없다. 이 세 줄과 숫자를 적어 둔다. 관찰 절이 이 값과 견주고, 견줄 것이 없으면 안 바뀌었다고 말할 수 없다.
### 3. Redis 가 살아 있고 비어 있는가
**무엇을 보는가** — 연결 여부와 키 개수.
```bash label="[lab host] ① 응답과 판 번호를 읽는 형태로 본다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping
kubectl -n keycloak-lab exec deploy/redis -- redis-cli info server | head
```
```bash label="[lab host] ② 키 개수와 키 이름을 본다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
```
**어디를 보나** — 모양은 이렇다(observed).
```text
PONG
# Server
redis_version:7.4.x
...
```
```text
(integer) 0
```
**이 값이 뜻하는 것** — `0` 이어야 뒤에서 찾은 키를 내가 만든 것이라고 말할 수 있다. `KEYS *` 대신 `--scan` 을 쓰는 까닭은 `KEYS` 가 서버를 블로킹하기 때문이다. 지금은 키가 0개라 차이가 없지만, 습관이 되면 운영에서 사고가 난다.
### 4. 파드 안에 이미 Redis 관련 환경변수가 있는가
**무엇을 보는가** — 아직 아무것도 안 바꿨는데 들어와 있는 값. 이 실험의 함정이 여기서 시작된다.
한 번은 통째로 보고 그다음 걸러 본다.
```bash label="[lab host] ① 환경변수를 통째로 본다"
kubectl -n keycloak-lab exec "$BFF" -- printenv | sort
```
```bash label="[lab host] ② Redis 쪽만 거른다"
kubectl -n keycloak-lab exec "$BFF" -- printenv | grep -i redis
```
**어디를 보나** — 모양은 이렇다(observed).
```text
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 마다 옛 Docker 링크 호환용 환경변수를 파드에 자동으로 넣고, 그 기능이 기본으로 켜져 있다. Service 이름이 `redis` 이므로 `REDIS_*` 가 들어오는데 애플리케이션 설정도 `${REDIS_PORT:6379}` 를 읽는다. 이름이 겹친다.
```text
Service 이름이 redis 이면
REDIS_SERVICE_HOST=10.43.57.116
REDIS_SERVICE_PORT=6379
REDIS_PORT=tcp://10.43.57.116:6379 ← 이게 문제
```
매니페스트에 `REDIS_PORT: "6379"` 를 명시하면 그쪽이 이긴다. 명시를 안 하면 자동 주입이 이기고, 오류 메시지는 쓰지도 않은 값을 지목한다. `REDIS` 와 `POSTGRES` 와 `MYSQL` 처럼 흔한 Service 이름일수록 위험하다고 원 가이드가 적는다. 지금은 애플리케이션이 그 변수를 안 읽으므로 아무 일도 안 일어나고, 읽기 시작하는 순간 파드가 죽는다.
## 주입
### 1. 의존성 둘을 함께 넣는다
**목적** — `SessionRepository` 를 Redis 로 갈아끼우고 연결을 제공한다. 하나만 넣으면 오류 없이 메모리에 남으므로 둘을 같이 넣는다.
무엇을 왜 넣는지 읽으면서 고쳐야 하는 파일이라 편집기로 연다.
```bash label="[워크스테이션] ① 빌드 파일을 연다"
vim bff/pom.xml
```
```xml
org.springframework.session
spring-session-data-redis
org.springframework.boot
spring-boot-starter-data-redis
```
설정에 Redis 연결과 세션 저장소를 적는다.
```bash label="[워크스테이션] ② 애플리케이션 설정을 연다"
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 가 없으므로 테스트에서만 저장소를 끈다.
```bash label="[워크스테이션] ③ 테스트를 연다"
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",
})
```
**예상 결과** — 세 파일이 `git diff --stat` 에 나온다.
**왜 필요한가** — `spring-session-data-redis` 를 넣으면 컨텍스트가 뜰 때 Redis 에 붙으려 하고, 테스트에는 Redis 가 없다. `spring.session.store-type=none` 한 줄이 없으면 빌드가 테스트 단계에서 죽는데 그 실패 메시지가 Redis 연결 오류라 배포 환경 문제로 읽히기 쉽다. 실패한 곳은 빌드다.
**문제가 생기면** — 뒤에서 `sessionRepository` 가 안 생기면 의존성을 하나만 넣은 것이므로 `pom.xml` 에 둘 다 있는지부터 본다.
### 2. 매니페스트를 일부러 고장 난 채로 올린다
**목적** — 자동 주입된 `REDIS_PORT` 가 애플리케이션 설정을 어떻게 이기는지 한 번 본다.
```bash label="[워크스테이션] ① 배포 매니페스트를 연다"
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 를 일부러 안 준다 — 자동 주입이 어떻게 이기는지 본다
```
빌드해서 두 노드에 밀어 넣고 배포한다. 아래 네 줄이 이 실험대가 실제로 친 형태다(observed).
```bash label="[워크스테이션 → kc-lab-1] ② 빌드하고 두 노드에 넣고 배포한다"
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 -'"
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab rollout restart deployment/bff
```
**이 블록은 한 블록인데 기계가 둘이다.** 앞의 네 줄(`docker build` · `echo` · `docker save` 두 줄)은 워크스테이션에서 치고, `kubectl` 로 시작하는 아래 두 줄은 `kc-lab-1` 에서 친다. 워크스테이션에는 kubeconfig 가 없어 거기서 `kubectl` 을 치면 클러스터에 못 붙고 끝난다. 그리고 `kubectl apply` 가 읽는 `deploy/lab/k8s/bff-redis.yaml` 은 방금 워크스테이션에서 고친 그 파일이므로, `kc-lab-1` 쪽 체크아웃에도 같은 내용이 있어야 한다. 옮기는 명령은 원 가이드에 없다(unknown).
가운데 두 줄은 한 줄에 `ssh` 가 두 겹이고 원격 셸의 인용이 겹쳐 있어 나눠 치고 싶어지는데, 원 가이드가 나눈 형태를 적어 두지 않아 여기에도 없다(unknown).
지금 멈추려면 롤아웃을 되돌린다.
```bash label="[lab host] 중간에 그만둘 때 치는 한 줄"
kubectl -n keycloak-lab rollout undo deployment/bff
```
**예상 결과** — 파드가 뜨지 않는다. 넓은 것부터 본다.
```bash label="[lab host] ③ 파드 상태를 본다"
kubectl -n keycloak-lab get pods -l app=bff
```
```text
NAME READY STATUS RESTARTS AGE
bff-695646ddb-kzs9k 0/1 CrashLoopBackOff 3 (20s ago) 90s
```
로그보다 먼저 이벤트를 보고, 그다음 로그를 본다. 지금 파드와 죽기 전 파드를 따로 본다.
```bash label="[lab host] ④ 이벤트를 본다"
kubectl -n keycloak-lab describe pod -l app=bff | tail -20
```
```bash label="[lab host] ⑤ 지금 로그와 죽기 전 로그를 본다"
kubectl -n keycloak-lab logs -l app=bff --tail=40
kubectl -n keycloak-lab logs -l app=bff --previous --tail=40
```
실측은 이렇다(observed, 해설 문서 1절).
```text
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"` 는 매니페스트 어디에도 쓰지 않은 값이다. 주입 전에 `printenv` 로 미리 본 그 환경변수이고 쿠버네티스가 넣었다. Redis 는 멀쩡하고, 파드는 Redis 에 붙어 보지도 못한 채 설정 바인딩에서 죽었다. 메시지가 `Failed to bind properties` 라고 말하고 있다.
```bash label="[lab host] ⑥ Redis 가 멀쩡한지 따로 확인한다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping
```
`PONG` 이 온다(observed).
**문제가 생기면** — 파드가 정상으로 떴다면 매니페스트에 `REDIS_PORT` 를 이미 줬다. 함정을 건너뛰어도 결과는 같으므로 다음 단계로 간다.
### 3. 자동 주입을 끄고 다시 올린다
**목적** — 이름 충돌의 근본을 없앤다.
처방은 둘인데 하나만 근본 처방이다.
| 처방 | 문제 |
|---|---|
| 환경변수 이름을 바꾼다 (`BFF_REDIS_PORT` 등) | 다음 사람이 같은 함정에 다시 빠진다 |
| 주입 자체를 끈다 | 근본 처방 |
```bash label="[워크스테이션] ① 배포 매니페스트를 다시 연다"
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 label="[lab host] ② 적용하고 롤아웃을 기다린다"
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
```
**예상 결과** — 실측은 이렇다(observed, `01-servicelinks-trap.txt`).
```text
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 의 파드가 아직 종료 전이다. 잠시 뒤 두 개가 된다.
**왜 필요한가** — `enableServiceLinks: false` 는 그 파드에 대해 Service 이름 기반 환경변수 주입 전체를 끈다. 이름 하나를 피해 가는 것과 달라서 다음에 Service 를 하나 더 만들어도 같은 충돌이 안 난다.
**문제가 생기면** — 고쳤는데 오류가 똑같이 나면 파드가 아직 안 바뀌었다. `rollout restart` 뒤에 `printenv` 를 다시 본다.
## 주입 검증
결과를 읽기 전에 주입이 의도한 것을 정확히 했는지 먼저 본다. 자동 주입이 정말 사라졌는지부터다.
### 1. 자동 주입된 환경변수가 사라졌는가
```bash label="[lab host] 새 파드 이름을 다시 잡고 환경변수를 본다"
BFF=$(kubectl -n keycloak-lab get pod -l app=bff \
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
kubectl -n keycloak-lab exec "$BFF" -- printenv | grep -i redis
```
모양은 이렇다(observed).
```text
REDIS_HOST=redis.keycloak-lab.svc
REDIS_PORT=6379
```
`REDIS_SERVICE_HOST` 계열이 전부 사라졌고 넘겨준 두 개만 남았다. `REDIS_PORT` 가 `6379` 다.
### 2. 자동구성이 실제로 걸렸는가
```bash label="[lab host] 파드 배치와 헬스를 본다"
kubectl -n keycloak-lab get pods -o wide -l app=bff
kubectl -n keycloak-lab exec "$BFF" -- \
wget -qO- http://localhost:8083/actuator/health
```
모양은 이렇다(observed).
```json
{"status":"UP","components":{"redis":{"status":"UP","details":{"version":"7.4.x"}},...}}
```
`redis` 컴포넌트가 있고 `UP` 이다. B-0 에서는 이 컴포넌트가 아예 없었다. `spring-boot-starter-data-redis` 가 헬스 인디케이터를 같이 들고 왔고, 건강 체크에 새 항목이 생긴 것이 자동구성이 걸렸다는 신호다.
### 3. replica 2 에서 로그인이 되는가
브라우저에서 쿠키를 먼저 지우고 `https://app1.hyeonworks.com/` 로 로그인한다.
로그인이 된다(observed, `b1-login-works-two-replicas.png`). B-0 에서 replica 2 로는 `/login?error` 였던 그 부분이다. 인가 요청의 state 와 PKCE verifier 가 이제 Redis 에 있으므로 콜백이 다른 인스턴스로 가도 찾을 수 있다. B-0 이 replica 를 1 로 줄여야 했던 문제는 고쳐졌다. 원 가이드는 여기에 곧바로 경고를 붙인다 — 여기서 멈추면 Redis 를 붙였더니 다 해결됐다로 끝나고, 그것이 이 실험이 막으려는 결론이다.
## 관찰
### 1. 빈 목록을 다시 찍어 before 와 견준다
**무엇을 보는가** — 늘어난 빈과 안 바뀐 빈.
B-0 의 방법을 그대로 다시 쓴다.
```bash label="[lab host] ① 빈 목록을 받고 개수를 센다"
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
```
**어디를 보나** — 실측은 이렇다(observed, `02-autoconfig-after.txt`).
```text
빈 수: 321 → 402 (+81)
```
새로 생긴 세션 저장소 빈을 뽑는다. 원 가이드가 아래 줄을 미검증으로 표시했다(unknown).
```bash label="[lab host] ② 세션·Redis 계열 빈을 뽑는다"
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-after.json \
| sed 's/{"aliases".*"type":"/ -> /' \
| grep -iE 'session|redis'
```
```text
--- 세션 저장소 관련 (새로 생긴 것) ---
★ cookieSerializer -> DefaultCookieSerializer
★ org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration -> RedisHttpSessionConfiguration
★ sessionRepository -> RedisSessionRepository
★ springSessionRepositoryFilter -> SessionRepositoryFilter
★ redisConnectionFactory -> LettuceConnectionFactory
★ redisTemplate -> RedisTemplate
```
같은 파일에서 인가된 클라이언트 쪽을 따로 뽑는다. 이 실험이 판정하려는 것이 이쪽이다.
```bash label="[lab host] ③ authorized client 계열을 뽑는다"
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-after.json \
| sed 's/{"aliases".*"type":"/ -> /' \
| grep -i authorizedclient
```
```text
--- OAuth2 authorized client — 바뀌었는가? ---
authorizedClientService
before: InMemoryOAuth2AuthorizedClientService
after : InMemoryOAuth2AuthorizedClientService 그대로 — Redis 로 안 옮겨졌다
authorizedClientRepository
before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
after : AuthenticatedPrincipalOAuth2AuthorizedClientRepository 그대로 — Redis 로 안 옮겨졌다
authorizedClientManager
before: AuthorizedClientServiceOAuth2AuthorizedClientManager
after : AuthorizedClientServiceOAuth2AuthorizedClientManager 그대로 — Redis 로 안 옮겨졌다
```
**이 값이 뜻하는 것** — 빈 81개가 늘었는데 인가된 클라이언트는 하나도 안 바뀌었다.
```text
Application Session ──▶ Redis (인증 상태, principal, 인가 요청)
OAuth2AuthorizedClient ──▶ 프로세스 메모리 (access token, refresh token)
```
`spring.session.store-type` 은 `HttpSession` 을 갈아끼우는 설정이고 `OAuth2AuthorizedClient` 는 그 설정과 무관한 다른 저장소에 있다. 찍어서 확인하지 않으면 이 사실을 알 방법이 없다. 로그인은 되고 화면도 뜨고 파드도 건강하다. B-0 을 실험으로 만든 까닭이 여기서 드러난다 — before 가 있어야 after 를 읽는다.
### 2. Redis 를 직접 연다
**무엇을 보는가** — 옮겨진 것 안에 무엇이 들었는지.
```bash label="[lab host] ① 키 개수와 키 이름을 본다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
```
실측은 이렇다(observed, `03-redis-contents.txt`).
```text
=== Redis 에 무엇이 들어 있는가 ===
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
총 키 수: 1
```
네임스페이스가 `bff:session` 이다. `application.yml` 의 `spring.session.redis.namespace` 가 그대로 접두어가 됐다.
키 이름을 변수로 잡는다. 이 줄에는 걸러 내는 조각이 둘 붙어 있다.
```bash label="[lab host] ② 세션 키 하나를 변수에 담는다"
KEY=$(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 을 달고 오기 때문에 붙인다. 빼면 키가 안 맞는데 오류는 안 난다.
바로 위 `redis-cli --scan` 이 이미 전체 키를 보여 줬으므로 그 출력에서 키 하나를 눈으로 골라 쳐도 되는데, 원 가이드가 그 두 단계 형태를 적어 두지 않았다(unknown).
```bash label="[lab host] ③ 타입과 필드 이름을 본다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli type "$KEY"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli hkeys "$KEY"
```
**어디를 보나** — 실측은 이렇다(observed, `03-redis-contents.txt`).
```text
타입: 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 이 평문으로 남는지 확인하는 것이 검증 항목이었는데, 답은 더 앞에 있었다 — 애초에 들어가지 않는다. 토큰 암호화를 어떻게 할지 고민하기 전에 토큰이 그 저장소에 가지도 않는다는 것을 먼저 알아야 한다.
### 3. TTL 과 값의 바이트를 본다
```bash label="[lab host] ① 남은 수명을 본다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli ttl "$KEY"
```
```text
=== TTL (Q3 검증 3번 — session TTL) ===
TTL: 1772 초
```
`spring.session.timeout=30m` 인 1800초에서 방금 지난 만큼 줄어든 값이다. 세션 TTL 1772초와 access token 수명 60초가 처음부터 어긋나 있다. 어느 쪽에 맞출지 고르기 전에 이미 어긋나 있고, 그 간극을 누가 메우는지가 B-3 의 주제다.
```bash label="[lab host] ② 값의 앞 네 줄을 이스케이프해서 본다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --no-raw hgetall "$KEY" | head -4
```
```text
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` 로 시작한다. Java 직렬화 매직 넘버이고 JSON 이 아니다. `--no-raw` 를 쓰는 것은 바이너리를 이스케이프해서 보여 주기 때문이다. 안 쓰면 터미널이 제어문자를 먹고 화면이 깨진다.
| 결과 | |
|---|---|
| 사람이 못 읽는다 | 운영 중 디버깅이 어렵다 |
| 클래스 버전에 묶인다 | 애플리케이션을 올리면 기존 세션이 역직렬화에 실패할 수 있다 |
| 역직렬화 취약점 | 신뢰할 수 없는 데이터가 들어오면 위험한 형식이다 |
D-2 의 버전 업그레이드에서 이 성질이 다시 나온다. Spring Security 버전이 바뀌면 Redis 에 있던 세션이 깨질 수 있다.
### 4. 파드를 전부 교체하고 사용자 화면을 본다
**목적** — 롤링 재시작으로 Redis 덕을 보는지 확인한다. 롤링 재시작은 정상 작업이라 되돌릴 것이 없다.
```bash label="[lab host] ① 파드를 전부 교체한다"
kubectl -n keycloak-lab rollout restart deployment/bff
kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
```
로그인은 그대로 둔 채 브라우저에서 `https://app1.hyeonworks.com/bff/token-boundary` 를 연다.
**예상 결과** — 실측은 이렇다(observed, `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` 다.
```text
사용자 관점: 로그인되어 있다고 나온다
실제: BFF 가 사용자를 대신해 아무것도 못 한다
```
파드가 전부 교체됐는데 로그인 상태는 살아남았다. Redis 덕분이다. 토큰은 같이 살아남지 못했다. 인스턴스 메모리에 있었으니까. 원 가이드는 이것을 부분적으로만 공유했을 때의 실패 모양이라고 부르고, 완전히 로그아웃되는 편이 차라리 낫다고 적는다. 적어도 사용자가 다시 로그인하는데, 지금은 화면상 로그인 상태라 사용자가 아무것도 안 한다.
| | B-0 (Redis 없음, replica 1) | B-1 (Redis 세션, replica 2) |
|---|---|---|
| `principal` | labuser | labuser |
| `accessTokenStoredOnServer` | true | false |
| 파드 재시작 후 | 로그아웃 | 로그인 상태만 남고 토큰은 소실 |
**문제가 생기면** — 스크린샷으로 시점을 구별하지 않는다. 증거 폴더의 `README.md` 가 적어 둔 대로 `b1-login-works-two-replicas.png` 와 `b1-token-boundary-after-redis.png` 는 동일 파일이다. 세 시점 모두 `accessTokenStoredOnServer: false` 인 같은 화면이었고, 시점 구별은 터미널 출력과 Redis·DB 조회가 한다.
그래서 무엇을 해야 하는가 — `OAuth2AuthorizedClientService` 를 공유 저장소로 옮기는 구현이 따로 필요하다.
| 후보 | |
|---|---|
| `JdbcOAuth2AuthorizedClientService` | Spring Security 기본 제공. PostgreSQL 이 이미 있다 |
| 직접 구현 (Redis) | `OAuth2AuthorizedClientService` 인터페이스를 Redis 로 구현 |
| 세션 안에 넣기 | `HttpSessionOAuth2AuthorizedClientRepository` 를 쓰면 세션과 함께 Redis 로 간다 |
세 번째는 조회 키 문제까지 같이 푼다. 세션 단위로 저장되므로 같은 사용자의 다른 브라우저가 서로를 덮어쓰지 않고, 대신 세션이 커진다. B-2 가 이 선택지를 비교한다.
## 복구와 원상복구 확인표
B-2 로 이어갈 것이면 이 구성이 B-2 의 출발이므로 아무것도 안 되돌린다.
### 1. 소스를 되돌리고 다시 빌드해 다시 밀어 넣는다
B-0 상태로 되돌릴 때는 소스만 되돌려서는 안 된다. 클러스터에는 여전히 옛 이미지가 돈다.
```bash label="[워크스테이션] ① 네 파일을 되돌리고 확인한다"
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 label="[워크스테이션 → kc-lab-1] ② 다시 빌드해 두 노드에 다시 넣고 다시 배포한다"
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 -'"
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab rollout restart deployment/bff
kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
```
② 도 한 블록에 기계가 둘이다. `docker` 로 시작하는 앞의 세 줄은 워크스테이션, `kubectl` 로 시작하는 뒤의 세 줄은 `kc-lab-1` 이다. ① 이 되돌린 파일이 `kc-lab-1` 쪽 체크아웃에도 반영돼 있어야 `apply` 가 B-0 구성을 올린다.
`git status --short` 가 빈 출력이어도 클러스터는 아직 옛 이미지를 쓰고 있다. ② 를 끝내야 소스와 클러스터가 같아진다.
### 2. Redis 를 비운다
Redis 를 비우는 것은 되돌릴 수 없다. 지운 세션은 돌아오지 않고 로그인한 사용자는 전부 로그아웃된다. 실험대라서 하는 일이다.
```bash label="[lab host] ① 개수를 보고 비우고 다시 센다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushdb
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
```
세션 하나만 지우려면 이쪽이다. `$KEY` 는 관찰 2번에서 담아 둔 키이고, 그 뒤로 `rollout restart` 와 브라우저 왕복이 들어가 절차가 40분쯤 걸리므로 터미널을 새로 열었으면 값이 비어 있다. 치기 전에 `echo "$KEY"` 로 키가 나오는지 보고, 안 나오면 관찰 2번의 `KEY=$(...)` 두 줄을 다시 쳐서 담는다. 바로 위 ① 의 `flushdb` 를 이미 쳤으면 지울 키가 없으므로 ② 를 건너뛴다.
```bash label="[lab host] ② 잡아 둔 키 하나만 지운다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli del "$KEY"
```
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 소스 | `git status --short` | 출력 없음 (B-0 로 되돌릴 때) |
| 이미지 | `sudo k3s ctr images ls \| grep keycloak-pattern-bff` | 두 노드 모두에 있다 |
| 파드 | `kubectl -n keycloak-lab get pods -o wide -l app=bff` | `2/2`, 두 노드에 하나씩 |
| Redis | `kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping` | `PONG` |
| Redis 키 | `... redis-cli dbsize` | 의도한 값 |
| Keycloak | `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` | — |
표의 두 줄은 한 기계에서 다 못 본다. **이미지** 줄의 `sudo k3s ctr images ls` 는 그 명령을 친 노드 하나만 본다. `kc-lab-1` 에서 치면 `kc-lab-2` 는 안 보이므로, 「두 노드 모두에 있다」를 확인하려면 B-0 이 짝으로 친 `ssh kc-lab-2 'sudo k3s ctr images ls | grep keycloak-pattern-bff'` 도 함께 봐야 한다. 한쪽에만 있으면 그 노드의 replica 만 뜨고 나머지는 `ErrImageNeverPull` 로 나타난다. **임시 파일** 줄도 앞의 두 파일은 `kc-lab-1`, `/tmp/build.log` 는 워크스테이션에 있으므로 각 기계에서 자기 쪽을 지운다.
## 막히면
원 가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다고 적는다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 파드가 `CrashLoopBackOff` 이고 오류에 `tcp://...:6379` | 쿠버네티스가 `REDIS_PORT` 를 주입했다 | `printenv \| grep -i redis` |
| 위 오류를 Redis 가 죽어서로 읽었다 | 메시지가 `Failed to bind properties` 다 | `redis-cli ping` 으로 Redis 를 따로 확인 |
| `enableServiceLinks` 를 넣었는데 그대로다 | 파드가 아직 옛 것이다 | `rollout restart` 후 `printenv` 다시 |
| 빌드가 Redis 연결 오류로 죽는다 | 테스트가 Redis 를 찾는다 | `spring.session.store-type=none` |
| `sessionRepository` 가 안 생긴다 | 의존성을 하나만 넣었다. 오류 없이 메모리에 남는다 | 두 개 다 있는지 `pom.xml` |
| `redis-cli --scan` 이 비어 있다 | 아직 로그인 안 했다 | 브라우저로 로그인 후 다시 |
| `hkeys` 가 빈 결과 | 만료 추적 키를 잡았다 | `grep -v expires` |
| 키가 맞는데 명령이 안 먹는다 | 출력에 CR 이 붙었다 | `tr -d '\r'` |
| 값이 깨져서 터미널이 이상해진다 | 바이너리를 그대로 찍었다 | `--no-raw` |
| API 호출이 `500` 인데 토큰은 멀쩡하다 | DNS 다. 다른 네임스페이스의 서비스 | 로그의 `UnresolvedAddressException` |
| `/actuator/beans` 가 `Bad Gateway` | 응답이 커서 프록시가 못 넘긴다 | 파드 안에서 받는다 |
| `jq: command not found` | 이 실험대에 `jq` 가 없다 | `grep` 으로 읽는다 |
| 로그인은 되는데 API 가 전부 실패한다 | 이게 이 실험의 결론이다 | `token-boundary` 의 두 `false` |
| 스크린샷으로 시점을 구별하려다 헷갈린다 | 두 파일이 동일하다 | 터미널 출력과 Redis 조회로 구별 |
`500` 쪽은 원인을 찾는 데 한 번 헛짚었다. 로그를 보니 토큰이 아니라 DNS 였다.
아래 줄의 `$BFF` 는 주입 검증 1번에서 담아 둔 파드 이름이다. 「막히면」은 아무 때나 펼치는 절이고 그 사이에 관찰 4번의 `rollout restart` 가 파드를 통째로 갈아치우므로, 그대로 치면 `NotFound` 가 온다. 치기 전에 주입 검증 1번의 `BFF=$(...)` 두 줄을 다시 쳐서 이름을 새로 담는다.
```bash label="[lab host] 예외만 골라 본다"
kubectl -n keycloak-lab logs "$BFF" --tail=100 | grep -iE 'exception|error'
```
```text
java.nio.channels.UnresolvedAddressException
```
`RESOURCE_API_BASE_URL=http://echo.keycloak-lab.svc:8080` 이었는데 `echo` 는 `header-lab` 네임스페이스의 8081 이었다. 배포조차 되어 있지 않았다.
```yaml
# 다른 네임스페이스의 서비스는 ..svc 로 부른다
- name: RESOURCE_API_BASE_URL
value: http://echo.header-lab.svc:8081
```
```bash label="[lab host] 그 서비스가 어디 있는지 본다"
kubectl -n header-lab get svc echo
```
## 이 실험대에서 아직 못 밟은 단계
2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.**
- **남은 것** — 브라우저 로그인 뒤의 Redis 키·필드·TTL 관찰. 지금까지 밟은 것 — 이미지 빌드·반입, 배포, `/actuator/beans` 로 `RedisSessionConfiguration` 확인.
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다.
- **그때까지 이 편의 실측 가운데 `(observed)` 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다.
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 13:59–14:03 KST` 에 돈 한 번의 실행에서 나왔다(observed).
- (observed) 자동 주입된 `REDIS_*` 일곱 줄과 `REDIS_PORT=tcp://10.43.57.116:6379`, `Failed to bind properties` 오류 전문, `enableServiceLinks: false` 뒤의 롤아웃 출력 세 줄, 빈 수 `321 → 402 (+81)`, 새로 생긴 세션 저장소 빈 여섯 줄, 안 바뀐 인가된 클라이언트 빈 세 개의 before 와 after, Redis 키 `bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae` 와 필드 일곱 개, `TTL: 1772 초`, `\xac\xed` 로 시작하는 바이트, 재시작 뒤 `token-boundary` 의 `principal` 생존과 두 토큰 `false`, `UnresolvedAddressException`.
- (unknown) 빈을 세는 `grep -o '"aliases":\['` 줄과 이름·타입을 뽑는 `grep`·`sed` 줄. 원 가이드가 미검증으로 표시했다. `jq` 판본과, `--scan` 출력에서 키를 눈으로 골라 치는 두 단계 형태와, 두 겹 `ssh` 를 나눠 치는 형태도 가이드에 없다.
- (observed) `b1-login-works-two-replicas.png` 와 `b1-token-boundary-after-redis.png` 가 동일 파일이라는 것은 증거 폴더의 `README.md` 가 적어 둔 사실이다. 같은 화면이 세 시점에 나왔기 때문이고, 그래서 시점은 터미널 출력과 저장소 조회로만 갈린다.
- 이 실험이 재지 않은 것 셋 — Redis 를 끊었을 때 무엇이 나는지는 B-5 의 주제이고, 로그아웃 뒤 두 저장소에 무엇이 있는지는 B-2 로 넘겼으며, 저장소 지연이 화면 지연으로 얼마나 번역되는지는 B-2 이후에 잰다.