docs: add SVG diagrams, explicit concept sections and the diagram convention

Twelve SVG architecture diagrams cover the experiments whose documents had little or no structure drawing, embedded under a 구조 heading with a shared convention file. Seven documents carried their concepts under narrative headings and now have an explicit 개념 section so they can be found.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-04 16:20:05 +09:00
co-authored by Claude Opus 5
parent 98a74e90a5
commit 78b270559c
9 changed files with 275 additions and 1 deletions
@@ -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 [ 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 [ 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 [ 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
+26
View File
@@ -137,6 +137,32 @@ readiness 프로브가 이 전환을 정확히 맞춰준다 — A-2 에서 본
--- ---
---
## 개념
### StatefulSet 롤링 재시작의 무중단 조건
```
한 번에 하나씩 내린다 + readiness 로 전환 시점을 맞춘다
└─ 항상 최소 하나는 Ready 다
```
**두 가지가 다 있어야 성립한다.** replica 1 이면 반드시 끊기고,
readiness 프로브가 없으면 아직 기동 중인 파드로 트래픽이 간다.
### 룩어사이드 캐시가 재시작을 견디는 이유
| | 재시작 후 |
|---|---|
| 캐시 (프로세스 메모리) | **사라진다** |
| DB (진실의 원천) | 남는다 |
| 정확성 | **유지된다** — 첫 접근만 느려진다 |
A-0 에서 세운 모델이 여기서 그대로 확인된다.
---
## 6. 재현 절차 (명령어) ## 6. 재현 절차 (명령어)
```bash ```bash
+38
View File
@@ -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 <pod> -- wget -qO- http://localhost:8083/actuator/beans
```
---
## 6. 재현 절차 (명령어) ## 6. 재현 절차 (명령어)
```bash ```bash
@@ -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. 재현 절차 (명령어) ## 8. 재현 절차 (명령어)
```bash ```bash
@@ -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. 재현 절차 (명령어) ## 7. 재현 절차 (명령어)
```bash ```bash
@@ -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. 재현 절차 (명령어) ## 7. 재현 절차 (명령어)
```bash ```bash
@@ -218,6 +218,43 @@ _oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a ← ★ 옛 세션. 남아 있
--- ---
---
## 개념
### 상태를 어디에 두는가가 공유 문제의 성격을 정한다
| | 상태 위치 | replica 간 공유 |
|---|---|---|
| BFF | **서버 메모리 / Redis** | **저장소를 공유해야** 한다 |
| oauth2-proxy | **쿠키 (서명·암호화)** | **secret 만 같으면** 된다 |
**공유할 상태가 없으면 공유 문제도 없다.** 대신 secret 이 단일 지점이 된다.
### 세션 티켓
`--session-store-type=redis` 를 쓰면 쿠키에는 **티켓**만 담긴다.
```
_oauth2_proxy=<ticket>|<timestamp>|<mac>
└─ Redis 키를 여기서 계산한다
```
**secret 이 바뀌면 티켓을 못 푼다 → Redis 키를 계산할 수 없다 →
정리도 못 한다.** 고아 세션이 남는 이유다.
### key 식별자가 없으면 회전에 겹침이 없다
B-6 에서 Keycloak 은 `kid` 로 여러 키를 구분해 무중단 회전을 했다.
**oauth2-proxy 의 쿠키에는 그런 식별자가 없고, `--cookie-secret` 도 단수다.**
```
식별자 있음 → 읽기는 여러 key, 쓰기는 하나 → 겹침 가능
식별자 없음 → 전부 한 번에 바뀐다 → 겹침 불가
```
---
## 6. 재현 절차 (명령어) ## 6. 재현 절차 (명령어)
```bash ```bash
+39
View File
@@ -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. 재현 절차 (명령어) ## 6. 재현 절차 (명령어)
```bash ```bash
+15 -1
View File
@@ -26,7 +26,21 @@
| **D-1** | 백업·복구 | `...d1-backup-restore` | **빈 데이터베이스가 `200` 을 냈다** | | **D-1** | 백업·복구 | `...d1-backup-restore` | **빈 데이터베이스가 `200` 을 냈다** |
| **D-2** | 버전 업그레이드 | `...d2-version-upgrade` | **이미지를 되돌려도 스키마는 안 돌아온다** | | **D-2** | 버전 업그레이드 | `...d2-version-upgrade` | **이미지를 되돌려도 스키마는 안 돌아온다** |
| **D-3** | 비밀 관리 | `...d3-secret-management` | **RBAC 만 실제로 감춘다** | | **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) 에 측정해 두었다.
## 문서 지도 ## 문서 지도