An audit against the standard the series set — concepts, procedure, commands, architecture diagram, evidence table, terminal output — found the three new experiments met it while twelve of the original ones had no diagram at all: A-0, A-1, A-3, A-4, A-5, A-6, A-8, B-0, B-2, B-7, C-2, D-2. Each now has one drawn from what that experiment actually found, not filler: A-0 shows sharing going through PostgreSQL rather than between the caches; A-3 the gap between the 200 and the WAL flush, with both failed injections; A-5 the three silent injection failures; A-6 the two places latency is multiplied; B-0 the repository keyed by principal with no session id; B-2 the primary key that causes the overwrite; D-2 why the rolling update stopped the accident halfway. Also corrected the index's stale claim of 11 experiments without a screenshot — it is 14, and the reason is recorded: those experiments were measured from terminals, the database and logs, and the observability stack does not scrape Redis, the BFF or PostgreSQL, so there is no console to photograph. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
347 lines
14 KiB
Markdown
347 lines
14 KiB
Markdown
# 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 의 자동구성 결과까지 확인해야** 정확하게 알 수 있다.
|
||
|
||
---
|
||
|
||
## 구조
|
||
|
||

|
||
|
||
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||
|
||
---
|
||
|
||
## 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 $(kubectl -n keycloak-lab get pod -l app=bff --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') -- 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 "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
|
||
|
||
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번("한쪽에서 로그인한 뒤 다른 인스턴스로 요청")보다
|
||
> **앞선 단계**다. 로그인이 끝나야 그 검증을 할 수 있는데, 로그인부터 막힌다.
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## 개념
|
||
|
||
### `AuthenticatedPrincipalOAuth2AuthorizedClientRepository`
|
||
|
||
이름이 곧 설명이다 — **인증된 주체(principal) 기준**으로 authorized client 를 찾는다.
|
||
|
||
```
|
||
인증되어 있으면 → OAuth2AuthorizedClientService 에 위임
|
||
키: (clientRegistrationId, principalName)
|
||
└─ session ID 가 없다 ★
|
||
인증되지 않았으면 → HttpSession 에 임시 보관
|
||
```
|
||
|
||
**같은 사용자의 두 브라우저가 같은 항목을 본다.** Q1 미지수 3 과 Q3 제약의 기제다.
|
||
|
||
### 인가 코드 흐름은 왕복이 두 번이다
|
||
|
||
```
|
||
① 브라우저 → 앱 → IdP 로 리다이렉트 (state·PKCE verifier 를 저장)
|
||
② IdP → 브라우저 → 앱의 콜백 (저장한 것을 꺼내 검증)
|
||
```
|
||
|
||
**②가 ①과 같은 인스턴스로 가야 한다.** 저장 위치가 인스턴스 메모리면
|
||
replica 를 늘리는 순간 로그인 자체가 실패한다.
|
||
|
||
### 자동구성은 조용히 고른다
|
||
|
||
빈을 직접 만들지 않으면 Spring Boot 가 조건에 따라 고른다.
|
||
**무엇을 골랐는지는 실행 중인 인스턴스를 봐야 안다.**
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab exec $(kubectl -n keycloak-lab get pod -l app=bff --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') -- wget -qO- http://localhost:8083/actuator/beans
|
||
```
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## 증거 파일
|
||
|
||
**증거 수집 시각: 2026-09-04 13:39 – 13:46 KST** (파일 mtime 기준. 문서 상단의 시각 표기는 작성 시점이라 다를 수 있다.)
|
||
|
||
| 파일 | 종류 |
|
||
|---|---|
|
||
| [`01-deploy.txt`](evidence/b0-bff-redis-deploy/01-deploy.txt) | 터미널 원문 |
|
||
| [`02-autoconfiguration.txt`](evidence/b0-bff-redis-deploy/02-autoconfiguration.txt) | 터미널 원문 |
|
||
| [`03-beans-analysis.txt`](evidence/b0-bff-redis-deploy/03-beans-analysis.txt) | 터미널 원문 |
|
||
| [`b0-bff-login-success-single-replica.png`](evidence/b0-bff-redis-deploy/b0-bff-login-success-single-replica.png) | 스크린샷 |
|
||
| [`b0-bff-token-boundary.png`](evidence/b0-bff-redis-deploy/b0-bff-token-boundary.png) | 스크린샷 |
|
||
|
||
파일별 상세는 [`evidence/b0-bff-redis-deploy/README.md`](evidence/b0-bff-redis-deploy/README.md).
|
||
|
||
## 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 $(kubectl -n keycloak-lab get pod -l app=bff --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') -- 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` 는 **내부 구조를 그대로 드러낸다.** 실험대에서만 연다 |
|