docs: B-0 — deploy the BFF and read what autoconfiguration actually chose
The authorized client repository is AuthenticatedPrincipalOAuth2AuthorizedClientRepository, keyed by principal with no session id in it, which is the mechanism behind the sharing problem Q1 and Q3 describe. Sharing a store does not fix a lookup key. Five problems on the way in: only build output was committed under bff/, a duplicate YAML key broke the image build and was invisible until the full log was captured, env placeholders without defaults broke the tests, actuator was behind the login redirect so a 200 was the login page, and the 117KB beans response failed through the proxy. Deploying two replicas made the login itself fail before any experiment started, because the authorization request lives in per-instance memory and the callback lands elsewhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8f6d67df35
commit
e62bbb4df0
@@ -0,0 +1,21 @@
|
||||
=== 배포 전 자원 ===
|
||||
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%
|
||||
|
||||
=== 배포 ===
|
||||
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
|
||||
|
||||
bff-574c6d658b-8cz4x true kc-lab-1
|
||||
bff-574c6d658b-zpkbp true kc-lab-2
|
||||
redis-568bd7c4-5c5vc true kc-lab-2
|
||||
@@ -0,0 +1,13 @@
|
||||
=== B-0: 자동구성이 실제로 고른 구현체 ===
|
||||
Q1 확인한 사실: "코드에 저장소를 직접 생성하는 Bean 이 없기 때문에,
|
||||
어떤 구현체가 실제로 사용되는지는 자동구성 결과까지 확인해야 정확하게 알 수 있다"
|
||||
|
||||
File "<stdin>", line 9
|
||||
print(f" {name:46} {t.rsplit(\".\",1)[-1]}")
|
||||
^
|
||||
SyntaxError: unexpected character after line continuation character
|
||||
|
||||
=== HttpSession 은 어디에 있는가 (서블릿 컨테이너 기본) ===
|
||||
|
||||
=== 외부 진입점 ===
|
||||
https://app1.hyeonworks.com/ HTTP 200
|
||||
@@ -0,0 +1,29 @@
|
||||
=== B-0 — 자동구성이 실제로 고른 구현체 ===
|
||||
컨텍스트: keycloak-bff
|
||||
전체 빈 수: 321
|
||||
|
||||
--- 세션 · 토큰 저장소 관련 ---
|
||||
authorizedClientManager -> AuthorizedClientServiceOAuth2AuthorizedClientManager
|
||||
authorizedClientManagerRegistrar -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerRegistrar
|
||||
authorizedClientRepository -> AuthenticatedPrincipalOAuth2AuthorizedClientRepository
|
||||
authorizedClientService -> InMemoryOAuth2AuthorizedClientService
|
||||
org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientConfigurations$OAuth2AuthorizedClientServiceConfiguration -> OAuth2ClientConfigurations$OAuth2AuthorizedClientServiceConfiguration
|
||||
org.springframework.security.config.annotation.web.configuration.OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerConfiguration -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerConfiguration
|
||||
|
||||
--- OAuth2 클라이언트 관련 전체 ---
|
||||
authorizedClientManager -> AuthorizedClientServiceOAuth2AuthorizedClientManager
|
||||
authorizedClientManagerRegistrar -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerRegistrar
|
||||
authorizedClientRepository -> AuthenticatedPrincipalOAuth2AuthorizedClientRepository
|
||||
authorizedClientService -> InMemoryOAuth2AuthorizedClientService
|
||||
clientRegistrationRepository -> InMemoryClientRegistrationRepository
|
||||
org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientAutoConfiguration -> OAuth2ClientAutoConfiguration
|
||||
org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientConfigurations$ClientRegistrationRepositoryConfiguration -> OAuth2ClientConfigurations$ClientRegistrationRepositoryConfiguration
|
||||
org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientConfigurations$OAuth2AuthorizedClientServiceConfiguration -> OAuth2ClientConfigurations$OAuth2AuthorizedClientServiceConfiguration
|
||||
org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientWebSecurityAutoConfiguration -> OAuth2ClientWebSecurityAutoConfiguration
|
||||
org.springframework.security.config.annotation.web.configuration.OAuth2ClientConfiguration -> OAuth2ClientConfiguration
|
||||
org.springframework.security.config.annotation.web.configuration.OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerConfiguration -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerConfiguration
|
||||
org.springframework.security.config.annotation.web.configuration.OAuth2ClientConfiguration$OAuth2ClientWebMvcSecurityConfiguration -> OAuth2ClientConfiguration$OAuth2ClientWebMvcSecurityConfiguration
|
||||
spring.security.oauth2.client-org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties -> OAuth2ClientProperties
|
||||
|
||||
--- Redis / Spring Session 이 구성되었는가 ---
|
||||
★ 없음 — Redis 도 Spring Session 도 구성되지 않았다
|
||||
@@ -0,0 +1,18 @@
|
||||
# B-0 — BFF·Redis 배포와 자동구성 확인 증거
|
||||
|
||||
2026-09-04 14:20–14:50 KST
|
||||
해설: [`docs/experiment-b0-bff-redis-deploy.md`](../../experiment-b0-bff-redis-deploy.md)
|
||||
|
||||
| 파일 | 무엇을 보여주는가 |
|
||||
|---|---|
|
||||
| `01-deploy.txt` | 배포 전 자원, Redis·BFF 롤아웃, 두 노드에 하나씩 배치됨 |
|
||||
| `02-autoconfiguration.txt` | 첫 조회 시도(파싱 실패)와 **외부 진입점 `HTTP 200`** |
|
||||
| `03-beans-analysis.txt` | **B-0 의 답** — `InMemoryOAuth2AuthorizedClientService`, `AuthenticatedPrincipalOAuth2AuthorizedClientRepository`, **Redis·Spring Session 없음** |
|
||||
| `b0-bff-login-success-single-replica.png` | replica 1 에서 로그인 성공한 BFF 화면 |
|
||||
| `b0-bff-token-boundary.png` | `/bff/token-boundary` — `principal: labuser`, `accessTokenStoredOnServer: true`, **`browserTokenCount: 0`** |
|
||||
|
||||
## 핵심 세 줄
|
||||
|
||||
1. **`AuthenticatedPrincipalOAuth2AuthorizedClientRepository`** — 조회 키가 principal 이고 session ID 가 없다. Q1·Q3 문제의 기제가 이 빈 하나에 있다.
|
||||
2. **Redis 를 붙여도 그건 안 고쳐진다.** 저장소 공유와 조회 키는 다른 문제다.
|
||||
3. **replica 2개에서는 로그인 자체가 실패한다.** 인가 코드 흐름의 왕복 두 번이 같은 인스턴스로 가야 하는데, 인가 요청이 인스턴스 메모리에 있다.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,283 @@
|
||||
# B-0 — 자동구성은 실제로 무엇을 골랐는가 (그리고 배포에서 겪은 것들)
|
||||
|
||||
브랜치 `feature/keycloak-b0-bff-redis-deploy` ·
|
||||
증거 [`docs/evidence/b0-bff-redis-deploy/`](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/` 이 없어 클래스 파일만 커밋되어 있었다.
|
||||
소스는 다른 브랜치에 있었다.
|
||||
|
||||
```bash
|
||||
git checkout origin/develop-keycloak-pattern3 -- bff/
|
||||
```
|
||||
|
||||
### 문제 ② — YAML 중복 키로 빌드가 깨졌다
|
||||
|
||||
actuator 를 열려고 `management:` 아래에 `endpoint:` 블록을 **하나 더** 넣었다.
|
||||
이미 있는데.
|
||||
|
||||
```
|
||||
org.yaml.snakeyaml.constructor.SafeConstructor.processDuplicateKeys
|
||||
```
|
||||
|
||||
**Docker 빌드 로그가 `tail` 로 잘려 원인이 안 보였다.** `--progress=plain` 으로
|
||||
전체를 받아서야 스택트레이스에서 `processDuplicateKeys` 를 찾았다.
|
||||
|
||||
```bash
|
||||
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}` 처럼 기본값 없이 쓰면 **테스트에서 컨텍스트가 안 뜬다.**
|
||||
테스트는 그 환경변수를 모른다.
|
||||
|
||||
```yaml
|
||||
authorization-uri: ${KC_ISSUER_EXTERNAL:http://localhost:8080/realms/keycloak-patterns}/protocol/openid-connect/auth
|
||||
```
|
||||
|
||||
### 문제 ④ — actuator 가 인증에 막혀 있었다
|
||||
|
||||
`/actuator/beans` 를 부르면 `200` 이 왔는데, **Keycloak 로그인 페이지**였다.
|
||||
`-L` 로 리다이렉트를 따라간 결과였다.
|
||||
|
||||
```java
|
||||
"/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` 이 있다.**
|
||||
|
||||
```bash
|
||||
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)
|
||||
|
||||
```bash
|
||||
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 을 분리했다
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
**2홉 헤더 실험에서 배운 것이 그대로 쓰인다** — 브라우저가 보는 이름과
|
||||
서버가 부르는 주소는 다르고, 섞으면 리다이렉트가 깨진다.
|
||||
`SERVER_FORWARD_HEADERS_STRATEGY=native` 도 같은 이유다. 없으면 Spring 이
|
||||
`redirect_uri` 를 `http://` 로 만들어 Keycloak 이 거부한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 동작 확인 — 브라우저 증거
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
```json
|
||||
{"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로 줄이고 다시 시도했다.
|
||||
|
||||
```bash
|
||||
kubectl -n keycloak-lab scale deployment/bff --replicas=1
|
||||
```
|
||||
|
||||
**로그인이 성공했다.** 가설 확정.
|
||||
|
||||
```
|
||||
replica 2 + 스티키 없음 → 로그인 실패 (콜백이 다른 인스턴스로)
|
||||
replica 1 → 로그인 성공
|
||||
```
|
||||
|
||||
> **Q1 의 문제가 실험을 시작하기도 전에 나타났다.**
|
||||
> "다중 인스턴스에서 어떻게 운영할 것인가"는 **로그인한 뒤의 문제가 아니라
|
||||
> 로그인 자체의 문제**다. 인가 코드 흐름은 **왕복 두 번**이고, 두 번 다 같은
|
||||
> 인스턴스로 가야 한다.
|
||||
>
|
||||
> 이건 B-2 의 검증 1번("한쪽에서 로그인한 뒤 다른 인스턴스로 요청")보다
|
||||
> **앞선 단계**다. 로그인이 끝나야 그 검증을 할 수 있는데, 로그인부터 막힌다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 재현 절차 (명령어)
|
||||
|
||||
```bash
|
||||
# 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` 는 **내부 구조를 그대로 드러낸다.** 실험대에서만 연다 |
|
||||
Reference in New Issue
Block a user