--- id: 4ef91d43-1f1d-481a-8b6f-5e015d068578 kind: SETUP slug: reproduce-b0-default-session-store title: 아무 저장소도 주지 않고 Spring 이 무엇을 고르는지 찍어서 확인한다 topic: where-application-state-lives topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기 project: keycloak-session-store status: 게시 전 studio: "https://hyeonworks.com/studio/documents/4ef91d43-1f1d-481a-8b6f-5e015d068578/edit" pinnedVersions: - name: keycloak-pattern-bff version: lab source: - final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-0 - final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서 sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af --- # 아무 저장소도 주지 않고 Spring 이 무엇을 고르는지 찍어서 확인한다 저장소를 하나도 붙이지 않은 BFF 를 두 노드에 배포하고 `/actuator/beans` 로 Spring 이 무엇을 골랐는지 찍어 보는 절차다. B층 뒤의 여덟 편이 이 배포 위에 서므로 Redis 는 띄우기만 하고 연결하지 않는다. 전 구간 약 40~60분이고 빌드 시간이 들어 있다. ## 관계 - **세션만 Redis 로 옮기자 토큰이 따라오지 않았다** 이 절차가 뽑아 둔 빈 세 개의 이름이 그 기록에서 after 와 견주는 값이 된다. 무엇을 발견했는지는 그쪽에 있고 여기에는 치는 순서만 있다. - **세션과 인가된 클라이언트는 조회 키가 다르다** `AuthenticatedPrincipalOAuth2AuthorizedClientRepository` 라는 이름 하나가 왜 이 층 전체의 문제인지를 그 기록이 설명한다. - **저장소를 옮기기 전에 조회 키를 본다** 여기서 빈 이름을 먼저 찍는 순서를 규칙으로 굳힌 기록이다. - **Redis 를 붙이고 무엇이 옮겨졌는지 빈 목록으로 견준다** 바로 다음 편이다. 이 절차가 만든 배포에 Redis 를 연결하고 같은 명령을 다시 친다. - **토큰을 PostgreSQL 로 옮기고 기본키와 로그아웃 정리를 확인한다** 여기서 이름으로 짐작한 조회 키가 그 편에서 테이블 정의로 확정된다. ## 본문 ## 읽기 전에 — 어디서 치는가 명령이 두 기계에 나뉜다. 소스를 고치고 이미지를 만드는 일은 워크스테이션에서 하고, 클러스터를 보고 배포하는 일은 `[lab host]` 에서 `kubectl` 과 `kcadm` 으로 한다. 그래서 코드블록마다 어디서 치는지를 붙여 두었다. **`[kc-lab-1]` 라벨이 붙은 블록은 게스트 셸이다.** lab host 에서 `ssh kc-lab-1` 로 들어가서 치고, 끝나면 `exit` 로 나온다. `ssh kc-lab-2 '…'` 한 줄 형태는 lab host 에서 그대로 쳐도 되므로 `[lab host]` 로 두었다. **원 가이드는 이 명령들을 `kc-lab-1` 에서 치라고 적었다.** 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 `sudo` 없는 `kubectl` 이 `permission denied` 로 막힌다 — kubeconfig 는 lab host 의 `~/.kube/config` 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 `kubectl` 블록의 기계 이름을 `[lab host]` 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다. **시작 전에 셋을 스스로 정해 둔다. 그 명령이 원 가이드에 없다(unknown).** 첫째, 워크스테이션에서 클러스터를 보는 기계로 건너가는 명령이 이 절차에 없다. 원 가이드의 라벨은 `[워크스테이션]` 과 `[kc-lab-1]` 을 여섯 번 오가는데 `ssh` 로 들어가는 줄도 `exit` 도 안 나온다. 경로는 이미지를 밀어 넣는 줄 하나에만 드러난다 — `ssh test-server "ssh kc-lab-1 '...'"`, 워크스테이션에서 `kc-lab-1` 까지 `test-server` 를 거친다. **`kubectl` 블록은 `kc-lab-1` 이 아니라 그 중간의 `test-server`, 즉 `[lab host]` 에서 친다** — 기반 가이드가 kubeconfig 를 거기에만 두기 때문이고, 까닭은 아래에 적었다. 그래서 건너갈 기계는 하나이고 `ssh test-server` 한 줄이면 된다. 이미지를 노드로 밀어 넣는 줄만 거기서 한 번 더 `ssh kc-lab-1` 로 들어간다. 둘째, 이 절차의 파일 경로가 전부 저장소 상대경로다 — `bff/pom.xml`, `deploy/lab/k8s/bff-redis.yaml`. 어느 디렉터리에서 치는지 정하는 줄이 없으므로 두 기계 각각에서 저장소 루트로 먼저 옮겨 두고 시작한다. 다른 디렉터리에서 치면 `kubectl apply` 가 경로를 못 찾고 끝난다. 셋째, 워크스테이션에서 고친 파일을 클러스터를 보는 기계로 옮기는 단계가 없다. `vim deploy/lab/k8s/bff-redis.yaml` 은 `[워크스테이션]` 이고 그 파일을 읽는 `kubectl apply -f deploy/lab/k8s/bff-redis.yaml` 은 `[lab host]` 인데, 사이에 파일을 넘기는 명령이 원 가이드에 없다. **안 옮기고 치면 오류가 안 난다** — 손 안 댄 매니페스트가 그대로 적용돼 `SPRING_SESSION_STORE_TYPE=redis` 와 `BFF_DB_*` 가 살아 있는 채로 배포되고, 화면에는 배포 성공만 뜬다. 어긋난 것은 관찰 절의 빈 수가 `321` 이 아닌 다른 숫자로 나올 때 비로소 보인다. `kubectl` 에 `sudo` 를 붙이지 않는다. root 홈에는 `~/.kube/config` 가 없어 `localhost:8080` 으로 붙으려다 `connection refused` 로 끝난다. 반입한 B층 아홉 편의 전제 한 줄만 옛 형태로 `sudo kubectl` 을 적고 있고, 본문 명령 블록에는 한 번도 쓰지 않는다. **그 규칙은 `[lab host]` 의 규칙이지 `kc-lab-1` 의 규칙이 아니다.** 기반 가이드는 kubeconfig 를 lab host 의 `~/.kube/config` 에만 두고 게스트의 사용자 홈에는 일부러 두지 않는다. 그래서 `kc-lab-1` 에서는 `sudo` 없는 `kubectl` 이 도리어 막힌다 — 2026-09-17 에 양쪽에서 쳐서 확인했다(observed). | 어디서 | `kubectl …` | `sudo kubectl …` | |---|---|---| | lab host | 된다 | 안 된다 | | `kc-lab-1` | 안 된다 | 된다 | `kc-lab-1` 에서 `sudo` 없이 치면 `connection refused` 가 아니라 이렇게 끝난다. ```text error: error loading config file "/etc/rancher/k3s/k3s.yaml": open /etc/rancher/k3s/k3s.yaml: permission denied ``` | 무엇 | 값 | |---|---| | 네임스페이스 | `keycloak-lab` | | 고치는 파일 | 넷 — `bff/pom.xml` · `SecurityConfig.java` · `application.yml` · `bff-redis.yaml` | | 주입 수단 | 편집기로 넷을 되돌린 뒤 다시 빌드해 두 노드에 import | | 무엇을 찍나 | `/actuator/beans` 의 전체 빈 수와 저장소 관련 빈 이름 | | 브라우저 | 필요하다. `https://app1.hyeonworks.com/` 이 열려야 한다 | | 도구 | `jq` 가 이 실험대에 없다. 빈 목록은 `grep` 으로 읽는다 | | 걸리는 시간 | 약 40~60분. 빌드 시간이 들어 있다 | ## 이 실험이 가르는 것 코드에 저장소를 직접 만드는 빈이 없으면 무엇이 실제로 쓰이는지는 Spring Boot 의 자동구성 결과까지 봐야 알 수 있다. 원 가이드는 그 문장을 그대로 인용해 시작한다. ```text 빈을 직접 만들지 않으면 └─ Spring Boot 가 조건에 따라 고른다 └─ 무엇을 골랐는지는 코드 어디에도 안 적혀 있다 └─ 돌아가는 인스턴스에 물어봐야 안다 ``` 추측으로도 답은 나온다. 저장소를 안 붙였으니 메모리겠지, 맞다. 그런데 빈 이름 하나가 B층 전체의 문제를 담고 있고 그 이름은 추측으로 안 나온다. 찍어 봐야 나온다. 절차를 끝까지 밟으면 여섯을 자기 화면에서 보게 된다 — 돌고 있는 인스턴스가 실제로 고른 구현체 이름, Redis 도 Spring Session 도 하나도 구성되지 않은 것, 조회 키에 session ID 가 없다는 것, replica 2 에서 로그인 자체가 실패하는 것, replica 를 1 로 줄이면 로그인이 되는 것, 브라우저에 토큰이 0개인 것. 저장소를 먼저 붙이면 이 실험은 성립하지 않는다. Redis 를 미리 연결하면 잴 것이 없어지므로 **Redis 는 배포만 하고 BFF 에 연결하지 않는다.** 연결은 B-1 에서 한다. ## 전제와 되돌리기 - `05-keycloak` 이 끝나 있다. A층 실험은 안 해도 된다. - 브라우저가 필요하다. 인가 코드 흐름은 왕복이 두 번이라 `curl` 로 대신할 수 없다. - BFF 이미지는 워크스테이션에서 빌드해서 두 노드에 밀어 넣는다. - `jq` 는 이 실험대에 깔려 있지 않다. 이 절차는 `grep` 으로 읽는다. B층은 A층과 건드리는 대상이 다르고, 그래서 되돌리기도 다르다. A층은 주입 하나를 되돌리면 끝났는데 여기서는 애플리케이션 소스와 매니페스트를 고치므로 소스를 되돌린 뒤 다시 빌드해 두 노드에 다시 밀어 넣어야 클러스터가 따라온다. | 무엇 | A층 | B층 | |---|---|---| | 무엇을 건드리나 | 클러스터 · 네트워크 · 데이터베이스 | 애플리케이션 소스와 매니페스트 | | 되돌리기 | 주입을 되돌린다 | `git checkout` 한 뒤 다시 빌드해 두 노드에 다시 밀어 넣는다 | | 브라우저 | 필요 없다 | B-3 을 뺀 셋은 브라우저가 있어야 한다 | | 이미지 | 이미 떠 있다 | 레지스트리가 없어 `imagePullPolicy: Never` 다. 두 노드에 각각 import 해야 두 replica 가 다 뜬다 | 저장소의 현재 소스는 이미 B-1 과 B-2 를 거친 뒤 상태다. `bff-redis.yaml` 에는 `SPRING_SESSION_STORE_TYPE=redis` 가 있고 `SecurityConfig` 에는 `JdbcOAuth2AuthorizedClientService` 빈이 있다. 그대로 배포하면 B-2 의 결과를 재게 된다. 어느 브랜치에도 B-0 시점의 파일이 없다고 원 가이드가 적어 두었고, 그래서 주입 절의 첫 단계가 손으로 되돌리는 일이다. 되돌리기는 둘이고 둘 다 시작 전에 읽어 둔다. ```bash label="[워크스테이션] 소스만 되돌린다" 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 ``` ```bash label="[lab host] 배포한 것을 통째로 지운다" kubectl delete -f deploy/lab/k8s/bff-redis.yaml ``` PVC 는 `delete -f` 로 같이 지워진다. Redis 데이터도 함께 사라진다. ## 주입 전에 같은 명령으로 먼저 본다 넓은 것부터 좁혀 간다. ```text 노드 자원 → 네임스페이스에 무엇이 있나 → Keycloak realm → 사용자 ``` ### 1. 노드에 BFF 두 개가 들어갈 자원이 있는가 **무엇을 보는가** — 두 노드의 메모리와 CPU 여유. ```bash label="[lab host] 메모리와 노드 사용률을 본다" free -m kubectl top nodes ``` **어디를 보나** — 실측은 이렇다(observed, `01-deploy.txt`). ```text === 배포 전 자원 === 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` 이거나 메모리 부족으로 죽는데, 그때 증상을 Spring 설정 문제로 읽게 된다. ### 2. 네임스페이스에 앞 실험의 잔재가 있는가 **무엇을 보는가** — 지금 무엇이 떠 있는지. ```bash label="[lab host] ① 워크로드를 본다" kubectl -n keycloak-lab get all ``` ```bash label="[lab host] ② Secret 과 Ingress 는 따로 본다" kubectl -n keycloak-lab get secret,ingress ``` **어디를 보나** — `keycloak` StatefulSet 과 `postgres` 가 있고 `bff` 와 `redis` 는 없어야 한다. **이 값이 뜻하는 것** — `bff` 나 `redis` 가 이미 있으면 앞 실험의 잔재이고, 그 위에 배포하면 내가 만든 것과 원래 있던 것이 섞인다. 줄을 둘로 나눈 까닭은 `get all` 이 워크로드 계열만 보여 주기 때문이다. Secret 과 PVC 와 Ingress 는 거기 안 나온다. ### 3. realm 과 클라이언트를 만든다 **목적** — BFF 가 로그인을 보낼 Keycloak realm 과 클라이언트를 세운다. realm 이 없으면 배포는 성공하는데 로그인에서 막힌다. 관리 자격증명을 잡는다. `kcadm` 은 Keycloak 이미지 안에 있다. ```bash label="[lab host] ① kcadm 에 로그인한다" kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ config credentials --server http://localhost:8080 --realm master --user admin \ --password "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \ -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" ``` 비밀번호를 화면에 찍지 않는다. 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 남지 않는다. 길이만 센다. ```bash label="[lab host] ② 비밀번호의 길이만 센다" kubectl -n keycloak-lab get secret keycloak-lab-secrets \ -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c ``` realm 과 클라이언트를 만든다. ```bash label="[lab host] ③ realm 과 confidential 클라이언트를 만든다" kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ create realms -s realm=keycloak-patterns -s enabled=true -s accessTokenLifespan=60 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 directAccessGrantsEnabled=true \ -s 'redirectUris=["https://app1.hyeonworks.com/*"]' ``` **`directAccessGrantsEnabled=true` 가 붙는 까닭은 B-3 때문이다.** 이 줄이 없으면 클라이언트는 기본값 `false` 로 만들어지고, B-3 이 같은 클라이언트로 `grant_type=password` 를 칠 때 이렇게 끝난다(2026-09-17, observed). ```text {"error":"unauthorized_client","error_description":"Client not allowed for direct access grants"} ``` B-0 의 브라우저 로그인은 인가 코드 흐름이라 이 값이 `false` 여도 되고, 그래서 **B-0 을 끝까지 밟아도 안 드러난다.** 나중에 B-3 에서 막히고, 그때는 원인이 B-0 에 있다는 것이 안 보인다. `-s secret=bff-lab-secret` 은 Keycloak 쪽에 저장되는 값이고, BFF 가 실제로 보내는 값은 배포 매니페스트가 만드는 Secret `bff-secrets` 의 `KEYCLOAK_CLIENT_SECRET` 이다. 둘이 같아야 로그인이 끝까지 간다. 이 절차에는 둘을 견주는 단계가 없으므로, 주입 절에서 `deploy/lab/k8s/bff-redis.yaml` 을 열었을 때 그 칸을 눈으로 확인한다. 만들어진 값을 되읽는다. ```bash label="[lab host] ④ realm 설정 세 칸만 뽑아 본다" kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ get realms/keycloak-patterns --fields realm,enabled,accessTokenLifespan ``` **예상 결과** — 비밀번호 길이는 `19` 다(observed). realm 조회는 `realm` 과 `enabled` 와 `accessTokenLifespan` 세 칸만 돌려준다. **왜 필요한가** — `accessTokenLifespan=60` 은 B-3 을 위해 미리 짧게 잡는 값이다. 만료를 기다리는 시간이 짧아야 refresh 경쟁이 재현되고, 여기서 정해 두면 나중에 realm 을 다시 안 만든다. **문제가 생기면** — `kcadm` 이 `401` 이면 `config credentials` 를 안 했거나 세션이 만료된 것이므로 ① 부터 다시 친다. ### 4. 로그인할 사용자를 만든다 **목적** — 브라우저에서 실제로 로그인할 계정을 하나 둔다. 사용자를 만든다. ```bash label="[lab host] ① 사용자를 만든다" kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ create users -r keycloak-patterns -s username=labuser -s enabled=true ``` 비밀번호를 준다. ```bash label="[lab host] ② 비밀번호를 준다" 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' ``` ```bash label="[lab host] ②b 계정을 완전하게 만든다 — 안 하면 B-3 에서 막힌다" U=$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ get users -r keycloak-patterns -q username=labuser --fields id --format csv --noquotes | tr -d '\r') kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ update users/$U -r keycloak-patterns \ -s emailVerified=true -s email=labuser@example.invalid \ -s firstName=Lab -s lastName=User -s 'requiredActions=[]' ``` **①②만 치면 계정이 「완전히 설정되지 않은」 상태로 남는다.** `create users` 는 `emailVerified` 를 `false` 로 두고 이름과 이메일 칸을 비운 채 만든다. 그 상태로 B-3 이 `grant_type=password` 를 치면 이렇게 끝난다(2026-09-17, observed). ```text {"error":"invalid_grant","error_description":"Account is not fully set up"} ``` **`$U` 를 쓸 때 변수 이름을 `UID` 로 짓지 않는다.** zsh 에서 `UID` 는 읽기 전용 숫자 변수라 `bad math expression` 으로 끝난다(2026-09-17, observed). 위처럼 `U` 같은 다른 이름을 쓴다. ②b 까지 치면 같은 요청이 토큰을 돌려준다(observed). **★ 뒤의 편들이 이 값을 그대로 쓴다.** 이 실험대는 `labpass` 를 썼고, B-3 과 B-6 의 토큰 요청이 `-d password=labpass` 로 그 값을 박아 놓고 있다. **여기서 다른 값을 정했으면 그 자리들도 같이 바꿔야 한다** — 안 바꾸면 B-3 의 첫 토큰 요청이 `401` 로 떨어지고, 그것이 주입이 안 걸린 것처럼 보인다. **예상 결과** — 두 명령 다 조용히 끝난다. **왜 필요한가** — 이 비밀번호는 브라우저에 직접 칠 값이므로 따라 하는 사람이 정한다. 위 값은 예시이고, 실제로 쓸 값을 셸 히스토리에 안 남기려면 `kcadm.sh` 를 대화식으로 쓰거나 나중에 관리 콘솔에서 바꾼다고 원 가이드가 적는다. **문제가 생기면** — realm 을 통째로 지우면 이 단계가 만든 것이 함께 사라진다. ```bash label="[lab host] realm 을 통째로 지운다" kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ delete realms/keycloak-patterns ``` ## 주입 주입은 둘이다. 첫째가 소스를 B-0 시점으로 되돌리는 일이고 둘째가 배포다. ### 1. 파일 넷을 편집기로 열어 B-1·B-2 가 넣은 것을 뺀다 **목적** — 자동구성이 고를 기회를 만든다. 빈을 직접 만들어 두면 무엇을 골랐는지 재는 실험이 성립하지 않는다. 무엇을 왜 지우는지 읽으면서 고쳐야 하는 파일이라 넷 다 편집기로 연다. 의존성을 뺀다. ```bash label="[워크스테이션] ① 빌드 파일을 연다" 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` 용 | 명시 빈을 지운다. ```bash label="[워크스테이션] ② 보안 설정을 연다" 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/**` 를 열어 주는 것이 그 안에 있고, 없으면 관찰 절이 전부 로그인 페이지를 받는다. 설정 블록을 뺀다. ```bash label="[워크스테이션] ③ 애플리케이션 설정을 연다" vim bff/src/main/resources/application.yml ``` | 지울 블록 | 왜 | |---|---| | `spring.session` | `store-type` 기본값이 `redis` 다. 남겨 두면 의존성만 빼도 경고가 난다 | | `spring.data.redis` | Redis 연결 설정 | | `spring.datasource` · `spring.sql.init` | B-2 의 JDBC 용 | 매니페스트에서 환경변수 여섯을 뺀다. ```bash label="[워크스테이션] ④ 배포 매니페스트를 연다" 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 label="[워크스테이션] ⑤ 바뀐 파일과 실제 diff 를 본다" git diff --stat git diff bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java ``` **예상 결과** — `git diff --stat` 에 위 네 파일만 나온다. **왜 필요한가** — 하나라도 덜 지우면 그 위에서 잰 빈 목록이 B-1 이나 B-2 의 것이 된다. 관찰 절의 `321` 이 다른 숫자로 나오면 여기로 돌아온다. **문제가 생기면** — `git diff --stat` 에 다섯 번째 파일이 보이면 다른 실험의 변경이 섞인 것이므로 그 파일만 `git checkout` 으로 되돌린다. ### 2. 이미지를 빌드해 두 노드에 각각 밀어 넣는다 **목적** — 고친 소스를 두 노드가 다 쓸 수 있는 이미지로 만든다. 빌드 로그를 파일로 받는다. ```bash label="[워크스테이션] ① 전체 로그를 파일로 받는다" docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1 echo "exit=$?" ``` 실패했으면 로그에서 원인 줄만 뽑는다. ```bash label="[워크스테이션] ② 테스트 결과와 예외만 골라 본다" grep -nE "Tests run|Caused by|\.java:[0-9]" /tmp/build.log ``` 이미지를 두 노드에 각각 넣는다. 레지스트리가 없으므로 한 노드에만 넣으면 나머지 replica 가 안 뜬다. ```bash label="[워크스테이션] ③ 이 실험대가 실제로 친 형태다" 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 label="[kc-lab-1] ④ 두 노드의 이미지 목록을 본다" sudo k3s ctr images ls | grep keycloak-pattern-bff ssh kc-lab-2 'sudo k3s ctr images ls | grep keycloak-pattern-bff' ``` **예상 결과** — 빌드가 성공하면 `exit=0` 이고, ④ 는 두 노드 모두에서 `keycloak-pattern-bff:lab` 줄을 낸다. 원래 실행이 여기서 만난 빌드 실패는 이렇게 보였다(observed). ```text org.yaml.snakeyaml.constructor.SafeConstructor.processDuplicateKeys ``` `management:` 아래에 `endpoint:` 블록을 하나 더 넣어서 난 오류다. 이미 있는데 또 넣었다. `yamllint` 는 이 실험대에 없고 YAML 중복 키는 빌드가 잡아 주는데, 그 메시지를 보려면 위처럼 전체 로그를 받아야 한다. **왜 필요한가** — `docker build` 기본 출력은 마지막 몇 줄만 보여 주고 Maven 스택트레이스는 그 위에 있다. 그래서 `--progress=plain` 과 파일로 받는 것을 함께 쓴다. ③ 의 두 줄은 한 줄에 `ssh` 가 두 겹이고 원격 셸의 인용이 겹쳐 있어 따라 하는 사람이 나눠 치고 싶어지는데, 원 가이드가 나눈 형태를 적어 두지 않아 여기에도 없다(unknown). 없는 명령을 지어내지 않는다. **문제가 생기면** — 이미지가 한쪽에만 있으면 그 노드에 스케줄된 replica 만 뜨고 나머지는 `ErrImageNeverPull` 로 나타난다. ③ 의 두 줄 중 빠진 쪽을 다시 친다. ### 3. 배포한다 **목적** — Redis 와 BFF 를 올린다. Redis 는 올리기만 하고 BFF 에 연결하지 않는다. 매니페스트를 적용하고 롤아웃이 끝날 때까지 기다린다. ```bash label="[lab host] ① 적용하고 두 롤아웃을 기다린다" kubectl apply -f deploy/lab/k8s/bff-redis.yaml kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s ``` **예상 결과** — 실측은 이렇다(observed, `01-deploy.txt`). ```text === 배포 === 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 ``` 배포된 모양은 이렇다. ```text 브라우저 ──https──▶ nginx ──▶ Traefik ──▶ bff (2 replica) │ ├──▶ Keycloak (realm: keycloak-patterns) └──▶ echo (resource server 대역) redis ── kc-lab-2 (postgres 와 같은 노드) ← 아직 연결하지 않았다 ``` **왜 필요한가** — 브라우저가 가는 주소와 BFF 가 서버끼리 부르는 주소를 나눠 둔 것도 이 매니페스트다. ```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 이 거부한다. **문제가 생기면** — 롤아웃이 타임아웃으로 끝나면 `describe pod` 의 Events 를 본다. `ErrImageNeverPull` 이면 이미지 import 로 돌아가고 `Pending` 이면 노드 메모리를 본다. ## 주입 검증 결과를 읽기 전에 주입이 의도한 것을 정확히 했는지 먼저 본다. ### 파드 두 개가 서로 다른 노드에 떴는가 ```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, `01-deploy.txt`). ```text bff-574c6d658b-8cz4x true kc-lab-1 bff-574c6d658b-zpkbp true kc-lab-2 redis-568bd7c4-5c5vc true kc-lab-2 ``` BFF 두 개가 서로 다른 노드에 있어야 한다. `topologySpreadConstraints` 가 그 일을 한다. 다른 인스턴스가 진짜 다른 기계여야 이 층의 질문이 성립하고, 같은 노드의 다른 프로세스면 재는 값이 절반만 뜻을 갖는다. ### 외부 진입점이 이 애플리케이션의 HTML 을 주는가 ```bash label="[lab host] 상태 줄과 헤더를 읽는 형태로 친다" curl -I https://app1.hyeonworks.com/ ``` 실측은 `https://app1.hyeonworks.com/ HTTP 200` 이고(observed, `02-autoconfiguration.txt`), 응답 머리는 이렇게 생겼다. ```text HTTP/2 200 content-type: text/html ``` 상태 줄과 `content-type` 을 같이 본다. `200` 이 왔다고 그게 이 애플리케이션의 HTML 이라는 보장이 없다. 원래 실행은 `/actuator/beans` 를 불렀을 때 `200` 을 받았는데 내용은 Keycloak 로그인 페이지였다. `-L` 로 리다이렉트를 따라간 결과다. `-o /dev/null -w '%{http_code}'` 만 쓰면 그 차이가 안 보이므로 여기서는 읽는 형태인 `-I` 를 쓰고, 여러 번 재서 비교할 때만 뽑는 형태로 바꾼다. ## 관찰 ### 1. 빈 목록을 파드 안에서 받는다 **무엇을 보는가** — 자동구성이 실제로 만든 빈 전부. `/actuator/beans` 는 117KB 이고 nginx 와 Traefik 을 거치면서 실패한다(observed, 해설 문서 1절). ```text $ curl https://app1.hyeonworks.com/actuator/beans Bad Gateway ``` 그래서 파드 안에서 직접 받는다. alpine 기반 JRE 이미지에는 `wget` 이 들어 있어서 Keycloak 이미지와 달리 파드 안에서 HTTP 요청을 보낼 수 있다. ```bash label="[lab host] ① 받을 파드 이름을 잡는다" kubectl -n keycloak-lab get pods -l app=bff BFF=$(kubectl -n keycloak-lab get pod -l app=bff \ --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') echo "$BFF" ``` ```bash label="[lab host] ② 파드 안에서 받아 크기를 센다" kubectl -n keycloak-lab exec "$BFF" -- \ wget -qO- http://localhost:8083/actuator/beans > /tmp/beans.json wc -c /tmp/beans.json ``` **어디를 보나** — 크기가 10만 바이트 대여야 한다. 모양은 이렇다(observed). ```text 119552 /tmp/beans.json ``` `0` 이면 못 받은 것이고 몇 백 바이트면 로그인 페이지나 오류 본문이다. 앞부분을 열어 확정한다. ```bash label="[lab host] ③ 앞 200바이트만 본다" 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 ``` `jq` 가 깔려 있으면 그것을 쓴다고 원 가이드가 적는데 어떤 표현을 쓰라고는 적지 않아 그 형태는 여기에도 없다(unknown). 없는 도구를 전제로 한 명령은 진단 도중에 패키지를 깔러 나가게 만든다. **어디를 보나** — ② 의 출력은 이렇게 생겼다(observed). 2026-09-17 에는 저장소를 **붙인 상태**(B-1·B-2 를 거친 매니페스트)에서 쳐서 이렇게 나왔다(observed). ```text 빈 개수: 437 authorizedClientService -> org.springframework.security.oauth2.client.JdbcOAuth2AuthorizedClientService authorizedClientRepository -> …AuthenticatedPrincipalOAuth2AuthorizedClientRepository RedisSessionConfiguration -> org.springframework.boot.autoconfigure.session.RedisSessionConfiguration ``` **`321` 이 아니라 `437` 인 것이 이 측정이 쓸모 있다는 증거다.** B-0 의 `321` 은 저장소를 하나도 안 붙였을 때의 수이고, Redis 세션과 JDBC 토큰 저장소가 붙으면 빈이 그만큼 늘어난다. 이 편을 B-0 상태에서 재려면 위 「주입」 절의 되돌리기를 먼저 끝내야 한다 — **빈 수 하나로 지금 어느 상태인지 알 수 있다.** ```text "authorizedClientManager" -> org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager "authorizedClientRepository" -> org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository "authorizedClientService" -> org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService ``` 정리한 실측은 이렇다(observed, `03-beans-analysis.txt`). ```text 컨텍스트: keycloak-bff 전체 빈 수: 321 ``` ```text --- 세션 · 토큰 저장소 관련 --- authorizedClientManager -> AuthorizedClientServiceOAuth2AuthorizedClientManager authorizedClientManagerRegistrar -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerRegistrar authorizedClientRepository -> AuthenticatedPrincipalOAuth2AuthorizedClientRepository authorizedClientService -> InMemoryOAuth2AuthorizedClientService clientRegistrationRepository -> InMemoryClientRegistrationRepository --- Redis / Spring Session 이 구성되었는가 --- ★ 없음 — Redis 도 Spring Session 도 구성되지 않았다 ``` 없다는 것은 세어서 확인한다. ```bash label="[lab host] ③ Redis · Spring Session 계열 빈을 센다" grep -ci 'RedisSessionRepository\|SpringHttpSessionConfiguration\|LettuceConnectionFactory' /tmp/beans.json ``` `0` 이 나온다(observed). 의존성 자체가 없으니 자동구성이 걸릴 조건이 없다. 세션은 서블릿 컨테이너인 Tomcat 의 기본 `StandardSession` 에 있고 그것이 인스턴스 메모리다. **이 값이 뜻하는 것** — 다섯 줄을 하나씩 읽으면 이렇다. | 빈 | 구현체 | 뜻 | |---|---|---| | `authorizedClientService` | `InMemoryOAuth2AuthorizedClientService` | 프로세스 메모리. 재시작하면 사라진다 | | `authorizedClientRepository` | `AuthenticatedPrincipalOAuth2AuthorizedClientRepository` | principal 기준 조회. session ID 가 없다 | | `authorizedClientManager` | `AuthorizedClientServiceOAuth2AuthorizedClientManager` | service 쪽을 쓴다 | | `clientRegistrationRepository` | `InMemoryClientRegistrationRepository` | 설정에서 읽은 것 | | SessionRepository | 없음 | Tomcat 의 기본 `StandardSession` | | Redis · Spring Session | 없음 | 의존성 자체가 없다 | `AuthenticatedPrincipalOAuth2AuthorizedClientRepository` 는 이름이 곧 설명이다. 인증된 요청이면 `OAuth2AuthorizedClientService` 에 위임하고, 그 서비스가 쓰는 키에 session ID 가 없다. ```text 요청이 인증되어 있으면 └─▶ OAuth2AuthorizedClientService 에 위임 └─▶ 키: (clientRegistrationId, principalName) └─ session ID 가 없다 ★ 인증되어 있지 않으면 └─▶ HttpSession 에 임시 보관 ``` 같은 사용자가 두 브라우저에서 로그인하면 principalName 이 같으므로 같은 항목을 보고, 한쪽에서 토큰을 갱신하면 다른 쪽 것을 덮어쓴다. Redis 를 붙여도 이건 안 고쳐진다. 저장소를 공유해도 키에 session ID 가 없기 때문이다. 메모리에 있겠거니 하는 데까지는 추측으로 맞혀도, 조회 키가 무엇인지는 빈 이름을 봐야 안다. ### 3. 브라우저로 로그인해 본다 **무엇을 보는가** — replica 2 에서 로그인이 되는지. 원 가이드가 예상 못 한 것으로 적어 둔 부분이다. 브라우저에서 `https://app1.hyeonworks.com/` 을 열고 로그인한다. **어디를 보나** — 주소창이 이렇게 끝난다(observed). ```text https://app1.hyeonworks.com/login?error ``` 로그를 본다. ```bash label="[lab host] 두 replica 의 로그를 접두사와 함께 본다" kubectl -n keycloak-lab logs -l app=bff --tail=100 --prefix ``` 아무 오류도 없다. Spring Security 는 로그인 실패를 DEBUG 로만 남긴다. 로그에 아무것도 없으니 애플리케이션 문제가 아니라고 읽으면 틀린다. 증상은 있는데 로그가 없고, 그럴 때는 가설을 세워 시험한다. **이 값이 뜻하는 것** — 인가 코드 흐름은 왕복이 두 번이고 두 번 다 같은 인스턴스로 가야 한다. ```text ① 브라우저 → 앱 → IdP 로 리다이렉트 (state·PKCE verifier 를 저장) ② IdP → 브라우저 → 앱의 콜백 (저장한 것을 꺼내 검증) ``` 저장 위치가 `HttpSession` 이고 그것이 인스턴스 메모리이므로 콜백이 다른 replica 로 가면 저장된 인가 요청이 없어 실패한다. 앞에서 본 SessionRepository 없음이 이 가설의 근거다. ### 4. replica 를 1 로 줄여 가설을 시험한다 **목적** — 왕복 두 번이 같은 인스턴스로 가게 만들어 가설을 가른다. replica 를 하나로 줄인다. ```bash label="[lab host] ① replica 를 1 로 줄인다" kubectl -n keycloak-lab scale deployment/bff --replicas=1 kubectl -n keycloak-lab rollout status deployment/bff --timeout=180s kubectl -n keycloak-lab get pods -l app=bff ``` 브라우저에서 쿠키를 먼저 지우고 다시 로그인한다. **예상 결과** — 실측은 이렇다(observed, `b0-bff-login-success-single-replica.png`). ```text replica 2 + 스티키 없음 → 로그인 실패 (콜백이 다른 인스턴스로) replica 1 → 로그인 성공 ``` **왜 필요한가** — 가설이 확정된다. 다중 인스턴스에서 어떻게 운영할 것인가는 로그인한 뒤의 문제가 아니라 로그인 자체의 문제이고, B-2 의 검증 1번인 한쪽에서 로그인한 뒤 다른 인스턴스로 요청하기보다 앞선 단계다. 로그인이 끝나야 그 검증을 하는데 로그인부터 막힌다. **문제가 생기면** — replica 1 에서도 `/login?error` 가 뜨면 쿠키를 안 지우고 다시 로그인했다. 앞선 실패의 세션이 섞이면 이 시험이 가르는 것이 없어진다. ### 5. 토큰 경계를 읽는다 **무엇을 보는가** — 브라우저와 서버 중 어느 쪽이 토큰을 들고 있는지. 브라우저에서 `https://app1.hyeonworks.com/bff/token-boundary` 를 연다. **어디를 보나** — 실측은 이렇다(observed, `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` | 브라우저에는 토큰이 하나도 없다 | **이 값이 뜻하는 것** — 브라우저는 세션 쿠키만 들고 있고 토큰은 전부 서버에 있다. 이 세 값을 적어 둬야 B-1 에서 무엇이 바뀌는지 읽을 수 있다. ## 복구와 원상복구 확인표 ### 1. replica 를 되돌린다 ```bash label="[lab host] replica 를 2 로 올린다" kubectl -n keycloak-lab scale deployment/bff --replicas=2 kubectl -n keycloak-lab rollout status deployment/bff --timeout=180s ``` B-1 로 이어서 갈 것이라면 배포는 그대로 둔다. 거기서 같은 파드에 Redis 를 붙인다. ### 2. 소스를 되돌린다 ```bash label="[워크스테이션] 네 파일을 되돌리고 확인한다" 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 구성이 다시 배포된다. 소스만 되돌리고 끝내면 클러스터에는 여전히 B-0 이미지가 도는데, 다음 편이 곧바로 다시 빌드하므로 B-1 로 이어 갈 때는 재빌드가 그 편의 첫 단계다. 여기서 멈출 것이면 되돌린 소스로 한 번 더 빌드해 두 노드에 다시 import 해야 클러스터가 소스와 같아진다. ### 3. 전부 지운다 **B-1 이나 B-2 로 이어서 갈 것이면 이 절을 치지 않는다.** B-1 은 「Redis 는 배포만 되어 있고 아직 연결되지 않았다. B-0 이 그렇게 만들어 뒀다」를 전제로 시작하고 B-2 는 그 위에서 시작한다. 아래 두 줄은 `bff` 와 `redis` Deployment 를 PVC 까지, realm `keycloak-patterns` 를 `labuser` 까지 한꺼번에 없앤다. 치고 나면 B-1 은 배포와 realm 과 사용자를 다시 만드는 데서 시작해야 하는데 그 순서는 B-1 에 안 적혀 있고 이 편의 주입 절과 realm·사용자 단계로 되돌아와야 한다. B층을 여기서 끝낼 때만 친다. ```bash label="[lab host] 배포와 realm 을 지운다" kubectl delete -f deploy/lab/k8s/bff-redis.yaml kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ delete realms/keycloak-patterns ``` 아래 확인표는 §1 과 §2 까지만 마친 상태를 본다 — 배포는 살아 있고 replica 가 2이며 소스가 깨끗한 상태다. §3 을 친 뒤에 이 표를 돌리면 `get deploy bff` 가 `NotFound` 를 내고 밖의 `curl -I` 도 `200` 을 못 낸다. 그때는 표가 틀린 것이 아니라 잴 대상이 없어졌으므로, §3 을 쳤으면 표를 건너뛰고 마지막 줄의 임시 파일만 지운다. | 항목 | 명령 | 돌아왔을 때 | |---|---|---| | replica | `kubectl -n keycloak-lab get deploy bff` | `2/2` | | 소스 | `git status --short` | 출력 없음 | | 파드 | `kubectl -n keycloak-lab get pods -o wide -l app=bff` | 두 노드에 하나씩 | | Keycloak | `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` | — | 마지막 줄의 `rm -f /tmp/beans.json /tmp/build.log` 는 한 줄인데 두 파일이 서로 다른 기계에 있다. `/tmp/beans.json` 은 `kc-lab-1` 에서 만들었고 `/tmp/build.log` 는 워크스테이션에서 만들었으므로 각 기계에서 자기 쪽 파일을 지운다. actuator 를 열어 둔 채로 두지 않는다. `/actuator/beans` 와 `/actuator/env` 는 내부 구조와 설정값을 그대로 드러낸다. 실험대라서 여는 것이고 운영이라면 `health` 만 남긴다고 원 가이드가 적는다. ## 막히면 원 가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다고 적는다. | 증상 | 원인 | 확인 | |---|---|---| | 빈 목록에 `RedisSessionRepository` 가 있다 | B-1·B-2 배선을 덜 지웠다 | 주입 절의 네 파일을 다시. `git diff` 로 확인 | | `authorizedClientService` 가 `Jdbc...` 다 | `SecurityConfig` 의 명시 빈을 안 지웠다 | 같은 절의 둘째 파일 | | `/actuator/beans` 가 `Bad Gateway` | 응답이 117KB 라 프록시가 못 넘긴다 | 파드 안에서 받는다 | | `/actuator/beans` 가 `200` 인데 HTML | Keycloak 로그인 페이지다 | `head -c 200` 으로 내용 확인 | | `beans.json` 이 0 바이트 | 파드 이름이 틀렸거나 포트가 다르다 | `get pods -l app=bff`, 포트는 `8083` | | 빌드가 실패하는데 원인이 안 보인다 | 마지막 15줄에 없다 | `--progress=plain` + 파일 | | 테스트에서 컨텍스트가 안 뜬다 | 환경변수에 기본값이 없다 | `grep -n 'KC_ISSUER' bff/src/main/resources/application.yml` | | 파드가 `ErrImageNeverPull` | 그 노드에 이미지가 없다 | 두 노드에 각각 import | | 파드가 `Pending` | 노드 메모리 부족 | `describe pod` Events, `top nodes` | | 브라우저가 `/login?error` | replica 2 인데 스티키 세션이 없다 | replica 1 로 줄여 확인 | | BFF 로그에 오류가 없다 | Spring Security 는 로그인 실패를 DEBUG 로만 남긴다 | 로그 없음을 문제 없음으로 읽지 않는다 | | 로그인 후 리다이렉트가 `http://` 로 간다 | `SERVER_FORWARD_HEADERS_STRATEGY` 가 없다 | 매니페스트 env 확인 | | `jq: command not found` | 이 실험대에 `jq` 가 없다 | `grep` 으로 읽는다 | | `kcadm` 이 `401` | `config credentials` 를 안 했거나 만료됐다 | realm 준비 단계를 다시 | 원래 실행이 겪은 것 가운데 둘은 소스 쪽 사고였다. 하나는 `bff/target/classes/...` 9개 파일만 커밋되어 있고 `bff/src/` 가 없던 상태다. `.gitignore` 에 `target/` 이 없어 클래스 파일만 들어갔고 소스는 다른 브랜치에 있었다. 빌드 산출물이 커밋되어 있으면 빌드는 되는데 소스를 바꿔도 결과가 안 바뀐다. ```bash label="[워크스테이션] ① 소스가 실제로 있는지 본다" ls bff/src/main/java/com/example/keycloakpattern/bff/ ``` ```bash label="[워크스테이션] ② 소스가 있는 브랜치에서 가져온다" git checkout origin/develop-keycloak-pattern3 -- bff/ ``` 다른 하나는 환경변수에 기본값이 없어 테스트가 죽은 것이다. 테스트는 그 환경변수를 모른다. ```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 ``` ## 이 실험대에서 아직 못 밟은 단계 2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.** - **남은 것** — 브라우저 로그인(3절 뒤 전부)과 그 로그인이 만드는 `/actuator/beans` 재측정. 지금까지 밟은 것 — 이미지 빌드·반입, realm·클라이언트·사용자 생성, 매니페스트 적용, 빈 437개 확인. - **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다. - **그때까지 이 편의 실측 가운데 `(observed)` 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다. ## 무엇이 관측이고 무엇이 아닌가 이 절차의 숫자는 `2026-09-04 13:39–13:46 KST` 에 돈 한 번의 실행에서 나왔다(observed). - (observed) 배포 전 노드 자원 `44%` 와 `33%`, 배포 출력 전문, 파드 세 줄과 그 노드 배치, 외부 진입점 `HTTP 200`, 전체 빈 수 `321`, 저장소 관련 빈 다섯 줄과 「★ 없음」, `/actuator/beans` 가 117KB 이고 프록시에서 `Bad Gateway` 인 것, 비밀번호 길이 `19`, `token-boundary` 의 세 값, replica 2 에서 `/login?error` 이고 replica 1 에서 로그인이 되는 것. - (unknown) 빈을 세는 `grep -o '"aliases":\['` 줄과 이름·타입을 한 줄로 뽑는 `grep`·`sed` 줄. 원 가이드가 미검증으로 표시했다. `jq` 로 같은 것을 읽는 형태와, 두 겹 `ssh` 를 나눠 치는 형태는 가이드에 없다. - (observed) 파이썬 한 줄로 JSON 을 파싱하려다 난 `SyntaxError` 도 측정 기록에 있다. 그 시도가 깨진 뒤 `grep` 형태로 다시 받았고, 위에 실은 빈 목록이 그 결과다. - (observed) 빌드 로그의 `processDuplicateKeys` 는 `management:` 아래에 `endpoint:` 를 한 번 더 넣어서 난 것이다. `yamllint` 가 이 실험대에 없어 빌드가 그 오류를 처음 알렸다. - 이 실험이 재지 않은 것 하나 — 스티키 세션을 켜면 replica 2 에서 로그인이 되는지는 재지 않았다. 같은 인스턴스로 보내면 된다는 것은 추론이고 측정이 아니다.