Files
keycloak-pattern/docs/experiment-b0-bff-redis-deploy.md
T
DongHyeonkaandClaude Opus 5 e0d27d47ce docs: correct the places where documents contradicted their own evidence
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>
2026-09-04 16:35:49 +09:00

13 KiB
Raw Blame History

B-0 — 자동구성은 실제로 무엇을 골랐는가 (그리고 배포에서 겪은 것들)

브랜치 feature/keycloak-b0-bff-redis-deploy · 증거 docs/evidence/b0-bff-redis-deploy/ · 2026-09-04 14:2014: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/                 없음

.gitignoretarget/ 이 없어 클래스 파일만 커밋되어 있었다. 소스는 다른 브랜치에 있었다.

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_urihttp:// 로 만들어 Keycloak 이 거부한다.


3. 동작 확인 — 브라우저 증거

로그인 성공

token 경계

{"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내부 구조를 그대로 드러낸다. 실험대에서만 연다