Files
keycloak-pattern/docs/open-questions-coverage.md
T

284 lines
12 KiB
Markdown

# 열린 질문 커버리지 — 이 실험대로 답할 수 있는가
공개 기록(`hyeonworks.com/questions`)에 등록된 KeyCloak Patterns 열린 질문
네 개를, 이 실험대가 실제로 검증할 수 있는지 대조한 결과.
**결론 — 네 개 모두 이 실험대에서 재현 가능하다. 다만 로드맵에 빠진 항목이
있고, 순서가 한 곳 뒤집혀 있다.**
| # | 질문 | 게시 | 로드맵 커버 |
|---|---|---|---|
| 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 <ns> exec -it deploy/redis -- redis-cli --scan --pattern 'spring:session:*'
kubectl -n <ns> exec -it deploy/redis -- redis-cli GET <key>
```
저장소를 직접 열어 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 <ns> 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)의 비교표 |