Files
document-haness/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/case/case-orphan-sessions-and-the-ttl-that-finds-them.md
T
DongHyeonkaandClaude Opus 5 2109f726fe feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다
기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만
있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문
종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다.

SSOT 결함 둘을 고쳤다.

- kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이
  「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽
  `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다.
- virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데
  원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다.

기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은
둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더
든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다).

계약을 셋 고쳤다.

- kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/**
  28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는
  것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온
  것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수
  대조했을 때 가장 가까운 커밋도 28개가 어긋났다).
- virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고
  재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다.
- kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고
  넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다.

style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져
(실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고,
engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고
있었다(Question 기록에서 11.94 → 3.86).

verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:01:55 +09:00

11 KiB
Raw Blame History

kind, slug, title, topic, topicName, project, status, lastVerifiedOn, sourceRevision, source, assets, evidence
kind slug title topic topicName project status lastVerifiedOn sourceRevision source assets evidence
CASE orphan-sessions-and-the-ttl-that-finds-them 쿠키에 세션을 담으면 지울 대상을 잃는다 — TTL 로 되찾은 고아 세션 trust-handed-over-at-the-edge 위조 신원 헤더와 로그아웃 전파 keycloak-session-store 게시 전 cdac9b8178391311d8eca1ebc6cac15bb62d79af
final/document.md#선택이-코드와-흐름에-반영되는-방식-b7-b7a
key file
b7-cookie-session-tradeoff ../../../final/assets/b7-cookie-session-tradeoff/b7-cookie-session-tradeoff.svg
../../../final/evidence/raw/b7a-orphan-session__01-orphan-lifecycle.txt
../../../final/evidence/raw/b7-cookie-secret__01-deploy.txt

쿠키에 세션을 담으면 지울 대상을 잃는다 — TTL 로 되찾은 고아 세션

secret 을 바꾼 뒤 남은 고아 세션은 TTL 로 생성 시각을 역산해 골라 지웠고, 산 세션만 남았다. 프록시는 티켓을 못 풀어 Redis 키를 지우지 못했고, 값으로는 어느 것이 고아인지 구별할 수 없었다. 고아는 생성 후 정확히 1시간이면 사라졌다.

관계

  • nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않았다 같은 엣지 프록시를 쓰고, 그쪽은 세션이 로그인 시점의 스냅샷이라 클레임 변경이 늦게 반영되는 것을 쟀다.
  • 믿기 전에 그 헤더를 먼저 지운다 엣지가 만든 인증 결과를 앱이 어디까지 믿을지 정하는 기준이고, 여기서는 그 결과를 담은 세션을 지우는 쪽을 다룬다.

문제

oauth2-proxy 의 cookie secret 은 값을 하나만 받는다. 옛 secret 도 당분간 받아 주는 구간을 만들 수 없으므로 교체하는 순간 모든 쿠키가 한꺼번에 무효가 된다.

Redis 세션 저장소를 켜면 쿠키에는 티켓만 담기고 세션 본문은 Redis 에 있다. 그러면 secret 을 바꿨을 때 무엇이 막히는지가 달라진다. 프록시가 티켓을 못 풀고, 티켓 안에 세션 id 가 있으므로 어느 Redis 키를 지울지도 모른다.

프록시 로그에 남은 줄 : Error removing session: error decoding ticket to clear session

B-7 은 여기서 멈췄다. 지우지 못한 키가 언제까지 살아 있는지, 운영자가 지울 수 있는지, 어느 것이 고아인지 구별할 수 있는지는 재지 않았다.

결론

고아 세션이 사라지는가 : o — 생성 후 정확히 1시간 TTL 이 요청으로 갱신되는가 : x — 기동 로그의 refresh:disabled 와 같다 운영자가 지울 수 있는가 : o — redis-cli del 뒤에도 산 세션은 200 Redis 값으로 고아를 구별할 수 있는가 : x 이름 · 타입 · 크기 : 같다 (3510바이트) 값 : 암호화되어 있다 고아를 고르는 방법 : TTL 로 생성 시각을 역산한다 역산한 시각과 로그의 AuthSuccess 차이 : 1초 그 기준으로 골라 지운 결과 : 산 세션만 남았다

지우지 못한 것은 oauth2-proxy 의 한계이고 Redis 의 한계가 아니다.

검증 환경

인증 프록시 : oauth2-proxy 두 replica 세션 저장소 : Redis 쿠키 secret : --cookie-secret 하나. 옛 값과 새 값이 함께 유효한 구간 없음 호스트 이름 : 인증서 SAN(Subject Alternative Name) 에 auth · app1 · app2 셋뿐이라 Grafana 의 app2 를 빌렸다 실험대 : 베어메탈 test-server 한 대 위에 VM 두 대 test-server : Arch Linux, 12GB, WiFi only kc-lab-1 : k3s server (컨트롤 플레인) · keycloak-1 kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis 분석 리비전 : cdac9b8178391311d8eca1ebc6cac15bb62d79af 실행일 : 이 측정 기록에 적혀 있지 않다

재현 조건

  1. oauth2-proxy 를 두 replica 로 띄우고 Redis 세션 저장소를 켠다.

  2. 로그인한 뒤 Redis 에 세션 키가 생겼는지 확인한다. kubectl exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'

  3. --cookie-secret 을 새 값으로 바꾸고 배포한다.

  4. 브라우저로 다시 접근한다. 옛 쿠키가 검증에 실패하고 새 세션이 생긴다.

  5. Redis 키를 다시 조회해 각 키의 TTL 을 읽는다.

  6. 일정 간격으로 TTL 을 다시 읽어 요청이 TTL 을 늘리는지 본다.

  7. 생성시각 = 지금 (cookie-expire TTL) 로 각 키의 생성 시각을 구하고 secret 을 바꾼 시각과 견준다.

  8. 회전 시각보다 이른 키를 지우고, 산 세션이 그대로 응답을 받는지 확인한다.

본문

서버에 상태가 없으니 공유할 것도 없다

B층 실험의 주제는 BFF(Backend For Frontend) 가 서버에 들고 있던 세션과 토큰을 Redis 와 PostgreSQL 로 빼는 것이었다. oauth2-proxy 는 정반대다. 세션 전체가 쿠키에 있고 replica 는 같은 k8s Secret 을 읽을 뿐이므로, 인스턴스 사이에 맞출 상태가 없어 콜백이 다른 replica 로 가도 문제가 없다.

대신 --cookie-secret 이 값을 하나만 받는다. 「옛 secret 도 당분간 받아 준다」를 표현할 방법이 없으니 겹침 구간을 만들 수 없고, secret 을 교체하는 순간 발급돼 있던 쿠키가 한꺼번에 무효가 된다.

쿠키가 한꺼번에 무효가 되는 것은 사용자 쪽에서 잘 보이지 않는다. Keycloak 의 SSO 세션이 살아 있으면 애플리케이션 세션이 죽어도 로그인 화면 없이 조용히 다시 인증되기 때문이다. 세션이 두 겹이라 앱 쪽 한 겹만 끊긴다.

티켓을 못 풀면 어느 키를 지울지 모른다

Redis 세션 저장소를 켜면 쿠키에 담기는 것이 세션 전체에서 티켓으로 바뀐다.

티켓 = <세션 ID>.<암호화 키>
         │            └─ 값을 복호화할 키
         └─ Redis 키 이름을 만든다 → _oauth2_proxy-<ID>

티켓 전체가 --cookie-secret 으로 암호화되어 있다. secret 을 바꾸면 프록시는 티켓을 열지 못하고, 세션 ID 를 읽지 못하니 Redis 키 이름도 만들지 못한다. 그래서 로그에 이 줄이 남는다.

Error removing session: error decoding ticket to clear session

세션이 쿠키에 담기고 replica 는 같은 Secret 만 읽는 구성. Redis 저장소를 켜면 쿠키에 티켓만 남고 서버에 세션이 생긴다.

그림에서 Redis 로 들어가는 화살표는 쿠키의 티켓에서만 나온다. 키 이름을 만드는 경로가 그 하나뿐이라, 티켓이 안 열리면 Redis 쪽에서 그 세션을 가리킬 방법이 없어진다. 이렇게 프록시가 지우지 못한 채 Redis 에 남은 세션이 고아 세션이다.

Redis 값으로는 고아를 구별할 수 없었다

B-7 이 남긴 말은 「지우지 못했다」였다. 그 문장을 그대로 믿으면 고아는 어쩔 수 없는 것이 되는데, 못 지우는 주체가 프록시인지 Redis 인지는 거기서 갈리지 않았다. 그 갈래를 포함해 B-7a 가 셋을 이어서 쟀다.

물음 잰 결과
고아는 정말 사라지는가 사라진다. 생성 후 정확히 1시간. TTL 이 갱신되지 않는다
운영자가 지울 수 있는가 있다. redis-cli del 후에도 산 세션은 200
어느 것이 고아인지 아는가 Redis 값으로는 모른다. 이름·타입·크기(3510바이트)가 같고 값은 암호화
그럼 어떻게 고르는가 TTL 로 생성 시각을 역산한다

첫 줄이 먼저 정해져야 나머지가 의미를 갖는다. 고아가 영영 쌓이는 것이라면 정리 규칙을 만드는 것과 별개로 저장소가 계속 커지기 때문이다. TTL 은 요청을 보내도 늘지 않았고, 이 구성의 기동 로그에 찍힌 refresh:disabled 와 맞는다.

고아가 생기는 시점도 secret 을 바꾸는 순간이 아니다. 회전 직후 Redis 의 키 수는 그대로였고, 브라우저가 옛 쿠키를 들고 다시 와서 프록시가 그것을 못 푼 다음에야 새 세션이 하나 더 생기면서 앞의 것이 고아가 됐다. 사람이 접근하지 않으면 고아도 안 생긴다.

회전을 한 번 더 걸었더니 1차 회전에서 살아남았던 세션이 이번에는 고아가 됐다. 회전 한 번이 그 시점에 로그인해 있던 사용자 수만큼 고아를 만드는 셈이고, 그 고아들도 생성 후 1시간이면 사라진다.

셋째 줄이 실제로 막혔던 곳이다. 키 이름은 접두사가 같고 뒤는 불투명한 값이며, 타입도 크기도 같고 값은 암호화되어 있다. 값의 md5 를 떠 봐도 둘이 다르다는 것만 알 뿐, 어느 쪽이 산 세션인지는 그 차이에서 나오지 않는다. 두 키를 나란히 놓고 보면 다른 것은 TTL 하나뿐이었다.

kubectl exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'

TTL 이 갱신되지 않으니 생성 시각을 되돌릴 수 있다

TTL 이 요청으로 갱신되지 않는다는 것이 구별의 근거가 됐다. 30초 간격으로 세 번 읽었더니 산 세션은 3557 · 3526 · 3494 였고 고아는 3479 · 3448 · 3417 이었다. 1초에 1초씩 줄기만 한다.

그래서 이 방법은 기동 로그의 refresh:disabled 한 단어에 통째로 매달려 있다. 재기 전에 그것부터 읽었다. 갱신이 없으면 남은 TTL 은 만료 설정에서 흘러간 시간을 뺀 값이므로, 거꾸로 계산하면 그 세션이 언제 만들어졌는지 나온다.

생성시각 = 지금  (cookie-expire  TTL)

이 값이 secret 을 바꾼 시각보다 이르면 그 키는 고아다. 회전 뒤에 생긴 세션은 새 secret 으로 만들어졌으므로 유효하다.

시각은 전부 UTC(협정 세계시)로 다뤘다. 이 방법의 결론이 시각 계산이라 한국 시간과 한 번만 섞여도 9시간이 통째로 틀어진다.

역산이 맞는지는 로그와 견줘 확인했다. 계산한 11:30:26 과 로그의 AuthSuccess 11:30:27 이 1초 차였다. 그 기준으로 실제로 골라 지웠고 산 세션만 남았다. 지우지 못한 것은 oauth2-proxy 의 한계이고 Redis 의 한계가 아니었다 — 프록시는 티켓을 못 풀어 키 이름을 만들지 못하지만 운영자는 키 목록을 직접 본다.

이 방법이 성립하지 않는 구성

이 정리는 남의 세션을 실제로 지우는 일이다. 기준을 잘못 잡아 산 세션을 지우면 그 사람은 다시 인증해야 하는데, Keycloak 의 SSO 세션이 살아 있으면 로그인 화면 없이 조용히 지나가므로 잘못 지웠다는 것이 밖에서 보이지 않는다.

--cookie-refresh 를 켜면 이 역산이 무너진다. TTL 이 요청마다 갱신되면 남은 TTL 이 생성 시각의 함수가 아니게 되고, 그러면 회전 시각과 견줄 값이 없다. 그 구성에서 고아를 어떻게 고를지는 재지 않았다. 회전 뒤 FLUSHDB 로 전부 지우고 모두 재인증시키는 편이 정직하다.

고아가 사라지기까지 잰 1시간도 이 구성에서 나온 값이다. 쿠키 만료를 다르게 잡은 구성에서는 다시 재지 않았다.