Files
document-haness/docs/keycloak-session-store/source/docs/guides/experiments/c1-multi-app-sso.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

31 KiB
Raw Blame History

C-1 재현 가이드 — 앱 둘에 SSO 를 걸고, IdP 세션을 죽여 본다

해설 문서: docs/experiment-c1-multi-app-sso.md · 증거 원문: docs/evidence/c1-multi-app-sso/

이 가이드가 끝나면

당신 터미널과 브라우저에서 이것들을 직접 본다.

보게 되는 것 어디서
두 번째 앱이 로그인 화면 없이 열리는 것 브라우저
user session 1client session 2 가 매달린 구조 PostgreSQL
서로 다른 구조의 두 앱이 같은 user session 을 공유하는 것 client 조인
IdP 세션을 죽여도 두 앱이 그대로 열리는 것 브라우저 + Redis
logout-all 이 오류 없이 아무것도 안 하는 것 세션 수가 안 변한다
realm 을 안 보고 세면 master 의 admin 세션에 속는 것 realm 조인

전제

  • B-2app1(BFF)B-7app2(oauth2-proxy)둘 다 떠 있다. 이 실험은 그 둘이 있어야 성립한다 — 없으면 SSO 가 아니라 로그인 한 번이다.
  • app2.hyeonworks.comGrafana 에서 빌린 이름이다(B-7 의 주의). 이 실험이 끝나면 5-3 에서 되돌린다.
  • 브라우저가 필요하다. SSO 는 브라우저 쿠키가 만드는 현상이고, curl 로는 「로그인 화면이 안 떴다」를 볼 수 없다.
  • 명령은 kc-lab-1 에서 친다. kubectlsudo 로 쓴다.
  • Keycloak 이미지에는 curlwget 도 없다(exit 127). kcadm.sh 는 파드 안에 있으므로 항상 kubectl exec 로 감싼다.
  • 이 실험대에는 jq 가 없다.

주의 — 이건 세션을 전부 지우고 시작하는 실험이다

기준선을 만들려고 Keycloak 세션 테이블을 직접 지우고, Redis 를 비우고, Keycloak StatefulSet 을 재시작한다. 그 순간 지금 로그인해 있는 모든 사람이 끊긴다. 실험대에서만 한다.

전 구간 약 20분이고, Keycloak 재시작에 1~2분이 든다. 중간에 그만두려면 5. 복구 로 간다.

표시 규약

표시
실측 2026-09-04 14:4414:48 KST 수집 기록의 출력 원문. 증거 파일에 그대로 있다
형태 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다
미검증 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했고 SQL 원문은 기록에 없다

세션 id·Redis 키·클라이언트 UUID 는 당신 환경에서 다르다. 이 문서는 자리표시자(<...>)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 수집 기록의 실제 값이다.


0. 왜 이 실험을 하는가

원래 질문은 한 줄이었다.

"SSO 를 추가하게 되면 어떻게 달라지는지"

「달라진다」에는 두 방향이 섞여 있다 — 편해지는 쪽과 위험해지는 쪽. 위험 쪽의 통념은 이렇다.

예측
통념 SSO 를 붙이면 IdP 가 단일 장애점이 된다. IdP 가 죽으면 다 죽는다
실측 절반만 맞다. 로그인 경로는 그렇고, 이미 로그인한 사용자는 아니다

둘 중 어느 쪽인지는 IdP 세션만 죽여 보면 판정된다. 그게 이 실험이다.

핵심은 수명이 세 층으로 나뉘어 있다는 것이다.

   ① IdP 세션 (Keycloak)           ssoSessionIdleTimeout
   ② 앱 세션  (BFF / oauth2-proxy)  각자 30분 / 1시간
   ③ access token                  60초

   ①을 지워도 ②는 자기 수명을 산다

로그아웃이 지우는 것은 ① 뿐이다. 이 실험대에는 서로 완전히 다르게 세션을 다루는 앱이 둘 있어서, ②가 어떻게 살아남는지를 두 형태로 동시에 볼 수 있다.

   app1.hyeonworks.com  →  BFF          서버 세션 (Redis)  +  토큰 (PostgreSQL)
   app2.hyeonworks.com  →  oauth2-proxy 쿠키 티켓         +  세션 (Redis)

                    둘 다 realm keycloak-patterns

