Files
keycloak-pattern/docs/session-store-lab-roadmap.md
T
DongHyeonkaandClaude Opus 5 d6f8b9f8b3 docs: restructure roadmap into A/B/operations layers and add five operational items
Adds backup rehearsal, version upgrade, observability, secret management and certificate renewal. Corrects the experiment order so the refresh-token contention test runs after the shared store exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:56:13 +09:00

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.tsvscripts/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. 인증서 갱신 실측

순서 근거는 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.810.42.1.12로, 노드까지 옮겨갔다). NetworkPolicy는 IP가 아니라 라벨로 지정하므로 이를 닫는다.

"헤더를 믿는다"와 "앞에 반드시 프록시가 있다"는 한 쌍이다. 이 교훈이 6번(Edge 인가)에서 결정적이 된다 — X-Auth-Request-*가 위조되면 그것은 쿠키 속성이 아니라 신원 위조다.

이 결과가 뒤에 미치는 영향 — Keycloak을 올릴 때 KC_PROXY_HEADERS=xforwardedKC_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 멀티노드에서 세션과 토큰이 어디에 저장되고 각 저장소가 죽으면 무엇이 어떻게 실패하는지 재현하고 복구했다. 프록시 체인의 헤더 계약을 측정으로 확정했고, 신뢰 경계의 구멍을 실증하고 막았다.

말하면 안 된다 — "운영해봤다", "대규모 트래픽을 다뤄봤다"

그 경계를 정확히 구분해 말하는 것 자체가 이 기록의 목적이다.

관련 문서

문서 내용
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홉 헤더 계약 원본