An independent audit found ten documents printing values their evidence files do not contain. C-1 printed a session count of 0 where the evidence says 4, C-2 printed a success readback for a command that exited 1, and A-1 credited the conntrack flush with a split that the timestamps attribute to a pod restart four seconds earlier. Also measured wal_writer_delay, which A-3 had asserted as matching without ever querying it, relabelled the A-6 control that moved 41 percent, noted A-8's nine-sample resolution, corrected D-1's RTO to the 41 seconds its own timeline shows, and added a correction banner to D-2. Every experiment document now links its evidence files with their real collection times, and the duplicate screenshots are documented as duplicates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
13 KiB
B-0 — 자동구성은 실제로 무엇을 골랐는가 (그리고 배포에서 겪은 것들)
브랜치 feature/keycloak-b0-bff-redis-deploy ·
증거 docs/evidence/b0-bff-redis-deploy/ ·
2026-09-04 14:20–14:50 KST
Q1 이 직접 요구한 확인이다.
코드에 저장소를 직접 생성하는 Bean 이 없기 때문에, 어떤 구현체가 실제로 사용되는지는 Spring Boot 의 자동구성 결과까지 확인해야 정확하게 알 수 있다.
0. 결론부터
authorizedClientService -> InMemoryOAuth2AuthorizedClientService
authorizedClientRepository -> AuthenticatedPrincipalOAuth2AuthorizedClientRepository
authorizedClientManager -> AuthorizedClientServiceOAuth2AuthorizedClientManager
clientRegistrationRepository -> InMemoryClientRegistrationRepository
SessionRepository -> 없음 (서블릿 컨테이너 in-memory)
Redis / Spring Session -> ★ 없음
추측이 맞았지만, 추측으로 두면 안 되는 이유가 두 번째 줄에 있다.
AuthenticatedPrincipalOAuth2AuthorizedClientRepository — 이름이 곧 설명이다.
"인증된 주체(principal) 기준" 으로 authorized client 를 찾는다.
session ID 가 아니다. Q1·Q3 가 지적한 "같은 사용자의 여러 브라우저가 같은
token 을 공유한다"는 문제의 기제가 이 빈 하나에 들어 있다.
그리고 배포하자마자 Q1 의 문제가 실험을 시작하기도 전에 나타났다 — replica 2개에서는 로그인 자체가 실패한다.
1. 배포에서 겪은 문제 다섯 가지
문제 ① — bff/ 가 소스 없이 빌드 산출물만 있었다
bff/target/classes/... 9개 파일
bff/src/ 없음
.gitignore 에 target/ 이 없어 클래스 파일만 커밋되어 있었다.
소스는 다른 브랜치에 있었다.
git checkout origin/develop-keycloak-pattern3 -- bff/
문제 ② — YAML 중복 키로 빌드가 깨졌다
actuator 를 열려고 management: 아래에 endpoint: 블록을 하나 더 넣었다.
이미 있는데.
org.yaml.snakeyaml.constructor.SafeConstructor.processDuplicateKeys
Docker 빌드 로그가 tail 로 잘려 원인이 안 보였다. --progress=plain 으로
전체를 받아서야 스택트레이스에서 processDuplicateKeys 를 찾았다.
docker build --progress=plain -t keycloak-pattern-bff:lab . > /tmp/build.log 2>&1
grep -nE "Tests run|Caused by|\.java:[0-9]" /tmp/build.log
빌드 실패는 마지막 15줄에 안 들어 있는 경우가 많다. 전체를 파일로 받는다.
문제 ③ — 환경변수에 기본값을 안 줘서 테스트가 죽었다
${KC_ISSUER_EXTERNAL} 처럼 기본값 없이 쓰면 테스트에서 컨텍스트가 안 뜬다.
테스트는 그 환경변수를 모른다.
authorization-uri: ${KC_ISSUER_EXTERNAL:http://localhost:8080/realms/keycloak-patterns}/protocol/openid-connect/auth
문제 ④ — actuator 가 인증에 막혀 있었다
/actuator/beans 를 부르면 200 이 왔는데, Keycloak 로그인 페이지였다.
-L 로 리다이렉트를 따라간 결과였다.
"/actuator/health",
"/actuator/health/**",
// 실험대 전용 — 운영에서는 절대 열지 않는다
"/actuator/**"
200이 곧 성공은 아니다. 무엇이 왔는지 봐야 한다.
문제 ⑤ — 큰 응답이 프록시에서 Bad Gateway
/actuator/beans 는 117KB 다. nginx → Traefik 을 거치면서 실패했다.
$ curl https://app1.hyeonworks.com/actuator/beans
Bad Gateway
파드 안에서 직접 받아 해결했다. alpine 기반 JRE 이미지에 wget 이 있다.
kubectl -n keycloak-lab exec <bff-pod> -- wget -qO- http://localhost:8083/actuator/beans
2. 배포 구성
브라우저 ──https──▶ nginx ──▶ Traefik ──▶ bff (2 replica)
│
├──▶ Keycloak (realm: keycloak-patterns)
└──▶ echo (resource server 대역)
redis ── kc-lab-2 (postgres 와 같은 노드) ← 아직 연결하지 않았다
Redis 는 배포만 하고 BFF 에 연결하지 않았다. B-0 의 질문이 "아무것도 주지 않았을 때 자동구성이 무엇을 고르는가"이므로, 아무것도 주지 않은 상태를 먼저 측정해야 한다.
Keycloak realm 준비 (kcadm)
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh config credentials \
--server http://localhost:8080 --realm master --user admin --password <pw>
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/*"]'
accessTokenLifespan=60 으로 둔 것은 B-3(refresh 경쟁)을 위해서다.
만료를 기다리는 시간이 짧아야 재현이 된다.
브라우저용 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
2홉 헤더 실험에서 배운 것이 그대로 쓰인다 — 브라우저가 보는 이름과
서버가 부르는 주소는 다르고, 섞으면 리다이렉트가 깨진다.
SERVER_FORWARD_HEADERS_STRATEGY=native 도 같은 이유다. 없으면 Spring 이
redirect_uri 를 http:// 로 만들어 Keycloak 이 거부한다.
3. 동작 확인 — 브라우저 증거
{"pattern":"AP3-backend-for-frontend","principal":"labuser",
"accessTokenStoredOnServer":true,"refreshTokenStoredOnServer":true,
"browserTokenCount":0,"csrfProtectionEnabled":true}
BFF 패턴이 성립한다 — 브라우저에 토큰이 0개이고, 서버가 access/refresh 를 들고 있다.
4. B-0 의 답 — 자동구성 결과
전체 빈 321개 중 관련된 것들이다.
| 빈 | 구현체 | 뜻 |
|---|---|---|
authorizedClientService |
InMemoryOAuth2AuthorizedClientService |
프로세스 메모리. 재시작하면 사라진다 |
authorizedClientRepository |
AuthenticatedPrincipalOAuth2AuthorizedClientRepository |
principal 기준 조회. session ID 가 없다 |
authorizedClientManager |
AuthorizedClientServiceOAuth2AuthorizedClientManager |
service(공유) 를 쓴다 |
clientRegistrationRepository |
InMemoryClientRegistrationRepository |
설정에서 읽은 것 |
| SessionRepository | 없음 | Tomcat 의 기본 StandardSession |
| Redis / Spring Session | 없음 | 의존성 자체가 없다 |
AuthenticatedPrincipalOAuth2AuthorizedClientRepository 가 핵심이다
요청이 인증되어 있으면
└─▶ OAuth2AuthorizedClientService 에 위임
└─▶ 키: (clientRegistrationId, principalName)
└─ session ID 가 없다 ★
인증되어 있지 않으면
└─▶ HttpSession 에 임시 보관
같은 사용자가 두 브라우저에서 로그인하면 principalName 이 같으므로 같은 항목을 본다. Q1 의 미지수 3 과 Q3 의 제약이 여기서 나온다.
Redis 를 붙여도 이건 안 고쳐진다. 저장소를 공유해도 키에 session ID 가 없기 때문이다. Q1 이 "Session Store 를 공유 저장소로 바꾸는 것만으로는 충분하지 않다"고 쓴 이유다.
5. 예상 못 한 것 — replica 2개에서 로그인 자체가 안 된다
배포 직후 브라우저에서 로그인하니 /login?error 로 떨어졌다.
BFF 로그에는 아무 오류도 없었다 (Spring Security 는 로그인 실패를 DEBUG 로만 남긴다).
가설 — 인가 요청(state, PKCE verifier)은 HttpSession 에 저장된다.
그런데 그 세션은 인스턴스 메모리다. 콜백이 다른 replica 로 가면 저장된
인가 요청이 없어 실패한다.
검증 — replica 를 1로 줄이고 다시 시도했다.
kubectl -n keycloak-lab scale deployment/bff --replicas=1
로그인이 성공했다. 가설 확정.
replica 2 + 스티키 없음 → 로그인 실패 (콜백이 다른 인스턴스로)
replica 1 → 로그인 성공
Q1 의 문제가 실험을 시작하기도 전에 나타났다. "다중 인스턴스에서 어떻게 운영할 것인가"는 로그인한 뒤의 문제가 아니라 로그인 자체의 문제다. 인가 코드 흐름은 왕복 두 번이고, 두 번 다 같은 인스턴스로 가야 한다.
이건 B-2 의 검증 1번("한쪽에서 로그인한 뒤 다른 인스턴스로 요청")보다 앞선 단계다. 로그인이 끝나야 그 검증을 할 수 있는데, 로그인부터 막힌다.
개념
AuthenticatedPrincipalOAuth2AuthorizedClientRepository
이름이 곧 설명이다 — 인증된 주체(principal) 기준으로 authorized client 를 찾는다.
인증되어 있으면 → OAuth2AuthorizedClientService 에 위임
키: (clientRegistrationId, principalName)
└─ session ID 가 없다 ★
인증되지 않았으면 → HttpSession 에 임시 보관
같은 사용자의 두 브라우저가 같은 항목을 본다. Q1 미지수 3 과 Q3 제약의 기제다.
인가 코드 흐름은 왕복이 두 번이다
① 브라우저 → 앱 → IdP 로 리다이렉트 (state·PKCE verifier 를 저장)
② IdP → 브라우저 → 앱의 콜백 (저장한 것을 꺼내 검증)
②가 ①과 같은 인스턴스로 가야 한다. 저장 위치가 인스턴스 메모리면 replica 를 늘리는 순간 로그인 자체가 실패한다.
자동구성은 조용히 고른다
빈을 직접 만들지 않으면 Spring Boot 가 조건에 따라 고른다. 무엇을 골랐는지는 실행 중인 인스턴스를 봐야 안다.
kubectl exec <pod> -- wget -qO- http://localhost:8083/actuator/beans
증거 파일
증거 수집 시각: 2026-09-04 13:39 – 13:46 KST (파일 mtime 기준. 문서 상단의 시각 표기는 작성 시점이라 다를 수 있다.)
| 파일 | 종류 |
|---|---|
01-deploy.txt |
터미널 원문 |
02-autoconfiguration.txt |
터미널 원문 |
03-beans-analysis.txt |
터미널 원문 |
b0-bff-login-success-single-replica.png |
스크린샷 |
b0-bff-token-boundary.png |
스크린샷 |
파일별 상세는 evidence/b0-bff-redis-deploy/README.md.
6. 재현 절차 (명령어)
# 1. 소스 가져오기 (target/ 만 커밋되어 있었다)
git checkout origin/develop-keycloak-pattern3 -- bff/
# 2. 빌드 — 실패하면 전체 로그를 파일로
docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
grep -nE "Tests run|Caused by" /tmp/build.log
# 3. 두 노드에 적재 (레지스트리 없음 → 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 -'"
# 4. realm · client · user
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh create realms -s realm=keycloak-patterns ...
# 5. 배포
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
# 6. 자동구성 결과 — 파드 안에서 (프록시는 큰 응답에서 502)
kubectl -n keycloak-lab exec <bff-pod> -- wget -qO- http://localhost:8083/actuator/beans > beans.json
python3 -c "import json;d=json.load(open('beans.json'));[print(n,'->',i['type']) for n,i in
list(d['contexts'].values())[0]['beans'].items() if 'AuthorizedClient' in i['type']]"
7. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| B-1 저장소 결정 | 전환 후 이 빈들이 바뀌는지 다시 찍는다. "Redis 붙였다"고 믿는데 자동구성이 안 걸리는 경우가 흔하다 |
| B-2 다중 인스턴스 | 로그인 자체가 실패한다는 것이 이미 관측됐다. 그것이 검증 0번이다 |
| B-3 refresh 경쟁 | accessTokenLifespan=60 으로 realm 을 만들어뒀다 |
| 운영 | actuator beans/env 는 내부 구조를 그대로 드러낸다. 실험대에서만 연다 |