우연히 좋은 실험대가 됐다. B-2 와 B-7 에서 각기 다른 이유로 만든 두 앱이 같은 IdP 를 쓰면서 세션을 정반대로 다룬다.


1. 기준선 — 깨끗한 상태를 만든다

넓은 것부터 좁혀 간다.

앱 둘이 살아 있나 → 세션을 지운다 → 안 지워진다 → 왜 → 세는 법을 고친다

1-1. 두 앱이 다 떠 있나

확인

kubectl -n keycloak-lab get pods -o wide
kubectl -n keycloak-lab get ingress

어디를 봐야 하는가bffoauth2-proxy둘 다 Running 이고, Ingress 에 app1.hyeonworks.comapp2.hyeonworks.com둘 다 있는가.

확인 — 밖에서

curl -s -o /dev/null -w 'app1 %{http_code}\n' https://app1.hyeonworks.com/
curl -s -o /dev/null -w 'app2 %{http_code}\n' https://app2.hyeonworks.com/

실측01-baseline.txt

  app1 HTTP 200 / app2 HTTP 200

app2 가 Grafana 로 간다면 B-7 의 Ingress 가 없는 것이다. B-7 의 1-1~1-2 를 먼저 한다.

1-2. 세션을 지우려고 시도한다 — 그리고 실패를 본다

kcadm 을 먼저 로그인시킨다. 파드가 재시작되면 세션이 사라지고 이후 모든 명령이 401 이 된다. 1-3 에서 실제로 재시작하므로 그때 다시 해야 한다.

하기

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  config credentials --server http://localhost:8080 --realm master --user admin \
  --password "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
                -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"

하기 — 가장 자연스러운 방법부터 친다

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  create realms/keycloak-patterns/logout-all

확인 — 세션이 정말 지워졌는지 센다. 미검증 (증거에는 이 SQL 의 원문이 없다)

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
  "select count(*) from offline_user_session where offline_flag='0'"

실측01-baseline.txt

=== 깨끗한 상태로 초기화 ===
DELETE 1

=== 기준선 ===
  Keycloak 온라인 세션: 4
  Redis 키: 0

어디를 봐야 하는가4. 0 이 아니다.

이 결과가 의미하는 것logout-all 이 안 먹었다. 오류도 안 났다. 세션이 그대로 4개 남아 있다.

★ 해설 문서 정정 — 이 문서는 처음에 이 값을 0 으로 인쇄했다. 증거 01-baseline.txt4 다. 0 은 그 다음 단계(DB 직접 삭제 + 재시작)의 값이었다. 이 가이드는 증거를 따른다 — 여기서 4 가 나오는 것이 정상이다.

왜 안 먹었나캐시 때문이다. A-1 에서 확인했듯 Keycloak 은 세션을 DB 에서 읽되 캐시로 답한다. 관리 API 가 무효화를 걸어도 각 노드의 캐시가 그대로면 세션은 살아 있는 것처럼 보인다.

1-3. 그래서 DB 를 직접 지우고 Keycloak 을 재시작한다

하기 — 자식 테이블부터 지운다

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "delete from offline_client_session" -c "delete from offline_user_session"

형태

DELETE 2
DELETE 4

하기 — 앱 세션도 비운다

kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall

flushall 은 이 Redis 전체를 지운다. BFF 세션·oauth2-proxy 세션· B-5 가 남긴 b5:pvc 까지 전부다. 기준선을 만드는 자리라서 의도한 것이고, 실험 도중에는 절대 쓰지 않는다(B-7a 5-2 참고).

하기 — 캐시를 비우려면 프로세스를 새로 띄우는 수밖에 없다

kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s

되돌리기 — 없다. 지운 세션은 안 돌아온다. 다시 로그인하면 된다.

확인 — 이제 비었는가

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
  "select count(*) from offline_user_session where offline_flag='0'"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'

실측01-baseline.txt

  Redis 키: 0

어디를 봐야 하는가 — Redis 키 0, 세션 수 0. 여기서도 0 이 아니면 재시작이 안 끝났거나 누가 로그인 중이다.

