# 열린 질문 커버리지 — 이 실험대로 답할 수 있는가 공개 기록에 등록된 KeyCloak Patterns 열린 질문 네 개를, 이 실험대가 실제로 검증할 수 있는지 대조한 결과. > **목록 경로는 [`/explore/questions`](https://hyeonworks.com/explore/questions)** > 다. `/questions` 는 404 이고 개별 문서만 `/questions/` 로 열린다. > > **2026-09-04 재확인** — Playwright 로 네 문서를 전문 재독하고 > 「남은 미지수」·「다음 검증」·「제약」을 항목 단위로 대조한 결과 > **계획에 빠진 항목 9개**를 찾아 보강했다. 항목별 실험 번호 대조표는 > [`experiment-plan.md`](experiment-plan.md) B층 머리에 있다. **결론 — 네 개 모두 이 실험대에서 재현 가능하다. 다만 로드맵에 빠진 항목이 있고, 순서가 한 곳 뒤집혀 있다.** | # | 질문 | 게시 | 로드맵 커버 | |---|---|---|---| | Q1 | [서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가](https://hyeonworks.com/questions/server-session-pattern-multi-instance) | 2026.08.29 | **부분** | | Q2 | [Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가](https://hyeonworks.com/questions/refresh-rotation-replica-contention) | 2026.08.26 | **부분** | | Q3 | [BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가](https://hyeonworks.com/questions/bff-session-authorized-client-store) | 2026.08.30 | **부분** | | Q4 | [Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가](https://hyeonworks.com/questions/edge-authorization-scope) | 2026.08.31 | **없음** | --- ## 발견한 구조적 문제 ### 1. 순서가 뒤집혀 있다 Q2가 명시한다. > 이 경쟁은 **저장소를 공유한 뒤에야 재현**되기 때문에 저장소 결정을 하고 > 나서 해당 문제를 이어서 풀어보자. 즉 **Q3(저장소 결정) → Q2(경쟁 재현)** 순서다. 그런데 로드맵은 `refresh-token-concurrency`를 `redis-app-session-store`보다 **앞**에 두었다. **Q2를 먼저 시도하면 재현 자체가 불가능하다.** 저장소가 process-local이면 두 replica가 같은 refresh token 항목을 보지 않기 때문이다. → 로드맵 순서를 교정한다. ### 2. Session과 Authorized Client는 조회 키가 다르다 Q3의 핵심이며 로드맵에 이 구분이 없었다. | 상태 | 조회 키 | 저장 위치(현재) | |---|---|---| | Application Session | **session ID** | 서블릿 컨테이너 in-memory | | OAuth2AuthorizedClient | **client registration 이름 + principal name** | 자동구성 in-memory | **`session ID`가 조회 키에 없다.** 그래서 같은 사용자가 두 브라우저에서 로그인하면 **동일한 authorized client 항목을 공유**한다. Q1의 제약이 이를 그대로 지적한다. > 여러 인스턴스가 같은 세션을 사용할 수 있도록 Session Store를 공유 > 저장소로 변경하는 것만으로는 **충분하지 않다.** → 실험을 "Redis 도입" 하나로 뭉뚱그리면 안 된다. **두 저장소를 각각 설계하고 각각 검증해야 한다.** ### 3. 이미 해결한 문제가 질문에도 있다 Q1의 제약: > Resource Server의 8081이 host에도 열려 있어서 모든 client가 BFF만 거치도록 > **network에서 강제된 상태가 아니다.** 이는 2홉 헤더 실험에서 마주친 **프록시 우회 경로**와 같은 문제이며, NetworkPolicy로 닫는 방법을 이미 확립했다 ([`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) 11절). → Q1에 답할 때 그 패턴을 그대로 재사용한다. --- ## Q1. 다중 인스턴스 운영 **질문이 요구하는 검증 5단계** | # | 검증 | 실험대 가능 | 로드맵 | |---|---|---|---| | 1 | 한쪽에서 로그인 후 **다른 인스턴스로 요청 시 200 유지** | 가능 | 없음 | | 2 | 한 인스턴스 재시작 후 **같은 session cookie로 상태 유지** | 가능 | 없음 | | 3 | 같은 사용자 두 브라우저 → **authorized client 덮어쓰는가** | 가능 | **없음** | | 4 | 한쪽 logout 후 **다른 쪽 요청** | 가능 | 부분 (백채널 로그아웃) | | 5 | **session 만료 ≠ token 만료** 각 경우의 응답과 화면 | 가능 | **없음** | **실험대 준비 상태** — BFF를 2 replica로 띄우면 전부 재현된다. 호스트 nginx의 `ip_hash` 주석을 켜고 끄면 **스티키 유무 비교**까지 같은 구성에서 된다. **추가로 필요한 것** - BFF 이미지 (아직 `bff/` 디렉터리에 소스 없음) - 로그아웃 전파를 관찰할 두 번째 앱 (`app2.hyeonworks.com` 이름은 확보) **3번이 특히 중요하다.** "Redis만 붙이면 해결"이라는 착각을 깨는 항목이고, 조회 키가 다르다는 사실의 실증이다. --- ## Q2. Refresh Token Rotation 경쟁 **질문이 요구하는 검증 5단계** | # | 검증 | 실험대 가능 | 로드맵 | |---|---|---|---| | 1 | replica 두 대에서 **access token 만료 직후 동시 요청** | 가능 | 있음 | | 2 | **이긴 쪽/지는 쪽 응답** 각각 기록 | 가능 | 부분 | | 3 | 지는 쪽이 **저장된 새 token으로 재시도해 성공하는가** | 가능 | **없음** | | 4 | **지는 쪽 사용자 화면**에 무엇이 보이는가 | 가능 | **없음** | | 5 | **lock 유무를 같은 입력으로 비교** (실패율·지연) | 가능 | **없음** | **5번이 결론을 내는 기준이다.** > 실패가 사용자에게 노출되면 lock을 고르고, 노출되지 않으면 재시도로 둔다. 로드맵에 없던 항목인데, **이것이 없으면 질문에 답할 수 없다.** **제약을 지켜야 한다** - rotation + 재사용 0회는 **전제로 고정**한다. 바꾸지 않고 답한다 - 이미 발급된 access token은 만료 전까지 통하므로 **재현은 access token 만료 직후에 맞춰 실행**한다. 그렇지 않으면 실패가 화면에 보이지 않는다 **선행 조건** — Q3의 저장소 공유가 먼저다. --- ## Q3. BFF 저장소 결정 **질문이 요구하는 검증 5단계** | # | 검증 | 실험대 가능 | 로드맵 | |---|---|---|---| | 1 | 인스턴스 두 대에서 **로그인 유지와 재시작 복구** | 가능 | 부분 | | 2 | 저장소를 열어 **refresh token이 평문인가** | 가능 | **없음** | | 3 | **session TTL ≠ token 만료** 그 순간의 응답과 화면 | 가능 | **없음** | | 4 | logout 뒤 **두 store에 잔여 항목이 없는가** | 가능 | 부분 | | 5 | **저장소를 끊은 상태**에서 로그인·API 호출 오류 | 가능 | 있음 | **로드맵에 없던 큰 항목 — 후보 비교** 질문은 "Redis로 간다"가 아니라 **"Redis와 JDBC 중 무엇이 이 접근 패턴에 맞는가"** 를 묻는다. > 요청마다 읽는 값과 가끔 읽는 값이 섞여 있다. 이 실험대에는 PostgreSQL이 이미 있으므로 **JDBC 후보를 같은 조건에서 비교할 수 있다.** Redis만 붙이면 질문의 절반만 답하는 셈이다. **2번(평문 확인)의 실행 방법** ```bash kubectl -n exec -it deploy/redis -- redis-cli --scan --pattern 'spring:session:*' kubectl -n exec -it deploy/redis -- redis-cli GET ``` 저장소를 직접 열어 refresh token이 그대로 읽히는지 본다. 읽힌다면 암호화 설계가 필요하고, 그 key 교체 절차는 별도 과제다. --- ## Q4. Edge 인가 범위 — 로드맵에 전혀 없다 이 축을 A층(Keycloak)·B층(앱 세션) 중심으로 잡으면서 **AP4의 인가 범위 질문을 빠뜨렸다.** **질문이 요구하는 검증** | 검증 | 실험대 가능 | |---|---| | role을 헤더에 담고 **다중 값 구분자·escaping** 확인 | 가능 | | **헤더 크기 상한** 초과 시 proxy가 자르는가 요청이 거부되는가 | 가능 | | role 변경 후 **몇 번째 요청부터 반영되는가** | 가능 | | upstream이 헤더 존재만 보는가 값과 service identity까지 보는가 | 가능 | **이 실험대에서 특히 잘 맞는 이유** nginx의 헤더 처리 특성을 이미 실측했다. 질문이 지적한 > Nginx는 client가 보낸 동명 헤더를 merge하지 않고 **덮어쓴다.** 는 2홉 헤더 실험에서 `proxy_set_header X-Forwarded-For $remote_addr`로 확인한 그 동작이다. **`X-Auth-Request-*`도 같은 규칙을 따르는지**를 같은 방법으로 검증할 수 있다. 그리고 질문의 제약 > internal token 검사가 controller 한 곳에만 있다. 헤더를 늘리기 전에 이 > 검사를 **공통 경계로 옮겨야** 된다. 는 코드 변경이므로 `backend/`에서 진행한다. --- ## 교정된 실험 순서 기존 로드맵의 순서를 질문의 의존 관계에 맞춰 조정한다. ``` ✅ 환경 구축 ✅ 2홉 프록시 헤더 계약 ────────────────────────────────────────────────────────── 1. Keycloak 멀티노드 클러스터 형성 (선행 인프라) 2. persistent vs volatile 세션 (A층) 3. BFF 저장소 결정 → Q3 ★ Q2 의 선행 조건 4. 다중 인스턴스 운영 → Q1 5. Refresh Token 경쟁 → Q2 ★ 3 이후여야 재현됨 6. Edge 인가 범위 → Q4 ← 새로 추가 7. 장애 주입과 복구 (전 항목 공통) ``` **바뀐 점** - `refresh-token-concurrency`가 `redis-app-session-store` **뒤로** 이동 - 저장소 결정이 **Redis 도입**이 아니라 **Redis vs JDBC 비교**로 확장 - **Edge 인가 범위(Q4)** 신규 추가 ## 브랜치 매핑 | 실험 | 브랜치 | 상태 | |---|---|---| | 멀티노드 클러스터 | `feature/keycloak-multinode-cluster-jdbc-ping` | 존재 | | persistent vs volatile | `feature/keycloak-persistent-vs-volatile-sessions` | 존재 | | BFF 저장소 (Q3) | `feature/keycloak-redis-app-session-store` | 존재 — **범위 확장 필요** | | 다중 인스턴스 (Q1) | — | **없음** | | refresh 경쟁 (Q2) | `feature/keycloak-refresh-token-concurrency` | 존재 | | Edge 인가 (Q4) | — | **없음** | | 장애 주입 | `feature/keycloak-failure-injection-recovery` | 존재 | **두 개를 새로 만들어야 한다.** ```bash git checkout develop-keycloak-session-store git checkout -b feature/keycloak-multi-instance-session-operation git checkout -b feature/keycloak-edge-authorization-scope ``` ## 공통 선행 조건 — BFF 구현은 이미 있다 세 질문(Q1·Q2·Q3)이 모두 **BFF를 2 replica로 띄우는 것**을 전제한다. `develop-keycloak-session-store`의 `bff/`에는 빌드 산출물만 있지만, **`develop-keycloak-pattern3`에 구현이 완성되어 있다.** ``` bff/Dockerfile bff/pom.xml bff/src/main/java/com/example/keycloakpattern/bff/ ├ BffApplication.java ├ BffController.java ├ CsrfController.java ├ SecurityConfig.java └ SpaCsrfTokenRequestHandler.java bff/src/main/resources/application.yml bff/src/main/resources/static/{index.html,app.js} bff/src/test/java/.../BffControllerTest.java ``` → 새로 구현할 필요가 없다. **AP3 브랜치에서 이 실험대로 가져온다.** ```bash git checkout develop-keycloak-session-store git checkout develop-keycloak-pattern3 -- bff/ ``` 가져온 뒤 확인할 것 — 질문들이 지목한 부분이 코드에 그대로 있는지. | 확인 | 어디를 볼 것인가 | |---|---| | Session 저장소가 in-memory 자동구성인가 | `SecurityConfig.java`, `application.yml`에 Spring Session 설정 부재 | | `OAuth2AuthorizedClientService`가 in-memory인가 | Bean 정의 부재 → 자동구성 결과 확인 필요 | | authorized client 조회에 session ID가 없는가 | Spring Security 기본 계약 | Q3가 "어떤 구현체가 실제로 쓰이는지는 자동구성 결과까지 확인해야 정확히 알 수 있다"고 남긴 미지수를, **기동 후 Bean을 실제로 조회해서** 확정할 수 있다. ```bash kubectl -n exec deploy/bff -- \ curl -s localhost:8082/actuator/beans | grep -i authorizedClientService ``` ## 참고 | 문서 | 관계 | |---|---| | [`session-store-lab-roadmap.md`](session-store-lab-roadmap.md) | 이 문서가 그 순서를 교정한다 | | [`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) | Q1의 우회 경로 제약, Q4의 헤더 덮어쓰기 근거 | | [`session-lab-operations.md`](session-lab-operations.md) | 실행 도구와 명령 | | [`four-pattern-tradeoff-matrix.md`](four-pattern-tradeoff-matrix.md) | Q4가 되돌아가는 선택지(BFF)의 비교표 |