Files
document-haness/docs/keycloak-session-store/final/document.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:59 +09:00

37 KiB
Raw Blame History

세션은 어디에 있는가 — Keycloak 다중 노드 실험 26건의 기록

Keycloak 을 두 대로 늘리면 세션은 어떻게 되는가. 저장소를 Redis 로 옮기면 무엇이 달라지는가. 저장소가 죽으면 어떻게 복구하는가.

이 문서는 그 물음들에 측정으로 답한 기록이다. 실험 26건, 증거 파일 147개, 그리고 그 과정에서 아홉 번의 조용한 주입 실패틀린 예측 다섯 개를 포함한다. 틀린 것을 지우지 않고 남긴 이유는 마지막 절에 있다.


코드보다 먼저 드러난 문제

답할 수 없던 질문 네 개

앞선 작업(인증 패턴 네 가지)은 네 가지 인증 패턴의 경계를 설계하고 끝에 열린 질문 네 개를 남겼다. 설계로는 답할 수 없고 돌려봐야 아는 것들이었다.

질문
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 을 문장 로깅으로 직접 잡았다. 세션 엔트리는 노드 사이를 건너가지 않는다. 각 노드는 자기가 처리한 로그인만 캐시하고, 두 노드가 같은 답을 내놓는 이유는 같은 데이터베이스를 보기 때문이다.

세션 공유가 실제로 지나는 경로

두 노드는 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 은 이렇다」고 쓸 수 있는 문장이 거의 없다. 버전과 설정을 같이 적지 않으면 절반은 틀린 말이 된다.


문제를 어렵게 만든 제약

실험대