★ kcadm 세션이 날아갔다. 1-2 의 config credentials다시 친다.

1-4. ★ 세는 법을 먼저 고친다 — realm 을 본다

이 절을 건너뛰면 4절의 결론을 반대로 읽는다.

offline_user_session 에는 모든 realm 의 세션이 들어 있다. 그리고 kcadm 을 쓰는 순간 master realm 에 admin 세션이 생긴다. 그러니 그냥 세면 내가 만든 노이즈를 남의 세션으로 읽는다.

확인 — 틀린 방법(전체를 센다)

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
  "select count(*) from offline_user_session where offline_flag='0'"

확인 — 맞는 방법(realm 을 조인한다). 미검증

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
  "select us.user_session_id, r.name as realm,
          (select count(*) from offline_client_session cs
            where cs.user_session_id=us.user_session_id) as clients
     from offline_user_session us join realm r on r.id=us.realm_id
    where us.offline_flag='0'"

어디를 봐야 하는가realm 열. keycloak-patterns 만이 이 실험의 대상이고, master당신이 방금 kcadm 을 쳐서 생긴 것이다.

이 결과가 의미하는 것 — 이 한 열 때문에 원래 실행은 「안 지워졌다」로 오독할 뻔했다. 해설 문서가 「세 번째가 특히 위험했다」고 쓴 것이 이 실수다.

여기서부터 세션 수를 말할 때는 항상 realm 을 붙인다. 「세션 1개」가 아니라 「keycloak-patterns 세션 0개, master 1개」다.


2. 주입 — 두 앱에 차례로 들어간다

여기서 SSO 상태를 만든다. 되돌리기는 간단하다 — 5절의 초기화를 다시 하면 된다. 파괴적인 조작은 4절에 있다.

2-1. app1 에 로그인한다 — 로그인 화면이 나온다

하기 — 브라우저에서

https://app1.hyeonworks.com/   →  labuser / labpass

어디를 봐야 하는가Keycloak 로그인 화면이 뜨는가. 주소창이 이렇게 바뀐다.

실측 — 해설 문서에 남은 형태

https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth
  ?client_id=bff-confidential&...
→ Sign in to keycloak-patterns

이 결과가 의미하는 것 — 첫 앱에서는 당연히 로그인 화면이 나온다. 이것이 2-2 의 대조군이다. 이걸 안 보면 「app2 에서 안 뜬 것」이 특별한 일인지 알 수 없다.

2-2. 로그인 직후 상태를 잰다

확인 — 1-4 의 맞는 쿼리를 그대로 쓴다

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
  "select us.user_session_id, r.name as realm,
          (select count(*) from offline_client_session cs
            where cs.user_session_id=us.user_session_id) as clients
     from offline_user_session us join realm r on r.id=us.realm_id
    where us.offline_flag='0' and r.name='keycloak-patterns'"

실측02-after-app1-login.txt

=== app1 로그인 직후 Keycloak 세션 ===
     user_session_id      | client_sessions 
--------------------------+-----------------
 oqOjHekin4JU-BZjgQLjUByW |               1
(1 row)

어디를 봐야 하는가user_session_id 를 적어 둔다. 2-3 과 3절에서 계속 쓴다. 그리고 client_sessions 가 1 이다.

확인 — 저장소 두 곳

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'

실측

  Redis 키: 1
    bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
  PostgreSQL authorized client: 1 행

어디를 봐야 하는가 — Redis 키 이름의 접두사 bff:session:sessions:. 이 접두사가 「BFF 가 만든 세션」이라는 뜻이고, 3절에서 프록시 것과 갈라진다.

authorized client 는 BFF 가 토큰을 넣어 둔 PostgreSQL 행이다. 미검증

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
  "select count(*) from oauth2_authorized_client"

이 결과가 의미하는 것한 번 로그인했는데 상태가 세 곳에 생겼다. Keycloak 세션 · Redis 세션 · PostgreSQL 토큰. 4절에서 이 셋의 운명이 갈린다.

2-3. app2 를 방문한다 — 여기가 SSO 다

하기같은 브라우저의 새 탭에서

https://app2.hyeonworks.com/api/echo

어디를 봐야 하는가로그인 화면이 뜨는가.

실측 — 뜨지 않았다. app2 가 로그인 없이 열린다

