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>
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 노드 상태 | Ready → NotReady (기본 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 --scan → GET 으로 직접 열어본다 |
| ③ | 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 교체)과 같은 모양의 문제다.
B-7. OAuth2-Proxy cookie secret 공유와 교체 → Q1 미지수 7
답하려는 질문
"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 재현 스크립트