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>
33 KiB
B-0 재현 가이드 — 아무것도 주지 않았을 때 Spring 이 무엇을 고르는지 본다
해설 문서: docs/experiment-b0-bff-redis-deploy.md ·
증거 원문: docs/evidence/b0-bff-redis-deploy/
이 가이드가 끝나면
당신 터미널과 브라우저에서 이것들을 직접 본다.
| 보게 되는 것 | 어디서 |
|---|---|
| 돌고 있는 인스턴스가 실제로 고른 구현체 이름 | /actuator/beans |
| Redis 도 Spring Session 도 하나도 구성되지 않은 것 | 같은 곳 |
| 조회 키에 session ID 가 없다는 것 | 빈 이름 하나가 그대로 설명이다 |
| replica 2 에서 로그인 자체가 실패하는 것 | 브라우저 · /login?error |
| replica 를 1 로 줄이면 되는 것 | 같은 브라우저 |
| 브라우저에 토큰이 0개인 것 | /bff/token-boundary |
전제
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 에서 한다.
그리고 이 저장소의 현재 소스는 이미 B-1·B-2 를 거친 뒤 상태다.
bff-redis.yaml 에는 SPRING_SESSION_STORE_TYPE=redis 가 있고, SecurityConfig
에는 JdbcOAuth2AuthorizedClientService 빈이 있다. 그대로 배포하면 B-2 의
결과를 재게 된다. 어느 브랜치에도 B-0 시점의 파일은 남아 있지 않다 —
2-1 에서 손으로 되돌린다.
전 구간 약 40~60분(빌드 시간 포함). 배포한 것을 지우는 명령은 5절에 있다.
표시 규약
| 표시 | 뜻 |
|---|---|
| 실측 | 2026-09-04 13:39–13:46 KST 수집 기록의 출력 원문. 증거 파일에 그대로 있다 |
| 형태 | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| 미검증 | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 다른 방법을 썼다 |
파드 이름·IP·빈 개수는 당신 환경에서 다르다. 이 문서는 자리표시자(<...>)를
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
0. 왜 이 실험을 하는가
Q1 이 직접 요구한 확인이다.
코드에 저장소를 직접 생성하는 Bean 이 없기 때문에, 어떤 구현체가 실제로 사용되는지는 Spring Boot 의 자동구성 결과까지 확인해야 정확하게 알 수 있다.
빈을 직접 만들지 않으면
└─ Spring Boot 가 조건에 따라 고른다
└─ 무엇을 골랐는지는 코드 어디에도 안 적혀 있다
└─ 돌아가는 인스턴스에 물어봐야 안다
추측으로도 답은 나온다. 「저장소를 안 붙였으니 메모리겠지.」 맞다. 그런데 추측으로 두면 안 되는 이유가 두 번째 줄에 있다.
빈 이름 하나가 이 층 전체의 문제를 담고 있는데, 그 이름은 추측으로 안 나온다. 찍어 봐야 나온다. 그게 이 실험이다.
1. 기준선 — 배포하기 전에
넓은 것부터 좁혀 간다.
노드 자원 → 네임스페이스에 무엇이 있나 → 이미지가 두 노드에 있나 → Keycloak realm
1-1. 노드에 자원이 있나
확인
free -m
sudo kubectl top nodes
실측 — 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. 네임스페이스에 무엇이 있나
확인
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 이미지 안에 있는 도구다
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)"
비밀번호를 화면에 찍지 않는다. 명령 치환으로 넘기므로 터미널에도 셸 히스토리에도 값이 남지 않는다. 길이만 보고 싶으면:
sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \ -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c실측 —
19
하기 — realm 과 클라이언트
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/*"]'
되돌리기
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
delete realms/keycloak-patterns
어디를 봐야 하는가 — accessTokenLifespan=60.
이 결과가 의미하는 것 — B-3(refresh 경쟁)을 위해 미리 짧게 잡는 것이다. 만료를 기다리는 시간이 짧아야 재현이 된다. 지금 정해 두면 나중에 realm 을 다시 안 만든다.
확인 — 만들어졌나
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get realms/keycloak-patterns --fields realm,enabled,accessTokenLifespan
로그인할 사용자도 하나 만든다.
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 을 재려면 그 배선을 빼야 한다.
되돌리기 — 실험이 끝나면 원래대로 돌린다
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 에서 두 블록을 지운다
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 에서 빈 두 개를 지운다
vim bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java
// 지운다 — B-2 가 넣은 것. 이게 있으면 자동구성이 고를 기회가 없다
@Bean
OAuth2AuthorizedClientService authorizedClientService(...) { ... }
// 지운다 — 이것도 직접 만들면 "자동구성이 골랐다" 가 아니다
@Bean
OAuth2AuthorizedClientManager authorizedClientManager(...) { ... }
관련 import (JdbcOAuth2AuthorizedClientService, JdbcOperations, 매니저 계열)도
같이 지운다. bffSecurity 빈은 남긴다 — /actuator/** 를 열어 주는 것이
그 안에 있다(문제 ④).
하기 ③ — application.yml 에서 세 블록을 지운다
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 를 지운다
vim deploy/lab/k8s/bff-redis.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 의 구성이다.
확인 — 무엇을 지웠는지 눈으로 본다
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/ 이 없어 클래스 파일만 커밋되어 있었고 소스는
다른 브랜치에 있었다.
확인 — 지금 당신의 저장소는 어떤가
ls bff/src/main/java/com/example/keycloakpattern/bff/
없으면 가져온다.
git checkout origin/develop-keycloak-pattern3 -- bff/
이 결과가 의미하는 것 — 빌드 산출물이 커밋되어 있으면 「빌드가 되는데 바꿔도 안 바뀐다」가 된다. 소스가 있는지부터 본다.
2-3. 문제 ② — 빌드 실패 원인이 마지막 15줄에 없다
하기
docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
echo "exit=$?"
확인 — 실패했으면 전체 로그에서 찾는다
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. 문제 ③ — 환경변수에 기본값이 없으면 테스트가 죽는다
# 이러면 테스트에서 컨텍스트가 안 뜬다 — 테스트는 그 환경변수를 모른다
authorization-uri: ${KC_ISSUER_EXTERNAL}/protocol/openid-connect/auth
# 기본값을 준다
authorization-uri: ${KC_ISSUER_EXTERNAL:http://localhost:8080/realms/keycloak-patterns}/protocol/openid-connect/auth
확인 — 지금 파일이 그렇게 되어 있나
grep -n 'KC_ISSUER' bff/src/main/resources/application.yml
2-5. 문제 ④ — actuator 가 인증에 막혀 200 인데 로그인 페이지
/actuator/beans 를 불렀는데 200 이 왔다. 내용은 Keycloak 로그인 페이지였다.
-L 로 리다이렉트를 따라간 결과다.
// 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 1절
$ curl https://app1.hyeonworks.com/actuator/beans
Bad Gateway
해결 — 파드 안에서 직접 받는다. alpine 기반 JRE 이미지에는 wget 이 있다.
(Keycloak 이미지와 다른 점이다 — 거기엔 curl 도 wget 도 없다.)
2-7. 이미지를 두 노드에 밀어 넣는다
레지스트리가 없다. imagePullPolicy: Never 라서 두 노드에 각각 있어야 한다.
하기 — 워크스테이션에서
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 k3s ctr images ls | grep keycloak-pattern-bff
ssh kc-lab-2 'sudo k3s ctr images ls | grep keycloak-pattern-bff'
한쪽만 있으면 그 노드에 스케줄된 replica 만 뜬다. ErrImageNeverPull 로
나타난다.
3. 배포
되돌리기 — 먼저 읽어 둔다
sudo kubectl delete -f deploy/lab/k8s/bff-redis.yaml
3-1. 적용
하기
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
=== 배포 ===
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 을 분리한다
authorization-uri: ${KC_ISSUER_EXTERNAL}/protocol/openid-connect/auth # 브라우저가 간다
token-uri: ${KC_ISSUER_INTERNAL}/protocol/openid-connect/token # BFF 가 서버끼리
- 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. 파드가 두 노드에 하나씩 떴나
확인
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
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. 밖에서 닿나
확인
curl -I https://app1.hyeonworks.com/
형태
HTTP/2 200
content-type: text/html
=== 외부 진입점 ===
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 를 파드 안에서 받는다
하기 — 파드 이름을 먼저 잡는다
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"
하기 — 파드 안에서 받아 파일로 저장한다
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 인지 앞부분을 본다
head -c 200 /tmp/beans.json ; echo
형태
{"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":"패키지.클래스", ...}
이름과 타입이 이 한 덩어리 안에 같이 있다. 그러니 그 덩어리만 뽑으면 된다.
확인 — 전체 빈 수. 미검증
grep -o '"aliases":\[' /tmp/beans.json | wc -l
컨텍스트: keycloak-bff
전체 빈 수: 321
확인 — 이름과 타입을 한 줄로. 미검증
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
어디를 봐야 하는가 — 화살표 오른쪽의 클래스 이름 끝부분.
★ 여기서 원래 실행이 실제로 넘어졌다
File "<stdin>", line 9
print(f" {name:46} {t.rsplit(\".\",1)[-1]}")
^
SyntaxError: unexpected character after line continuation character
JSON 을 파이썬 한 줄로 파싱하려다 따옴표 이스케이프에서 깨졌다.
빈 목록은 결국 다음 시도에서 나왔고, 그 결과가
03-beans-analysis.txt 다.
jq가 있으면 그걸 쓴다. 없으면grep으로 충분하다. 이 실험대에는jq가 없다. 없는 도구를 전제로 한 명령은 진단 도중에 패키지를 깔러 나가게 만든다. 그러지 않으려고 위 형태를 쓴다.
5-3. B-0 의 답
--- 세션 · 토큰 저장소 관련 ---
authorizedClientManager -> AuthorizedClientServiceOAuth2AuthorizedClientManager
authorizedClientManagerRegistrar -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerRegistrar
authorizedClientRepository -> AuthenticatedPrincipalOAuth2AuthorizedClientRepository
authorizedClientService -> InMemoryOAuth2AuthorizedClientService
clientRegistrationRepository -> InMemoryClientRegistrationRepository
--- Redis / Spring Session 이 구성되었는가 ---
★ 없음 — Redis 도 Spring Session 도 구성되지 않았다
확인 — Redis 와 Spring Session 이 정말 없는지 직접 센다
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
확인 — 로그를 본다. 두 파드를 다 봐야 한다
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로 줄인다
되돌리기 — 먼저 읽어 둔다
sudo kubectl -n keycloak-lab scale deployment/bff --replicas=2
하기
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
replica 2 + 스티키 없음 → 로그인 실패 (콜백이 다른 인스턴스로)
replica 1 → 로그인 성공
이 결과가 의미하는 것 — 가설 확정.
「다중 인스턴스에서 어떻게 운영할 것인가」는 로그인한 뒤의 문제가 아니라 로그인 자체의 문제다. B-2 의 검증 1번(「한쪽에서 로그인한 뒤 다른 인스턴스로 요청」)보다 앞선 단계다. 로그인이 끝나야 그 검증을 할 수 있는데, 로그인부터 막힌다.
6-4. 토큰 경계 — 브라우저에 무엇이 있나
하기 — 브라우저에서 https://app1.hyeonworks.com/bff/token-boundary
실측 — b0-bff-token-boundary.png
{"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 에서 어떻게 바뀌는지가 다음 실험의 요지다. 지금 값을 적어 둔다.
7. 복구
7-1. replica 를 되돌린다
하기
sudo kubectl -n keycloak-lab scale deployment/bff --replicas=2
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=180s
B-1 로 이어서 갈 것이라면 배포는 그대로 둔다. 거기서 같은 파드에 Redis 를 붙인다.
7-2. 소스 변경을 되돌린다
하기
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. 전부 지울 때
하기
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 저장소 결정 | 전환 후 이 빈들을 다시 찍는다. 「Redis 붙였다」고 믿는데 자동구성이 안 걸리는 경우가 흔하다 |
| B-2 다중 인스턴스 | 로그인 자체가 실패한다는 것이 이미 관측됐다. 그게 검증 0번이다 |
| B-3 refresh 경쟁 | accessTokenLifespan=60 으로 realm 을 만들어 뒀다 |
| 운영 | actuator beans/env 는 내부 구조를 그대로 드러낸다. 실험대에서만 연다 |