이 결과가 의미하는 것SSO 가 동작한다. app2 는 Keycloak 으로 리다이렉트했지만, Keycloak 에 이미 세션이 있어서 묻지 않고 바로 돌려보냈다.

다른 브라우저나 시크릿 창에서 열면 안 된다. SSO 를 만드는 것은 auth.hyeonworks.com 에 붙은 브라우저 쿠키다. 창이 다르면 쿠키가 없고, 그러면 로그인 화면이 뜨는 것이 정상이다.


3. 주입이 만든 구조를 확인한다

3-1. user session 하나에 client session 둘

확인 — 2-2 와 똑같은 쿼리

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
  "select us.user_session_id, r.name as realm,
          (select count(*) from offline_client_session cs
            where cs.user_session_id=us.user_session_id) as clients
     from offline_user_session us join realm r on r.id=us.realm_id
    where us.offline_flag='0' and r.name='keycloak-patterns'"

실측03-after-app2-visit.txt

=== app2 방문 후 — 로그인 화면 없이 통과했는가 ===
     user_session_id      | client_sessions 
--------------------------+-----------------
 oqOjHekin4JU-BZjgQLjUByW |               2
(1 row)

어디를 봐야 하는가user_session_id 가 2-2 와 같고, client_sessions 만 1 → 2 로 늘었다.

이 결과가 의미하는 것두 번째 로그인이 아니라 같은 로그인에 앱이 하나 붙은 것이다. 이것이 SSO 의 데이터 구조다.

   user session  (사용자 · 브라우저 하나당 하나)
     ├─ client session : bff-confidential
     └─ client session : oauth2-proxy

3-2. 어느 클라이언트가 붙었는가

확인미검증 (증거에는 이 SQL 의 원문이 없다. 출력은 실측이다)

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
  "select cs.client_id, c.client_id as name
     from offline_client_session cs join client c on c.id = cs.client_id
    where cs.user_session_id = 'oqOjHekin4JU-BZjgQLjUByW'"

실측03-after-app2-visit.txt

=== 어느 클라이언트가 붙었는가 ===
              client_id               |       name       
--------------------------------------+------------------
 9055fa46-6abb-4d6d-a339-8a9183bbf26d | bff-confidential
 80431dbc-af81-4673-9790-ad06d1570b2e | oauth2-proxy
(2 rows)

어디를 봐야 하는가client_id 열은 UUID 이고, 사람이 아는 이름은 client 테이블에 있다. 조인 없이 보면 UUID 두 개만 나와서 어느 앱인지 알 수 없다.

이 결과가 의미하는 것 — 구조가 완전히 다른 두 앱이 같은 user session 아래에 나란히 있다. Keycloak 은 앱이 세션을 어떻게 다루는지 모르고, 알 필요도 없다.

3-3. 저장소 세 곳이 각자 무엇을 들고 있는가

확인

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'

실측03-after-app2-visit.txt

=== 저장소 상태 ===
  Redis 키:
    _oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
    bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
  PostgreSQL authorized client: 1 행

어디를 봐야 하는가같은 Redis 에 접두사가 다른 두 세션이 있다. bff:session:sessions: 는 Spring Session 이 쓰는 이름이고, _oauth2_proxy- 는 프록시가 쓰는 이름이다.

이 결과가 의미하는 것 — 「세션 저장소를 공유한다」는 말이 「같은 Redis 를 쓴다」일 뿐 「같은 세션을 본다」가 아니다. 둘은 서로의 키를 모른다. B-7a 에서 FLUSHDB 를 금지한 이유가 이것이다.

개념 — 두 층으로 나뉘어 있는 이유

무엇인가. Keycloak 은 세션을 user session(사람 하나)과 client session(그 사람이 쓰는 앱 하나)으로 나눠 둔다.

왜 여기 나오나. A층·B층에서 본 두 사건이 서로 다른 층을 건드렸다.

무엇이 사라졌나 결과
A-3 DB 크래시 user_session 행이 통째로 모든 앱이 끊긴다
B-3 refresh 재사용 탐지 client_session 그 앱만 끊긴다

두 층이 나뉘어 있는 이유가 SSO 다. 앱 하나의 사고가 다른 앱으로 번지지 않게 하려면 client session 이 따로 있어야 한다.

