Files
keycloak-pattern/docs/guides/experiments/b0-bff-redis-deploy.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

831 lines
33 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-0 재현 가이드 — 아무것도 주지 않았을 때 Spring 이 무엇을 고르는지 본다
해설 문서: [`docs/experiment-b0-bff-redis-deploy.md`](../../experiment-b0-bff-redis-deploy.md) ·
증거 원문: [`docs/evidence/b0-bff-redis-deploy/`](../../evidence/b0-bff-redis-deploy/)
## 이 가이드가 끝나면
당신 터미널과 브라우저에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 돌고 있는 인스턴스가 **실제로 고른 구현체 이름** | `/actuator/beans` |
| Redis 도 Spring Session 도 **하나도 구성되지 않은 것** | 같은 곳 |
| 조회 키에 **session ID 가 없다**는 것 | 빈 이름 하나가 그대로 설명이다 |
| **replica 2 에서 로그인 자체가 실패하는 것** | 브라우저 · `/login?error` |
| replica 를 1 로 줄이면 되는 것 | 같은 브라우저 |
| 브라우저에 토큰이 **0개**인 것 | `/bff/token-boundary` |
## 전제
- [`05-keycloak`](../05-keycloak/) 이 끝나 있다. A층 실험은 안 해도 된다.
- **브라우저가 필요하다.** 인가 코드 흐름은 **왕복이 두 번**이라 `curl`
대신할 수 없다. `https://app1.hyeonworks.com/` 이 당신 브라우저에서 열려야 한다.
- BFF 이미지는 **워크스테이션에서 빌드해서 두 노드에 밀어 넣는다.**
레지스트리가 없으므로 `imagePullPolicy: Never` 다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- `jq` 는 이 실험대에 **깔려 있지 않다.** 이 가이드는 `grep` 으로 읽는다.
## 주의 — ★ 저장소를 먼저 붙이면 이 실험은 성립하지 않는다
B-0 의 질문은 **「아무것도 주지 않았을 때 자동구성이 무엇을 고르는가」**다.
Redis 를 먼저 연결하면 잴 것이 없어진다. **Redis 는 배포만 하고 BFF 에 연결하지
않는다.** 연결은 [B-1](b1-redis-session-store.md) 에서 한다.
**그리고 이 저장소의 현재 소스는 이미 B-1·B-2 를 거친 뒤 상태다.**
`bff-redis.yaml` 에는 `SPRING_SESSION_STORE_TYPE=redis` 가 있고, `SecurityConfig`
에는 `JdbcOAuth2AuthorizedClientService` 빈이 있다. **그대로 배포하면 B-2 의
결과를 재게 된다.** 어느 브랜치에도 B-0 시점의 파일은 남아 있지 않다 —
[2-1](#2-1-b-0-상태로-되돌린다--네-파일) 에서 손으로 되돌린다.
전 구간 약 40~60분(빌드 시간 포함). 배포한 것을 지우는 명령은 5절에 있다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 13:3913:46 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 다른 방법을 썼다 |
파드 이름·IP·빈 개수는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
---
# 0. 왜 이 실험을 하는가
Q1 이 직접 요구한 확인이다.
> 코드에 저장소를 직접 생성하는 Bean 이 없기 때문에, 어떤 구현체가 실제로
> 사용되는지는 **Spring Boot 의 자동구성 결과까지 확인해야** 정확하게 알 수 있다.
```
빈을 직접 만들지 않으면
└─ Spring Boot 가 조건에 따라 고른다
└─ 무엇을 골랐는지는 코드 어디에도 안 적혀 있다
└─ 돌아가는 인스턴스에 물어봐야 안다
```
**추측으로도 답은 나온다.** 「저장소를 안 붙였으니 메모리겠지.」 맞다.
**그런데 추측으로 두면 안 되는 이유가 두 번째 줄에 있다.**
빈 이름 하나가 이 층 전체의 문제를 담고 있는데, **그 이름은 추측으로 안 나온다.**
찍어 봐야 나온다. 그게 이 실험이다.
---
# 1. 기준선 — 배포하기 전에
넓은 것부터 좁혀 간다.
```
노드 자원 → 네임스페이스에 무엇이 있나 → 이미지가 두 노드에 있나 → Keycloak realm
```
## 1-1. 노드에 자원이 있나
**확인**
```bash
free -m
sudo kubectl top nodes
```
**실측** — [`01-deploy.txt`](../../evidence/b0-bff-redis-deploy/01-deploy.txt)
```
=== 배포 전 자원 ===
Mem: 11648 7329 280 4 4377 4319
NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%)
kc-lab-1 115m 5% 2192Mi 44%
kc-lab-2 121m 6% 1324Mi 33%
```
**어디를 봐야 하는가** — 노드 메모리 사용률. 여기서는 `44%` · `33%` 다.
**이 결과가 의미하는 것** — BFF 는 JVM 이고 replica 가 2 다. 매니페스트는
`requests: 320Mi` · `limits: 512Mi` 로 잡혀 있다. **여유가 없으면 파드가
`Pending` 이거나 OOM 으로 죽는데, 그걸 「Spring 설정 문제」로 읽게 된다.**
배포 전에 한 번 보고 시작한다.
## 1-2. 네임스페이스에 무엇이 있나
**확인**
```bash
sudo kubectl -n keycloak-lab get all
sudo kubectl -n keycloak-lab get secret,ingress
```
**어디를 봐야 하는가**`keycloak` StatefulSet 과 `postgres` 가 있고,
**`bff` · `redis` 는 없는 것.**
**이 결과가 의미하는 것** — 이미 있으면 앞 실험의 잔재이고, 그 위에 배포하면
「내가 만든 것」과 「원래 있던 것」이 섞인다. 있으면 5-1 로 먼저 지운다.
> `get all` 은 워크로드 계열만 보여준다. **Secret·PVC·Ingress 는 안 나온다.**
> 그래서 두 줄로 나눠 친다.
## 1-3. Keycloak realm 을 준비한다
BFF 가 붙을 realm 과 클라이언트가 있어야 한다. **없으면 배포는 성공하는데
로그인에서 막힌다.**
**하기**`kcadm` 에 로그인한다. Keycloak 이미지 안에 있는 도구다
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
config credentials --server http://localhost:8080 --realm master --user admin \
--password "$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
```
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 터미널에도 셸
> 히스토리에도 값이 남지 않는다. 길이만 보고 싶으면:
> ```bash
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
> ```
> **실측** — `19`
**하기** — realm 과 클라이언트
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
create realms -s realm=keycloak-patterns -s enabled=true -s accessTokenLifespan=60
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
create clients -r keycloak-patterns \
-s clientId=bff-confidential -s publicClient=false -s secret=bff-lab-secret \
-s 'redirectUris=["https://app1.hyeonworks.com/*"]'
```
**되돌리기**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
delete realms/keycloak-patterns
```
**어디를 봐야 하는가**`accessTokenLifespan=60`.
**이 결과가 의미하는 것****B-3(refresh 경쟁)을 위해 미리 짧게 잡는 것이다.**
만료를 기다리는 시간이 짧아야 재현이 된다. 지금 정해 두면 나중에 realm 을
다시 안 만든다.
**확인** — 만들어졌나
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get realms/keycloak-patterns --fields realm,enabled,accessTokenLifespan
```
로그인할 사용자도 하나 만든다.
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
create users -r keycloak-patterns -s username=labuser -s enabled=true
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
set-password -r keycloak-patterns --username labuser --new-password 'lab-user-change-me'
```
**★ 이 비밀번호는 브라우저에 직접 칠 것이므로 당신이 정한다.** 위 값은 예시고,
**실제로 쓸 값은 셸 히스토리에 남지 않게** 하려면 `kcadm.sh` 를 대화식으로
쓰거나 나중에 관리 콘솔에서 바꾼다.
---
# 2. 준비 — 배포에서 겪은 문제 다섯 가지를 먼저 읽는다
**이 절을 건너뛰면 다섯 번 막힌다.** 전부 이 실험대가 실제로 겪은 것이다.
## 2-1. B-0 상태로 되돌린다 — 네 파일
현재 소스는 B-1·B-2 의 결과를 담고 있다. **B-0 을 재려면 그 배선을 빼야 한다.**
**되돌리기** — 실험이 끝나면 원래대로 돌린다
```bash
git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java \
deploy/lab/k8s/bff-redis.yaml
```
**하기 ①**`bff/pom.xml` 에서 두 블록을 지운다
```bash
vim bff/pom.xml
```
| 지울 의존성 | 왜 |
|---|---|
| `spring-session-data-redis` | 있으면 `SessionRepository` 가 Redis 로 갈린다 (B-1) |
| `spring-boot-starter-data-redis` | 있으면 Redis 연결 빈이 잔뜩 생긴다 (B-1) |
| `spring-boot-starter-jdbc` · `postgresql` · `h2` | B-2 의 `JdbcOAuth2AuthorizedClientService` 용 |
**하기 ②**`SecurityConfig.java` 에서 **빈 두 개**를 지운다
```bash
vim bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java
```
```java
// 지운다 — B-2 가 넣은 것. 이게 있으면 자동구성이 고를 기회가 없다
@Bean
OAuth2AuthorizedClientService authorizedClientService(...) { ... }
// 지운다 — 이것도 직접 만들면 "자동구성이 골랐다" 가 아니다
@Bean
OAuth2AuthorizedClientManager authorizedClientManager(...) { ... }
```
관련 `import` (`JdbcOAuth2AuthorizedClientService`, `JdbcOperations`, 매니저 계열)도
같이 지운다. **`bffSecurity` 빈은 남긴다** — `/actuator/**` 를 열어 주는 것이
그 안에 있다(문제 ④).
**하기 ③**`application.yml` 에서 세 블록을 지운다
```bash
vim bff/src/main/resources/application.yml
```
| 지울 블록 | 왜 |
|---|---|
| `spring.session` | `store-type` 기본값이 **`redis`** 다. 남겨 두면 의존성만 빼도 경고가 난다 |
| `spring.data.redis` | Redis 연결 설정 |
| `spring.datasource` · `spring.sql.init` | B-2 의 JDBC 용 |
**하기 ④**`deploy/lab/k8s/bff-redis.yaml``bff` 컨테이너에서 env 를 지운다
```bash
vim deploy/lab/k8s/bff-redis.yaml
```
```yaml
# 지운다 — B-1 · B-2 가 넣은 것
- name: SPRING_SESSION_STORE_TYPE
- name: REDIS_HOST
- name: REDIS_PORT
- name: BFF_DB_URL
- name: BFF_DB_USER
- name: BFF_DB_PASSWORD
```
**Redis Deployment·Service·PVC 는 그대로 둔다.** 배포는 하되 **연결만 안 한다**
그게 B-0 의 구성이다.
**확인** — 무엇을 지웠는지 눈으로 본다
```bash
git diff --stat
git diff bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java
```
## 2-2. 문제 ① — 소스 없이 빌드 산출물만 커밋되어 있었다
원래 실행은 `bff/` 에 이런 상태를 만났다.
```
bff/target/classes/... 9개 파일
bff/src/ 없음
```
`.gitignore``target/` 이 없어 **클래스 파일만** 커밋되어 있었고 소스는
다른 브랜치에 있었다.
**확인** — 지금 당신의 저장소는 어떤가
```bash
ls bff/src/main/java/com/example/keycloakpattern/bff/
```
없으면 가져온다.
```bash
git checkout origin/develop-keycloak-pattern3 -- bff/
```
**이 결과가 의미하는 것** — **빌드 산출물이 커밋되어 있으면 「빌드가 되는데
바꿔도 안 바뀐다」가 된다.** 소스가 있는지부터 본다.
## 2-3. 문제 ② — 빌드 실패 원인이 마지막 15줄에 없다
**하기**
```bash
docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
echo "exit=$?"
```
**확인** — 실패했으면 전체 로그에서 찾는다
```bash
grep -nE "Tests run|Caused by|\.java:[0-9]" /tmp/build.log
```
**실측** — 원래 실행이 만난 것
```
org.yaml.snakeyaml.constructor.SafeConstructor.processDuplicateKeys
```
`management:` 아래에 `endpoint:` 블록을 **하나 더** 넣어서 난 오류였다.
이미 있는데 또 넣은 것이다.
**이 결과가 의미하는 것****`docker build` 기본 출력은 마지막 몇 줄만 보여준다.**
Maven 스택트레이스는 그 위에 있다. `--progress=plain` 으로 전체를 파일로 받고
`grep` 으로 찾는다.
> `yamllint` 는 이 실험대에 깔려 있지 않다. YAML 중복 키는 **빌드가 잡아 준다** —
> 다만 그 메시지를 보려면 위처럼 해야 한다.
## 2-4. 문제 ③ — 환경변수에 기본값이 없으면 테스트가 죽는다
```yaml
# 이러면 테스트에서 컨텍스트가 안 뜬다 — 테스트는 그 환경변수를 모른다
authorization-uri: ${KC_ISSUER_EXTERNAL}/protocol/openid-connect/auth
# 기본값을 준다
authorization-uri: ${KC_ISSUER_EXTERNAL:http://localhost:8080/realms/keycloak-patterns}/protocol/openid-connect/auth
```
**확인** — 지금 파일이 그렇게 되어 있나
```bash
grep -n 'KC_ISSUER' bff/src/main/resources/application.yml
```
## 2-5. 문제 ④ — actuator 가 인증에 막혀 200 인데 로그인 페이지
`/actuator/beans` 를 불렀는데 `200` 이 왔다. **내용은 Keycloak 로그인 페이지였다.**
`-L` 로 리다이렉트를 따라간 결과다.
```java
// SecurityConfig 의 permitAll 목록
"/actuator/health",
"/actuator/health/**",
// 실험대 전용 — 운영에서는 절대 열지 않는다
"/actuator/**"
```
> **`200` 이 곧 성공은 아니다.** 무엇이 왔는지 봐야 한다. 이 함정은
> `-o /dev/null -w '%{http_code}'` 만 쓸 때 **절대 안 보인다.**
## 2-6. 문제 ⑤ — 큰 응답이 프록시에서 `Bad Gateway`
`/actuator/beans`**117KB** 다. nginx → Traefik 을 거치면서 실패했다.
**실측** — [`experiment-b0-bff-redis-deploy.md`](../../experiment-b0-bff-redis-deploy.md) 1절
```
$ curl https://app1.hyeonworks.com/actuator/beans
Bad Gateway
```
**해결** — 파드 안에서 직접 받는다. **alpine 기반 JRE 이미지에는 `wget` 이 있다.**
(Keycloak 이미지와 다른 점이다 — 거기엔 curl 도 wget 도 없다.)
## 2-7. 이미지를 두 노드에 밀어 넣는다
레지스트리가 없다. `imagePullPolicy: Never` 라서 **두 노드에 각각 있어야 한다.**
**하기** — 워크스테이션에서
```bash
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 -'"
```
**확인** — 두 노드에 들어갔나
```bash
sudo k3s ctr images ls | grep keycloak-pattern-bff
ssh kc-lab-2 'sudo k3s ctr images ls | grep keycloak-pattern-bff'
```
**한쪽만 있으면** 그 노드에 스케줄된 replica 만 뜬다. `ErrImageNeverPull`
나타난다.
---
# 3. 배포
**되돌리기** — 먼저 읽어 둔다
```bash
sudo kubectl delete -f deploy/lab/k8s/bff-redis.yaml
```
## 3-1. 적용
**하기**
```bash
sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml
sudo kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
```
**실측** — [`01-deploy.txt`](../../evidence/b0-bff-redis-deploy/01-deploy.txt)
```
=== 배포 ===
secret/bff-secrets created
deployment.apps/redis created
service/redis created
deployment.apps/bff created
service/bff created
ingress.networking.k8s.io/bff created
deployment "redis" successfully rolled out
Waiting for deployment "bff" rollout to finish: 1 of 2 updated replicas are available...
deployment "bff" successfully rolled out
```
## 3-2. 배포 구성 — 무엇이 어디에 있나
```
브라우저 ──https──▶ nginx ──▶ Traefik ──▶ bff (2 replica)
├──▶ Keycloak (realm: keycloak-patterns)
└──▶ echo (resource server 대역)
redis ── kc-lab-2 (postgres 와 같은 노드) ← 아직 연결하지 않았다
```
**Redis 는 배포만 하고 BFF 에 연결하지 않았다.** 이 상태를 먼저 재는 것이 B-0 이다.
### 브라우저용 URL 과 백채널 URL 을 분리한다
```yaml
authorization-uri: ${KC_ISSUER_EXTERNAL}/protocol/openid-connect/auth # 브라우저가 간다
token-uri: ${KC_ISSUER_INTERNAL}/protocol/openid-connect/token # BFF 가 서버끼리
```
```yaml
- name: KC_ISSUER_EXTERNAL
value: https://auth.hyeonworks.com/realms/keycloak-patterns
- name: KC_ISSUER_INTERNAL
value: http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns
```
**브라우저가 보는 이름과 서버가 부르는 주소는 다르고, 섞으면 리다이렉트가 깨진다.**
`SERVER_FORWARD_HEADERS_STRATEGY=native` 도 같은 이유다 — 없으면 Spring 이
`redirect_uri``http://` 로 만들어 Keycloak 이 거부한다.
---
# 4. 배포가 실제로 걸렸는지 확인한다
## 4-1. 파드가 두 노드에 하나씩 떴나
**확인**
```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
```
**실측** — [`01-deploy.txt`](../../evidence/b0-bff-redis-deploy/01-deploy.txt)
```
bff-574c6d658b-8cz4x true kc-lab-1
bff-574c6d658b-zpkbp true kc-lab-2
redis-568bd7c4-5c5vc true kc-lab-2
```
**어디를 봐야 하는가****BFF 두 개가 서로 다른 노드에 있는 것.**
**이 결과가 의미하는 것**`topologySpreadConstraints` 가 일했다. **「다른
인스턴스」가 진짜 다른 기계여야** 이 층의 질문이 성립한다. 같은 노드의 다른
프로세스면 재는 의미가 절반이다.
`Pending` 이면 `describe pod` 의 Events 를 본다. `ErrImageNeverPull` 이면
2-7 로 돌아간다.
## 4-2. 밖에서 닿나
**확인**
```bash
curl -I https://app1.hyeonworks.com/
```
**형태**
```
HTTP/2 200
content-type: text/html
```
**실측** — [`02-autoconfiguration.txt`](../../evidence/b0-bff-redis-deploy/02-autoconfiguration.txt)
```
=== 외부 진입점 ===
https://app1.hyeonworks.com/ HTTP 200
```
**어디를 봐야 하는가** — 상태 줄과 **`content-type`.** 2-5 의 함정 때문이다.
`text/html` 이 왔다고 그게 **당신의** HTML 이라는 보장은 없다. 다음 절에서
내용까지 본다.
> `-I` 는 헤더만 본다. 여기서는 **닿는지**를 물어보는 것이라 이 형태가 맞다.
> 나중에 여러 번 재서 비교할 때는 `-o /dev/null -w '%{http_code}'` 를 쓴다.
---
# 5. 관찰 — 자동구성이 실제로 고른 것
## 5-1. `/actuator/beans` 를 파드 안에서 받는다
**하기** — 파드 이름을 먼저 잡는다
```bash
sudo kubectl -n keycloak-lab get pods -l app=bff
BFF=$(sudo kubectl -n keycloak-lab get pod -l app=bff \
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
echo "$BFF"
```
**하기** — 파드 안에서 받아 파일로 저장한다
```bash
sudo kubectl -n keycloak-lab exec "$BFF" -- \
wget -qO- http://localhost:8083/actuator/beans > /tmp/beans.json
wc -c /tmp/beans.json
```
**형태**
```
119552 /tmp/beans.json
```
**어디를 봐야 하는가** — 크기가 **10만 바이트 대**인 것. 원래 실행에서 **117KB**
였다. `0` 이면 못 받은 것이고, 몇 백 바이트면 **로그인 페이지나 오류 본문**이다.
**확인** — 진짜 JSON 인지 앞부분을 본다
```bash
head -c 200 /tmp/beans.json ; echo
```
**형태**
```json
{"contexts":{"keycloak-bff":{"beans":{"actuatorEndpointsSupplier":{"aliases":[],"scope":"singleton","type":"org.springframework
```
**어디를 봐야 하는가**`{"contexts":{"keycloak-bff"` 로 시작하는 것.
`<!DOCTYPE html` 로 시작하면 **2-5 의 함정**이다 — 로그인 페이지를 받았다.
## 5-2. `jq` 없이 빈 목록을 읽는다
**빈 하나는 이런 모양이다.**
```
"이름":{"aliases":[],"scope":"singleton","type":"패키지.클래스", ...}
```
**이름과 타입이 이 한 덩어리 안에 같이 있다.** 그러니 그 덩어리만 뽑으면 된다.
**확인** — 전체 빈 수. **미검증**
```bash
grep -o '"aliases":\[' /tmp/beans.json | wc -l
```
**실측** — [`03-beans-analysis.txt`](../../evidence/b0-bff-redis-deploy/03-beans-analysis.txt)
```
컨텍스트: keycloak-bff
전체 빈 수: 321
```
**확인** — 이름과 타입을 한 줄로. **미검증**
```bash
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans.json \
| sed 's/{"aliases".*"type":"/ -> /' \
| grep -i authorizedclient
```
**형태**
```
"authorizedClientManager" -> org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager
"authorizedClientRepository" -> org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository
"authorizedClientService" -> org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService
```
**어디를 봐야 하는가** — 화살표 오른쪽의 **클래스 이름 끝부분.**
### ★ 여기서 원래 실행이 실제로 넘어졌다
**실측** — [`02-autoconfiguration.txt`](../../evidence/b0-bff-redis-deploy/02-autoconfiguration.txt)
```
File "<stdin>", line 9
print(f" {name:46} {t.rsplit(\".\",1)[-1]}")
^
SyntaxError: unexpected character after line continuation character
```
**JSON 을 파이썬 한 줄로 파싱하려다 따옴표 이스케이프에서 깨졌다.**
빈 목록은 결국 다음 시도에서 나왔고, 그 결과가
[`03-beans-analysis.txt`](../../evidence/b0-bff-redis-deploy/03-beans-analysis.txt) 다.
> **`jq` 가 있으면 그걸 쓴다. 없으면 `grep` 으로 충분하다.**
> 이 실험대에는 `jq` 가 없다. 없는 도구를 전제로 한 명령은 **진단 도중에
> 패키지를 깔러 나가게 만든다.** 그러지 않으려고 위 형태를 쓴다.
## 5-3. B-0 의 답
**실측** — [`03-beans-analysis.txt`](../../evidence/b0-bff-redis-deploy/03-beans-analysis.txt)
```
--- 세션 · 토큰 저장소 관련 ---
authorizedClientManager -> AuthorizedClientServiceOAuth2AuthorizedClientManager
authorizedClientManagerRegistrar -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerRegistrar
authorizedClientRepository -> AuthenticatedPrincipalOAuth2AuthorizedClientRepository
authorizedClientService -> InMemoryOAuth2AuthorizedClientService
clientRegistrationRepository -> InMemoryClientRegistrationRepository
--- Redis / Spring Session 이 구성되었는가 ---
★ 없음 — Redis 도 Spring Session 도 구성되지 않았다
```
**확인** — Redis 와 Spring Session 이 정말 없는지 직접 센다
```bash
grep -ci 'RedisSessionRepository\|SpringHttpSessionConfiguration\|LettuceConnectionFactory' /tmp/beans.json
```
**형태**
```
0
```
**어디를 봐야 하는가**`0`.
**이 결과가 의미하는 것** — 의존성 자체가 없으니 자동구성이 걸릴 조건이 없다.
**세션은 서블릿 컨테이너(Tomcat)의 기본 `StandardSession` 에 있다.** 즉 **인스턴스
메모리**다.
| 빈 | 구현체 | 뜻 |
|---|---|---|
| `authorizedClientService` | **`InMemoryOAuth2AuthorizedClientService`** | **프로세스 메모리.** 재시작하면 사라진다 |
| `authorizedClientRepository` | **`AuthenticatedPrincipalOAuth2AuthorizedClientRepository`** | **principal 기준 조회.** session ID 가 없다 |
| `authorizedClientManager` | `AuthorizedClientServiceOAuth2AuthorizedClientManager` | **service**(공유)를 쓴다 |
| `clientRegistrationRepository` | `InMemoryClientRegistrationRepository` | 설정에서 읽은 것 |
| SessionRepository | **없음** | Tomcat 의 기본 `StandardSession` |
| Redis / Spring Session | **없음** | 의존성 자체가 없다 |
## 5-4. ★ 이름 하나가 이 층 전체의 문제다
`AuthenticatedPrincipalOAuth2AuthorizedClientRepository`**이름이 곧 설명이다.**
```
요청이 인증되어 있으면
└─▶ OAuth2AuthorizedClientService 에 위임
└─▶ 키: (clientRegistrationId, principalName)
└─ session ID 가 없다 ★
인증되어 있지 않으면
└─▶ HttpSession 에 임시 보관
```
**같은 사용자가 두 브라우저에서 로그인하면 principalName 이 같으므로 같은 항목을
본다.** 한쪽에서 토큰을 갱신하면 다른 쪽 것을 덮어쓴다.
> **Redis 를 붙여도 이건 안 고쳐진다.** 저장소를 공유해도 **키에 session ID 가
> 없기 때문**이다. 「Session Store 를 공유 저장소로 바꾸는 것만으로는 충분하지
> 않다」의 기제가 이 빈 하나에 들어 있다.
>
> **이것이 추측으로는 안 나오는 부분이다.** 「메모리겠지」까지는 맞혔어도
> **조회 키가 무엇인지는 빈 이름을 봐야 안다.**
---
# 6. ★ 예상 못 한 것 — replica 2개에서 로그인 자체가 안 된다
**여기부터는 브라우저로 한다.**
## 6-1. 증상
**하기** — 브라우저에서 `https://app1.hyeonworks.com/` 을 열고 로그인한다.
**형태** — 주소창이 이렇게 끝난다
```
https://app1.hyeonworks.com/login?error
```
**확인** — 로그를 본다. **두 파드를 다 봐야 한다**
```bash
sudo kubectl -n keycloak-lab logs -l app=bff --tail=100 --prefix
```
**어디를 봐야 하는가****아무 오류도 없다.**
**이 결과가 의미하는 것** — Spring Security 는 **로그인 실패를 DEBUG 로만
남긴다.** 「로그에 아무것도 없으니 애플리케이션 문제가 아니다」로 읽으면 틀린다.
**증상은 있는데 로그가 없는 상태**이고, 그럴 때는 가설을 세워 시험한다.
## 6-2. 가설
```
① 브라우저 → 앱 → IdP 로 리다이렉트 (state·PKCE verifier 를 저장)
② IdP → 브라우저 → 앱의 콜백 (저장한 것을 꺼내 검증)
```
**인가 코드 흐름은 왕복이 두 번이고, 두 번 다 같은 인스턴스로 가야 한다.**
저장 위치가 `HttpSession` 이고 그게 **인스턴스 메모리**이므로, 콜백이 다른
replica 로 가면 저장된 인가 요청이 없어 실패한다.
**5-3 에서 본 「SessionRepository 없음」이 이 가설의 근거다.**
## 6-3. 검증 — replica 를 1로 줄인다
**되돌리기** — 먼저 읽어 둔다
```bash
sudo kubectl -n keycloak-lab scale deployment/bff --replicas=2
```
**하기**
```bash
sudo kubectl -n keycloak-lab scale deployment/bff --replicas=1
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=180s
sudo kubectl -n keycloak-lab get pods -l app=bff
```
**하기** — 브라우저에서 다시 로그인한다. **쿠키를 먼저 지운다** (앞선 실패의
세션이 남아 있으면 결과가 섞인다).
**실측** — [`b0-bff-login-success-single-replica.png`](../../evidence/b0-bff-redis-deploy/b0-bff-login-success-single-replica.png)
```
replica 2 + 스티키 없음 → 로그인 실패 (콜백이 다른 인스턴스로)
replica 1 → 로그인 성공
```
**이 결과가 의미하는 것****가설 확정.**
> **「다중 인스턴스에서 어떻게 운영할 것인가」는 로그인한 뒤의 문제가 아니라
> 로그인 자체의 문제다.** [B-2](b2-multi-instance-session.md) 의
> 검증 1번(「한쪽에서 로그인한 뒤 다른 인스턴스로 요청」)보다 **앞선 단계**다.
> 로그인이 끝나야 그 검증을 할 수 있는데, 로그인부터 막힌다.
## 6-4. 토큰 경계 — 브라우저에 무엇이 있나
**하기** — 브라우저에서 `https://app1.hyeonworks.com/bff/token-boundary`
**실측** — [`b0-bff-token-boundary.png`](../../evidence/b0-bff-redis-deploy/b0-bff-token-boundary.png)
```json
{"pattern":"AP3-backend-for-frontend","principal":"labuser",
"accessTokenStoredOnServer":true,"refreshTokenStoredOnServer":true,
"browserTokenCount":0,"csrfProtectionEnabled":true}
```
**어디를 봐야 하는가** — 세 값.
| 필드 | 값 | 뜻 |
|---|---|---|
| `accessTokenStoredOnServer` | `true` | 서버가 access token 을 들고 있다 |
| `refreshTokenStoredOnServer` | `true` | refresh token 도 서버에 있다 |
| **`browserTokenCount`** | **`0`** | **브라우저에는 토큰이 하나도 없다** |
**이 결과가 의미하는 것****BFF 패턴이 성립한다.** 브라우저는 세션 쿠키만
들고 있고 토큰은 전부 서버에 있다. 이 세 값이 [B-1](b1-redis-session-store.md)
에서 어떻게 바뀌는지가 다음 실험의 요지다. **지금 값을 적어 둔다.**
---
# 7. 복구
## 7-1. replica 를 되돌린다
**하기**
```bash
sudo kubectl -n keycloak-lab scale deployment/bff --replicas=2
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=180s
```
**[B-1](b1-redis-session-store.md) 로 이어서 갈 것이라면 배포는 그대로 둔다.**
거기서 같은 파드에 Redis 를 붙인다.
## 7-2. 소스 변경을 되돌린다
**하기**
```bash
git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java \
deploy/lab/k8s/bff-redis.yaml
git status --short
```
**★ 잊으면 다음에 `apply` 할 때 B-0 구성이 다시 배포된다.**
## 7-3. 전부 지울 때
**하기**
```bash
sudo kubectl delete -f deploy/lab/k8s/bff-redis.yaml
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
delete realms/keycloak-patterns
```
**★ PVC 는 `delete -f` 로 같이 지워진다.** Redis 데이터도 사라진다.
## 7-4. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| replica | `sudo kubectl -n keycloak-lab get deploy bff` | `2/2` |
| 소스 | `git status --short` | 출력 없음 |
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide -l app=bff` | 두 노드에 하나씩 |
| Keycloak | `sudo kubectl -n keycloak-lab get pods -o wide \| grep keycloak` | 둘 다 `1/1 Running` |
| 밖 | `curl -I https://app1.hyeonworks.com/` | `200` |
| 임시 파일 | `rm -f /tmp/beans.json /tmp/build.log` | — |
> **★ actuator 를 열어 둔 채로 두지 않는다.** `/actuator/beans` 와
> `/actuator/env` 는 **내부 구조와 설정값을 그대로 드러낸다.** 실험대라서
> 여는 것이고, 운영이라면 `health` 만 남긴다.
> **이 실험이 재지 않은 것** — 스티키 세션(세션 어피니티)을 켜면 replica 2 에서
> 로그인이 되는지는 재지 않았다. 「같은 인스턴스로 보내면 된다」는 추론이지
> 측정이 아니다.
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 빈 목록에 `RedisSessionRepository` 가 있다 | **B-1·B-2 배선이 남아 있다** | 2-1 을 다시. `git diff` 로 확인 |
| `authorizedClientService``Jdbc...` 다 | `SecurityConfig` 의 명시 빈이 남아 있다 | 2-1 하기 ② |
| `/actuator/beans``Bad Gateway` | 응답이 117KB 라 프록시가 못 넘긴다 | 파드 안에서 받는다 — 5-1 |
| `/actuator/beans``200` 인데 HTML | **Keycloak 로그인 페이지다** | `head -c 200` 으로 내용 확인 — 5-1 |
| `beans.json` 이 0 바이트 | 파드 이름이 틀렸거나 포트가 다르다 | `get pods -l app=bff`, 포트는 `8083` |
| 빌드가 실패하는데 원인이 안 보인다 | 마지막 15줄에 없다 | `--progress=plain` + 파일 — 2-3 |
| 테스트에서 컨텍스트가 안 뜬다 | 환경변수에 기본값이 없다 | 2-4 |
| 파드가 `ErrImageNeverPull` | 그 노드에 이미지가 없다 | 두 노드에 각각 import — 2-7 |
| 파드가 `Pending` | 노드 메모리 부족 | `describe pod` Events, `top nodes` — 1-1 |
| 브라우저가 `/login?error` | **replica 2 + 스티키 없음** | replica 1 로 줄여 확인 — 6-3 |
| BFF 로그에 오류가 없다 | Spring Security 는 로그인 실패를 DEBUG 로만 남긴다 | 로그 없음을 「문제 없음」으로 읽지 않는다 — 6-1 |
| 로그인 후 리다이렉트가 `http://` 로 간다 | `SERVER_FORWARD_HEADERS_STRATEGY` 가 없다 | 매니페스트 env 확인 — 3-2 |
| `jq: command not found` | **이 실험대에 `jq` 가 없다** | `grep` 으로 읽는다 — 5-2 |
| `kcadm``401` | `config credentials` 를 안 했거나 만료됐다 | 1-3 을 다시 |
---
# 다음
| 실험 | B-0 이 남긴 것 |
|---|---|
| [B-1](b1-redis-session-store.md) 저장소 결정 | **전환 후 이 빈들을 다시 찍는다.** 「Redis 붙였다」고 믿는데 자동구성이 안 걸리는 경우가 흔하다 |
| [B-2](b2-multi-instance-session.md) 다중 인스턴스 | **로그인 자체가 실패한다**는 것이 이미 관측됐다. 그게 검증 0번이다 |
| [B-3](b3-refresh-token-contention.md) refresh 경쟁 | `accessTokenLifespan=60` 으로 realm 을 만들어 뒀다 |
| 운영 | actuator `beans`/`env`**내부 구조를 그대로 드러낸다.** 실험대에서만 연다 |