베어메탈 한 대(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 으로 프록시

이름 셋(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 표, 패킷 카운터 — 결과가 아니라 상태를 본다.


검토한 선택지와 막힌 지점

관측을 어디에 둘 것인가

처음에는 밖에서만 쟀다. curl 로 외부 진입점을 찍고 상태 코드를 셌다. A-1 에서 그 방식이 무너졌다.

7800 을 끊었는데 외부 응답이 전부 200 이었다. 장애가 없어서가 아니라 분단된 노드가 readiness 실패로 스스로 로드밸런서에서 빠졌기 때문이다. 밖에서만 보면 이 실험은 「아무 일도 없음」이다.

그래서 관측 지점을 셋으로 늘렸다.

지점 무엇을 보는가
외부 curl 사용자가 겪는 것
Prometheus 지표 vendor_cluster_size · vendor_jgroups_* · agroal_*
PostgreSQL 직접 조회 실제로 무엇이 저장됐는가

up 지표를 신뢰할 수 없다는 것도 여기서 나왔다. A-2 에서 503 이 나는 동안에도 up 은 1 이었다. 프로세스가 살아 있고 /metrics 가 응답하면 up 은 1 이다. 「살아 있지만 쓸모없는」 상태를 못 본다.

스크립트를 쓰지 않는다

절차를 스크립트로 감싸면 「무엇을 했는지」가 스크립트 안으로 숨는다. 그래서 모든 절차를 셸에 그대로 붙여넣을 수 있는 명령으로 적었다.

이 결정에는 대가가 있었다. 나중에 재현 절차를 점검하니 측정 장치 자체가 산문으로 적힌 자리가 여럿 있었다 — ( 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 을 누락시켜 막는다. 이 두 포트가 하중을 진다 — 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)에 죽으면 그만큼이 사라진다.

의도된 설계이고, 그 대가를 숫자로 확인한 것이다.

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초

장애 시간의 대부분은 복구가 아니라 「누가 죽은 것을 알아채는 데」 걸린 시간이었다.

A-5 · 비대칭 분단 — 전면 장애 경로가 없다

한 방향만 막으면 열린 방향으로 재연결한다. 가르지 못한다. 양방향을 다 막으면 갈라지지만 한쪽만 DOWN 이 된다 — 코디네이터 쪽이 살아남고, 분단된 쪽은 스스로 로드밸런서에서 빠지고, 서비스는 계속된다.

이 실험에서 주입을 세 번 실패했다(위 표의 #4·#5·#6). 세 번 모두 다른 이유였고, 셋 다 「아무 일도 없었다」로 보였다.

A-6 · 지연 주입 — 200밀리초가 22초가 된다

측정
주입한 네트워크 지연 200 ms
로그인 응답 시간 66 ms → 1,872 ms (28배)
동시 20건에서 최대 응답 22.2 초
커넥션 획득 대기 최대 20,000 ms
readiness 프로브 타임아웃으로 실패

두 단계로 곱해진다. 로그인 한 번이 DB 왕복을 여러 번 하므로 왕복 횟수만큼 더해지고, 요청이 커넥션을 오래 붙들어 뒤의 요청이 풀에서 대기하므로 거기서 한 번 더 곱해진다.

그리고 파드가 죽는다. readiness 가 타임아웃으로 실패해 느린 노드가 로드밸런서에서 빠진다. 느림이 장애로 승격된다.

A-8 · 롤링 재시작 — 세션은 살아남고 캐시만 사라진다

확인 결과
재시작 중 서비스 중단 없음. 전 구간 200
재시작 전 발급한 refresh token 여전히 200
DB 세션 수 151 → 151 그대로
세션 캐시 0 으로 초기화

이것이 persistent-user-sessions 를 켜는 진짜 이유다.

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 정지 시의 동작은 「무엇을 하느냐」가 아니라 「그 경로가 이미 캐시를 채웠느냐」 로 결정된다. 이런 종류는 한 번 재고 표로 적으면 안 된다.


선택이 코드와 흐름에 반영되는 방식

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 가 저장하는 두 가지와 그 조회 키

같은 요청이 두 갈래로 조회된다. 세션은 세션 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 한 줄이다.

PRIMARY KEY (client_registration_id, principal_name)

세션 id 가 키에 없다. 같은 사용자의 두 세션이 같은 행을 쓰고, 나중 로그인이 앞의 토큰을 덮어쓴다. 그리고 로그아웃 후:

Redis 세션      : 0 키    ← 정리됨
PostgreSQL 토큰 : 1 행    ← 평문 refresh token 이 그대로 남는다

B-3 · Refresh Token Rotation 경쟁 (Q2)

revokeRefreshToken=true · refreshTokenMaxReuse=0 에서 같은 refresh token 으로 동시에 5건을 보냈다. 순차로 돌리면 재현되지 않는다 — &wait 이 있어야 경합이 생긴다.

이긴 요청이 받은 새 토큰조차 쓸 수 없다. 경쟁이 감지되면 Keycloak 이 client session 을 지우기 때문이다. 「하나는 성공하고 나머지가 실패한다」가 아니라 전부 못 쓰게 된다.

B-4 · Edge 인가의 범위 (Q4)

nginx → oauth2-proxy → 앱의 2홉 구조에서 헤더를 위조해 봤다.

예측이 틀렸다. nginx 는 자기가 설정하지 않은 동명 헤더를 덮어쓰지 않는다. proxy_set_header X-Auth-Request-Roles "" 로 먼저 지우지 않으면 위조 헤더가 그대로 통과한다.

그리고 IdP 에서 값을 바꿔도 반영되지 않는다. 12회 요청·6초 동안 옛 값이 갔고, Redis 세션을 지워 재인증시킨 뒤에야 새 값이 왔다.

세션은 로그인 시점의 스냅샷이다. --cookie-refresh 가 없으면 쿠키 만료나 재인증까지 옛 값이 간다. 요청 횟수와 무관하다.

B-5 · B-6 — 저장소 상실과 키 회전

B-5 에서 redis-cli config set appendonly yes 를 켜도 아무것도 달라지지 않았다. /data 가 컨테이너 파일시스템이라 컨테이너와 함께 죽는다. 볼륨 없는 영속화 설정은 장식이다.

B-6 에서 realm 키를 회전하고 JWKS 캐시의 유예 구간을 기대했는데 없었다. NimbusJwtDecoder 는 모르는 kid 를 만나면 JWKS 를 다시 가져온다.

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:271초 오차. 실제로 골라 지웠고 산 세션만 남았다.

전제도 같이 적는다 — --cookie-refresh 를 켜면 이 역산이 무너진다. 그때는 FLUSHDB 로 전부 지우고 모두 재인증시키는 편이 정직하다.

C층 — SSO 와 로그아웃 전파

C-1 에서 두 앱이 같은 realm 으로 SSO 되는 것을 확인했고, 로그아웃이 다른 앱으로 퍼지지 않는 것을 관측했다. C-2 가 그 원인을 봤는데 단순했다.

확인 결과
백채널 로그아웃이 설정되어 있었는가 아니다. 두 클라이언트 모두 backchannelLogoutUrl 없음
앱에 그 엔드포인트가 있는가 아니다. 소스에 oidcLogout 설정이 없다
IdP 쪽만 설정하면 되는가 ★ 안 된다. 앱 세션이 그대로 남았다
Keycloak 이 앱 URL 에 닿기는 하는가 닿는다 (HTTP 200) — 네트워크 문제가 아니다

아무도 구현하지 않았다. 그리고 「설정이 빠졌다」와 「기능이 없다」는 다르게 고쳐야 한다. 여기는 둘 다였고, 확인 순서를 바꿨다면 한쪽만 고치고 끝냈을 것이다.

D층 — 운영

D-1 · D-2 — 백업과 업그레이드

D-2 에서 26.7.0 → 26.7.3 은 무중단이었다(87회 요청 전부 200). 되돌리기는 막혔다.

liquibase ValidationFailedException: 1 changesets check sum

새 버전이 남긴 체크섬을 옛 버전이 거부한다. 그런데 서비스는 살아 있었다 — StatefulSet 롤링 업데이트가 첫 파드에서 멈추고 나머지를 건드리지 않았기 때문이다. 「롤백 계획」이 없어도 사고가 전면화되지 않았다.

이 결론은 나중에 정밀해졌다. 「롤백 불가」는 조건부다 — 스키마가 움직였을 때만이고, 판단 기준은 하나다.

select count(*) from databasechangelog

업그레이드 전후 이 수가 같으면 롤백된다. 늘었으면 안 된다. 26.7.3 → 26.7.0 을 스키마 변경 없이 되돌리는 것은 실제로 성공했다(전환 순간 000 1회).

D-3 · 비밀

kubectl get secret -o yaml 의 base64 는 암호화가 아니다. etcd 에 평문으로 있다. 파드 안에서 env | grep -i secret 이면 그대로 나온다.

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.serviceExecStartPost 없음
/etc/letsencrypt/renewal-hooks/{deploy,post,pre}/ 셋 다 비었음
certbot 의 nginx 플러그인 없음 (dns-cloudflare, manual, null, standalone, webroot)

nginx 는 인증서를 기동 시점에 읽어 메모리에 들고 있다. certbot 은 경로가 아니라 live/ 심볼릭 링크를 갈아끼운다. 설정은 멀쩡해 보이는데 서빙되는 것은 옛 것이다. 필요한 것은 설정 변경이 아니라 reload 다.

인증서 갱신이 서빙에 닿기까지

live/ 는 심볼릭 링크라 경로가 그대로이고 가리키는 대상만 바뀐다. 그래서 nginx 설정을 고칠 필요가 없고, 바로 그 때문에 「설정이 그대로니 괜찮다」고 착각하기 쉽다. 필요한 것은 설정 변경이 아니라 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). 옛 워커가 그 요청을 끝까지 책임졌다.


결정이 지켜지는지 확인하는 방법

측정이 거짓말하는 자리들

이 실험대가 남긴 것 중 결과표보다 오래 갈 것은 어디서 측정이 틀리는가다.

대조군 없이는 아무것도 귀속할 수 없다

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

대조군이 오보를 막았다.

두 시계에서 온 값을 빼면 안 된다

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 을 확인했다.


얻은 것, 잃은 것, 적용하지 않을 때

열린 질문 네 개에 대한 답

질문
Q1 다중 인스턴스 세션 운영 저장소를 밖으로 빼면 ①② 는 풀린다. ③④ 는 저장소가 아니라 스키마 문제다 — PRIMARY KEY (client_registration_id, principal_name) 에 세션 id 가 없다
Q2 Refresh Rotation 경쟁 이긴 요청의 토큰조차 못 쓴다. 경쟁이 감지되면 client session 이 지워진다
Q3 Session 과 AuthorizedClient 를 어디에 둘은 조회 키가 다르므로 각각 결정해야 한다. 세션을 Redis 로 옮겨도 토큰은 따라오지 않는다
Q4 Edge 인가의 범위 nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않는다. 먼저 지워야 한다. 그리고 IdP 의 클레임 변경은 재인증 전까지 반영되지 않는다

이 기록이 적용되지 않는 조건

  • Keycloak 26 미만. persistent-user-sessions 가 기본이 아니면 A층 결론 중 셋이 뒤집힌다. A-7·A-7a 가 그 대조군이다
  • 캐시가 더운지 찬지. volatile 에서 DB 정지 시의 동작은 캐시 온도로 갈린다
  • 단일 인스턴스. B층 질문은 인스턴스가 둘 이상일 때만 생긴다
  • --cookie-refresh 를 켠 oauth2-proxy. B-7a 의 TTL 역산 정리 규칙이 무너진다
  • NTP 가 동기된 환경. 이 실험대는 106초 왜곡이 있었고 그것을 보정한 수치다

재보지 않은 것

항목
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. 대조군 없이 귀속하지 않는다. 평시를 모르면 이상을 해석할 수 없다

세 번째가 가장 자주 어겨졌고, 가장 비쌌다.


자료

원본 ../source/docs/ — 실험 문서 29편 · 계획서 · 개념 문서 · 선수지식 문서
매니페스트 ../source/deploy/ — 실험이 쓰는 k8s 매니페스트 8개
증거 원문 evidence/raw/ — 125건. 정본이다
실행 메타 evidence/meta/ — 125건
브라우저 캡처 evidence/browser/ — 22건
그림 assets/ — techviz 로 만든 3건. 정본은 .techviz/ 의 VizSpec
실험 목록 ../source/docs/experiment-index.md
로드맵 ../source/docs/experiment-plan.md — 실험별 예측·판정 규칙
개념 ../source/docs/session-lab-concepts.md · ../source/docs/session-lab-prerequisites.md

원본 저장소 리비전은 ../source/.source-revision 에 있다.


이 기록에 아직 없는 것

그림 3건만 techviz 로 만들었다. 원본 저장소에는 손으로 그린 SVG 28개가 있고 ../source/docs/diagrams/ 에 그대로 있다. 이 저장소의 규약은 손으로 SVG 를 그리지 않고 techviz 파이프라인 (context → profile → VizSpec 1.1 → lint → render)을 거치게 하며, 발행 SVG 안에 제목·부제·설명 밴드를 넣지 못하게 한다. 손그림 28개는 전부 캔버스 안에 제목과 설명 문단을 담고 있어 그 계약을 어긴다.

그래서 원본은 source/ 에 두고, final/assets/ 에는 규약을 통과한 것만 넣었다. 나머지는 같은 파이프라인으로 다시 만들어야 한다 — 각 그림마다 문서 줄 범위를 인용하는 VizSpec 을 쓰고 lint(레이아웃 검사 포함)를 통과시켜야 하므로, 형식 변환이 아니라 다시 그리는 일이다.

Studio 기록은 아직 쓰지 않았다. 이 문서까지가 SSOT 이고, tech-log-studio/ 아래 글감 추출과 기록 작성은 다음 단계다.