없거나 틀리면. 한 층뿐이라면 B-3 의 재사용 탐지 한 번이 모든 앱을 끊었을 것이다.


4. 관찰 — IdP 세션만 죽인다

4-1. 무엇을 지우는지 먼저 정한다

지우려는 것은 ①(IdP 세션)뿐이다. ②(앱 세션)와 ③(토큰)은 손대지 않는다. 그 구분이 이 실험의 전부다.

되돌리기 — 다시 로그인하면 된다. 파괴적이지만 회복은 쉽다.

4-2. 지우는 방법을 고른다 — 두 개는 안 먹는다

하기 — 세션 id 를 지목해서 지운다. 미검증 · 안 먹는다

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  delete sessions/oqOjHekin4JU-BZjgQLjUByW -r keycloak-patterns

어디를 봐야 하는가오류도 안 나고 세션도 안 줄어든다. 1-2 의 logout-all 과 같은 유형이다.

하기 — 사용자 단위로 끊는다. 이건 먹는다

USERID=$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get users -r keycloak-patterns -q username=labuser --fields id \
  --format csv --noquotes | tail -1)
echo "$USERID"

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  create "users/$USERID/logout" -r keycloak-patterns

어디를 봐야 하는가echo "$USERID"UUID 한 줄인가. 비어 있거나 여러 줄이면 --format csv --noquotes | tail -1 가 다른 것을 잡은 것이다. 그 상태로 다음 명령을 치면 엉뚱한 경로를 부른다.

자리표시자를 두지 않으려고 두 단계로 나눴다. 한 줄로 이어 붙일 수도 있지만, 그러면 UID 가 비었을 때 그 사실이 안 보인다.

4-3. IdP 쪽은 정말 끊겼는가 — realm 을 보고 센다

확인 — 1-4 의 맞는 쿼리

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
  "select us.user_session_id, r.name as realm,
          (select count(*) from offline_client_session cs
            where cs.user_session_id=us.user_session_id) as clients
     from offline_user_session us join realm r on r.id=us.realm_id
    where us.offline_flag='0'"

실측04-sso-session-killed.txt

=== 사용자 단위 로그아웃 (IdP 세션만 끊는다) ===
  남은 Keycloak 세션: 1

=== 남은 세션의 realm 과 client ===
     user_session_id      | realm  | clients 
--------------------------+--------+---------
 E1q5xI7tt4U_WhZpW7rEPIF2 | master |       1
(1 row)

어디를 봐야 하는가「남은 세션 1」과 「그 1의 realm 이 master」를 같이 본다.

이 결과가 의미하는 것keycloak-patterns 세션은 0 이다. 남은 하나는 당신이 kcadm 을 쳐서 생긴 admin 세션이다.

★ 여기가 이 실험에서 가장 잘 틀리는 자리다. 「1이 남았네, 로그아웃이 안 먹었구나」로 읽으면 4-4 의 결론이 통째로 뒤집힌다. 숫자 옆에 realm 을 붙이지 않으면 그 숫자는 아무 뜻이 없다.

4-4. ★ 앱 세션은 그대로 남아 있다

확인 — 3-3 과 똑같은 명령

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'

실측04-sso-session-killed.txt

=== 두 앱의 애플리케이션 세션은 그대로인가 ===
  _oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
  bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
  PostgreSQL authorized client: 1 행

  → IdP 세션은 없어졌는데 앱 세션은 남아 있다면, 두 계층의 수명이 어긋난 것이다

어디를 봐야 하는가키 이름이 3-3 과 글자 하나까지 같다. 아무것도 안 지워졌다.

이 결과가 의미하는 것로그아웃은 ①만 지웠다. ②도 ③도 아무도 안 건드렸다.

4-5. 브라우저로 두 앱을 다시 연다

하기 — 아까 그 브라우저에서

https://app1.hyeonworks.com/
https://app2.hyeonworks.com/api/echo

어디를 봐야 하는가로그인 화면이 뜨는가.

실측 — 둘 다 로그인 화면 없이 그대로 열렸다. IdP 세션이 없어도 앱은 동작한다

