# 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:39–13: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"` 로 시작하는 것. ` /' \ | 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 "", 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` 는 **내부 구조를 그대로 드러낸다.** 실험대에서만 연다 |