Files
document-haness/docs/keycloak-session-store/source/docs/experiment-plan.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

50 KiB

실험 실행 계획 — 구조 · 주입 지점 · 확인 항목

session-store-lab-roadmap.md무엇을 할지의 지도라면, 이 문서는 각 실험을 어떤 구조에서 어디를 건드려 무엇을 볼지를 미리 확정해둔 것이다.

실험 전에 예측을 적어둔다. 결과를 보고 나서 "그럴 줄 알았다"고 쓰면 아무것도 배우지 못한다. 예측이 빗나갔을 때가 배울 거리다.

맥락이 안 잡히면 session-lab-prerequisites.md 먼저.


1. 기준 구조 — 모든 실험이 여기서 출발한다

                        [개발 노트북]  브라우저 · kubectl · Playwright
                              │
                              │  https  (tailnet 100.x, split DNS)
                              ▼
   ┌──────────────────────────────────────────────────────────────┐
   │  test-server   Arch Linux · 12GB RAM · WiFi only              │
   │                                                               │
   │    nginx :443   ← TLS 종료 (Let's Encrypt, DNS-01)            │
   │       auth.hyeonworks.com / app1 / app2                       │
   └───────────────────────────┬───────────────────────────────────┘
                               │  http, libvirt NAT (virbr0)
               ┌───────────────┴────────────────┐
               ▼                                ▼
   ┌───────────────────────┐        ┌───────────────────────┐
   │  kc-lab-1   5120MB    │        │  kc-lab-2   4096MB    │
   │  k3s server           │        │  k3s agent            │
   │  10.42.0.0/24         │        │  10.42.1.0/24         │
   │                       │        │                       │
   │   traefik   ← 진입점  │        │                       │
   │   coredns             │        │                       │
   │   metrics-server      │        │                       │
   │   local-path-prov.    │        │                       │
   │                       │◀ 7800 ▶│                       │
   │   keycloak-1          │ JGroups│   keycloak-0          │
   │                       │        │   postgres  (PVC)     │
   │   prometheus (PVC)    │        │                       │
   │   grafana             │        │                       │
   └───────────────────────┘        └───────────────────────┘
        ▲                                    ▲
        │ 관측 스택은 여기 고정               │ 장애 주입은 여기
        │ (nodeSelector 로 못박음)            │

배치 규칙 — 왜 이렇게 두었는가

규칙 이유
관측 스택은 kc-lab-1 고정 관측 시스템이 관측 대상과 같이 죽으면 기록이 없다
장애 주입은 kc-lab-2 server 를 죽이면 노드 상실이 아니라 컨트롤 플레인 상실이다
postgres 는 kc-lab-2 DB 손실과 노드 손실을 한 실험에서 겹쳐 볼 수 있다
PVC 는 노드에 못박힘 local-path 프로비저너. 결함이 아니라 조건

공통 관측 지점

이후 모든 실험에서 이 표의 것들을 본다.

무엇 어디서 값의 뜻
up Prometheus 0이라는 값으로 남는다. 죽은 시각을 사후에 특정할 수 있는 유일한 지표
vendor_statistics_approximate_entries_unique{cache="sessions"} KC :9000 노드별 세션 캐시 보유량
vendor_jgroups_fd_sock2_get_num_suspected_members KC :9000 상대를 의심하기 시작한 순간
vendor_jgroups_merge3_get_views KC :9000 split brain 후 재병합
ISPN000094 kubectl logs 새 클러스터 뷰(멤버 명단)
JGROUPS_PING 테이블 PostgreSQL 디스커버리 등록 상태
offline_user_session (offline_flag='0') PostgreSQL 온라인 세션의 진실
node_load1, node_memory_* node-exporter 기계가 죽었나 프로세스가 죽었나

2. 전체 목록

# 실험 답하는 실무 질문 상태 선행
A-0 세션 복제 확인 정상일 때 세션은 어디 있나
A-1 TCP 7800 차단 노드 간 통신이 끊기면? 다음 A-0
A-2 DB 정상 정지 DB가 죽으면? A-0
A-3 DB 강제 종료 복구 시 무엇을 잃나 (RPO) A-2
A-4 노드 전원 차단 Keycloak 한 대가 죽으면? A-0
A-5 비대칭 파티션 한쪽만 안 들리면? (split brain) A-1
A-6 지연 주입 (netem) 느려지기만 하면? A-1
A-7 volatile 모드 비교 옛 방식(KC≤24)은 뭐가 다른가 A-1·A-2·A-4
A-8 롤링 재시작 배포하면 로그아웃되나 A-0
B-0 자동구성 구현체 확인 믿는 구현체가 실제로 쓰이나 배포 필요
B-1 BFF + Redis 저장소 Redis vs JDBC 어느 쪽인가 → Q3 B-0
B-2 BFF 다중 인스턴스 인스턴스 늘리면? → Q1 B-1
B-3 Refresh 토큰 경쟁 동시 갱신하면? → Q2 B-1
B-4 Edge 인가 범위 인가를 Edge에 얼마나? → Q4 B-1
B-5 Redis 상실과 영속화 죽으면? 재시작하면 뭐가 남나 B-1
B-6 암호화 key 교체 key 를 바꾸면 로그인한 사람은? → Q3 B-1
B-7 OAuth2-Proxy cookie secret secret 을 바꾸면? → Q1 배포 필요
C-1 다중 앱 SSO SSO 붙이면 뭐가 달라지나 B-1
C-2 백채널 로그아웃 로그아웃이 전 앱에 퍼지나 C-1
D-1 백업·복구 리허설 복구해본 적 있나 A-2
D-2 버전 업그레이드 올릴 때 세션·스키마는? D-1
D-3 비밀 관리 Secret은 정말 감춰지나
D-4 인증서 갱신 갱신 중 무중단인가

A층 — Keycloak 자체

A-1. TCP 7800 차단

답하려는 질문

노드 사이 네트워크가 끊기면 로그인한 사용자는 어떻게 되는가?

이 실험이 A-0 다음인 이유 — 통념과 측정이 정면으로 어긋나 있다. 둘 중 하나는 틀렸고, 이 실험이 판정한다.

통념 (KC 24 이전 자료) 세션은 7800으로 복제된다 → 막으면 세션 공유가 깨진다
A-0 측정 세션은 DB로 공유된다 → 막아도 안 깨진다

구조 — 주입 지점

   kc-lab-1                              kc-lab-2
   ┌────────────────┐                    ┌────────────────┐
   │  keycloak-1    │ ✂✂✂ 7800 차단 ✂✂✂ │  keycloak-0    │
   │                │   (NetworkPolicy)  │                │
   └───────┬────────┘                    └───────┬────────┘
           │                                     │
           │         둘 다 DB 는 그대로 본다      │
           └──────────────┬──────────────────────┘
                          ▼
                  ┌───────────────┐
                  │  postgres     │  ← 여기는 안 건드린다
                  │  JGROUPS_PING │     디스커버리는 계속 된다
                  │  세션 테이블   │
                  └───────────────┘

핵심: 디스커버리(DB)는 살리고 트랜스포트(7800)만 끊는다. 그래서 "DB엔 서로 등록되는데 클러스터는 안 붙는" 상태가 만들어진다. 단일 노드에서는 재현 불가능한 고장이며, 이 실험대를 2노드로 만든 이유다.

주입 방법

# NetworkPolicy 로 파드 간 7800 만 막는다. IP 가 아니라 라벨로 지정한다
# (파드 IP 는 재시작마다 바뀐다 — 2홉 실험에서 확인)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: block-jgroups, namespace: keycloak-lab }
spec:
  podSelector: { matchLabels: { app: keycloak } }
  policyTypes: [Ingress]
  ingress:
    - from: [{ podSelector: { matchLabels: { app: keycloak } } }]
      ports: [{ port: 7800, protocol: TCP }]   # ← 이 규칙을 뺀 채 적용하면 차단

확인할 것

관측 예상
fd_sock2_get_num_suspected_members 0 → 1 (가장 먼저 움직일 것)
ISPN000094 로그 각자 멤버 1개짜리 뷰 발행 (split brain)
JGROUPS_PING 테이블 양쪽 다 남아 있다 (디스커버리는 살아 있으므로)
교차 노드 refresh 200 유지 ← 이게 판정의 핵심
로그아웃 전파 ? — DB 삭제로 되면 유지, 클러스터 메시지면 깨진다
sessions 캐시 엔트리 변화 없음

예측

세션 공유    안 깨진다     (A-0 근거: 세션은 7800 으로 안 다닌다)
로그아웃     안 깨진다     (DB 행 삭제로 전파되므로)
realm 설정 변경 전파   깨진다  (work 캐시는 클러스터 메시지를 쓴다)
로그인 실패 횟수 집계  깨진다  (loginFailures 캐시)

판정 기준

결과
교차 refresh 가 200 A-0 모델이 맞다. 통념이 틀렸다
교차 refresh 가 400/401 A-0 해석이 틀렸다. 무엇을 놓쳤는지 다시 판다

복구

kubectl -n keycloak-lab delete networkpolicy block-jgroups
# MERGE3 가 뷰를 다시 합치는지 확인 — merge3_get_views 증가

A-2. DB 정상 정지

답하려는 질문

PostgreSQL이 죽으면 어떻게 되는가? 로그인한 사람은? 새 로그인은?

구조

   kc-lab-1                              kc-lab-2
   ┌────────────────┐                    ┌────────────────┐
   │  keycloak-1    │◀────── 7800 ──────▶│  keycloak-0    │
   │  캐시: 세션 N개 │    (살아 있다)      │  캐시: 세션 M개 │
   └───────┬────────┘                    └───────┬────────┘
           │                                     │
           └──────────────┬──────────────────────┘
                          ▼
                  ┌───────────────┐
                  │  postgres  ✗  │  ← replicas=0 으로 정지
                  └───────────────┘

   질문: 캐시에 세션을 들고 있는 노드는 그 세션을 계속 쓸 수 있는가?

A-0에서 얻은 예측의 시험대다. DB가 진실의 원천이라면, 캐시에 있어도 못 쓴다는 것이 논리적 귀결이다. 하지만 룩어사이드 캐시라면 읽기는 될 수도 있다 — 이게 갈리는 지점이다.

주입 방법

kubectl -n keycloak-lab scale deployment/postgres --replicas=0

확인할 것 — 세 가지 경로를 구분해서 본다

경로 무엇을 보는가 예상
① 캐시 있는 노드에서 refresh 캐시가 DB를 대신할 수 있나 실패 (쓰기가 필요)
② 캐시 없는 노드에서 refresh 완전히 DB 의존 실패
③ 새 로그인 쓰기 경로 실패
④ 이미 발급된 access token 검증 서명만 보면 되는 경로 성공 (60초 동안)

④가 중요하다. DB가 죽어도 access token 수명 동안은 리소스 서버가 정상 동작한다. 이게 "짧은 access token + 긴 refresh token" 구조가 장애에 주는 유예 시간이다.

관측 예상
up{job="keycloak"} 1 유지 — 프로세스는 살아 있다
KC /health/ready DOWN 으로 바뀌는가?
KC 로그 커넥션 풀 고갈 / FATAL: the database system is shutting down
에러 응답 코드 500? 503? — 클라이언트가 재시도할지 결정하는 값

예측

로그인          실패
refresh         실패 (캐시 유무 무관)
기존 access token 검증   성공 — 만료까지
KC 프로세스     살아 있음 (up=1) — 헬스체크만 DOWN

판정 기준

up=1인데 기능은 전멸 → "프로세스 살아 있음"이 헬스체크로 부족하다는 증명. /health/ready가 DB를 보는지 여부가 롤링 배포 안전성을 좌우한다.

복구

kubectl -n keycloak-lab scale deployment/postgres --replicas=1
# 자동 복구되는가, Keycloak 재시작이 필요한가 ← 이것도 측정 대상

A-3. DB 강제 종료 — 무엇을 잃는가 (RPO)

답하려는 질문

정상 종료가 아니라 전원이 나가면 무엇을 잃는가?

A-0에서 발견한 SET LOCAL synchronous_commit TO OFF의 시험대다.

update OFFLINE_USER_SESSION set LAST_SESSION_REFRESH=$1, VERSION=$2 where ...
SET LOCAL synchronous_commit TO OFF      WAL 플러시를 기다리지 않고 커밋
COMMIT

구조

   ┌────────────────┐
   │  keycloak-0    │  refresh 를 초당 여러 번 날린다
   └───────┬────────┘
           │  UPDATE LAST_SESSION_REFRESH
           ▼
   ┌───────────────────────────────┐
   │  postgres                     │
   │    메모리 (WAL 버퍼)  ← 여기까지만 쓰고 COMMIT 응답  │
   │        ↓ 아직 안 내려감        │
   │    디스크 (WAL)                │  ✗ SIGKILL
   └───────────────────────────────┘
        이 사이의 갱신이 사라진다

주입 방법

# 정상 종료가 아니라 강제 종료. 컨테이너에 SIGKILL
kubectl -n keycloak-lab delete pod -l app=postgres --grace-period=0 --force

확인할 것

절차 무엇을 보는가
1. refresh 를 반복하며 매번 LAST_SESSION_REFRESH 를 기록 기준값
2. 마지막 성공 시각을 클라이언트 쪽에 남긴다 클라이언트가 믿는 값
3. 강제 종료 → 재기동
4. DB의 LAST_SESSION_REFRESH 를 다시 읽는다 2와 4의 차이 = 잃은 것
5. postgres 로그의 crash recovery 메시지 database system was not properly shut down

예측

잃는 양      수백 밀리초 분량의 LAST_SESSION_REFRESH 갱신
잃지 않는 것  로그인·로그아웃 자체 (다른 트랜잭션일 가능성 — 확인 필요)
crash recovery  자동 실행되고 성공

"COMMIT 응답을 받았는데 없어졌다"를 실제로 관측하는 것이 목표다. 이건 버그가 아니라 의도된 트레이드오프이며, 그걸 눈으로 봐야 "세션 갱신은 잃어도 되는 쓰기"라는 설계 판단을 체감할 수 있다.


A-4. 노드 전원 차단

답하려는 질문

Keycloak 한 대가 통째로 죽으면 그 노드가 처리하던 사용자는 어떻게 되는가?

구조

   kc-lab-1  (살아 있다)                 kc-lab-2  ✗ 전원 차단
   ┌────────────────────┐                ┌────────────────────┐
   │  traefik  ← 진입점  │                │  keycloak-0        │
   │  coredns           │                │    캐시 세션 M개 ✗  │
   │  keycloak-1        │                │  postgres      ✗    │  ← 같이 죽는다
   │  prometheus        │                │                    │
   └────────────────────┘                └────────────────────┘

   ⚠ postgres 가 kc-lab-2 에 있으므로 이 실험은 A-2 와 겹친다.
      순수한 "노드 상실"을 보려면 postgres 를 먼저 kc-lab-1 로 옮긴다.

중요virsh destroy는 프로세스 kill이 아니라 전원 차단이다. 게스트에 종료 신호가 가지 않으므로 진짜 노드 상실이다.

두 판본으로 나눠 실행한다

판본 배치 보는 것
4a. 순수 노드 상실 postgres 를 kc-lab-1 로 옮긴 뒤 Keycloak 한 대만 잃었을 때
4b. 복합 장애 현재 배치 그대로 노드 + DB 동시 상실 — 더 현실적

주입 방법

virsh destroy kc-lab-2        # ACPI 종료가 아니라 전원 차단

확인할 것

관측 예상 (4a)
up{node="kc-lab-2"} 0 — 언제 죽었는지 기록됨
죽은 노드의 마지막 샘플 node-exporter 가 DaemonSet 인 이유
kubectl 노드 상태 ReadyNotReady (기본 40초 후)
죽은 노드가 만든 세션의 refresh 성공 ← 판정 핵심. DB 에 있으므로
ISPN000094 남은 노드가 멤버 1개 뷰 발행
JGROUPS_PING 죽은 노드 행이 남아 있다 (지울 주체가 없다)
파드 재배치 PVC 때문에 postgres 는 재배치 못 한다

예측

4a  세션 살아남는다. 남은 노드가 DB 에서 읽어 계속 서비스한다
4b  전면 장애 — DB 가 같이 죽었으므로 A-2 와 같아진다

복구

virsh start kc-lab-2
# JGROUPS_PING 의 유령 행이 어떻게 정리되는지 관찰 — 자동인가 수동인가

A-5. 비대칭 파티션 (split brain)

답하려는 질문

한쪽 방향만 끊기면? 서로 상대가 죽었다고 생각하면?

가장 고약한 장애다. 완전 단절보다 부분 단절이 훨씬 흔하고 진단이 어렵다.

구조

   kc-lab-1                              kc-lab-2
   ┌────────────────┐   ──────────▶      ┌────────────────┐
   │  keycloak-1    │   나가는 건 됨      │  keycloak-0    │
   │                │                    │                │
   │                │   ◀━━✗━━━━━━       │                │
   │                │   들어오는 건 막힘  │                │
   └────────────────┘                    └────────────────┘

   kc-lab-1 → "상대가 응답이 없네, 죽었나보다"
   kc-lab-2 → "나는 잘 보내고 있는데?"

주입 방법

# 게스트 안에서 nftables 로 인바운드만 차단 (커널이 분리돼 있어 가능)
ssh kc-lab-2 'sudo nft add rule inet filter input tcp dport 7800 drop'

확인할 것

관측 보는 이유
양쪽의 ISPN000094 서로 다른 명단을 갖는가
coord 컬럼 양쪽 다 코디네이터가 되는가 (진짜 split brain)
fd_sock2_suspected 어느 쪽이 먼저 의심하는가
세션 동작 DB 는 멀쩡하므로 영향 없어야 한다
복구 후 merge3_get_views MERGE3 가 합치는가, 재시작이 필요한가

A-6. 지연 주입 (tc netem)

답하려는 질문

끊기지 않고 느려지기만 하면? — 실제 장애의 대부분이 이 모양이다

구조

   keycloak ──▶ [ tc netem: +200ms ] ──▶ postgres
                     지연만 넣는다

주입 방법

ssh kc-lab-2 'sudo tc qdisc add dev eth0 root netem delay 200ms'

확인할 것

관측
refresh 응답 시간 분포 200ms 가 그대로 더해지는가, 증폭되는가
DB 커넥션 풀 사용률 느려지면 커넥션이 반환 안 돼 고갈된다
타임아웃 발생 지점 어느 계층이 먼저 포기하는가
낙관적 락 충돌 증가 트랜잭션이 길어져 VERSION 충돌이 늘어야 한다

여기서 A-0의 낙관적 락 발견이 다시 쓰인다 — 지연이 길수록 읽고 쓰는 사이가 벌어지므로 충돌 확률이 오른다.


A-7. volatile 모드 비교 — 옛 방식은 뭐가 다른가

답하려는 질문

Keycloak 24 이전 방식(메모리가 진실의 원천)이었다면 지금까지의 결론이 어떻게 달라지는가?

A-0의 모든 결론이 "26 기본값"이라는 전제 위에 있다. 전제를 뒤집어 확인하는 것이 이 실험이다.

구조

   현재 (persistent, KC 26 기본)          비교군 (volatile)
   ┌────────────────┐                    ┌────────────────┐
   │  keycloak      │                    │  keycloak      │
   │   캐시(로컬)    │                    │   캐시  ←──────┼── 진실의 원천
   └───────┬────────┘                    └───────┬────────┘
           │ 진실의 원천                          │ ✗ 세션 저장 안 함
           ▼                                     ▼
      [ PostgreSQL ]                        [ PostgreSQL ]
                                            (realm 설정만)

설정 변경

args: ["start", "--features-disabled=persistent-user-sessions"]

확인할 것 — A-0부터 A-4를 전부 다시 돌린다

실험 persistent (측정 완료) volatile (예측)
A-0 캐시 위치 각자 자기 것만 양쪽에 복제되어야 한다
A-1 7800 차단 안 깨짐 치명적 — 정반대
A-2 DB 정지 전면 장애 세션은 살아남음
A-4 노드 상실 세션 생존 복제됐다면 생존
A-8 롤링 재시작 세션 유지 전부 로그아웃

A-1이 정반대로 뒤집히는 것을 보는 게 이 실험의 목적이다. 같은 주입, 같은 관측, 정반대 결과 — 그래야 "버전 확인이 1순위"라는 교훈이 말이 아니라 데이터가 된다.


A-8. 롤링 재시작 — 배포하면 로그아웃되는가

답하려는 질문

운영에서 가장 자주 겪는 것. 배포할 때마다 사용자가 로그아웃되는가?

구조

   시각 →
   keycloak-0  ████████──✗ 재시작 ──████████
   keycloak-1  ████████████████──✗ 재시작 ──████

   사용자 세션  ────────────────────────────────▶  유지되는가?

주입 방법

kubectl -n keycloak-lab rollout restart statefulset/keycloak

확인할 것

관측 예상 (persistent)
재시작 전후 sid 동일
재시작 중 refresh 일시 실패 후 성공 (파드 전환 시점)
재시작 후 캐시 엔트리 0에서 다시 시작 — 캐시는 사라진다
브라우저 SSO 쿠키 유지

A-7과 짝이다. volatile 이었다면 여기서 전원 로그아웃된다. 이게 persistent 를 켜는 진짜 이유다.


B층 — 애플리케이션 세션 (Redis) · 열린 질문 4개

공개된 열린 질문 네 개는 전부 이 층에 있다. A/C/D층에 대응하는 질문은 등록되어 있지 않다.

목록 경로는 /questions 가 아니라 /explore/questions 다. 2026-09-04 확인 시점에 KeyCloak Patterns 소속 열린 질문은 4개.

커버리지 대조표 — 질문의 「남은 미지수」와 「다음 검증」 전수

각 질문 페이지의 항목을 하나씩 옮겨 실험 번호를 붙였다. 빠진 것이 있으면 여기서 드러난다.

Q1. 다중 인스턴스 운영

# 미지수 / 검증 항목 실험
1 재시작 뒤 로그인이 유지되는가 B-2 ②
2 인스턴스가 바뀌어도 같은 session 을 찾는가 B-2 ①
3 여러 session 이 authorized client 를 공유·덮어쓰는가 B-2 ③
4 한쪽에서 로그아웃하면 다른 쪽도 끊기는가 B-2 ④
5 저장된 refresh token 이 암호화되는가 B-1 ②
6 logout 에서 두 저장소를 모두 정리하는가. 한쪽만 지웠을 때 다음 요청·재로그인에서 무엇이 복원되는가 B-2 ⑤ ← 추가
7 session 만료와 token 만료가 어긋나면 무엇이 먼저 실패하고 화면에 어떻게 보이는가 B-1 ③
8 OAuth2-Proxy replica 들이 cookie secret 을 어떻게 공유·교체하는가. 교체 중 로그인해 있던 사람은? B-7 ← 실험 신설
9 Spring Boot 자동구성이 실제로 고른 구현체가 무엇인가 B-0 ← 추가
10 Resource Server 8081 이 host 에 열려 우회 가능 (제약) B-2 ⑥

Q2. Refresh Token 경쟁

# 미지수 / 검증 항목 실험
1 동시 갱신 시 각 replica 가 어떻게 동작하는가 B-3 ①②
2 사용자 화면에 로그인 만료로 보이는가 일시 오류로 보이는가 B-3 ④
3 새 token 을 다시 읽어 재시도하면 성공하는가 B-3 ③
4 한 곳에서만 갱신할지, 각자 하고 실패는 재시도로 둘지 B-3 ⑤ 판정
5 lock 을 쓴다면 어디에 두고 얼마나 잡는가. 잡은 채 프로세스가 내려가면 어떻게 푸는가 B-3 ⑥ ← 추가
6 갱신 실패를 로그인 만료와 구분해 표시할 수 있는가 B-3 ④
7 rotation·재사용 0회 전제를 바꿔서도 비교 (제약: 추후 검토) B-3 ⑦ ← 추가

Q3. BFF 저장소 결정

# 미지수 / 검증 항목 실험
1 Redis 와 JDBC 중 무엇이 이 접근 패턴에 맞는가 B-1 전체
2 session 과 authorized client 를 같은 store 에 둘지 나눌지 B-1 ⑥
3 암호화 key 를 어디 두고 어떻게 교체하는가. 교체 중 이전 key 로 저장된 값은 어떻게 읽는가 B-6 ← 실험 신설
4 session TTL 과 refresh token 수명 중 무엇을 기준으로 맞추는가 B-1 ③
5 두 store 를 logout 에서 한 번에 지우는가 B-1 ④ · B-2 ⑤
6 sticky session 이 durable store 의 대안인가 보완인가 B-2 ⑦
7 저장소 지연이 화면 지연으로 드러난다 (제약) — 얼마나? B-1 ⑦ ← 추가

Q4. Edge 인가 범위

# 미지수 / 검증 항목 실험
1 다중 값 role 의 구분자·escaping. 값 안에 구분자가 들어오면 B-4 ①
2 헤더 크기 상한 초과 시 자르는가 거부하는가 B-4 ②
3 role 변경이 proxy session 과 downstream 에 언제 반영되는가 B-4 ③
4 upstream 이 헤더 존재만 보는가 값과 service identity 까지 보는가 B-4 ④
5 internal token 검사를 controller 에서 공통 경계로 옮기기 (제약) B-4 ⑤ ← 추가
6 설계 판단 5문항 — claim 이 계속 느는가 / 즉시 반영이 필요한가 / 정책이 도메인을 아는가 / 헤더가 인가 근거인가 / 서비스별 정책차가 큰가 B-4 ⑥ ← 추가

선행 — 배포가 필요하다

현재 실험대에 BFF도 Redis도 없다.

   지금                              B층에 필요한 것
   ┌──────────────┐                 ┌──────────────────────────┐
   │ keycloak x2  │                 │ keycloak x2              │
   │ postgres     │                 │ postgres                 │
   │ echo         │        +        │ bff x2        ← 신규      │
   │ 관측 스택     │                 │ redis         ← 신규      │
   │              │                 │ resource-server ← 신규    │
   │              │                 │ oauth2-proxy  ← 신규(B-7) │
   └──────────────┘                 └──────────────────────────┘

BFF 구현은 develop-keycloak-pattern3에 있다.

git checkout develop-keycloak-pattern3 -- bff/

자원 예산 — 호스트 12GB 중 여유 약 4GB. Redis(~64Mi) + BFF 2개(~512Mi) + resource-server(~256Mi)는 들어가지만, A층을 마친 뒤 배포하는 편이 안전하다.


B-0. 자동구성이 실제로 고른 구현체 확인

답하려는 질문

Q1 확인한 사실: "코드에 저장소를 직접 생성하는 Bean이 없기 때문에, 어떤 구현체가 실제로 사용되는지는 Spring Boot 자동구성 결과까지 확인해야 정확하게 알 수 있다."

추측이 아니라 실물을 본다. A-0에서 "설정 파일이 껍데기라 동작을 재야 했다"와 같은 종류의 확인이다.

확인 방법

# 실제 주입된 빈의 클래스명을 찍는다
curl -s localhost:8080/actuator/beans | jq '.contexts.application.beans
  | with_entries(select(.key | test("SessionRepository|AuthorizedClientService|AuthorizedClientRepository")))'

# 또는 기동 시 자동구성 리포트
--debug   # ConditionEvaluationReport 에서 Positive/Negative matches
확인 예상
OAuth2AuthorizedClientService 구현체 InMemoryOAuth2AuthorizedClientService
SessionRepository 없음 (서블릿 컨테이너 in-memory)
Redis 전환 후 RedisIndexedSessionRepository 등으로 바뀌는지 확인

전환 전후를 둘 다 찍는다. "Redis 붙였다"고 믿는데 자동구성이 안 걸려 in-memory 그대로인 경우가 실제로 흔하다.


B-1. BFF 저장소 결정 → Q3

구조

   브라우저 ──쿠키(SESSION)──▶ ┌─────────┐          ┌──────────────┐
                              │ bff-0   │─────────▶│              │
                              ├─────────┤          │    Redis     │
                              │ bff-1   │─────────▶│              │
                              └─────────┘          └──────────────┘
                                   │                 두 가지가 들어간다
                                   │                 ① Application Session
                                   │                    키: session ID
                                   │                 ② OAuth2AuthorizedClient
                                   │                    키: registration + principal
                                   ▼                    ★ session ID 가 없다
                              [ Keycloak ]

두 상태의 조회 키가 다르다는 것이 이 질문의 핵심이며, 그래서 "Redis 하나 붙이면 끝"이 성립하지 않는다.

확인할 것

# 항목 방법
두 인스턴스에서 로그인 유지 · 재시작 복구 B-2 와 겹침
refresh token 이 평문으로 남는가 redis-cli --scanGET 으로 직접 열어본다
session TTL ≠ token 만료 — 어긋나게 두고 응답·화면 기록 TTL 을 짧게/길게 두 번
logout 뒤 두 store 에 잔여 항목이 없는가 --scan 으로 전수 확인
저장소를 끊은 상태에서 로그인·API 호출의 오류 형태 Redis 파드 정지 (B-5)
같은 store 에 둘 때 vs 나눌 때 두 구성으로 각각
저장소 지연이 화면 지연으로 얼마나 드러나는가 tc netem 으로 Redis 앞에 지연 주입

⑦이 Q3의 제약을 직접 재는 것이다"모든 UI 요청이 BFF를 지나기 때문에 저장소 지연이 화면 지연으로 바로 드러나게 된다." A-6에서 쓴 netem 을 그대로 재사용한다.

②를 Redis 와 JDBC 양쪽에서 한다. 어느 쪽이 더 잘 보이는지가 판단 재료다.


B-2. 다중 인스턴스 운영 → Q1

구조

   nginx  ip_hash 주석 on/off  ← 스티키 유무를 같은 구성에서 비교
      │
      ├──▶ bff-0 ──┐
      └──▶ bff-1 ──┴──▶ Redis
                        │
   ✗ 우회 경로:  브라우저 ──직접──▶ resource-server:8081
                 (Q1 제약. NetworkPolicy 로 닫는다)

확인할 것

# 항목 예상 / 보는 이유
한쪽에서 로그인 → 다른 인스턴스로 요청 시 200 유지 공유 저장소의 기본
한 인스턴스 재시작 후 같은 session cookie 로 상태 유지
두 브라우저에서 같은 사용자로 로그인 → authorized client 덮어쓰기 "Redis만 붙이면 해결"을 깨는 항목. 조회 키에 session ID 가 없어서 생긴다
한쪽 logout 후 다른 쪽 요청 같은 authorized client 를 보므로 같이 끊길 것
HttpSession 만 지우고 authorized client 를 남겼을 때 / 그 반대일 때 재로그인에서 무엇이 복원되는가. 부분 삭제는 실제 사고 모양이다
resource-server 직접 호출 차단 2홉 실험의 NetworkPolicy 패턴 재사용
ip_hash on/off 비교 sticky 가 durable store 의 대안인가 보완인가 (Q3 미지수 6)

⑤가 새로 넣은 항목이다. Q1은 "한쪽만 삭제했을 때 다음 요청이나 재로그인에서 어떤 상태가 복원되는가"를 명시적으로 묻는다.


B-3. Refresh Token 경쟁 → Q2

구조

   access token 만료 직후, 동시에 두 요청
        │                         │
        ▼                         ▼
     bff-0                     bff-1
        │                         │
        └──── 같은 refresh token ──┴────▶ Keycloak
                                            │
                            rotation + 재사용 0회
                            → 한쪽만 성공

★ B-1 이후여야 재현된다. 저장소가 process-local 이면 두 replica 가 같은 항목을 보지 않아 경쟁 자체가 성립하지 않는다. Q2가 제약에 직접 써둔 것이다.

A-0에서 Keycloak 쪽 절반은 이미 나왔다 — 낙관적 락(VERSION)과 SKIP LOCKED. 남은 것은 BFF 쪽 재시도·lock 설계다.

확인할 것

# 항목
만료 직후 동시 요청 — 이긴 쪽 응답
진 쪽 응답 (invalid_grant? 다른 코드?)
진 쪽이 저장소에서 새 token 을 다시 읽어 재시도하면 성공하는가
진 쪽 사용자 화면 — 로그인 만료로 보이는가 일시 오류로 보이는가. 구분해 표시할 수 있는가
lock 있는 구성 / 없는 구성을 같은 입력으로 — 실패율과 지연
lock 을 잡은 채 프로세스가 죽으면 어떻게 풀리는가 — TTL? 수동? 다음 요청이 막히는 시간은?
rotation·재사용 0회를 끈 구성과 비교

판정 기준실패가 사용자에게 노출되면 lock, 노출되지 않으면 재시도.

⑥은 lock 을 고를 경우의 진짜 비용이다. Redis lock 을 잡은 파드가 OOM 으로 죽으면 TTL 만료까지 그 사용자는 갱신이 막힌다. 이걸 재보지 않고 lock 을 고르면 안 된다.

⑦은 Q2 제약이 "추후 바꿔서 검토"라고 남긴 것이다. 전제를 바꿨을 때 경쟁이 사라지는지, 대신 무엇을 잃는지(탈취된 token 재사용 탐지)를 본다.


B-4. Edge 인가 범위 → Q4

구조

   브라우저 ──▶ nginx ──auth_request──▶ oauth2-proxy / BFF
                  │                          │
                  │◀── X-Auth-Request-User ──┘
                  │    X-Auth-Request-Email
                  │    X-Auth-Request-Roles  ← 새로 추가할 것
                  │
                  ▼  allowlist + 항상 덮어쓰기
            resource-server (upstream)
                  │
                  └─ JWT 를 입력으로 받지 않는다
                     → 헤더 값을 검증할 방법이 없다

2홉 실험의 결론이 여기서 결정적이다 — 헤더 위조가 통하면 그것은 쿠키 속성이 아니라 신원 위조다. NetworkPolicy 패턴을 그대로 재사용한다.

확인할 것

# 항목
다중 값 role 의 구분자와 escaping. 값 안에 그 구분자가 들어오면
헤더 크기 상한 초과 시 — proxy 가 자르는가 요청 자체가 거부되는가
role 을 바꾼 뒤 몇 번째 요청부터 반영되는가. proxy session 과 downstream 각각
upstream 이 헤더 존재만 보는가, 값과 service identity 까지 보는가
internal token 검증을 controller 에서 공통 경계로 옮긴다 (Filter / Interceptor / Security)
설계 판단 5문항에 답한다

⑤는 코드 변경이며 Q4의 제약이 "헤더를 늘리기 전에" 하라고 못박은 것이다. 현재 permitAll 아래 특정 컨트롤러에만 검증이 있어, 같은 경로에 엔드포인트를 추가하면 검증이 자동으로 안 걸린다. 헤더를 늘리기 전에 이걸 먼저 옮긴다.

⑥ 설계 판단 5문항 — Q4가 정한 결론 규칙

# 질문 그렇다면
1 전달할 claim 이 계속 늘어나는가
2 role·tenant 변경이 즉시 반영돼야 하는가 BFF 구조로
3 정책이 애플리케이션 도메인을 알아야 하는가 BFF 구조로
4 헤더 값이 인가 판단의 근거가 되는가 BFF 구조로
5 서비스별 정책 차이가 커지는가 BFF 구조로

2번부터 5번 중 하나라도 그렇다면 헤더를 늘리기보다 BFF 구조로 구성하자.

실험이 이 5문항에 데이터를 대는 것이 목적이다. ①~③의 측정 결과가 1·2번의 답을 정한다.


B-5. Redis 상실과 영속화

A-2·A-3의 Redis 판본. 같은 질문을 다른 저장소에 던진다.

① 상실

   bff-0 ──┐
           ├──▶ Redis  ✗ 파드 정지
   bff-1 ──┘
확인 예상
로그인한 사용자의 다음 요청 세션 소멸 → 로그인 화면
오류 형태 500? 302 로그인 리다이렉트? — Q3 검증 5번
복구 후 자동 재연결 BFF 재시작이 필요한가
브라우저 쿠키는 남아 있다 쿠키는 있는데 세션이 없는 상태 — 어떻게 보이나

② 영속화 — 재시작하면 무엇이 남는가

Redis 는 기본이 메모리다. 껐다 켜면 사라진다. 두 가지 영속화 방식이 있다.

방식 무엇 잃는 것
RDB 주기적 스냅샷 마지막 스냅샷 이후 전부
AOF (appendonly yes) 모든 쓰기를 로그로 appendfsync 설정만큼
둘 다 없음 (기본) 없음 전부
# 세 구성을 같은 입력으로 비교한다
redis-server --appendonly no --save ""        # 영속화 없음
redis-server --save "60 1"                    # RDB
redis-server --appendonly yes                 # AOF
확인
각 구성에서 정상 재시작 후 세션 생존율
각 구성에서 강제 종료(SIGKILL) 후 생존율 A-3 과 같은 질문
appendfsync everysec 의 실제 손실 폭 Keycloak 의 synchronous_commit OFF같은 모양의 트레이드오프

A-3에서 PostgreSQL 로 한 것을 Redis 로 반복하는 것이다. 저장소가 달라도 "내구성을 어디까지 포기하는가"라는 질문은 같다.


B-6. 암호화 key 보관과 교체 → Q3 미지수 3

답하려는 질문

"암호화 key 를 어디에 두고 어떻게 교체하게 되는가. 교체하는 동안 이전 key 로 저장된 값은 어떻게 읽는가."

구조

   교체 전                 교체 중                    교체 후
   ┌────────┐            ┌────────┐                 ┌────────┐
   │ key A  │            │ key B  │ 쓰기            │ key B  │
   └────────┘            │ key A  │ 읽기(구값)      └────────┘
        │                └────────┘                      │
        ▼                     │                          ▼
   [ Redis: A로 암호화된 값 ]  │            [ Redis: B로 재암호화된 값 ]
                              ▼
                    이 구간에 로그인해 있던 사람은?
확인
key 를 어디에 두는가 환경변수 / k8s Secret / 외부 KMS — D-3 과 연결
교체 중 이전 key 로 저장된 값을 읽을 수 있는가 복수 key 를 동시에 들 수 있는 구조인가
교체 중 로그인해 있던 사람 강제 로그아웃되는가, 무중단인가
재암호화 시점 읽을 때마다? 배치로 한 번에?

Q3은 "후보를 고른 뒤에 따로 설계한다"고 미뤄둔 항목이지만, 실험대에서는 같이 재볼 수 있다. B-7(cookie secret 교체)과 같은 모양의 문제다.


답하려는 질문

"OAuth2-Proxy 구조의 replica 들이 같은 cookie secret 을 어떻게 공유하고 교체하게 되는가. 교체하는 동안 로그인해 있던 사람은 어떻게 되는가."

계획서에 아예 없던 실험이다. Q1을 정독하고 나서야 드러났다.

왜 다른 문제인가

   BFF / Mediator            서버에 세션을 둔다      → 공유 저장소 문제
   OAuth2-Proxy              쿠키에 세션을 담는다    → 서명·암호화 key 문제
                                                      (서버 저장소가 없다)

OAuth2-Proxy 는 서버 저장소를 안 쓴다. 로그인 상태를 브라우저 쿠키에 담고, 그 쿠키를 cookie secret 으로 서명·암호화한다. 현재 설정의 쿠키 유효 시간은 1시간.

결과
replica 간 공유 문제가 없다 쿠키에 다 들어 있으므로
대신 모든 replica 가 같은 secret 을 가져야 한다 다르면 서로의 쿠키를 못 읽는다
secret 을 바꾸면 기존 쿠키가 전부 무효 → 전원 재로그인

구조

   ┌──────────────────┐        ┌──────────────────┐
   │ oauth2-proxy-0   │        │ oauth2-proxy-1   │
   │  secret = X      │        │  secret = X      │  ← 같아야 한다
   └────────┬─────────┘        └────────┬─────────┘
            │                           │
            └──── 브라우저 쿠키 ─────────┘
                  (서버에 아무것도 없다)

   secret 을 Y 로 바꾸면?  →  X 로 서명된 쿠키를 아무도 못 읽는다

확인할 것

# 항목
replica 간 secret 이 다를 때 무슨 일이 생기는가 — 요청마다 다른 결과?
secret 교체 시 로그인해 있던 사람 — 전원 로그아웃인가
무중단 교체가 가능한가 — 구 secret 을 읽기 전용으로 병행할 수 있는가
쿠키 TTL 1시간과 실제 세션 수명의 관계
secret 을 k8s Secret 으로 두면 회전 시 파드 재시작이 필요한가

②·③이 B-6(암호화 key 교체)과 같은 모양이다. 둘을 나란히 하면 "key 회전"이라는 한 주제의 두 사례가 된다.


C층 — SSO

C-1. 다중 앱 SSO

답하려는 질문

당신 원래 질문: "SSO를 추가하면 어떻게 달라지는가"

구조

                    ┌──────────────────────────┐
                    │   Keycloak SSO 세션       │
                    │   쿠키 KEYCLOAK_IDENTITY  │
                    └────────┬─────────────────┘
                             │
              ┌──────────────┴──────────────┐
              ▼                             ▼
      ┌──────────────┐             ┌──────────────┐
      │  app1        │             │  app2        │
      │ (BFF 세션)    │             │ (BFF 세션)    │
      └──────────────┘             └──────────────┘

   app1 로그인 → app2 는 로그인 화면 없이 통과
   Keycloak 세션 소멸 → 양쪽 다 재로그인

확인할 것

항목 보는 것
app1 로그인 후 app2 접속 로그인 화면 없이 통과하는가
A-2(DB 정지)를 겹친다 장애 반경이 앱 하나에서 전체로 커지는가
SSO 세션 vs 앱 세션 수명 어긋나면 무슨 일이 생기는가
노드 상실 중 SSO 새 앱 진입이 되는가

이게 SSO의 대가다 — 편의를 얻는 대신 단일 장애점이 하나 늘어난다.

C-2. 백채널 로그아웃

   Keycloak ──logout token(sid)──▶ app1 /backchannel-logout
            └─────────────────────▶ app2 /backchannel-logout

A-0에서 확인한 sid가 여기서 쓰인다. 토큰·DB·관리 API에서 같았던 그 문자열이 로그아웃 전파의 키다.

확인 — 한 앱이 응답 안 하면? 부분 실패 시 재시도는? 역인덱스는 어디에?


D층 — 운영 역량

D-1. 백업과 복구 리허설

"백업이 있다"와 "복구해봤다"는 완전히 다르다.

   1. pg_dump 로 전체 덤프
   2. 일부러 파괴 (PVC 삭제 또는 DROP DATABASE)
   3. 덤프에서 복구
   4. 로그인이 되는가 / 기존 세션이 살아나는가
   5. 걸린 시간 = RTO,  잃은 데이터 = RPO

A-3에서 측정한 RPO와 합쳐진다. synchronous_commit OFF 때문에 백업 시점 이후뿐 아니라 크래시 직전 수백 ms도 잃는다.

D-2. 버전 업그레이드

   26.7.0 → 다음 마이너
      │
      ├── DB 스키마 자동 마이그레이션  ← 실패하면 되돌리기 어렵다
      ├── 롤링 중 세션 유지되는가       ← A-8 과 연결
      └── 롤백 가능한가                ← 스키마가 바뀐 뒤에는 구버전이 못 뜬다

D-1이 선행이어야 한다. 백업 없이 스키마 마이그레이션을 돌리지 않는다.

D-3. 비밀 관리

kubectl get secret -o yaml → base64 디코드가 한 줄로 되는 것을 직접 본다. "Secret은 암호화가 아니다"를 문장이 아니라 화면으로.

D-4. 인증서 갱신

sudo certbot renew --force-renewal   # 90일을 안 기다린다

nginx reload 중 진행 중이던 요청은 어떻게 되는가.


실행 순서와 의존 관계

   A-0 ✅
    │
    ├─▶ A-1 (7800)  ─────┬─▶ A-5 (비대칭)
    │                    └─▶ A-6 (지연)
    │
    ├─▶ A-2 (DB 정지) ──▶ A-3 (강제 종료) ──▶ D-1 (백업/복구) ──▶ D-2 (업그레이드)
    │
    ├─▶ A-4 (노드 상실)
    │
    ├─▶ A-8 (롤링 재시작)
    │
    └─▶ A-7 (volatile 비교)   ← A-1·A-2·A-4·A-8 을 다 한 뒤 전부 재실행
         │
         ▼
        [ BFF + Redis + resource-server 배포 ]
         │
         ├─▶ B-0 (자동구성 확인)   ← 저장소를 바꾸기 전과 후 둘 다 찍는다
         │    │
         │    └─▶ B-1 (저장소 결정 · Q3) ──┬─▶ B-2 (다중 인스턴스 · Q1)
         │                                 ├─▶ B-3 (refresh 경쟁 · Q2)
         │                                 ├─▶ B-4 (edge 인가 · Q4)
         │                                 ├─▶ B-5 (Redis 상실·영속화)
         │                                 └─▶ B-6 (암호화 key 교체)
         │
         ├─▶ B-7 (oauth2-proxy cookie secret)   ← B-6 과 짝. "key 회전" 한 주제
         │
         └─▶ C-1 (SSO) ─▶ C-2 (백채널 로그아웃)

   D-3 (비밀), D-4 (인증서) 는 언제든 독립적으로

A-7(volatile)을 A층 마지막에 두는 이유 — 앞의 실험들을 전부 마친 뒤 설정 하나만 바꿔 재실행하면, 같은 주입에 대한 정반대 결과를 한 벌로 얻는다. 이게 이 실험대에서 가장 값진 산출물이 될 가능성이 높다.


공통 판정 규칙

A-0에서 비싸게 배운 것들. 모든 실험에 적용한다.

규칙
대조군을 먼저 잰다 반대편 응답은 발급 노드 응답과 나란히 놓기 전엔 의미 없다
개수가 아니라 식별자로 추적 실험 도구 자신이 만든 잡음에 오염된다
저장소를 실제로 건드리는 탐침 userinfo 는 세션을 몰라도 통과한다
계측 코드가 자기 실패를 말하게 스냅샷이 비면 델타가 조용히 0이 된다
예측을 먼저 적는다 사후에 "그럴 줄 알았다"는 아무것도 아니다
증거는 원본 그대로 터미널 출력과 브라우저 스크린샷. 재구성하지 않는다

실험마다 남기는 것

docs/experiment-NN-<이름>.md          해설 · 예측 · 결과 · 개념 · 함정
docs/evidence/<이름>/README.md        파일별 무엇을 보여주는지
docs/evidence/<이름>/*.txt            터미널 원문
docs/evidence/<이름>/*.png            브라우저 스크린샷 (Grafana / 관리 콘솔)
deploy/lab/scripts/experiment-*.sh    재현 스크립트