★ 증거의 정직성에 관한 주의 — 위 스크린샷과 2-3 의 스크린샷은 바이트 단위로 동일한 파일이다(md5 2c703176…). 두 시점의 화면이 실제로 같은 내용이었기 때문이며 조작이 아니지만, 그래서 두 시점을 구별하는 증거가 되지 못한다. 구별은 03-04- 의 터미널 출력이 한다 — client_sessions 1→2, 그리고 IdP 세션 삭제 후에도 Redis 키가 남아 있는 것. 화면이 같아 보인다는 것 자체가 이 실험의 결론이라, 화면만으로는 증명이 안 된다.

4-6. 왜 그런가 — 세 개의 독립된 수명

   ① IdP 세션 (Keycloak)          ssoSessionIdleTimeout 1800초
   ② 앱 세션  (BFF / oauth2-proxy) 각자 30분 / 1시간
   ③ access token                 60초

   ①을 지워도 ②는 자기 수명을 산다

앱은 매 요청마다 IdP 에 물어보지 않는다. 자기 세션이 살아 있으면 그걸로 답한다. 그래서 ①이 사라진 것을 모른다.

그러면 언제 알게 되는가.

언제 끊기는가
BFF access token 이 만료되어 refresh 를 시도할 때Session not active
oauth2-proxy 쿠키 만료(1시간) 또는 토큰 갱신을 시도할 때

즉시가 아니라 지연되어 끊긴다. 최대 지연은 access token 수명(60초)이 아니라 앱이 다음에 IdP 를 부를 때까지다.

B-2 에서 「로그아웃했는데 다시 들어가진다」를 겪은 것의 반대편이다. 거기서는 앱 세션을 지웠는데 IdP 세션이 남아 재로그인이 됐고, 여기서는 IdP 세션을 지웠는데 앱 세션이 남아 계속 들어가진다. 두 방향 모두 「한쪽만 지우면 다른 쪽이 남는다」이다.

확인 — 실제로 끊기는 순간을 보고 싶으면 기다린다. 미검증

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get realms/keycloak-patterns --fields accessTokenLifespan,ssoSessionIdleTimeout

그 시간이 지난 뒤 app1 을 새로고침하면 로그인 화면으로 떨어진다.

4-7. 그래서 SSO 의 대가는 무엇인가

원래 질문에 대한 답이다.

앱이 하나일 때 SSO 일 때
로그인 앱마다 한 번
IdP 가 죽으면 그 앱만 로그인 불가 모든 앱이 로그인 불가
이미 로그인한 사용자 ★ 영향 없다 (앱 세션이 살아 있으므로)
로그아웃 그 앱만 전 앱을 끊으려면 백채널 로그아웃이 필요
세션 수명 하나 세 층이 각자 — 어긋나면 예측이 어렵다

IdP 는 「로그인 경로」의 단일 장애점이지 「이미 로그인한 사용자」의 단일 장애점이 아니다. A-2(DB 상실)와 합치면 장애의 모양이 이렇게 된다.

   Keycloak DB 죽음  →  새 로그인 불가 (전 앱)
                     →  이미 로그인한 사용자는 앱 세션 수명 동안 계속 쓴다
                     →  그 뒤 갱신 시점에 한꺼번에 끊긴다

장애가 즉시 전면화되지 않고 「앱 세션 수명만큼 지연되어 몰려온다」. 이것이 SSO 구조의 장애 모양이고, 모니터링이 어려운 이유다.

그리고 마지막 줄이 다음 실험을 부른다 — 전 앱을 끊으려면 백채널 로그아웃이 필요하다. 그게 되는지는 C-2 가 잰다.


5. 복구

5-1. 세션을 정리한다

하기 — 1-3 과 같은 절차

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "delete from offline_client_session" -c "delete from offline_user_session"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s

그냥 둬도 된다. 앱 세션은 수명(30분 / 1시간)이 지나면 사라지고, IdP 세션은 이미 없다. 정리는 다음 실험을 깨끗하게 시작하려는 것뿐이다.

5-2. 브라우저 쿠키를 지운다

하기auth.hyeonworks.com·app1·app2 의 쿠키를 지우거나 시크릿 창을 새로 연다.

