Twenty experiments across four layers, each with a topology diagram marking where the fault goes in, the metrics to watch, a falsifiable prediction written before the run, and a pass/fail rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
18 KiB
세션 저장소 실험 축 — 계획과 진행
develop-keycloak-session-store 브랜치가 담당하는 작업의 전체 지도.
무엇이 끝났고 무엇이 남았는지를 여기서 추적한다.
왜 별도 축인가
네 인증 패턴(AP1~AP4)은 브라우저와 토큰의 관계를 비교한다. 이 축은 그것과 직교하는 질문을 다룬다 — 세션과 토큰이 서버 쪽 어디에 저장되고, 그 저장소가 죽으면 무슨 일이 벌어지는가.
초기 검토에서 전제 하나가 교정됐다. Keycloak은 Redis를 세션 저장소로 지원하지 않는다. 그래서 이 축은 두 계층으로 갈린다.
| 계층 | 저장소 | 해당 패턴 |
|---|---|---|
| A. Keycloak 자체 | 임베디드 Infinispan + PostgreSQL | 네 패턴 공통 |
| B. 애플리케이션 세션 | Redis | AP2 / AP3 / AP4 |
A층은 네 패턴과 무관하게 공통이고, B층은 서버 세션을 갖는 세 패턴에만 존재한다. 그래서 이 축을 AP1~AP4 어디에도 넣지 않고 별도로 둔다.
기존 브랜치 레지스트리에 넣지 않는 이유
docs/keycloak-branch-manifest.tsv와 scripts/audit-keycloak-branches.sh는
정확히 39개 브랜치를 강제하고, 각 브랜치가 외부 노트 파일과 1:1로
대응하는지 검사한다.
if [ "$expected_count" -ne 39 ]; then
echo "manifest must contain exactly 39 Keycloak branches" >&2
이 축의 브랜치를 manifest에 추가하면 그 감사가 깨진다. 원래 39개는 완결된 인벤토리이므로 건드리지 않고, 이 축은 이 문서로 추적한다.
진행 상황
✅ 환경 구축
✅ 2홉 프록시 헤더 계약
──────────────────────────────────────────────────────────
A층 — Keycloak 자체 (공개 질문에 없는 영역)
1. 멀티노드 클러스터 형성
2. persistent vs volatile 세션
B층 — 애플리케이션 세션 (공개 열린 질문 대응)
3. BFF 저장소 결정 → Q3
4. 다중 인스턴스 운영 → Q1
5. Refresh Token 경쟁 → Q2 ★ 3 이후여야 재현됨
6. Edge 인가 범위 → Q4
공통 — 운영 역량
7. 장애 주입과 복구
8. 백업과 복구 리허설
9. 버전 업그레이드
10. 관측성
11. 비밀 관리
12. 인증서 갱신 실측
각 실험의 구조·주입 지점·확인 항목은
experiment-plan.md에 미리 확정해두었다.
순서 근거는 open-questions-coverage.md에 있다.
특히 5번(refresh 경쟁)은 3번(저장소 공유) 이후여야 재현 자체가 성립한다.
✅ 완료 — 환경 구축
2노드 k3s 실험대. 상세는 deploy/lab/README.md,
개념은 session-lab-concepts.md,
운영 도구는 session-lab-operations.md.
브라우저 ─https─▶ 호스트 nginx(TLS 종료) ─▶ Traefik ─▶ Pod
kc-lab-1 / kc-lab-2
왜 Docker Compose가 아닌가 — 한 커널에서 "노드 죽이기"는 프로세스 죽이기일 뿐이다. 노드 간 방화벽·비대칭 파티션·진짜 노드 상실은 독립된 커널 두 개가 있어야 성립한다.
✅ 완료 — 2홉 프록시 헤더 계약
two-hop-proxy-header-contract.md ·
증거 evidence/two-hop-proxy-headers/
확인한 것 — docs/reverse-proxy-headers.md의 1홉 계약이 2홉에서
성립하지 않는다. 원인이 둘이었고 스위치가 셋이었다.
| 스위치 | 하는 일 |
|---|---|
nginx proxy_set_header |
헤더를 만든다 |
Traefik forwardedHeaders.trustedIPs |
받은 헤더를 전달할지 버릴지 |
앱 forward-headers-strategy / KC_PROXY_HEADERS |
도착한 헤더를 읽을지 |
발견한 취약점 — 헤더 신뢰를 켠 순간 위조가 통했다
세 스위치를 다 켜고 나니 새 구멍이 생겼다. Traefik을 거치지 않고 앱에 직접 요청하면서 헤더를 붙이자 그대로 통과했다.
serverName evil.example.com ← 위조 성공
remoteAddr 1.2.3.4 ← 위조 성공
requestUrl https://evil.example.com/api/echo
두 신뢰 설정이 모두 "대역"을 믿기 때문이다. IP로는 Traefik을 특정할 수
없다 — 파드 IP가 재시작마다 바뀐다(측정 중 10.42.0.8 → 10.42.1.12로,
노드까지 옮겨갔다). NetworkPolicy는 IP가 아니라 라벨로 지정하므로 이를
닫는다.
"헤더를 믿는다"와 "앞에 반드시 프록시가 있다"는 한 쌍이다.
이 교훈이 6번(Edge 인가)에서 결정적이 된다 — X-Auth-Request-*가 위조되면
그것은 쿠키 속성이 아니라 신원 위조다.
이 결과가 뒤에 미치는 영향 — Keycloak을 올릴 때
KC_PROXY_HEADERS=xforwarded와 KC_HOSTNAME=https://auth.hyeonworks.com을
근거를 갖고 넣을 수 있고, 로그인이 깨져도 프록시 원인은 배제하고 볼 수 있다.
A층 — Keycloak 자체
공개 열린 질문 네 개는 전부 애플리케이션 계층(B층)이다. Keycloak 자체의 클러스터링과 세션 저장을 다루는 질문은 아직 등록되어 있지 않다.
그러나 이 두 항목이 이 실험대의 존재 이유에 더 가깝다. B층 실험은 replica 2개면 되므로 Docker Compose로도 상당 부분 가능하지만, A층은 독립된 커널 두 개를 요구한다. 실험 후 결과를 새 열린 질문으로 등록할 후보다.
1. Keycloak 멀티노드 클러스터 형성
브랜치: feature/keycloak-multinode-cluster-jdbc-ping
확인할 것
- Keycloak 2개 파드가 서로를 발견해 하나의 클러스터를 이루는가
- Keycloak 26의 기본 디스커버리는
jdbc-ping— PostgreSQL의JGROUPS_PING테이블로 서로를 찾는다. 멀티캐스트가 필요 없다 - TCP 7800이 막히면 무엇이 먼저 보이는가 — 디스커버리는 DB로 하지만 실제 클러스터 통신은 7800이다. 막으면 "DB에는 서로 등록되는데 클러스터가 안 붙는" 증상이 나온다. 단일 노드에서는 재현 불가능한 고장이며, 이 실험대를 2노드로 만든 이유 중 하나다
주의 — Traefik이 replica 1이므로 그 파드가 있는 노드를 죽이면 진입점
자체가 사라질 수 있다. 훈련 3(session-lab-operations.md)에서
먼저 확인하고 replica 조정 여부를 정한다.
설정 근거 — 헤더 계약에서 확정한 값을 그대로 쓴다.
KC_HOSTNAME=https://auth.hyeonworks.com
KC_HOSTNAME_STRICT=true
KC_PROXY_HEADERS=xforwarded
KC_HTTP_ENABLED=true
2. persistent vs volatile 세션
브랜치: feature/keycloak-persistent-vs-volatile-sessions
확인할 것
- Keycloak 26 기본값은
persistent-user-sessions— 세션이 DB가 진실의 원천이다 --features-disabled=persistent-user-sessions로 volatile 전환 시 비교- PostgreSQL을 죽이면 각각 어떻게 되는가
- 노드 하나를 죽이면 세션이 살아남는가
- 롤링 배포 시 로그아웃되는가 ← 운영에서 가장 자주 겪는 시나리오이며, 사실상 persistent를 켜는 진짜 이유다
이것이 "세션을 DB에 둘 때 vs 안 둘 때"의 Keycloak 버전이다.
B층 — 애플리케이션 세션
공개 열린 질문 네 개에 대응한다. 각 질문이 요구하는 검증 단계는
open-questions-coverage.md에 항목별로 있다.
공통 선행 조건 — BFF 2 replica. 구현은 develop-keycloak-pattern3에
이미 있으므로 가져온다.
git checkout develop-keycloak-pattern3 -- bff/
3. BFF 저장소 결정 → Q3
브랜치: feature/keycloak-redis-app-session-store
질문의 핵심은 "Redis 도입"이 아니라 "Redis와 JDBC 중 무엇이 맞는가"다. PostgreSQL이 이미 있으므로 같은 조건에서 비교할 수 있다.
Session과 Authorized Client는 조회 키가 다르다.
| 상태 | 조회 키 |
|---|---|
| Application Session | session ID |
| OAuth2AuthorizedClient | registration 이름 + principal name |
session ID가 없으므로 같은 사용자의 여러 브라우저가 동일한 authorized
client를 공유한다. 따라서 두 저장소를 각각 설계해야 한다.
검증 5단계 — 로그인 유지·재시작 복구 / refresh token 평문 여부 / session TTL ≠ token 만료 / logout 후 잔여 항목 / 저장소 끊김 시 오류.
4. 다중 인스턴스 운영 → Q1
브랜치: feature/keycloak-multi-instance-session-operation (생성 필요)
검증 5단계 — 다른 인스턴스로 요청 시 200 유지 / 재시작 후 session cookie / authorized client 덮어쓰기 / logout 전파 / 만료 어긋남.
3번(덮어쓰기)이 특히 중요하다. "Redis만 붙이면 해결"이라는 착각을 깨는 항목이다.
질문의 제약 하나는 이미 해결법을 안다 — "Resource Server의 8081이 host에도 열려 있어 BFF만 거치도록 강제되지 않았다"는 2홉 실험의 프록시 우회 경로와 같은 문제이며, NetworkPolicy 패턴을 그대로 재사용한다.
호스트 nginx의 ip_hash 주석을 켜고 끄면 스티키 유무 비교까지 같은
구성에서 된다.
5. Refresh Token 경쟁 → Q2
브랜치: feature/keycloak-refresh-token-concurrency
★ 3번 이후여야 한다. 저장소가 process-local이면 두 replica가 같은 refresh token 항목을 보지 않아 경쟁 자체가 재현되지 않는다.
검증 5단계 — 만료 직후 동시 요청 / 이긴 쪽·지는 쪽 응답 / 지는 쪽이 새 token으로 재시도해 성공하는가 / 지는 쪽 사용자 화면 / lock 유무를 같은 입력으로 비교.
마지막이 결론 기준이다 — 실패가 사용자에게 노출되면 lock, 노출되지 않으면 재시도.
제약 — rotation + 재사용 0회는 전제로 고정한다. 그리고 이미 발급된 access token은 만료 전까지 통하므로 재현은 access token 만료 직후에 맞춰 실행한다.
6. Edge 인가 범위 → Q4
브랜치: feature/keycloak-edge-authorization-scope (생성 필요)
확인할 것 — role을 헤더에 담고 다중 값 구분자·escaping / 헤더 크기 상한 초과 시 자르는가 거부하는가 / role 변경이 몇 번째 요청부터 반영되는가 / upstream이 헤더 존재만 보는가 값과 service identity까지 보는가.
2홉 실험에서 확인한 nginx가 동명 헤더를 merge하지 않고 덮어쓴다는 동작이
X-Auth-Request-*에도 적용되는지 같은 방법으로 검증한다.
질문의 제약 — internal token 검사가 controller 한 곳에만 있어 공통 경계로
옮겨야 한다. 코드 변경이므로 backend/에서 진행한다.
공통 — 운영 역량
여기부터는 "구성했다"가 아니라 "운영해봤다"에 필요한 항목이다. 백업과 업그레이드는 빠지면 티가 난다.
7. 장애 주입과 복구
브랜치: feature/keycloak-failure-injection-recovery
| 주입 | 방법 |
|---|---|
| 노드 상실 | virsh destroy — 프로세스 kill 이 아닌 진짜 상실 |
| 비대칭 파티션 | 한쪽 게스트의 인바운드만 nftables 로 차단 |
| JGroups 7800 차단 | NetworkPolicy — 운영에서 쓸 방식 그대로 |
| DB 상실 | PostgreSQL 파드 정지 |
| Redis 상실 | Redis 파드 정지 |
| 지연 주입 | 게스트 안에서 tc netem — 커널이 분리돼 있어 안전 |
복구 절차를 각각 기록한다. 실제 장애의 대부분은 완전 사망이 아니라 부분 장애(느려짐, 일부 실패)이므로 netem 지연을 기본값으로 둔다.
8. 백업과 복구 리허설
"백업이 있다"와 "복구해봤다"는 완전히 다르다.
pg_dump로 realm·세션·JGROUPS_PING 포함 전체 덤프- 일부러 파괴 — PVC 삭제 또는 DB 드롭
- 덤프에서 복구하고 로그인이 되는지, 기존 세션이 살아나는지 확인
- 복구에 걸린 시간을 기록한다 (RTO)
- 백업 시점 이후 데이터가 무엇을 잃는지 확인한다 (RPO)
Redis 쪽은 appendonly 유무에 따른 차이를 함께 본다.
9. Keycloak 버전 업그레이드
운영에서 가장 무서운 작업 중 하나다. realm 마이그레이션과 DB 스키마 변경이 자동으로 실행되며, 실패하면 되돌리기 어렵다.
- 현재 26.7.0 → 다음 마이너로 이미지 태그 변경
- 업그레이드 전 백업을 먼저 확보한다 (8번의 전제)
- 롤링 중 기존 세션이 유지되는가 (2번의 persistent 설정과 연결된다)
- 스키마 변경 로그를 확인한다
- 롤백이 되는가 — 스키마가 바뀐 뒤에는 이전 버전이 뜨지 않을 수 있다
10. 관측성
지금은 kubectl top뿐이라 장애 중 무슨 일이 있었는지 사후 추적이 안 된다.
KC_METRICS_ENABLED=true+KC_HEALTH_ENABLED=true- Prometheus + Grafana 배포
- 볼 지표 — Infinispan 캐시 항목 수·축출, DB 커넥션 풀 사용률, 로그인 성공/실패율, 클러스터 멤버 수
- 장애 주입(7번) 중에 어떤 지표가 먼저 움직이는지 기록한다
11. 비밀 관리
지금 방식대로면 client secret과 DB 비밀번호가 매니페스트에 평문으로 들어간다.
- k8s
Secret으로 분리 - 저장소에는
.example만 커밋 (기존.env.example관례 그대로) - 평문 Secret은 etcd에 base64로만 저장되므로 실제로는 감춰지지 않는다는 점을
확인한다 —
kubectl get secret -o yaml로 직접 본다 - SealedSecret 또는 외부 저장소가 필요한 지점을 판단한다
12. 인증서 갱신 실측
90일을 기다리지 않고 강제로 겪는다.
sudo certbot renew --force-renewal
- nginx reload 타이밍에 무중단인가
- 갱신 중 진행 중이던 요청은 어떻게 되는가
certbot-renew.timer가 실제로 동작하는가 (--dry-run이 아니라 실제 갱신)
스코프에서 제외한 것
이 실험대가 재현하지 못하는 것
| 항목 | 이유 |
|---|---|
| 성능·처리량 측정 | 단일 물리 머신의 숫자는 운영에 대해 아무것도 말해주지 않는다 |
| 실제 AZ 간 지연 | 한 박스 안이라 재현 불가. tc netem 으로 근사만 |
| ALB 고유 동작 | 자체 스티키 쿠키·60초 idle timeout 은 실물 ALB 가 있어야 한다 |
| PostgreSQL HA | 스코프 폭발. "죽으면 어떻게 되나"까지가 현실적 선 |
| 멀티 사이트 / cross-site Infinispan | 로컬에서 "사이트"가 가짜라 배우는 것이 적다 |
이 실험대가 검증하는 것은 계약(정합성)이지 성능이 아니다.
구조적으로 줄 수 없는 경험
실험 설계로는 만들 수 없는 것들. 무엇을 겪지 않았는지 아는 것도 기록의 일부다.
| 없는 것 | 왜 |
|---|---|
| 규모 | 수천 세션에서의 커넥션 풀 고갈, Infinispan 캐시 축출 |
| 시간 | 몇 달 돌면서 드러나는 디스크 참, 로그 누적, 메모리 누수 |
| 다른 사람 | 동시에 만지는 사람, 온콜, 인수인계, "내가 안 바꿨는데 바뀌어 있음" |
| 실제 사용자 | 봇, 오래된 클라이언트, 예측 못 한 사용 패턴 |
| 클라우드 관리형 컴포넌트 | ALB·RDS·ElastiCache의 고유 동작과 그것들의 장애 모드 |
| 비용 | 운영 판단의 큰 축인데 실험대엔 없다 |
| 보안 사고 대응 | 실제 침해, 토큰 유출 후 회수, 감사 로그 추적 |
"시간"은 부분적으로 살 수 있다. 실험이 끝나도 클러스터를 지우지 않고 몇 주 켜둔 채로 두면 인증서가 갱신되고, 로그가 쌓이고, 예상 못 한 것이 죽는다. 실험 설계로는 만들 수 없는 종류의 관찰이다.
따라서 말할 수 있는 것과 없는 것
말할 수 있다 — Keycloak 멀티노드에서 세션과 토큰이 어디에 저장되고 각 저장소가 죽으면 무엇이 어떻게 실패하는지 재현하고 복구했다. 프록시 체인의 헤더 계약을 측정으로 확정했고, 신뢰 경계의 구멍을 실증하고 막았다.
말하면 안 된다 — "운영해봤다", "대규모 트래픽을 다뤄봤다"
그 경계를 정확히 구분해 말하는 것 자체가 이 기록의 목적이다.
관련 문서
| 문서 | 내용 |
|---|---|
experiment-plan.md |
실험 20개의 구조도·주입 방법·관측 지점·예측 |
session-lab-prerequisites.md |
이 실험을 이해하기 위한 선수 지식 |
experiment-00-session-replication.md |
A-0 결과 — 세션 공유의 실체 |
open-questions-coverage.md |
공개 열린 질문 4개와의 대조, 순서 근거 |
session-lab-concepts.md |
등장 개념 전체 (가상화·네트워크·k3s·TLS·패키지) |
session-lab-operations.md |
관측 도구 · 자주 쓰는 명령 · 훈련 · 자원 예산 |
two-hop-proxy-header-contract.md |
첫 실험의 측정·진단·수정 |
deploy/lab/README.md |
실험대 구축·복구 절차 |
four-pattern-tradeoff-matrix.md |
AP1~AP4 비교. "server session" 행이 B층 대상 |
refresh-token-rotation.md |
회전 계약 (1홉·단일 노드 가정) |
reverse-proxy-headers.md |
1홉 헤더 계약 원본 |