기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만 있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문 종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다. SSOT 결함 둘을 고쳤다. - kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이 「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽 `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다. - virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데 원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다. 기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은 둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더 든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다). 계약을 셋 고쳤다. - kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/** 28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는 것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온 것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수 대조했을 때 가장 가까운 커밋도 28개가 어긋났다). - virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고 재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다. - kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고 넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다. style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져 (실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고, engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고 있었다(Question 기록에서 11.94 → 3.86). verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
42 KiB
id, kind, slug, title, topic, topicName, project, status, studio, pinnedVersions, source, sourceRevision
| id | kind | slug | title | topic | topicName | project | status | studio | pinnedVersions | source | sourceRevision | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 4ef91d43-1f1d-481a-8b6f-5e015d068578 | SETUP | reproduce-b0-default-session-store | 아무 저장소도 주지 않고 Spring 이 무엇을 고르는지 찍어서 확인한다 | where-application-state-lives | 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기 | keycloak-session-store | 게시 전 | https://hyeonworks.com/studio/documents/4ef91d43-1f1d-481a-8b6f-5e015d068578/edit |
|
|
cdac9b8178391311d8eca1ebc6cac15bb62d79af |
아무 저장소도 주지 않고 Spring 이 무엇을 고르는지 찍어서 확인한다
저장소를 하나도 붙이지 않은 BFF 를 두 노드에 배포하고 /actuator/beans 로 Spring 이 무엇을 골랐는지 찍어 보는 절차다. B층 뒤의 여덟 편이 이 배포 위에 서므로 Redis 는 띄우기만 하고 연결하지 않는다. 전 구간 약 40~60분이고 빌드 시간이 들어 있다.
관계
- 세션만 Redis 로 옮기자 토큰이 따라오지 않았다 이 절차가 뽑아 둔 빈 세 개의 이름이 그 기록에서 after 와 견주는 값이 된다. 무엇을 발견했는지는 그쪽에 있고 여기에는 치는 순서만 있다.
- 세션과 인가된 클라이언트는 조회 키가 다르다
AuthenticatedPrincipalOAuth2AuthorizedClientRepository라는 이름 하나가 왜 이 층 전체의 문제인지를 그 기록이 설명한다. - 저장소를 옮기기 전에 조회 키를 본다 여기서 빈 이름을 먼저 찍는 순서를 규칙으로 굳힌 기록이다.
- Redis 를 붙이고 무엇이 옮겨졌는지 빈 목록으로 견준다 바로 다음 편이다. 이 절차가 만든 배포에 Redis 를 연결하고 같은 명령을 다시 친다.
- 토큰을 PostgreSQL 로 옮기고 기본키와 로그아웃 정리를 확인한다 여기서 이름으로 짐작한 조회 키가 그 편에서 테이블 정의로 확정된다.
본문
읽기 전에 — 어디서 치는가
명령이 두 기계에 나뉜다. 소스를 고치고 이미지를 만드는 일은 워크스테이션에서 하고, 클러스터를 보고 배포하는 일은 kc-lab-1 에서 kubectl 과 kcadm 으로 한다. 그래서 코드블록마다 어디서 치는지를 붙여 두었다.
시작 전에 셋을 스스로 정해 둔다. 그 명령이 원 가이드에 없다(unknown).
첫째, 워크스테이션에서 kc-lab-1 로 건너가는 명령이 이 절차에 없다. 라벨은 [워크스테이션] 과 [kc-lab-1] 을 여섯 번 오가는데 ssh 로 들어가는 줄도 exit 도 안 나온다. 경로는 이미지를 밀어 넣는 줄 하나에만 드러난다 — ssh test-server "ssh kc-lab-1 '...'", 워크스테이션에서 kc-lab-1 까지 test-server 를 거친다. [워크스테이션] 블록은 처음 시작한 셸에서 치고 [kc-lab-1] 블록은 그 기계에 붙은 셸에서 친다.
둘째, 이 절차의 파일 경로가 전부 저장소 상대경로다 — bff/pom.xml, deploy/lab/k8s/bff-redis.yaml. 어느 디렉터리에서 치는지 정하는 줄이 없으므로 두 기계 각각에서 저장소 루트로 먼저 옮겨 두고 시작한다. 다른 디렉터리에서 치면 kubectl apply 가 경로를 못 찾고 끝난다.
셋째, 워크스테이션에서 고친 파일을 kc-lab-1 로 옮기는 단계가 없다. vim deploy/lab/k8s/bff-redis.yaml 은 [워크스테이션] 이고 그 파일을 읽는 kubectl apply -f deploy/lab/k8s/bff-redis.yaml 은 [kc-lab-1] 인데, 사이에 파일을 넘기는 명령이 원 가이드에 없다. 안 옮기고 치면 오류가 안 난다 — 손 안 댄 매니페스트가 그대로 적용돼 SPRING_SESSION_STORE_TYPE=redis 와 BFF_DB_* 가 살아 있는 채로 배포되고, 화면에는 배포 성공만 뜬다. 어긋난 것은 관찰 절의 빈 수가 321 이 아닌 다른 숫자로 나올 때 비로소 보인다.
kubectl 에 sudo 를 붙이지 않는다. root 홈에는 ~/.kube/config 가 없어 localhost:8080 으로 붙으려다 connection refused 로 끝난다. 반입한 B층 아홉 편의 전제 한 줄만 옛 형태로 sudo kubectl 을 적고 있고, 본문 명령 블록에는 한 번도 쓰지 않는다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | 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 의 자동구성 결과까지 봐야 알 수 있다. 원 가이드는 그 문장을 그대로 인용해 시작한다.
빈을 직접 만들지 않으면
└─ 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 시점의 파일이 없다고 원 가이드가 적어 두었고, 그래서 주입 절의 첫 단계가 손으로 되돌리는 일이다.
되돌리기는 둘이고 둘 다 시작 전에 읽어 둔다.
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
kubectl delete -f deploy/lab/k8s/bff-redis.yaml
PVC 는 delete -f 로 같이 지워진다. Redis 데이터도 함께 사라진다.
주입 전에 같은 명령으로 먼저 본다
넓은 것부터 좁혀 간다.
노드 자원 → 네임스페이스에 무엇이 있나 → Keycloak realm → 사용자
1. 노드에 BFF 두 개가 들어갈 자원이 있는가
무엇을 보는가 — 두 노드의 메모리와 CPU 여유.
free -m
kubectl top nodes
어디를 보나 — 실측은 이렇다(observed, 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 이거나 메모리 부족으로 죽는데, 그때 증상을 Spring 설정 문제로 읽게 된다.
2. 네임스페이스에 앞 실험의 잔재가 있는가
무엇을 보는가 — 지금 무엇이 떠 있는지.
kubectl -n keycloak-lab get all
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 이미지 안에 있다.
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)"
비밀번호를 화면에 찍지 않는다. 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 남지 않는다. 길이만 센다.
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
realm 과 클라이언트를 만든다.
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 'redirectUris=["https://app1.hyeonworks.com/*"]'
-s secret=bff-lab-secret 은 Keycloak 쪽에 저장되는 값이고, BFF 가 실제로 보내는 값은 배포 매니페스트가 만드는 Secret bff-secrets 의 KEYCLOAK_CLIENT_SECRET 이다. 둘이 같아야 로그인이 끝까지 간다. 이 절차에는 둘을 견주는 단계가 없으므로, 주입 절에서 deploy/lab/k8s/bff-redis.yaml 을 열었을 때 그 칸을 눈으로 확인한다.
만들어진 값을 되읽는다.
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. 로그인할 사용자를 만든다
목적 — 브라우저에서 실제로 로그인할 계정을 하나 둔다.
사용자를 만든다.
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
create users -r keycloak-patterns -s username=labuser -s enabled=true
비밀번호를 준다.
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'
★ 뒤의 편들이 이 값을 그대로 쓴다. 이 실험대는 labpass 를 썼고, B-3 과 B-6 의 토큰
요청이 -d password=labpass 로 그 값을 박아 놓고 있다. 여기서 다른 값을 정했으면 그
자리들도 같이 바꿔야 한다 — 안 바꾸면 B-3 의 첫 토큰 요청이 401 로 떨어지고, 그것이
주입이 안 걸린 것처럼 보인다.
예상 결과 — 두 명령 다 조용히 끝난다.
왜 필요한가 — 이 비밀번호는 브라우저에 직접 칠 값이므로 따라 하는 사람이 정한다. 위 값은 예시이고, 실제로 쓸 값을 셸 히스토리에 안 남기려면 kcadm.sh 를 대화식으로 쓰거나 나중에 관리 콘솔에서 바꾼다고 원 가이드가 적는다.
문제가 생기면 — realm 을 통째로 지우면 이 단계가 만든 것이 함께 사라진다.
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
delete realms/keycloak-patterns
주입
주입은 둘이다. 첫째가 소스를 B-0 시점으로 되돌리는 일이고 둘째가 배포다.
1. 파일 넷을 편집기로 열어 B-1·B-2 가 넣은 것을 뺀다
목적 — 자동구성이 고를 기회를 만든다. 빈을 직접 만들어 두면 무엇을 골랐는지 재는 실험이 성립하지 않는다.
무엇을 왜 지우는지 읽으면서 고쳐야 하는 파일이라 넷 다 편집기로 연다.
의존성을 뺀다.
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 용 |
명시 빈을 지운다.
vim bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java
// 지운다 — B-2 가 넣은 것. 이게 있으면 자동구성이 고를 기회가 없다
@Bean
OAuth2AuthorizedClientService authorizedClientService(...) { ... }
// 지운다 — 이것도 직접 만들면 "자동구성이 골랐다" 가 아니다
@Bean
OAuth2AuthorizedClientManager authorizedClientManager(...) { ... }
관련 import(JdbcOAuth2AuthorizedClientService, JdbcOperations, 매니저 계열)도 같이 지운다. bffSecurity 빈은 남긴다. /actuator/** 를 열어 주는 것이 그 안에 있고, 없으면 관찰 절이 전부 로그인 페이지를 받는다.
설정 블록을 뺀다.
vim bff/src/main/resources/application.yml
| 지울 블록 | 왜 |
|---|---|
spring.session |
store-type 기본값이 redis 다. 남겨 두면 의존성만 빼도 경고가 난다 |
spring.data.redis |
Redis 연결 설정 |
spring.datasource · spring.sql.init |
B-2 의 JDBC 용 |
매니페스트에서 환경변수 여섯을 뺀다.
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
예상 결과 — git diff --stat 에 위 네 파일만 나온다.
왜 필요한가 — 하나라도 덜 지우면 그 위에서 잰 빈 목록이 B-1 이나 B-2 의 것이 된다. 관찰 절의 321 이 다른 숫자로 나오면 여기로 돌아온다.
문제가 생기면 — git diff --stat 에 다섯 번째 파일이 보이면 다른 실험의 변경이 섞인 것이므로 그 파일만 git checkout 으로 되돌린다.
2. 이미지를 빌드해 두 노드에 각각 밀어 넣는다
목적 — 고친 소스를 두 노드가 다 쓸 수 있는 이미지로 만든다.
빌드 로그를 파일로 받는다.
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
이미지를 두 노드에 각각 넣는다. 레지스트리가 없으므로 한 노드에만 넣으면 나머지 replica 가 안 뜬다.
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'
예상 결과 — 빌드가 성공하면 exit=0 이고, ④ 는 두 노드 모두에서 keycloak-pattern-bff:lab 줄을 낸다. 원래 실행이 여기서 만난 빌드 실패는 이렇게 보였다(observed).
org.yaml.snakeyaml.constructor.SafeConstructor.processDuplicateKeys
management: 아래에 endpoint: 블록을 하나 더 넣어서 난 오류다. 이미 있는데 또 넣었다. yamllint 는 이 실험대에 없고 YAML 중복 키는 빌드가 잡아 주는데, 그 메시지를 보려면 위처럼 전체 로그를 받아야 한다.
왜 필요한가 — docker build 기본 출력은 마지막 몇 줄만 보여 주고 Maven 스택트레이스는 그 위에 있다. 그래서 --progress=plain 과 파일로 받는 것을 함께 쓴다. ③ 의 두 줄은 한 줄에 ssh 가 두 겹이고 원격 셸의 인용이 겹쳐 있어 따라 하는 사람이 나눠 치고 싶어지는데, 원 가이드가 나눈 형태를 적어 두지 않아 여기에도 없다(unknown). 없는 명령을 지어내지 않는다.
문제가 생기면 — 이미지가 한쪽에만 있으면 그 노드에 스케줄된 replica 만 뜨고 나머지는 ErrImageNeverPull 로 나타난다. ③ 의 두 줄 중 빠진 쪽을 다시 친다.
3. 배포한다
목적 — Redis 와 BFF 를 올린다. Redis 는 올리기만 하고 BFF 에 연결하지 않는다.
매니페스트를 적용하고 롤아웃이 끝날 때까지 기다린다.
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).
=== 배포 ===
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
배포된 모양은 이렇다.
브라우저 ──https──▶ nginx ──▶ Traefik ──▶ bff (2 replica)
│
├──▶ Keycloak (realm: keycloak-patterns)
└──▶ echo (resource server 대역)
redis ── kc-lab-2 (postgres 와 같은 노드) ← 아직 연결하지 않았다
왜 필요한가 — 브라우저가 가는 주소와 BFF 가 서버끼리 부르는 주소를 나눠 둔 것도 이 매니페스트다.
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 이 거부한다.
문제가 생기면 — 롤아웃이 타임아웃으로 끝나면 describe pod 의 Events 를 본다. ErrImageNeverPull 이면 이미지 import 로 돌아가고 Pending 이면 노드 메모리를 본다.
주입 검증
결과를 읽기 전에 주입이 의도한 것을 정확히 했는지 먼저 본다.
파드 두 개가 서로 다른 노드에 떴는가
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).
bff-574c6d658b-8cz4x true kc-lab-1
bff-574c6d658b-zpkbp true kc-lab-2
redis-568bd7c4-5c5vc true kc-lab-2
BFF 두 개가 서로 다른 노드에 있어야 한다. topologySpreadConstraints 가 그 일을 한다. 다른 인스턴스가 진짜 다른 기계여야 이 층의 질문이 성립하고, 같은 노드의 다른 프로세스면 재는 값이 절반만 뜻을 갖는다.
외부 진입점이 이 애플리케이션의 HTML 을 주는가
curl -I https://app1.hyeonworks.com/
실측은 https://app1.hyeonworks.com/ HTTP 200 이고(observed, 02-autoconfiguration.txt), 응답 머리는 이렇게 생겼다.
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절).
$ curl https://app1.hyeonworks.com/actuator/beans
Bad Gateway
그래서 파드 안에서 직접 받는다. alpine 기반 JRE 이미지에는 wget 이 들어 있어서 Keycloak 이미지와 달리 파드 안에서 HTTP 요청을 보낼 수 있다.
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"
kubectl -n keycloak-lab exec "$BFF" -- \
wget -qO- http://localhost:8083/actuator/beans > /tmp/beans.json
wc -c /tmp/beans.json
어디를 보나 — 크기가 10만 바이트 대여야 한다. 모양은 이렇다(observed).
119552 /tmp/beans.json
0 이면 못 받은 것이고 몇 백 바이트면 로그인 페이지나 오류 본문이다. 앞부분을 열어 확정한다.
head -c 200 /tmp/beans.json ; echo
{"contexts":{"keycloak-bff":{"beans":{"actuatorEndpointsSupplier":{"aliases":[],"scope":"singleton","type":"org.springframework
이 값이 뜻하는 것 — {"contexts":{"keycloak-bff" 로 시작해야 한다. <!DOCTYPE html 로 시작하면 Keycloak 로그인 페이지를 받은 것이므로 bffSecurity 빈을 지우지 않았는지 본다.
2. 빈을 세고 저장소 관련 이름을 뽑는다
무엇을 보는가 — 전체 빈 수와 저장소 계열 빈의 구현체 이름.
빈 하나는 이런 모양이고 이름과 타입이 한 덩어리 안에 같이 있다.
"이름":{"aliases":[],"scope":"singleton","type":"패키지.클래스", ...}
이 실험대는 jq 가 없어 grep 으로 덩어리를 뽑았다. 원 가이드가 아래 두 줄을 미검증으로 표시했다(unknown).
grep -o '"aliases":\[' /tmp/beans.json | wc -l
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans.json \
| sed 's/{"aliases".*"type":"/ -> /' \
| grep -i authorizedclient
jq 가 깔려 있으면 그것을 쓴다고 원 가이드가 적는데 어떤 표현을 쓰라고는 적지 않아 그 형태는 여기에도 없다(unknown). 없는 도구를 전제로 한 명령은 진단 도중에 패키지를 깔러 나가게 만든다.
어디를 보나 — ② 의 출력은 이렇게 생겼다(observed).
"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).
컨텍스트: keycloak-bff
전체 빈 수: 321
--- 세션 · 토큰 저장소 관련 ---
authorizedClientManager -> AuthorizedClientServiceOAuth2AuthorizedClientManager
authorizedClientManagerRegistrar -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerRegistrar
authorizedClientRepository -> AuthenticatedPrincipalOAuth2AuthorizedClientRepository
authorizedClientService -> InMemoryOAuth2AuthorizedClientService
clientRegistrationRepository -> InMemoryClientRegistrationRepository
--- Redis / Spring Session 이 구성되었는가 ---
★ 없음 — 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 가 없다.
요청이 인증되어 있으면
└─▶ OAuth2AuthorizedClientService 에 위임
└─▶ 키: (clientRegistrationId, principalName)
└─ session ID 가 없다 ★
인증되어 있지 않으면
└─▶ HttpSession 에 임시 보관
같은 사용자가 두 브라우저에서 로그인하면 principalName 이 같으므로 같은 항목을 보고, 한쪽에서 토큰을 갱신하면 다른 쪽 것을 덮어쓴다. Redis 를 붙여도 이건 안 고쳐진다. 저장소를 공유해도 키에 session ID 가 없기 때문이다. 메모리에 있겠거니 하는 데까지는 추측으로 맞혀도, 조회 키가 무엇인지는 빈 이름을 봐야 안다.
3. 브라우저로 로그인해 본다
무엇을 보는가 — replica 2 에서 로그인이 되는지. 원 가이드가 예상 못 한 것으로 적어 둔 부분이다.
브라우저에서 https://app1.hyeonworks.com/ 을 열고 로그인한다.
어디를 보나 — 주소창이 이렇게 끝난다(observed).
https://app1.hyeonworks.com/login?error
로그를 본다.
kubectl -n keycloak-lab logs -l app=bff --tail=100 --prefix
아무 오류도 없다. Spring Security 는 로그인 실패를 DEBUG 로만 남긴다. 로그에 아무것도 없으니 애플리케이션 문제가 아니라고 읽으면 틀린다. 증상은 있는데 로그가 없고, 그럴 때는 가설을 세워 시험한다.
이 값이 뜻하는 것 — 인가 코드 흐름은 왕복이 두 번이고 두 번 다 같은 인스턴스로 가야 한다.
① 브라우저 → 앱 → IdP 로 리다이렉트 (state·PKCE verifier 를 저장)
② IdP → 브라우저 → 앱의 콜백 (저장한 것을 꺼내 검증)
저장 위치가 HttpSession 이고 그것이 인스턴스 메모리이므로 콜백이 다른 replica 로 가면 저장된 인가 요청이 없어 실패한다. 앞에서 본 SessionRepository 없음이 이 가설의 근거다.
4. replica 를 1 로 줄여 가설을 시험한다
목적 — 왕복 두 번이 같은 인스턴스로 가게 만들어 가설을 가른다.
replica 를 하나로 줄인다.
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).
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).
{"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 를 되돌린다
kubectl -n keycloak-lab scale deployment/bff --replicas=2
kubectl -n keycloak-lab rollout status deployment/bff --timeout=180s
B-1 로 이어서 갈 것이라면 배포는 그대로 둔다. 거기서 같은 파드에 Redis 를 붙인다.
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 구성이 다시 배포된다. 소스만 되돌리고 끝내면 클러스터에는 여전히 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층을 여기서 끝낼 때만 친다.
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/ 이 없어 클래스 파일만 들어갔고 소스는 다른 브랜치에 있었다. 빌드 산출물이 커밋되어 있으면 빌드는 되는데 소스를 바꿔도 결과가 안 바뀐다.
ls bff/src/main/java/com/example/keycloakpattern/bff/
git checkout origin/develop-keycloak-pattern3 -- bff/
다른 하나는 환경변수에 기본값이 없어 테스트가 죽은 것이다. 테스트는 그 환경변수를 모른다.
# 이러면 테스트에서 컨텍스트가 안 뜬다 — 테스트는 그 환경변수를 모른다
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-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 에서 로그인이 되는지는 재지 않았다. 같은 인스턴스로 보내면 된다는 것은 추론이고 측정이 아니다.