— 서버 세션을 다 지워도 브라우저에 낡은 쿠키가 남는다. 다음 실험에서 「왜 로그인 화면이 안 뜨지」로 헤매는 원인이 대개 이것이다.

5-3. 빌린 것을 돌려준다

app2 는 Grafana 의 이름이다. C-2 를 이어서 하지 않을 거라면 지금 되돌린다.

하기

kubectl -n keycloak-lab delete ingress oauth2-proxy
kubectl apply -f ~/grafana-ingress-backup.yaml
curl -sI https://app2.hyeonworks.com/ | head -3

어디를 봐야 하는가 — app2 가 다시 Grafana 로 가는가.

C-2 를 이어서 할 생각이면 아직 돌려주지 않는다. C-2 가 두 앱을 그대로 쓴다. 그 대신 C-2 가 끝난 뒤에 반드시 복구한다.

5-4. 원상복구 확인표

항목 명령 돌아왔을 때
Keycloak 세션 1-4 의 realm 조인 쿼리 keycloak-patterns 0 (master 는 있을 수 있다)
앱 세션 redis-cli --scan --pattern '*' 비었거나 남기기로 한 것만
토큰 select count(*) from oauth2_authorized_client 0
파드 kubectl -n keycloak-lab get pods 전부 Running, keycloak 둘 다 1/1
Ingress kubectl -n observability get ingress grafana 있다 (돌려줬다면)
curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/ 200

이 실험이 재지 않은 것 — 4-6 의 「언제 끊기는가」를 실제로 기다려서 확인하지 않았다. IdP 세션을 지운 뒤 access token 수명이 지날 때까지 두고 app1 을 새로고침하면 Session not active 가 나와야 한다. 재려면 그렇게 한다.


막히면

전부 이 실험대가 실제로 겪은 증상이다. 지어낸 것은 없다.

증상 원인 확인
logout-all 이 오류 없이 아무 일도 안 한다 캐시. DB 를 지워도 노드 캐시가 답한다 DB 직접 삭제 + rollout restart — 1-3
kcadm delete sessions/<id> 가 조용히 안 먹는다 같은 유형 users/<id>/logout 을 쓴다 — 4-2
기준선 세션이 0 이 아니라 4 다 원래 실행도 4 였다. 해설의 0 은 정정됐다 1-2 의 정정 박스
로그아웃했는데 세션이 1 남았다 master 의 admin 세션이다. 당신이 kcadm 을 쳐서 생겼다 realm 을 조인한다 — 1-4·4-3
kcadm 이 전부 401 재시작으로 kcadm 세션이 날아갔다 config credentials 를 다시 — 1-2
$USERID 가 비었다 --format csv --noquotes 출력이 예상과 다르다 echo "$USERID" 로 먼저 확인 — 4-2
app2 에서 로그인 화면이 뜬다 다른 브라우저·시크릿 창이다. SSO 쿠키가 없다 같은 창의 새 탭에서 연다 — 2-3
app2 가 Grafana 로 간다 B-7 의 Ingress 가 없다 B-7 1-1~1-2
client_id 가 UUID 뿐이라 어느 앱인지 모른다 client 테이블을 조인해야 이름이 나온다 3-2
Redis 를 비웠더니 app1 도 끊겼다 flushall 은 BFF 세션도 지운다 기준선에서만 쓴다 — 1-3
스크린샷 두 장이 똑같다 실제로 같은 파일이다. 조작이 아니다 구별은 터미널 출력이 한다 — 4-5
kubectl exec keycloak-0 -- curlexit 127 Keycloak 이미지에 curl 도 wget 도 없다 밖에서 치거나 임시 curl 파드

다음

실험 C-1 이 남긴 질문
C-2 백채널 로그아웃 이 실험이 C-2 가 왜 필요한지 보여준다 — IdP 로그아웃이 앱에 안 퍼진다. 원인은 아무도 구현하지 않았기 때문이다
D-1 백업·복구 user_session 을 잃으면 전 앱이 끊긴다. 백업 범위에 들어간다
A-3 DB 크래시 여기서 본 두 층 구조가 거기서 「전체 소실 vs 일부 소실」로 갈렸다
운영 세 층의 수명을 맞추거나, 어긋날 때의 동작을 정의해야 한다