# 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 -- 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 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. 동작 확인 — 브라우저 증거 ![로그인 성공](evidence/b0-bff-redis-deploy/b0-bff-login-success-single-replica.png) ![token 경계](evidence/b0-bff-redis-deploy/b0-bff-token-boundary.png) ```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 exec -- 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 -- 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` 는 **내부 구조를 그대로 드러낸다.** 실험대에서만 연다 |