diff --git a/.playwright-mcp/console-2026-09-04T07-16-34-745Z.log b/.playwright-mcp/console-2026-09-04T07-16-34-745Z.log index ba710d3..f03486a 100644 --- a/.playwright-mcp/console-2026-09-04T07-16-34-745Z.log +++ b/.playwright-mcp/console-2026-09-04T07-16-34-745Z.log @@ -12,3 +12,10 @@ [ 90768ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 [ 105475ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 [ 114995ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 125443ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 137321ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 147252ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 167227ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 179100ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 187598ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 207362ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 diff --git a/docs/experiment-a8-rolling-restart.md b/docs/experiment-a8-rolling-restart.md index 7ca66bc..0caf346 100644 --- a/docs/experiment-a8-rolling-restart.md +++ b/docs/experiment-a8-rolling-restart.md @@ -137,6 +137,32 @@ readiness 프로브가 이 전환을 정확히 맞춰준다 — A-2 에서 본 --- +--- + +## 개념 + +### StatefulSet 롤링 재시작의 무중단 조건 + +``` + 한 번에 하나씩 내린다 + readiness 로 전환 시점을 맞춘다 + └─ 항상 최소 하나는 Ready 다 +``` + +**두 가지가 다 있어야 성립한다.** replica 1 이면 반드시 끊기고, +readiness 프로브가 없으면 아직 기동 중인 파드로 트래픽이 간다. + +### 룩어사이드 캐시가 재시작을 견디는 이유 + +| | 재시작 후 | +|---|---| +| 캐시 (프로세스 메모리) | **사라진다** | +| DB (진실의 원천) | 남는다 | +| 정확성 | **유지된다** — 첫 접근만 느려진다 | + +A-0 에서 세운 모델이 여기서 그대로 확인된다. + +--- + ## 6. 재현 절차 (명령어) ```bash diff --git a/docs/experiment-b0-bff-redis-deploy.md b/docs/experiment-b0-bff-redis-deploy.md index 4c0e189..ac0612a 100644 --- a/docs/experiment-b0-bff-redis-deploy.md +++ b/docs/experiment-b0-bff-redis-deploy.md @@ -245,6 +245,44 @@ kubectl -n keycloak-lab scale deployment/bff --replicas=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 +``` + +--- + ## 6. 재현 절차 (명령어) ```bash diff --git a/docs/experiment-b2-multi-instance-session.md b/docs/experiment-b2-multi-instance-session.md index f473afe..4f35bcf 100644 --- a/docs/experiment-b2-multi-instance-session.md +++ b/docs/experiment-b2-multi-instance-session.md @@ -271,6 +271,42 @@ access_token 헤더 : {"alg":"RS256","typ":"JWT","kid":"OY-caYDNGoP4HMAz-..."} --- +--- + +## 개념 + +### 조회 키는 저장소와 독립이다 + +```sql +PRIMARY KEY (client_registration_id, principal_name) +``` + +**저장소를 메모리에서 DB 로 옮겨도 이 키는 그대로다.** +"공유 저장소로 바꾼다" 와 "세션별로 구분한다" 는 다른 문제이며, +전자만 하면 인스턴스 간 공유는 되고 브라우저 간 격리는 안 된다. + +### Liquibase 스키마의 방언 차이 + +Spring Security 는 DDL 을 두 벌 제공한다. + +| 파일 | 타입 | +|---|---| +| `oauth2-client-schema.sql` | `blob` — PostgreSQL 에 **없는 타입** | +| `oauth2-client-schema-postgres.sql` | `bytea` | + +`spring.sql.init.continue-on-error: true` 는 이 실패를 삼킨다. +**"없어도 되는 초기화" 에만 써야 하는 이유다.** + +### 로그아웃이 지워야 하는 것은 셋이다 + +``` + ① HttpSession (Spring Security 가 지운다) + ② OAuth2AuthorizedClient ★ 아무도 안 지운다 + ③ IdP SSO 세션 ★ RP-initiated logout 을 보내야 한다 +``` + +--- + ## 8. 재현 절차 (명령어) ```bash diff --git a/docs/experiment-b3-refresh-token-contention.md b/docs/experiment-b3-refresh-token-contention.md index 27c87c2..af13068 100644 --- a/docs/experiment-b3-refresh-token-contention.md +++ b/docs/experiment-b3-refresh-token-contention.md @@ -240,6 +240,44 @@ select VERSION from OFFLINE_USER_SESSION ... for no key update skip locked --- +--- + +## 개념 + +### user session 과 client session + +``` + user session "이 브라우저는 labuser 로 로그인함" + ├─ client session : bff-confidential + └─ client session : oauth2-proxy +``` + +**재사용 탐지는 client session 만 제거한다.** user session 은 껍데기로 남아 +`Session doesn't have required client` 가 된다. + +### `revokeRefreshToken` 과 `refreshTokenMaxReuse` + +| 설정 | 뜻 | +|---|---| +| `revokeRefreshToken` | **회전 스위치.** 켜면 새 토큰 발급 시 옛 토큰을 무효화 | +| `refreshTokenMaxReuse` | 그 위에서 **몇 번까지 봐줄 것인가** | + +**이름이 "회전" 이 아니라 "취소" 라서 헷갈린다.** +그리고 `maxReuse` 를 올리는 것은 해법이 아니다 — 동시 요청이 N개면 +`N-1` 이 필요하고, 그러면 회전의 보안 목적이 사라진다. + +### lock 의 수명은 어디에 묶이는가 + +| 방식 | 프로세스가 죽으면 | +|---|---| +| **DB 행 잠금** | **연결이 끊기면 자동 해제** | +| Redis lock + TTL | TTL 만료까지 막힌다 | + +**잠금 수명이 연결 수명과 묶이는 것이 DB 잠금의 이점**이며, +A-0 에서 Keycloak 자신이 `for no key update skip locked` 를 쓰는 이유다. + +--- + ## 7. 재현 절차 (명령어) ```bash diff --git a/docs/experiment-b4-edge-authorization-scope.md b/docs/experiment-b4-edge-authorization-scope.md index 2753815..c2a2039 100644 --- a/docs/experiment-b4-edge-authorization-scope.md +++ b/docs/experiment-b4-edge-authorization-scope.md @@ -221,6 +221,45 @@ Keycloak 의 role 이름은 임의 문자열이므로 **막을 수 있는 것이 --- +--- + +## 개념 + +### nginx 의 헤더 처리는 조건부다 + +```nginx +proxy_set_header X-Forwarded-Proto https; # 설정한 것 → 덮어쓴다 +# X-Auth-Request-Roles 설정 없음 # 안 한 것 → 통과시킨다 +``` + +HTTP 는 **같은 이름의 헤더가 여러 번 오는 것을 허용**하므로, +edge 가 붙인 것과 클라이언트가 보낸 것이 **함께 도착**한다. +Spring 의 `request.getHeader()` 는 **첫 번째**를 돌려주고, +그 순서는 프록시가 정한다. + +### 헤더 크기 한계는 계층마다 다르다 + +| 크기 | 누가 거부하나 | 클라이언트가 보는 것 | +|---|---|---| +| ~8KB | **Tomcat** (`maxHttpHeaderSize`) | `400` + HTML | +| ~16KB | **nginx** (`large_client_header_buffers`) | 응답 없음 | + +**같은 원인이 두 가지로 보인다.** 그리고 점진적이 아니라 절벽이며, +**role 이 많은 사용자만** 깨진다. + +### 세 곳이 독립적으로 필요하다 + +``` + ① 외부 → upstream 직접 경로 차단 (NetworkPolicy) + ② edge 에서 동명 헤더 덮어쓰기 (proxy_set_header) + ③ upstream 에서 내부 credential 검증 (공통 경계) +``` + +**하나라도 빠지면 나머지 둘이 무의미하다.** 2홉 실험의 결론이 그대로 적용되며, +거기서는 쿠키 속성이었지만 **여기서는 신원 자체**다. + +--- + ## 7. 재현 절차 (명령어) ```bash diff --git a/docs/experiment-b7-cookie-secret-rotation.md b/docs/experiment-b7-cookie-secret-rotation.md index 8319083..cce4981 100644 --- a/docs/experiment-b7-cookie-secret-rotation.md +++ b/docs/experiment-b7-cookie-secret-rotation.md @@ -218,6 +218,43 @@ _oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a ← ★ 옛 세션. 남아 있 --- +--- + +## 개념 + +### 상태를 어디에 두는가가 공유 문제의 성격을 정한다 + +| | 상태 위치 | replica 간 공유 | +|---|---|---| +| BFF | **서버 메모리 / Redis** | **저장소를 공유해야** 한다 | +| oauth2-proxy | **쿠키 (서명·암호화)** | **secret 만 같으면** 된다 | + +**공유할 상태가 없으면 공유 문제도 없다.** 대신 secret 이 단일 지점이 된다. + +### 세션 티켓 + +`--session-store-type=redis` 를 쓰면 쿠키에는 **티켓**만 담긴다. + +``` +_oauth2_proxy=|| + └─ Redis 키를 여기서 계산한다 +``` + +**secret 이 바뀌면 티켓을 못 푼다 → Redis 키를 계산할 수 없다 → +정리도 못 한다.** 고아 세션이 남는 이유다. + +### key 식별자가 없으면 회전에 겹침이 없다 + +B-6 에서 Keycloak 은 `kid` 로 여러 키를 구분해 무중단 회전을 했다. +**oauth2-proxy 의 쿠키에는 그런 식별자가 없고, `--cookie-secret` 도 단수다.** + +``` + 식별자 있음 → 읽기는 여러 key, 쓰기는 하나 → 겹침 가능 + 식별자 없음 → 전부 한 번에 바뀐다 → 겹침 불가 +``` + +--- + ## 6. 재현 절차 (명령어) ```bash diff --git a/docs/experiment-d1-backup-restore.md b/docs/experiment-d1-backup-restore.md index 51b50a2..1fd3c2f 100644 --- a/docs/experiment-d1-backup-restore.md +++ b/docs/experiment-d1-backup-restore.md @@ -215,6 +215,45 @@ volatile 이었다면 세션은 애초에 DB 에 없으므로 **복구해도 전 --- +--- + +## 개념 + +### `pg_dump` 의 두 옵션 + +| 옵션 | 없으면 | +|---|---| +| `--clean` | 복구 시 기존 객체와 충돌 | +| `--if-exists` | 없는 객체 DROP 에서 오류가 쏟아진다 | + +### "DB 가 살아 있다" 와 "데이터가 있다" 는 다르다 + +``` + A-2 DB 프로세스 정지 → 커넥션 실패 → readiness DOWN → 파드가 Service 에서 빠짐 + D-1 스키마만 삭제 → 커넥션 정상 → readiness UP → ★ 파드가 그대로 트래픽을 받는다 +``` + +**헬스체크는 커넥션만 본다.** 그래서 빈 데이터베이스를 통과시킨다. +그리고 Keycloak 이 realm 캐시로 일부를 계속 서빙해 **부분적으로만 깨진다.** + +### RPO 는 두 겹이다 + +``` + ① 마지막 덤프 이후의 변경 ← 백업 주기가 정한다 + ② synchronous_commit OFF 손실 ← A-3 에서 측정한 수백 ms + 실제 RPO = ① + ② +``` + +**백업 주기만 보고 RPO 를 말하면 ②를 빠뜨린다.** + +### 백업의 장애 도메인 + +이번 덤프는 `test-server:/tmp` 에 있었다. **호스트가 죽으면 같이 사라진다.** +A-4 에서 PVC 가 노드에 묶인 것과 같은 문제이며, +**같은 장애 도메인에 있는 백업은 백업이 아니다.** + +--- + ## 6. 재현 절차 (명령어) ```bash diff --git a/docs/experiment-index.md b/docs/experiment-index.md index f9ddf35..dbfa785 100644 --- a/docs/experiment-index.md +++ b/docs/experiment-index.md @@ -26,7 +26,21 @@ | **D-1** | 백업·복구 | `...d1-backup-restore` | **빈 데이터베이스가 `200` 을 냈다** | | **D-2** | 버전 업그레이드 | `...d2-version-upgrade` | **이미지를 되돌려도 스키마는 안 돌아온다** | | **D-3** | 비밀 관리 | `...d3-secret-management` | **RBAC 만 실제로 감춘다** | -| **D-4** | 인증서 갱신 | `...d4-certificate-renewal` | 구성은 정상. **강제 갱신은 못 했다** | +| **D-4** | 인증서 갱신 | `...d4-certificate-renewal` | 구성은 정상. **강제 갱신은 사람 손이 필요** | +| **후속** | 미측정 항목 채우기 | `...followup-untested-items` | 정방향 업그레이드 무중단 · **롤백 불가는 조건부였다** · role 변경은 요청으로 반영 안 됨 | + +## 시각 자료 + +| | | +|---|---| +| [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg) | 실험대 전체 구조 | +| [`diagrams/_style.md`](diagrams/_style.md) | 다이어그램 규약 (붉은 점선 = 죽인 것) | +| `diagrams/*.svg` | 실험별 구조도 12개 | +| `evidence/*/*.png` | Grafana · 브라우저 스크린샷 | + +**B층(B-1·B-3·B-4·B-5)에는 Grafana 증거가 없다.** 안 찍은 것이 아니라 +Prometheus 가 Redis·BFF·PostgreSQL 을 긁지 않는다 — +[`followup/04-observability-gap.txt`](evidence/followup/04-observability-gap.txt) 에 측정해 두었다. ## 문서 지도