# 세션은 어디에 있는가 — Keycloak 다중 노드 실험 26건의 기록 Keycloak 을 두 대로 늘리면 세션은 어떻게 되고, 저장소를 Redis 로 옮기면 무엇이 달라지며, 그 저장소가 죽으면 어떻게 복구하는가. 이 문서는 그 물음들에 설계가 아니라 **측정으로** 답한 기록이다. 실험 26건과 증거 파일 147개가 들어 있고, 그 안에는 **아홉 번의 조용한 주입 실패**와 **틀린 예측 다섯 개**도 지우지 않고 남겨 두었다. 왜 지우지 않았는지는 마지막 절에서 따로 적는다. --- ## 코드보다 먼저 드러난 문제 ### 답할 수 없던 질문 네 개 앞선 작업([인증 패턴 네 가지](../../keycloak/final/document.md))은 네 가지 인증 패턴의 경계를 설계한 뒤 끝에 **열린 질문 네 개**를 남겼다. 넷 다 설계로는 답이 나오지 않고 실제로 돌려봐야 아는 것들이었다. | | 질문 | |---|---| | Q1 | 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가 | | Q2 | Refresh Token Rotation 과 다중 Replica 경쟁을 어떻게 처리할 것인가 | | Q3 | BFF 의 Session 과 OAuth2AuthorizedClient 를 어디에 저장할 것인가 | | Q4 | Forward-Auth 구조에서 Application Authorization 을 어디까지 Edge 에 둘 것인가 | 네 질문은 같은 전제 위에 서 있다. **인스턴스가 둘 이상이고, 요청이 어느 쪽으로 갈지 모른다**는 것인데, 인스턴스가 하나면 이 질문들은 애초에 생기지 않으므로 전제를 갖춘 환경이 먼저 있어야 했다. 그래서 인스턴스를 둘로 만들고 그 사이를 끊어 보고 저장소를 죽여 보는 실험대를 세웠다. ### 그런데 첫 실험에서 전제가 무너졌다 실험대를 세우고 가장 먼저 확인한 것은 「한 노드에서 만든 세션을 다른 노드가 쓸 수 있는가」였고, 답은 **그렇다**였다. 그런데 **그렇게 되는 이유가 예상과 달랐다.** 로그만 보면 클러스터가 형성됐고 그것이 원인처럼 보인다. ``` ISPN000094: Received new cluster view for channel ISPN: [keycloak-0-10001|1] (2) [keycloak-0-10001, keycloak-1-52537] ``` `JGROUPS_PING` 테이블에도 둘 다 등록되어 있으니 「Infinispan 이 세션을 복제한다」고 읽기 쉬운데, 확인해 보니 그렇지 않았다. 노드 A 로 로그인하고 노드 B 로 refresh 했을 때 **노드 B 가 PostgreSQL 로 날린 SQL 을 문장 로깅으로 직접 잡았다.** 세션 엔트리는 노드 사이를 건너가지 않으며 각 노드는 자기가 처리한 로그인만 캐시하므로, 두 노드가 같은 답을 내놓는 이유는 복제가 아니라 **같은 데이터베이스를 보기 때문**이다. ![세션 공유가 실제로 지나는 경로](assets/session-sharing-path/session-sharing-path.svg) 두 노드는 `JGROUPS_PING` 으로 서로를 발견해 클러스터 뷰를 만들지만 `sessions` 캐시 사이에는 경로가 없어서, 같은 답이 나오는 것은 둘 다 `OFFLINE_USER_SESSION` 을 읽기 때문이다. > **클러스터가 형성됐다는 것과 세션이 복제된다는 것은 다른 얘기였다.** 이 하나가 이후 실험 전체의 해석을 바꿔 놓았다. 「클러스터를 끊으면 세션 공유가 깨질 것」이라는 예측이 A-1 에서 빗나간 이유도 여기에 있었다. ### 그리고 이 결론에는 버전 조건이 붙어 있었다 Keycloak 26 은 `persistent-user-sessions` 가 기본값이라 세션을 DB 에 쓰는데, 24 이전은 그렇지 않아서 메모리에 두고 Infinispan 으로 복제했다. 같은 실험을 `--features-disabled=persistent-user-sessions` 로 다시 돌리자 **세 결과가 정반대로 뒤집혔다.** | 실험 | persistent (26 기본) | volatile (24 이전) | |---|---|---| | A-1 · 7800 차단 후 교차 노드 refresh | `200` — 안 깨진다 | `400 Session not active` — 깨진다 | | A-8 · 롤링 재시작 후 refresh | `200` — 세션 생존 | `400 Session not active` — 전원 로그아웃 | | A-2 · DB 정지 중 새 로그인 | `500` | `200` — 된다 | 그래서 **「Keycloak 은 이렇다」고 쓸 수 있는 문장이 거의 없다.** 버전과 설정을 같이 적지 않으면 절반은 틀린 말이 되기 때문이다. ![설정에 따라 갈리는 세션의 거처](assets/version-conditional-results/version-conditional-results.svg) 설정 하나가 세션의 거처를 바꾸고, 그 거처가 장애 결과를 결정한다. --- ## 문제를 어렵게 만든 제약 ### 실험대 베어메탈 한 대(`test-server`, Arch Linux, 12GB, WiFi only) 위에 VM 두 대를 올렸다. | | 역할 | |---|---| | kc-lab-1 | k3s server (컨트롤 플레인) · keycloak-1 | | kc-lab-2 | k3s agent · keycloak-0 · PostgreSQL · Redis | | 호스트 nginx | Let's Encrypt TLS 종단 → traefik 으로 프록시 | ![실험대의 구성](assets/lab-topology/lab-topology.svg) 저장소가 kc-lab-2 한 곳에 몰려 있어서 A-4 의 두 결과가 이 배치에서 갈린다. 이름 셋(`auth` · `app1` · `app2`)이 한 인증서의 SAN 에 들어 있고 와일드카드가 아닌데, 이 제약은 나중에 실제 비용을 청구했다. oauth2-proxy 실험(B-7)을 할 때 네 번째 이름이 없어 **Grafana 의 `app2` 를 빌려야 했다.** ### 게스트와 호스트의 sudo 가 다르다 kc-lab-1/2 는 무암호 sudo 라 `conntrack`·`tc`·`iptables` 를 자유롭게 썼지만 **호스트는 비밀번호를 요구한다.** 이 차이를 모르고 한동안 nginx 설정을 읽으려 했는데 계속 빈 결과가 나왔고, **sudo 가 조용히 실패하고 있었다.** 하마터면 「빈 로그」를 「아무 일도 없음」으로 읽을 뻔했다. 호스트에서 해야 하는 일(인증서 강제 갱신, nginx reload)은 결국 **사람이 직접 쳐야** 했으므로, D-4 에서는 「명령 한 줄을 헛되이 쓰지 않는 것」이 설계의 일부가 됐다. ### 주입이 먹지 않는다 — 아홉 번, 전부 조용히 이 실험대에서 가장 많은 시간을 쓴 곳인데, **주입이 실패하면 「아무 일도 없었다」로 보이고 그 모습은 「영향이 없다」와 구별되지 않기 때문이다.** | # | 무엇을 했나 | 왜 안 먹었나 | |---|---|---| | 1 | NetworkPolicy 로 7800 차단 | **conntrack** — ESTABLISHED 연결은 규칙 평가를 건너뛴다. `cluster_size` 가 25분간 2 로 남았다 | | 2 | `kubectl delete --grace-period=0 --force` | **크래시가 아니다.** 런타임이 SIGTERM 을 보내 PostgreSQL 이 정상 플러시했다 | | 3 | `kill -9 1` | **PID 1 은 자기 네임스페이스의 SIGKILL 을 무시한다** | | 4 | `iptables -I FORWARD 1` | **kube-router** 가 자기 체인을 FORWARD 맨 위에 다시 끼워 넣는다 (패킷 0) | | 5 | raw 규칙을 한쪽 노드에 | **방향이 뒤집혀 있었다.** JGroups 의 client/server 역할은 재시작마다 바뀐다 | | 6 | `tc ... dev eth0` | **Debian 은 `enp1s0`** 이고, flannel VXLAN 이 이미 캡슐화해 파드 IP 가 안 보인다 | | 7 | `spring.sql.init` 로 스키마 생성 | 기본 DDL 이 `blob` 인데 PostgreSQL 은 `bytea` 다. `continue-on-error: true` 가 삼켰다 | | 8 | 호스트에서 `sudo` | **비밀번호를 요구한다.** 빈 출력이 곧 실패였다 | | 9 | `kubectl run --rm -i` 로 동시 20건 | **일회성 파드의 stdout 이 유실된다.** 20줄 중 일부만 도착하거나 아예 끊긴다 | 여기서 배운 규칙이 하나 있고, 이후 모든 실험에 적용했다. > **주입했다고 걸린 것이 아니다.** > 주입 뒤에는 「대상이 실제로 그 상태인가」를 따로 확인한다. > `cluster_size`, 워커 PID, conntrack 표, 패킷 카운터 — 결과가 아니라 상태를 본다. ![주입이 걸렸는지 따로 확인한다](assets/injection-verification/injection-verification.svg) 아홉 번의 실패가 모두 같은 단계에서 생겼다 — 주입과 관측 사이가 비어 있었다. --- ## 검토한 선택지와 막힌 지점 ### 관측을 어디에 둘 것인가 처음에는 밖에서만 쟀다. `curl` 로 외부 진입점을 찍고 상태 코드를 셌는데 **A-1 에서 그 방식이 무너졌다.** 7800 을 끊었는데도 외부 응답이 전부 200 이었기 때문이다. 장애가 없어서가 아니라 **분단된 노드가 readiness 실패로 스스로 로드밸런서에서 빠졌기** 때문이었고, 밖에서만 보면 이 실험은 「아무 일도 없음」으로 끝난다. 그래서 관측 지점을 셋으로 늘렸다. | 지점 | 무엇을 보는가 | |---|---| | 외부 `curl` | 사용자가 겪는 것 | | Prometheus 지표 | `vendor_cluster_size` · `vendor_jgroups_*` · `agroal_*` | | PostgreSQL 직접 조회 | 실제로 무엇이 저장됐는가 | `up` 지표를 신뢰할 수 없다는 것도 여기서 나왔다. A-2 에서 **503 이 나는 동안에도 `up` 은 1 이었는데**, 프로세스가 살아 있고 `/metrics` 가 응답하기만 하면 1 이 되므로 **「살아 있지만 쓸모없는」 상태를 못 보기 때문이다.** ![관측을 어디에 둘 것인가](assets/observation-points/observation-points.svg) 세 지점이 서로 다른 층을 보기 때문에, 하나만 두면 그 층의 사각이 그대로 사각으로 남는다. ### 스크립트를 쓰지 않는다 절차를 스크립트로 감싸면 「무엇을 했는지」가 스크립트 안으로 숨는다. 그래서 모든 절차를 **셸에 그대로 붙여넣을 수 있는 명령**으로 적었다. 이 결정에는 대가가 따랐다. 나중에 재현 절차를 점검해 보니 `( curl ... ) & 를 20개 띄우고 wait` 처럼 **측정 장치 자체를 산문으로 적어 둔 곳이 여럿** 있었고, 22.2초라는 헤드라인 수치를 만든 부하 생성기부터가 실행 가능한 형태가 아니었다. 전부 셸 표현식으로 바꾸고 **실제로 돌려서 확인**했더니 그 확인에서 한 건이 깨졌다(위 표의 #9). 문법은 멀쩡한데 실행하면 조용히 실패하는 종류였다. > **「명령을 실행 가능하게 고쳤다」와 「고친 명령이 동작한다」는 다른 주장이다.** --- ## 선택의 이유와 지킨 경계 ### A층 — Keycloak 자체가 깨질 때 여덟 개 실험을 같은 모양으로 돌렸다. 예측을 **먼저 문서에 적어 두고** 주입한 뒤 관측하고, 마지막에 그 예측과 대조하는 순서였다. #### A-1 · JGroups 전송(TCP 7800) 차단 예측을 둘 세웠고 하나는 맞고 하나는 틀렸다. | 예측 | 결과 | |---|---| | 세션 공유는 안 깨진다 | **맞다.** 교차 노드 refresh 가 `200` | | 로그아웃 전파는 안 깨진다 | **틀렸다.** `400` 이어야 할 것이 `200` | 세션은 DB 에 있으니 7800 과 무관한데, **로그아웃 무효화 통지는 7800 을 타기 때문에** 끊으면 반대편 노드가 「이 세션은 죽었다」를 알 방법이 없다. NetworkPolicy 는 허용목록이라 「deny 7800」 같은 규칙을 쓸 수 없으므로, 8080·9000 만 열고 7800 을 **목록에서 빼는 방식으로** 막았다. ![발견과 전송은 다른 경로다](assets/a1-transport-vs-discovery/a1-transport-vs-discovery.svg) 발견은 DB 를 쓰고 전송은 7800 을 쓴다 — 예측이 하나만 맞은 이유가 이 분기에 있다. 이 두 포트가 하중을 진다 — 9000(health·metrics)을 빠뜨리면 kubelet 이 파드를 죽여서 **분단이 아니라 죽은 Keycloak 을 재게 된다.** #### A-2 · A-3 — DB 가 멈출 때와 죽을 때 | | A-2 정지 | A-3 강제 종료 | |---|---|---| | 새 로그인 | `500` | — | | 관측된 것 | **`up` 이 계속 1** | **RPO 가 0 이 아니다** | A-3 이 이 실험대에서 가장 값진 숫자를 냈다. ``` 클라이언트가 200 과 토큰을 받은 로그인 : 153 건 그중 DB 에 실제로 존재 : 149 건 ★ 유실 : 4 건 ``` **로그인이 성공했다고 응답받았는데 세션이 존재하지 않는다.** 버그는 아니고, Keycloak 이 트랜잭션마다 `SET LOCAL synchronous_commit TO OFF` 를 걸기 때문에 COMMIT 이 WAL 디스크 기록을 기다리지 않고 즉시 반환한다. 그 사이(측정된 `wal_writer_delay` 200ms)에 프로세스가 죽으면 그만큼이 사라진다. 의도된 설계이므로, 이 실험은 그 대가가 얼마인지를 숫자로 확인했을 뿐이다. ![200 과 디스크 사이의 빈 구간](assets/a3-commit-to-disk-gap/a3-commit-to-disk-gap.svg) 성공 응답과 영속화가 다른 사건이어서, RPO 가 0 이 아닌 이유가 그 사이에 있다. #### A-4 · 노드 상실 — 둘 다 전면 장애지만 이유가 다르다 | | 4a 워커 상실 | 4b 컨트롤 플레인 상실 | |---|---|---| | 외부 응답 | `503` | `000` (연결 자체가 안 됨) | | `kubectl` | 정상 | 불통 | | 원인 | **DB 가 같이 죽었다** | **들어갈 길이 없다** | | 복구 | `virsh start` 이후 60초 | `virsh start` 이후 60초 | 4b 에서 keycloak-0 은 **계속 돌고 있었는데도** 도달할 수 없어서 장애였다. **진입점이 단일 노드에 있으면 워크로드를 이중화해도 소용이 없다.** 그리고 예상하지 못한 것이 셋 더 나왔다. 1. **죽은 파드가 산 파드보다 건강해 보인다** — kubelet 이 사라져 상태가 갱신되지 않으니 `Running` 으로 남는다 2. **StatefulSet 은 Terminating 파드의 대체를 만들지 않는다** — 이름이 같아야 하므로 지워지기를 기다린다 3. `node-monitor-grace-period` 40초 + `tolerationSeconds` 300초 = 축출까지 **5분 40초** > 장애 시간의 대부분은 복구가 아니라 **「누가 죽은 것을 알아채는 데」** 걸린 시간이었다. ![노드를 잃는 두 가지](assets/a4-two-node-losses/a4-two-node-losses.svg) 저장소 상실과 진입 경로 상실은 복구 시간이 같아도 대비하는 방법이 다르다. #### A-5 · 비대칭 분단 — 전면 장애 경로가 없다 한 방향만 막으면 **열린 방향으로 재연결하므로** 클러스터가 갈라지지 않는다. 양방향을 다 막으면 갈라지기는 하는데 **한쪽만 DOWN** 이 되어서, 코디네이터 쪽이 살아남고 분단된 쪽은 스스로 로드밸런서에서 빠지며 서비스는 이어진다. 이 실험에서 주입을 세 번 실패했다(위 표의 #4·#5·#6). **세 번 모두 다른 이유였고, 셋 다 「아무 일도 없었다」로 보였다.** ![비대칭 차단은 가르지 못한다](assets/a5-partition-asymmetry/a5-partition-asymmetry.svg) 한 방향을 막을 때와 둘을 다 막을 때의 차이가 여기서 갈린다. #### A-6 · 지연 주입 — 200밀리초가 22초가 된다 | 측정 | 값 | |---|---| | 주입한 네트워크 지연 | 200 ms | | 로그인 응답 시간 | 66 ms → **1,872 ms** (28배) | | 동시 20건에서 최대 응답 | **22.2 초** | | 커넥션 획득 대기 최대 | 20,000 ms | | readiness 프로브 | **타임아웃으로 실패** | 두 단계로 곱해지는데, 로그인 한 번이 DB 왕복을 여러 번 하므로 **왕복 횟수만큼** 더해지고 요청이 커넥션을 오래 붙들어 뒤의 요청이 풀에서 대기하므로 **거기서 한 번 더** 곱해진다. 마지막에는 파드가 죽는다. readiness 가 타임아웃으로 실패해 느린 노드가 로드밸런서에서 빠지므로, **느림이 곧바로 장애로 승격된다.** ![지연이 곱해지는 두 단계](assets/a6-latency-multiplication/a6-latency-multiplication.svg) 왕복 누적과 풀 경합을 하나로 보면 28배가 어디서 왔는지 설명되지 않는다. #### A-8 · 롤링 재시작 — 세션은 살아남고 캐시만 사라진다 | 확인 | 결과 | |---|---| | 재시작 중 서비스 중단 | 없음. 전 구간 `200` | | 재시작 전 발급한 refresh token | 여전히 `200` | | DB 세션 수 | 151 → **151** 그대로 | | 세션 캐시 | **0 으로 초기화** | `persistent-user-sessions` 를 켜는 진짜 이유가 여기에 있다. ![재시작이 지우는 것과 남기는 것](assets/a8-cache-vs-session/a8-cache-vs-session.svg) 캐시와 세션을 분리하지 않으면 재시작 후 로그인이 유지되는 이유를 설명할 수 없다. #### A-7 · A-7a — 전부 뒤집는 설정 하나, 그리고 그 표에도 조건이 있었다 A-7 은 `--features-disabled=persistent-user-sessions` 로 A층을 다시 돌려 세 결과가 뒤집히는 것을 보였다. 그리고 **refresh 가 `500` 인 이유를 가설로 남겼다** — `REVOKED_TOKEN` 테이블일 것이라고. A-7a 에서 문장 로깅으로 확정했더니 **가설이 틀렸다.** 로그인은 SQL 을 **0개** 쏜다. refresh 는 딱 한 문장을 쏘는데, 그것이었다. ``` select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0 where cscme1_0.CLIENT_ID=$1 and cscme1_0.DEFAULT_SCOPE=$2 parameters: $1 = '131a9912-b578-4b9c-b16a-97518704077e', $2 = 'f' ``` `REVOKED_TOKEN` 은 한 번도 나오지 않으며, `DEFAULT_SCOPE='f'` 이므로 **선택적 클라이언트 스코프** 조회다. refresh 는 새 access token 에 어떤 스코프를 담을지 다시 계산하는데 그 목록이 이 테이블에 들어 있다. 그 다음이 더 중요하다. **그 조회는 첫 refresh 에서 한 번만 일어나고 캐시된다.** 그래서 같은 설정이 캐시 온도만으로 세 가지 답을 낸다. | 캐시 상태 | 로그인 | refresh | 실패한 SQL | |---|---|---|---| | 완전 냉시동 | **400** | 400 | `select ce1_0.ID from CLIENT ...` | | CLIENT 만 더움 ← A-7 이 본 것 | 200 | **500** | `CLIENT_SCOPE_CLIENT ...` | | 완전히 더움 | 200 | **200** | 없음 (SQL 0건) | 셋 다 재현해 보니 **A-7 이 적은 「volatile 이면 DB 없이 로그인된다」도 조건부였는데**, 냉시동에서는 클라이언트 조회조차 캐시에 없어 `400` 이 나온다. > volatile 에서 DB 정지 시의 동작은 「무엇을 하느냐」가 아니라 > **「그 경로가 이미 캐시를 채웠느냐」** 로 결정된다. > 이런 종류는 **한 번 재고 표로 적으면 안 된다.** ![캐시 온도가 결과를 가른다](assets/cache-temperature-outcomes/cache-temperature-outcomes.svg) 세 결과를 만드는 것은 조회 두 개이고, 캐시가 그 조회를 삼키는 순간 결과가 바뀐다. --- ## 선택이 코드와 흐름에 반영되는 방식 ### B층 — 열린 질문 네 개에 대한 답 A층이 Keycloak 자체를 다뤘다면 B층은 **애플리케이션 쪽**이다. BFF(Spring Boot) 두 인스턴스와 Redis, 그리고 oauth2-proxy 두 replica 를 올리고 잰다. #### B-0 · 아무것도 설정하지 않으면 무엇이 선택되는가 저장소를 붙이기 **전에** 먼저 들여다봤고, 추측으로 두면 안 되는 이유가 여기서 나왔다. ``` authorizedClientService → InMemoryOAuth2AuthorizedClientService authorizedClientRepository → AuthenticatedPrincipalOAuth2AuthorizedClientRepository SessionRepository → 없음 (서블릿 컨테이너 in-memory) Redis / Spring Session → 없음 ``` 둘째 줄이 핵심인데, **`AuthenticatedPrincipalOAuth2AuthorizedClientRepository` 는 principal 이름으로 찾기 때문에 조회 키에 session id 가 없다.** 그래서 이름은 비슷해도 서로 다른 것을 저장하는 두 개가 따로 굴러간다. | | 무엇을 담나 | 조회 키 | |---|---|---| | Application Session | 누가 로그인했는지 | **세션 id** | | OAuth2AuthorizedClient | access · refresh token | **principal 이름** | 이 둘을 하나로 생각하면 다음 실험의 결과를 해석할 수 없다. ![BFF 가 저장하는 두 가지와 그 조회 키](assets/bff-store-lookup-keys/bff-store-lookup-keys.svg) 같은 요청이 세션은 세션 id 로, 토큰은 principal 이름으로 두 갈래로 조회되기 때문에, B-1 에서 세션만 Redis 로 옮겼을 때 토큰이 따라오지 않았고 B-2 에서 따로 PostgreSQL 로 옮겨야 했다. #### B-1 · 세션만 Redis 로 옮기면 — 반쪽만 옮겨진다 `SPRING_SESSION_STORE_TYPE=redis` 로 Application Session 을 Redis 로 옮기자 파드를 재시작해도 로그인이 유지됐지만 **토큰은 같이 살아남지 못했다.** 조회 키가 다르므로 세션 저장소를 바꿔도 `OAuth2AuthorizedClient` 는 따라오지 않는다 — B-0 에서 확인한 그대로다. #### B-2 · 저장소를 나눠 풀자 다른 두 문제가 남았다 토큰은 `JdbcOAuth2AuthorizedClientService` 로 PostgreSQL 에 옮겼고, 이것이 **Q3 가 말한 「각각 설계한다」를 실제로 해 본 모습**이다. | Q1 검증 | 결과 | |---|---| | ① 다른 인스턴스로 요청해도 되는가 | **된다** | | ② 재시작 후 로그인 유지 | **된다** | | ③ 같은 사용자의 다른 브라우저가 덮어쓰는가 | **★ 덮어쓴다** | | ④ 로그아웃하면 두 저장소가 다 정리되는가 | **★ 아니다. 한쪽만** | ③④ 의 뿌리는 저장소 선택이 아니라 **DDL 한 줄**이다. ```sql PRIMARY KEY (client_registration_id, principal_name) ``` **세션 id 가 키에 없어서** 같은 사용자의 두 세션이 같은 행을 쓰게 되고, 나중 로그인이 앞의 토큰을 덮어쓴다. 로그아웃한 뒤에는 이렇게 남는다. ``` Redis 세션 : 0 키 ← 정리됨 PostgreSQL 토큰 : 1 행 ← 평문 refresh token 이 그대로 남는다 ``` ![덮어쓰기를 만드는 기본키](assets/b2-primary-key-overwrite/b2-primary-key-overwrite.svg) 저장소를 무엇으로 골랐느냐가 아니라 스키마가 원인이다. #### B-3 · Refresh Token Rotation 경쟁 (Q2) `revokeRefreshToken=true` · `refreshTokenMaxReuse=0` 에서 같은 refresh token 으로 동시에 5건을 보냈다. 순차로 돌리면 재현되지 않는다 — `&` 와 `wait` 이 있어야 경합이 생긴다. 경쟁이 감지되면 Keycloak 이 client session 을 지우기 때문에, 이긴 요청이 받은 **새 토큰조차 쓸 수 없다.** 「하나는 성공하고 나머지가 실패한다」가 아니라 **전부 못 쓰게 되는** 쪽이다. ![회전 경쟁에서 이긴 요청도 진다](assets/b3-rotation-contention/b3-rotation-contention.svg) 실패가 진 요청에만 오는 것이 아니어서, 재시도를 어떻게 설계할지가 여기서 갈린다. #### B-4 · Edge 인가의 범위 (Q4) nginx → oauth2-proxy → 앱의 2홉 구조에서 헤더를 위조해 봤다. 여기서도 예측이 틀렸다. **nginx 는 자기가 설정하지 않은 동명 헤더를 덮어쓰지 않기 때문에** `proxy_set_header X-Auth-Request-Roles ""` 로 먼저 지우지 않으면 위조 헤더가 그대로 통과한다. 그리고 **IdP 에서 값을 바꿔도 반영되지 않는다.** 12회 요청·6초 동안 옛 값이 갔고, Redis 세션을 지워 재인증시킨 뒤에야 새 값이 왔다. > **세션은 로그인 시점의 스냅샷이어서**, `--cookie-refresh` 가 없으면 > 요청을 몇 번 보내든 쿠키 만료나 재인증까지 옛 값이 그대로 간다. ![지우지 않으면 통과한다](assets/b4-header-trust-boundary/b4-header-trust-boundary.svg) 위조 경로와 정상 경로가 같은 헤더 이름을 쓰므로, 지우는 단계가 없으면 둘을 구별할 수 없다. #### B-5 · B-6 — 저장소 상실과 키 회전 B-5 에서 `redis-cli config set appendonly yes` 를 켜도 아무것도 달라지지 않았는데, `/data` 가 컨테이너 파일시스템이라 컨테이너와 함께 죽기 때문이다. **볼륨 없는 영속화 설정은 장식에 그친다.** B-6 에서 realm 키를 회전하고 JWKS 캐시의 유예 구간을 기대했는데 **없었다.** `NimbusJwtDecoder` 는 모르는 `kid` 를 만나면 JWKS 를 다시 가져온다. ![볼륨 없는 영속화와 유예 없는 회전](assets/b5-b6-storage-and-keys/b5-b6-storage-and-keys.svg) 설정과 매체를 나눠서 보아야 한다. 설정만 보면 둘 다 되어 있는 것으로 읽히기 때문이다. #### B-7 · B-7a — 쿠키에 담는 세션, 그리고 그 대가 oauth2-proxy 는 BFF 와 정반대로 **서버 상태를 갖지 않는다.** 세션 전체가 쿠키에 있고 replica 는 같은 k8s Secret 만 읽으므로, 공유할 것이 없어서 콜백이 다른 replica 로 가도 문제가 없다. 대신 `--cookie-secret` 이 단수라서 **겹침 구간을 만들 수 없다.** 「옛 secret 도 당분간 받아준다」가 불가능하므로, 교체하는 순간 모든 쿠키가 한꺼번에 무효가 된다. Redis 세션 저장소를 켜면 쿠키에는 티켓만 남는데, 그러면 문제의 성격이 바뀐다. secret 을 바꾸면 티켓을 못 풀고, **티켓 안에 세션 id 가 있으므로 어느 Redis 키를 지울지도 모른다.** ``` Error removing session: error decoding ticket to clear session ``` B-7 은 여기서 「지우지 못했다」로 멈췄다. B-7a 가 이어받아 잰 결과 — **oauth2-proxy 의 한계이지 Redis 의 한계가 아니었다.** | 물음 | 답 | |---|---| | 고아는 정말 사라지는가 | **사라진다.** 생성 후 정확히 1시간. TTL 이 갱신되지 않는다 | | 운영자가 지울 수 있는가 | **있다.** `redis-cli del` 후에도 산 세션은 `200` | | 어느 것이 고아인지 아는가 | **Redis 값으로는 모른다.** 이름·타입·크기(3510바이트)가 같고 값은 암호화 | | 그럼 어떻게 고르는가 | **TTL 로 생성 시각을 역산한다** | TTL 이 요청으로 갱신되지 않으므로(`refresh:disabled`) **TTL 은 생성 시각의 정확한 함수**다. ``` 생성시각 = 지금 − (cookie-expire − TTL) ``` 이 값이 회전 시각보다 이르면 고아인데, 역산한 `11:30:26` 과 로그의 `AuthSuccess 11:30:27` 이 **1초 차**였다. 그 기준으로 실제로 골라 지웠고 산 세션만 남았다. 전제도 같이 적는다 — **`--cookie-refresh` 를 켜면 이 역산이 무너진다.** 그때는 `FLUSHDB` 로 전부 지우고 모두 재인증시키는 편이 정직하다. ![쿠키에 담으면 공유할 것이 없다](assets/b7-cookie-session-tradeoff/b7-cookie-session-tradeoff.svg) 쿠키 저장과 Redis 저장을 나란히 두면, 옮기는 순간 지울 수 없는 상태가 생기는 것이 보인다. ### C층 — SSO 와 로그아웃 전파 C-1 에서 두 앱이 같은 realm 으로 SSO 되는 것을 확인하면서 로그아웃이 다른 앱으로 퍼지지 않는 것도 함께 관측했고, C-2 에서 들여다본 원인은 단순했다. | 확인 | 결과 | |---|---| | 백채널 로그아웃이 설정되어 있었는가 | **아니다.** 두 클라이언트 모두 `backchannelLogoutUrl` 없음 | | 앱에 그 엔드포인트가 있는가 | **아니다.** 소스에 `oidcLogout` 설정이 없다 | | IdP 쪽만 설정하면 되는가 | **★ 안 된다.** 앱 세션이 그대로 남았다 | | Keycloak 이 앱 URL 에 닿기는 하는가 | 닿는다 (`HTTP 200`) — 네트워크 문제가 아니다 | **아무도 구현하지 않았다.** 그리고 「설정이 빠졌다」와 「기능이 없다」는 고치는 방법이 다른데 여기는 둘 다였으므로, 확인 순서를 바꿨다면 한쪽만 고치고 끝냈을지도 모른다. ![백채널 로그아웃은 양쪽이 있어야 한다](assets/c2-backchannel-both-sides/c2-backchannel-both-sides.svg) IdP 쪽 결손과 앱 쪽 결손이 한 경로 위에 있어서, 하나만 고쳐서는 여전히 되지 않는다. ### D층 — 운영 #### D-1 · D-2 — 백업과 업그레이드 D-2 에서 26.7.0 → 26.7.3 은 **무중단**이었지만(87회 요청 전부 200) 되돌리기는 **막혔다.** ``` liquibase ValidationFailedException: 1 changesets check sum ``` 새 버전이 남긴 체크섬을 옛 버전이 거부한다. 그런데 **서비스는 살아 있었다** — StatefulSet 롤링 업데이트가 첫 파드에서 멈추고 나머지를 건드리지 않았기 때문이다. **「롤백 계획」이 없어도 사고가 전면화되지 않았다.** 이 결론은 나중에 더 정밀해졌다. **「롤백 불가」는 조건부여서** 스키마가 움직였을 때만 해당하며 판단 기준도 하나로 정해진다. ```sql select count(*) from databasechangelog ``` 업그레이드 전후로 이 수가 같으면 롤백이 되고 늘었으면 안 되는데, 26.7.3 → 26.7.0 을 스키마 변경 없이 되돌리는 것은 **실제로 성공했다**(전환 순간 `000` 1회). ![방향에 따라 갈리는 업그레이드](assets/d2-upgrade-direction/d2-upgrade-direction.svg) 체크섬 검증은 되돌리기를 막고, 롤링 업데이트는 그 사고의 피해를 줄인다. #### D-3 · 비밀 `kubectl get secret -o yaml` 의 base64 는 암호화가 아니라 인코딩이므로 etcd 에는 평문으로 있고, 파드 안에서 `env | grep -i secret` 을 치면 그대로 나온다. ![base64 는 암호화가 아니다](assets/d3-secret-exposure/d3-secret-exposure.svg) 인코딩과 암호화는 다르고, 드러나는 두 경로 모두 끝이 평문이다. #### D-4 · D-4a — 인증서, 그리고 이 실험대 최대의 발견 계획서의 물음은 「nginx reload 중 진행 중이던 요청은 어떻게 되는가」였고, 답하기 전에 **대조군부터** 잡았다. | 대조군 | 결과 | |---|---| | 새 연결 (0.2초 × 900회 / 180초) | **900 전부 200, 오류 0** · 중앙 98ms · p95 195ms | | 진행 중 요청 (845KB @ 20k/s) | 200 · 845361바이트 · 연결수 1 · 42.3초 완주 | 두 번째가 왜 필요했는가 하면, 첫 폴링은 **TLS 핸드셰이크가 900/900** 이라 매 요청이 새 연결이고 그래서 「새 연결을 받아주는가」만 재기 때문이다. 계획서가 물은 것은 **「진행 중이던 요청」** 이므로 reload 순간에 실제로 전송 중인 요청이 있어야 했다. 845KB 짜리 번들을 일부러 느리게 받아 요청 하나를 42초 동안 살려 두었다. 그리고 강제 갱신을 했더니 — **인증서가 바뀌지 않았다.** ``` 디스크 cert2.pem 2026-09-04 17:22:13 KST 기록됨 네트워크 일련번호 564표본 내내 옛 것. 08:58:52 에야 바뀜 ``` | | 시각 (실제 UTC) | |---|---| | 새 인증서 디스크 기록 | 08:20:27 | | 실제 서빙 시작 (`nginx -s reload`) | 08:58:52 | | **공백** | **2305초 = 38분 25초** (그 사이 428회 관측) | 그 38분은 **우연히 짧았을 뿐인데**, reload 를 시킨 것이 자동화가 아니라 사람이었기 때문이다. 아무도 치지 않았다면 다음 nginx 재시작까지, 사실상 무기한으로 옛 인증서가 나갔을 터였다. 원인이 셋 겹쳤고 **셋 다 비어 있었다.** | | 상태 | |---|---| | `certbot-renew.service` 의 `ExecStartPost` | 없음 | | `/etc/letsencrypt/renewal-hooks/{deploy,post,pre}/` | **셋 다 비었음** | | certbot 의 nginx 플러그인 | 없음 (`dns-cloudflare, manual, null, standalone, webroot`) | nginx 는 인증서를 기동 시점에 읽어 메모리에 들고 있는데 certbot 은 경로가 아니라 `live/` 심볼릭 링크를 갈아끼우므로, **설정은 멀쩡해 보이는데 옛 인증서가 나간다.** 설정을 고칠 일이 아니라 reload 를 걸 일이다. ![인증서 갱신이 서빙에 닿기까지](assets/renewal-to-serving-gap/renewal-to-serving-gap.svg) `live/` 는 심볼릭 링크라 **경로가 그대로이고 가리키는 대상만 바뀐다.** 그래서 nginx 설정을 고칠 필요가 없고, 바로 그 때문에 「설정이 그대로니 괜찮다」고 착각하기 쉽다. 필요한 것은 설정 변경이 아니라 reload 이며, 그 reload 를 부르는 경로가 이 실험대에서는 셋 다 비어 있었다. reload 가 실제로 일어났는지 가리는 방법도 여기서 나왔다. **마스터 PID 는 유지되고 워커 PID 만 바뀌면 reload 된 것**이다. ``` 585 1 80529 Thu Sep 3 19:00:39 nginx: master process 586 585 80529 Thu Sep 3 19:00:39 nginx: worker process ``` 워커가 마스터 기동 직후의 첫 fork(585→586) 그대로 22.4시간째다. **이 결함은 88일 동안 드러나지 않는다.** 타이머는 정상이고 매번 `SUCCESS` 로 끝나는데, 만료 30일 전까지는 갱신 자체를 하지 않아 발현할 기회가 없기 때문이다. 발현하는 날의 증상은 **인증서 만료**이고, 그날에도 로그에는 `SUCCESS` 라고 적혀 있을 것이다. D-4a 에서 처방(`deploy/` 훅 하나)을 실제로 넣고 검증했다. | | 훅 없음 | 훅 있음 | |---|---|---| | 갱신 → 서빙 | 2305초 = 38분 25초 | **1~2초** | | 무엇이 reload 했나 | 사람 | certbot deploy 훅 | 여기에도 함정이 하나 더 있었다. certbot 이 `Hook 'deploy-hook' ran with error output` 이라고 찍지만 **실패는 아니고**, nginx 의 `types_hash` 경고가 stderr 로 나갔을 뿐이어서 내용은 `test is successful` · `signal process started` 다. **로그에서 `error` 를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다.** reload 자체는 무중단이었다 — 새 연결 **8856건 전부 200**, p95 205.7 → 204.3ms. 그리고 전송 12초째에 reload 를 맞은 42초짜리 요청이 **845361바이트를 온전히** 받았다(연결수 1). 옛 워커가 그 요청을 끝까지 책임졌기 때문이다. ![훅 하나가 만드는 차이](assets/d4a-hook-effect/d4a-hook-effect.svg) 훅이 있고 없고가 이 차이를 만든다 — 판정은 로그 문구가 아니라 워커 PID 로 한다. --- ## 결정이 지켜지는지 확인하는 방법 ### 측정이 거짓말할 때 이 실험대가 남긴 것 중 결과표보다 오래 갈 것은 **어디서 측정이 틀리는가**다. #### 대조군 없이는 아무것도 귀속할 수 없다 D-4 에서 갱신 중 비200 이 한 번 나왔다고 하면, **평시 오류율을 모르는 채로는 그게 갱신 탓인지 알 수 없다.** 그래서 주입 전에 900건을 재서 오류가 0 이라는 것부터 확인해 두었다. 이 규칙을 어긴 사례가 이미 있었다. A-6 에서 **−41% 인 대조군을 「영향 없음」** 이라고 적었고 A-8 에서는 **표본 9개로 무중단을 주장**했다. 둘 다 나중에 고쳤다. 가장 최근 사례는 D-4 의 in-flight 감시다. 76건이 실패했고 그대로 적었으면 「갱신 중 대규모 요청 실패」라는 오보가 됐을 것이다. 확인해 보니 서버 탓이 아니었다. | 근거 | 값 | |---|---| | 같은 순간 폴링 | 49건 **전부 200** | | 연결수 | **0** — TCP 연결 시도조차 못 했다 | | 소요 시간 | **50µs** — DNS 조회보다 짧다 | | 재현 | **0/100** | 대조군이 있었기에 오보를 내지 않을 수 있었다. ![대조군 없이는 귀속할 수 없다](assets/measurement-control/measurement-control.svg) 대조군이 관측과 귀속 사이에 있어서, 그것이 없으면 같은 관측이 두 가지로 읽힌다. #### 두 시계에서 온 값을 빼면 안 된다 D-4a 에서 1~2초를 재려다 걸렸다. `test-server` 는 NTP 가 꺼져 있어 **106초 빠른** 반면 dev 머신은 Google 및 Let's Encrypt ACME 응답과 0초 차였다. 그 사실을 적지 않고 계산한 D-4 의 공백은 **106초 짧았다**(2199 → 2305초). 그리고 1~2초를 재는 D-4a 에서는 보정 없이는 **훅이 인증서 발급보다 104초 먼저 실행된 것**이 되어 물리적으로 불가능해진다. 보정은 독립 기준으로 교차검증했다 — 새 인증서의 SCT(`Sep 4 12:27:49.054 GMT`, CT 로그가 자체 시계로 서명)가 보정한 훅 시각의 정확히 1초 앞에 놓인다. > 두 시계에서 온 값을 그냥 빼면 **자릿수가 아니라 방향까지 틀릴 수 있다.** #### 관측 도구는 진실의 부분집합만 본다 | 도구 | 못 보는 것 | |---|---| | `up` 지표 | **「살아 있지만 쓸모없는」 상태.** 503 이 나는 동안에도 1이었다 | | crt.sh | 서빙 중인 인증서에 **SCT 가 두 개 박혀 있는데도 0건**으로 답한다 | | Prometheus | **Redis·BFF·PostgreSQL 을 아예 긁지 않는다** | 마지막 것 때문에 **B층 실험 대부분에 Grafana 스크린샷이 없는데**, 안 찍어서가 아니라 지표 자체가 없어서다. 그래서 이 사실을 「스크린샷 누락」이 아니라 **측정된 공백**으로 기록했다. #### 문서가 자기 증거와 어긋난 곳 기록을 다 쓴 뒤 증거와 하나씩 대조했더니 어긋난 곳이 여럿 나왔다. | 어디 | 무엇이 어긋났나 | |---|---| | C-1 | 본문은 「세션 0」인데 증거는 4 | | C-2 | `exit code 1` 인 명령의 성공 읽기를 실었다 | | A-1 | 4초 전 파드 재시작이 만든 분단을 conntrack 공으로 돌렸다 | | A-2 | 첫 측정의 `000000{"error":"HTTP 401"}401` 을 숨겼다 | | A-3 | `wal_writer_delay` 를 재지 않고 단언했다 (실측 200ms, 로그인율도 19/s 가 아니라 14/s) | | D-1 | 본문은 RTO 30초, 자기 타임라인은 41초 | 전부 고치면서 **무엇이 어긋났는지를 표로 남겼다.** 지워 버리면 다음 사람이 같은 방식으로 틀리기 때문이다. ### 재현 가능성을 어떻게 보장했나 절차를 명령으로 적는 것만으로는 부족했다. **측정 장치 자체를 산문으로 적어 둔 곳**이 남아 있었는데, 하필 그것들이 헤드라인 수치를 만든 바로 그 명령이었다. | 어디 | 산문이던 것 | |---|---| | A-6 | `( curl ... ) & 를 20개 띄우고 wait` ← 22.2초의 출처 | | A-3 | `<로그인 반복, sid 를 /tmp/sids 에>` ← RPO 측정 전체 | | A-8 | `/tmp/tok` 에 쓰고 `/tmp/rt` 를 읽는다 ← 빈 토큰을 보내고 있었다 | | A-1 | conntrack 튜플을 손으로 적는다 ← 방향이 재시작마다 바뀐다 | 전부 셸 표현식으로 바꾸고 **실제로 실행해 확인**했다. 그 확인에서 A-6 의 부하 생성기가 깨졌다 — 일회성 파드의 출력 스트림이 유실됐다. 상주 탐침 + 파드 안 파일 수집으로 고쳐 20/20 을 확인했다. ![명령으로 적는 것과 도는 것](assets/reproducibility-gap/reproducibility-gap.svg) 산문에서 명령으로, 다시 실행 확인으로 두 단계를 거쳤고 두 번째에서 한 건이 깨졌다. --- ## 얻은 것, 잃은 것, 적용하지 않을 때 ### 열린 질문 네 개에 대한 답 | | 질문 | 답 | |---|---|---| | Q1 | 다중 인스턴스 세션 운영 | **저장소를 밖으로 빼면 ①② 는 풀린다.** ③④ 는 저장소가 아니라 **스키마** 문제다 — `PRIMARY KEY (client_registration_id, principal_name)` 에 세션 id 가 없다 | | Q2 | Refresh Rotation 경쟁 | **이긴 요청의 토큰조차 못 쓴다.** 경쟁이 감지되면 client session 이 지워진다 | | Q3 | Session 과 AuthorizedClient 를 어디에 | **둘은 조회 키가 다르므로 각각 결정해야 한다.** 세션을 Redis 로 옮겨도 토큰은 따라오지 않는다 | | Q4 | Edge 인가의 범위 | **nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않는다.** 먼저 지워야 한다. 그리고 **IdP 의 클레임 변경은 재인증 전까지 반영되지 않는다** | ![열린 질문 네 개가 닿은 곳](assets/open-questions-answered/open-questions-answered.svg) 네 질문이 공통 원인으로 모이면서, 저장소 선택만으로는 풀리지 않는 것들이 함께 드러난다. ### 이 기록이 적용되지 않는 조건 - **Keycloak 26 미만.** `persistent-user-sessions` 가 기본이 아니면 A층 결론 중 셋이 뒤집힌다. A-7·A-7a 가 그 대조군이다 - **캐시가 더운지 찬지.** volatile 에서 DB 정지 시의 동작은 캐시 온도로 갈린다 - **단일 인스턴스.** B층 질문은 인스턴스가 둘 이상일 때만 생긴다 - **`--cookie-refresh` 를 켠 oauth2-proxy.** B-7a 의 TTL 역산 정리 규칙이 무너진다 - **NTP 가 동기된 환경.** 이 실험대는 106초 왜곡이 있었고 그것을 보정한 수치다 ![이 기록이 적용되지 않는 조건](assets/not-applicable-conditions/not-applicable-conditions.svg) 적용 조건을 목록이 아니라 무엇을 무효로 만드는가로 이었다. ### 재보지 않은 것 | 항목 | 왜 | |---|---| | `certbot-renew.timer` 가 **실제 갱신**을 하는가 | 만료 30일 전(약 89일 뒤)에야 조건이 성립한다 | --- ## 결국 지키려던 것은 무엇이었나 이 실험대에서 **예측 다섯 개가 틀렸고 주입이 아홉 번 조용히 실패했다.** 그것을 지우지 않고 남긴 이유가 이 기록의 요지다. | 틀린 예측 | 실제 | |---|---| | A-1 로그아웃 전파는 안 깨진다 | 깨졌다 — A-0 의 인과 설명을 고쳐야 했다 | | A-2 `up` 이 장애를 보여준다 | 503 내내 1이었다 | | A-6 낙관적 락 충돌이 보인다 | 0건 — 로그인은 INSERT 라 경합하지 않는다 | | B-4 nginx 가 동명 헤더를 덮어쓴다 | 덮어쓰지 않는다 | | B-6 JWKS 캐시가 유예를 준다 | 주지 않는다 | | A-7 refresh 500 은 `REVOKED_TOKEN` 때문 | `CLIENT_SCOPE_CLIENT` 였다 | **틀린 예측이 맞은 예측보다 많은 것을 가르쳤는데**, A-1 이 틀리지 않았다면 A-0 의 인과 설명이 잘못된 채로 남았을 것이고 A-7 의 가설이 확정되지 않았다면 「volatile 이면 이렇다」는 표가 조건 없이 유통됐을 것이기 때문이다. 그래서 이 기록을 쓰면서 지킨 규칙은 셋이다. 1. **예측을 먼저 적는다.** 결과를 보고 나면 무엇을 예상했는지 정직하게 쓸 수 없다 2. **주입이 걸렸는지를 결과와 따로 확인한다.** 「아무 일도 없었다」는 「영향이 없다」와 구별되지 않는다 3. **대조군 없이 귀속하지 않는다.** 평시를 모르면 이상을 해석할 수 없다 세 번째가 가장 자주 어겨졌고 치른 값도 가장 컸다. ![틀린 예측이 남긴 것](assets/wrong-predictions/wrong-predictions.svg) 세 규칙을 순서대로 놓으면, 각 단계가 빠졌을 때 어떻게 틀리는지가 실제 이력으로 남는다. --- ## 자료 | | | |---|---| | 원본 | [`../source/docs/`](../source/docs/) — 실험 문서 29편 · 계획서 · 개념 문서 · 선수지식 문서 | | 매니페스트 | [`../source/deploy/`](../source/deploy/) — 실험이 쓰는 k8s 매니페스트 8개 | | 증거 원문 | [`evidence/raw/`](evidence/raw/) — 125건. 정본이다 | | 실행 메타 | [`evidence/meta/`](evidence/meta/) — 125건 | | 브라우저 캡처 | [`evidence/browser/`](evidence/browser/) — 22건 | | 그림 | [`assets/`](assets/) — techviz 로 만든 28건. 정본은 [`.techviz/`](.techviz/) 의 VizSpec | | 실험 목록 | [`../source/docs/experiment-index.md`](../source/docs/experiment-index.md) | | 로드맵 | [`../source/docs/experiment-plan.md`](../source/docs/experiment-plan.md) — 실험별 예측·판정 규칙 | | 개념 | [`../source/docs/session-lab-concepts.md`](../source/docs/session-lab-concepts.md) · [`../source/docs/session-lab-prerequisites.md`](../source/docs/session-lab-prerequisites.md) | 원본 저장소의 리비전은 [`../source/.source-revision`](../source/.source-revision) 에 적어 두었다. --- ## 이 기록에 아직 없는 것 **그림은 28건 모두 techviz 로 다시 만들었다.** 원본 저장소의 손그림 28개는 [`../source/docs/diagrams/`](../source/docs/diagrams/) 에 그대로 있다. 형식만 바꾼 것이 아니라 처음부터 다시 그렸다. 이 저장소는 발행 SVG 안에 제목·부제· 설명 밴드를 넣지 못하게 하는데 손그림은 전부 캔버스 안에 제목과 설명 문단을 담고 있었다. 그래서 그림 안에는 이름만 남기고 문장은 `` 와 옆 문단으로 옮겼으며, 각 그림마다 문서의 줄 범위를 인용하는 VizSpec 을 쓰고 lint 를 통과시켰다. lint 가 잡아낸 것 중 사람이 놓치기 쉬운 것 둘을 적어 둔다. | 검사 | 무엇을 막았나 | |---|---| | `edge-through-node` | 화살표가 무관한 노드를 관통해 잘못된 인접을 암시하는 것 | | `evidence-outside-prepared-context` | 그림이 다른 절의 내용을 근거로 대는 것 | 두 번째 때문에 그림 하나는 앵커를 옮겨야 했다. B-0 절에 앵커를 두고 B-2 의 내용을 인용하려다 막혔고, B-1 로 옮겨 세 절이 문맥에 들어오게 했다. **Studio 기록은 아직 쓰지 않았다.** 이 문서까지가 SSOT 이고 `tech-log-studio/` 아래 글감 추출과 기록 작성은 다음 단계이기 때문이다. --- ## 딥리서치 대기 목록 — 이 실험대에서 쓴 개념 전수 실험을 돌리면서 이름을 쓴 개념과, 그 개념을 실제로 설명한 곳을 대조했다. **쓰기는 썼는데 설명이 어디에도 없는 것이 24개**였다. `conntrack` 은 실험 문서에 35번 나오는데 무엇인지 설명한 곳이 없고, `refresh token rotation` 은 17번, `JWKS` 와 `Liquibase` 는 각각 11번 나오는데 마찬가지다. 이 목록은 그 대조 결과다. 채우기 전까지는 **이 기록이 이름만 알고 쓴 것**이 어디인지 가리키는 표로 둔다. **상태 표기** | | 뜻 | |---|---| | 중앙 | [`session-lab-concepts.md`](../source/docs/session-lab-concepts.md) 에 절이 있다 | | 분산 | 해당 실험 문서 안에만 「개념」 절이 있다 | | **없음** | **쓰기만 하고 설명한 곳이 없다** | ### 1. 리눅스 · systemd — 바닥 계층 이 실험대의 모든 것이 이 위에서 돈다. 그런데 **전부 「없음」이다.** 지금까지 `systemctl` 을 명령으로만 여덟 번 썼고 무엇인지 설명한 적이 없다. | 개념 | 어디서 나왔나 | 상태 | 알아야 할 것 | |---|---|---|---| | systemd 유닛 파일 구조 | D-4 `certbot-renew.service` · 호스트 nginx | 없음 | `[Unit]`·`[Service]`·`[Install]` 의 역할, `systemctl cat` 과 `systemctl show` 의 차이(작성값 대 적용값) | | `Type=forking` · `PIDFile` | 호스트 nginx | 없음 | systemd 가 데몬을 추적하는 방식, `simple`·`notify` 와의 차이, 왜 nginx 는 forking 인가 | | `Restart=` 정책 | 호스트 nginx (`on-failure`) | 없음 | `no`·`always`·`on-failure`·`on-abnormal` 의 경계, `RestartSec` | | `StartLimitBurst` 크래시 루프 상한 | 호스트 nginx (5회/10초) | 없음 | 상한에 걸리면 systemd 가 포기한다 — `reset-failed` 없이는 안 살아난다 | | `KillMode` · `KillSignal` | 호스트 nginx (`mixed`/`SIGQUIT`) | 없음 | nginx 의 SIGQUIT 이 graceful 이라는 것과 D-4a 의 reload 무중단이 같은 성질인가 | | cgroup v2 | `systemctl status` 의 `CGroup:` 블록 | 중앙(4회 언급) | `/sys/fs/cgroup` 실물, `pids.max`·`memory.max`·`cpu.stat`, 쿠버네티스 자원 제한과 같은 메커니즘인지 | | systemd slice | `system.slice` | 없음 | slice·scope·service 의 관계, 자원 제한이 상속되는 방식 | | journald | `systemctl status` 하단 로그 | 없음 | `-u` 필터·우선순위·보존 정책. **B-7 의 502 원인이 여기 있었는데 수집하지 않았다** | | `PrivateTmp=true` | nginx · `certbot-renew.service` 양쪽 | 없음 | 왜 두 유닛 모두 켜져 있나, 네임스페이스 격리와의 관계 | | PID 1 시그널 보호 | A-3 (`kill -9 1` 무시) | 분산(A-3) | 왜 자기 네임스페이스의 SIGKILL 을 무시하는가 | | OOM killer · `oom_score` | 미측정 | 없음 | cgroup 메모리 상한과 OOM 의 관계 | ### 2. 네트워크 · netfilter | 개념 | 어디서 나왔나 | 상태 | 알아야 할 것 | |---|---|---|---| | **conntrack** | A-1 (35회 언급) | **없음** | 연결 추적 상태 기계, `ESTABLISHED` 가 규칙 평가를 건너뛰는 이유, 표를 지우면 왜 다시 걸리는가 | | netfilter 처리 순서 | A-5 | 분산(A-5) | `raw`→`mangle`→`nat`→`filter` 와 훅 지점, conntrack 이 어디서 개입하나 | | iptables `raw` 테이블 | A-5 (12회) | 없음 | PREROUTING 에서 conntrack 보다 먼저 잡는다는 것의 정확한 의미 | | kube-router 체인 재삽입 | A-5 | 없음 | 왜 `-I FORWARD 1` 이 무시되는가, 컨트롤러가 규칙을 되돌리는 주기 | | flannel VXLAN | A-5 | 중앙 | 캡슐화 때문에 물리 인터페이스에서 파드 IP 가 안 보이는 것 | | `tc` netem | A-6 | 중앙(2회) | qdisc 계층, 지연 주입이 어느 방향에만 걸리는가 | ### 3. PostgreSQL · 영속성 | 개념 | 어디서 나왔나 | 상태 | 알아야 할 것 | |---|---|---|---| | `synchronous_commit` | A-3 (24회) | 언급만 | `on`·`off`·`local`·`remote_write` 의 차이, Keycloak 이 왜 트랜잭션마다 끄는가 | | WAL · `wal_writer_delay` | A-3 | 언급만 | WAL 기록·플러시·체크포인트의 순서, 실측 200ms 가 어디서 나온 값인가 | | fsync · 페이지 캐시 | A-3 | 언급만 | COMMIT 반환과 디스크 도달 사이에 무엇이 있나 | | 낙관적 락 · `VERSION` 컬럼 | A-6 (충돌 0건) | 언급만 | 왜 로그인은 경합하지 않는가(INSERT 라서), 어떤 연산이 경합하나 | | `FOR NO KEY UPDATE SKIP LOCKED` | A-6 | 없음 | 잠금 수준과 SKIP LOCKED 의 의미 | | PostgreSQL 문장 로깅 | A-7a | 분산(A-7a) | `log_statement` 수준별 비용, 운영에서 켜도 되는가 | | Liquibase `databasechangelog` | D-2 (11회) | 분산(D-2) | 체크섬이 계산되는 방식, 왜 옛 버전이 새 체크섬을 거부하나 | ### 4. 쿠버네티스 | 개념 | 어디서 나왔나 | 상태 | 알아야 할 것 | |---|---|---|---| | readiness vs liveness · health group | A-2 · B-5 | 분산 | 프로브가 실패했을 때 각각 무슨 일이 일어나나 | | `node-monitor-grace-period` | A-4 (40초) | 없음 | 컨트롤 플레인이 노드를 죽었다고 판정하는 절차 | | `tolerationSeconds` | A-4 (300초) | 없음 | taint 기반 축출 타이머, 합쳐서 5분 40초가 되는 계산 | | local-path PVC 노드 친화성 | A-4 | 중앙 | 왜 재배치가 불가능한가 | | StatefulSet 재생성 규칙 | A-4 | 중앙 | Terminating 파드의 대체를 만들지 않는 이유 | | NetworkPolicy 허용목록 | A-1 | 분산(A-1) | deny 규칙을 쓸 수 없는 구조 | | `enableServiceLinks` | B-1 | 분산(B-1) | Docker link 시절 환경변수 주입이 남아 있는 이유 | | Secret 과 etcd | D-3 | 중앙 | base64 가 인코딩인 것과 저장 시 암호화(EncryptionConfiguration)의 차이 | ### 5. Keycloak · Infinispan | 개념 | 어디서 나왔나 | 상태 | 알아야 할 것 | |---|---|---|---| | `persistent-user-sessions` | A-7 · A-7a | 중앙 | 버전별 기본값 변화와 마이그레이션 경로 | | 디스커버리 vs 트랜스포트 | A-1 | 중앙 | `JGROUPS_PING` 과 TCP 7800 이 나뉜 이유 | | FD_SOCK2 · MERGE3 · GMS | A-1 · A-5 | 중앙 | 지표 이름에 그대로 나오는 프로토콜들 | | **refresh token rotation** | B-3 (17회) | **없음** | `revokeRefreshToken`·`refreshTokenMaxReuse` 의 정확한 의미, 경쟁 시 client session 을 지우는 것이 규격인가 구현인가 | | `sid` 와 세션 두 겹 | 전 실험 | 언급만 | SSO 세션·클라이언트 세션·`KEYCLOAK_IDENTITY`·`AUTH_SESSION_ID` 의 관계 | | `CLIENT_SCOPE_CLIENT` · `DEFAULT_SCOPE` | A-7a | 분산(A-7a) | default 와 optional 스코프가 토큰 발급에서 갈리는 지점 | | 백채널 로그아웃 | C-2 | 분산(C-1) | `backchannelLogoutUrl` 과 앱 엔드포인트 규격, `sid` 역인덱스 | ### 6. Spring · 애플리케이션 저장소 | 개념 | 어디서 나왔나 | 상태 | 알아야 할 것 | |---|---|---|---| | Spring Session | B-1 | 언급만 | `SessionRepository` 추상화, Redis 구현의 키 구조와 만료 처리 | | `OAuth2AuthorizedClientService` 계열 | B-0 · B-2 | 없음 | In-memory·JDBC 구현의 차이, `AuthenticatedPrincipal…Repository` 가 principal 로 찾는 이유 | | **인가 클라이언트 `PRIMARY KEY`** | B-2 | **없음** | 기본 스키마가 세션 id 를 키에 넣지 않은 설계 의도, 바꿀 수 있는가 | | Java 직렬화 `\xac\xed` | B-1 | 없음 | Redis 에 들어간 바이트가 무엇인지, JSON 직렬화로 바꿀 때의 대가 | | agroal 커넥션 풀 | A-6 (9회) | 없음 | 획득 대기·최대 크기·검증 주기, `blocking_time` 지표의 정의 | ### 7. TLS · 인증서 | 개념 | 어디서 나왔나 | 상태 | 알아야 할 것 | |---|---|---|---| | `fullchain.pem` vs `cert.pem` | D-4 | 분산(D-4) | 체인 단계와 클라이언트가 보완해 주는 경우 | | ACME · HTTP-01 vs DNS-01 | 구축 | 중앙 | 어느 쪽이 어떤 제약을 푸는가 | | certbot `renewal-hooks` | D-4a | 분산(D-4a) | `pre`·`deploy`·`post` 의 실행 조건 | | **SCT · Certificate Transparency** | D-4a | **없음** | SCT 가 인증서에 박히는 절차, crt.sh 가 왜 색인하지 못했나 | | **Let's Encrypt 백데이트** | D-4a | **없음** | `notBefore` 를 앞당기는 이유와 정확한 폭 | | JWKS · `kid` | B-6 (11회) | 없음 | 키 회전 시 캐시 동작, 유예 구간이 없는 이유 | | oauth2-proxy 티켓 구조 | B-7a | 분산(B-7a) | 세션 id 와 복호화 키가 함께 암호화되는 형식 | ### 8. 측정 · 시계 | 개념 | 어디서 나왔나 | 상태 | 알아야 할 것 | |---|---|---|---| | **NTP 와 시계 왜곡** | D-4a (106초) | **없음** | `NTPSynchronized` 의 의미, 왜곡을 재는 올바른 방법, 왜 두 시계를 빼면 안 되는가 | | Prometheus `up` 의 한계 | A-2 | 중앙 | 합성 지표가 무엇을 못 보는가 | | exporter 패턴 · relabel | 구축 | 중앙 | 관측 대상에 Redis·BFF·PostgreSQL 이 빠진 이유 | ### 분해 계약과의 대응 이 프로젝트에는 이미 주제 여섯과 글감 서른셋으로 분해 계약이 서 있다 ([`tech-log-studio/tech-log-tree.json`](../tech-log-studio/tech-log-tree.json)). 위 여덟 묶음은 그것과 나란한 별개 구조가 아니라, **각 주제가 서 있는 바닥을 채우는 목록**이다. | 계약의 주제 | 그 아래에서 이름만 쓰고 넘어간 개념 | |---|---| | `session-custody-across-nodes` | 5번 — 디스커버리 대 트랜스포트, `sid` 와 세션 두 겹 | | `losing-a-node-or-the-store` | 1번 전체 · 3번(WAL·fsync) · 4번(`node-monitor-grace-period`·`tolerationSeconds`) | | `where-application-state-lives` | 6번 전체 · 5번(refresh token rotation) | | `trust-handed-over-at-the-edge` | 2번(conntrack·netfilter) · 7번(oauth2-proxy 티켓) | | `operations-that-report-success` | 1번(systemd 일체) · 7번(SCT·백데이트) · 3번(Liquibase) | | `when-the-measurement-lies` | 8번 전체 | `operations-that-report-success` 의 글감 두 개(「새 인증서가 디스크에 있고 38분 25초 동안 옛 인증서가 나갔다」, 「deploy 훅 하나가 그 공백을 1~2초로 줄였다」)는 **systemd 를 설명하지 않고는 쓸 수 없다.** `Restart=` 도 `journald` 도 `Type=forking` 도 이 목록에서 「없음」이기 때문이다. ### 채우는 순서 바닥부터 올라간다. **1번(리눅스·systemd)** 이 먼저인 이유는 나머지 전부가 그 위에서 돌기 때문이고, 실제로 이 목록에서 「없음」이 가장 많은 층이기도 하다. 그 다음은 **2번(네트워크)** 인데 A층 실험의 주입이 전부 거기서 이루어졌고 아홉 번의 조용한 실패 중 넷이 그 층의 개념을 몰라서 생겼다. ### 이 목록을 만들면서 하나 더 나왔다 `systemctl status nginx` 를 쳐 보면 저널이 이것을 보여 준다. ``` Sep 04 14:37:44 nginx[586]: [error] upstream sent too big header while reading response header from upstream, ... request: "GET /oauth2/callback?state=..." ``` **B-7 이 502 의 원인으로 지목한 것을 호스트 nginx 가 문장으로 적어 두었다.** B-7 은 계층을 나눠(traefik 을 직접 불러 nginx 를 우회) 원인을 좁혔다고 기록했는데, 증거는 저널에 있었고 증거 파일 147개 중 이것을 담은 것은 없다. `journald` 가 「없음」이었던 대가가 여기서 나타난다.