Files
document-haness/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-c1-multi-app-sso.md
T
DongHyeonkaandClaude Opus 5 024362d096 fix(setup): 실험대를 새로 세워 setup 35편을 밟고 어긋난 명령과 결과를 고친다
기반 가이드 7단계로 실험대를 철거하고 다시 세운 뒤 virtualization setup 9편과
keycloak-session-store 26편을 순서대로 밟았다. 24편은 끝까지, 11편은 되는 데까지
밟았고 밟은 범위를 편마다 적었다.

명령이 못 도는 것을 고쳤다.

- kubectl 을 `kc-lab-1` 에서 치라고 적었는데 그 기계에 kubeconfig 가 없다.
  라벨 639개와 각 편의 「어디서 치는가」를 `[lab host]` 로 옮겼다
- `-o custom-columns=…[0]…` 이 zsh 에서 글로브로 읽혀 안 돈다. 28곳에 따옴표
- busybox `sed` 가 끝 개행을 안 붙여 A-3 의 측정이 언제나 0 이었다
- `--token-file ~/node-token` 뒤에 그 파일을 지우면 k3s agent 가 재부팅을
  못 견딘다. `/etc/rancher/node-token` 으로 옮기는 처방을 재서 넣었다
- 게스트에 없는 도구를 전제로 한 명령 넷 — `conntrack`·`dig`·`strings`·`nginx -v`
- `echo` 와 JWT 헤더가 `"이름" : [ 값 ]` 으로 찍는데 문서는 공백 없이 옮겨 적어
  그 실측으로 만든 grep·sed 가 한 줄도 못 잡는다
- B-0 이 `directAccessGrantsEnabled` 와 계정 완성을 빠뜨려 B-3 이 못 돈다
- D-4·D-4a 가 `test-server` 와 `certbot-renew.*` 를 가리키는데 실제로는
  `kc-lab-edge` 의 `certbot.service` 다
- `virsh setmaxmem --config` 를 `dominfo` 로 판정하면 틀린다. `--inactive` 로
- `LIBVIRT_DEFAULT_URI` 를 rc 에만 넣으면 `ssh host '명령'` 에서 안 먹는다

결과가 조건부인 것을 갈랐다.

- readiness 는 즉시 안 뒤집힌다. A-1·A-2 의 60초 창을 적었다
- 03 의 층 ②③ `301` 은 04 이후의 값이고 그 단계에서는 `404` 다
- A-0 의 로그 필터를 요청 직후에 치면 정반대 결론이 나온다
- A-5 의 한 방향 차단은 잠깐 `1` 이었다 `2` 로 돌아온다

증거는 두 프로젝트의 `evidence/raw/` 에 99벌을 README 와 함께 남겼다. 비밀은
길이만 적었고 화면에 찍힌 토큰은 가렸다.

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

42 KiB
Raw Blame History

id, kind, slug, title, topic, topicName, project, status, studio, pinnedVersions, source, sourceRevision
id kind slug title topic topicName project status studio pinnedVersions source sourceRevision
3421185f-5f3c-4263-9455-6243306e9fc9 SETUP reproduce-c1-multi-app-sso 두 앱을 한 로그인으로 묶고 IdP 세션만 끊어 앱 세션이 남는지 본다 trust-handed-over-at-the-edge 위조 신원 헤더와 로그아웃 전파 keycloak-session-store 게시 전 https://hyeonworks.com/studio/documents/3421185f-5f3c-4263-9455-6243306e9fc9/edit
name version
keycloak-pattern-bff lab
name version
Redis 7.4.x
final/document.md#c층-재현-절차-두-편을-직접-치는-순서-c-1
final/document.md#c층-재현-절차-두-편을-직접-치는-순서
cdac9b8178391311d8eca1ebc6cac15bb62d79af

두 앱을 한 로그인으로 묶고 IdP 세션만 끊어 앱 세션이 남는지 본다

app1 과 app2 를 한 번의 로그인으로 묶은 뒤 IdP 세션만 끊고, 두 앱의 Redis 키가 글자 하나까지 같은지 터미널 출력으로 판정하는 절차다. 세션을 전부 지우고 시작하므로 실험대에서만 한다. 약 20분.

관계

  • 백채널 로그아웃은 양쪽 다 없었다 이 절차가 관측한 「로그아웃이 안 퍼진다」의 원인을 그 기록이 셋으로 나눠 판정한다. 여기서는 원인을 묻지 않고 현상까지만 친다.
  • 클러스터는 형성됐는데 세션을 나르는 것은 데이터베이스였다 Keycloak 세션이 캐시로 답한다는 것을 그쪽이 먼저 확인했다. 그래서 이 절차에서 logout-all 이 오류도 안 내고 세션도 안 줄인다.
  • 세션과 인가된 클라이언트는 조회 키가 다르다 같은 Redis 에 접두사가 다른 두 세션이 나란히 놓이는 것을 그 기록이 키 설계 쪽에서 설명한다.
  • IdP 쪽에만 로그아웃 주소를 넣고 한쪽만 고치면 안 퍼지는 것을 확인한다 이 절차가 만든 상태 위에 선다. 이어서 할 생각이면 빌린 이름을 아직 돌려주지 않는다.
  • 토큰을 PostgreSQL 로 옮기고 기본키와 로그아웃 정리를 확인한다 먼저 해 둬야 하는 편이다. 여기서 app1 로 쓰는 BFF 를 거기서 세운다.
  • cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다 먼저 해 둬야 하는 편이다. 여기서 app2 로 쓰는 oauth2-proxy 와 빌린 Ingress 를 거기서 만든다.

본문

읽기 전에 — 어디서 치는가

명령은 [lab host] 에서 kubectl 로 친다. kubectlsudo 를 붙이지 않는다. 반입한 가이드의 전제 문장은 sudo kubectl 로 적혀 있지만 같은 폴더의 README 가 반대로 적고, 본문 명령 블록에도 sudo kubectl 은 한 번도 없다. sudo 를 붙이면 root 환경으로 돌아 사용자 홈의 kubeconfig 를 못 본다.

원 가이드는 이 명령들을 kc-lab-1 에서 치라고 적었다. 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 sudo 없는 kubectlpermission denied 로 막힌다 — kubeconfig 는 lab host 의 ~/.kube/config 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 kubectl 블록의 기계 이름을 [lab host] 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다.

[밖에서] 라벨이 붙은 curl 은 클러스터 밖에서 공개 이름을 두드린다는 뜻이다. 어느 기계에서 치라는 줄은 가이드에 없으므로(unknown), https://app1.hyeonworks.com 이 풀리는 기계면 어디서든 친다. 거기로 가는 ssh 명령도 가이드에 없다.

앱이 둘 필요하다. app1 은 BFF(Backend for Frontend, 브라우저 대신 토큰을 들고 있는 백엔드)이고 app2 는 oauth2-proxy 다.

브라우저도 필요하다. 인가 코드 흐름은 브라우저와 Keycloak 사이를 두 번 왕복하고, 두 번째 왕복에서 화면이 뜨는가 안 뜨는가가 이 절차의 관측 대상이다. curl 로는 「로그인 화면이 안 떴다」를 볼 수단이 없다. 브라우저 창 하나와 터미널 하나를 나란히 둔다.

무엇
네임스페이스 keycloak-lab · Grafana 는 observability
둘. 둘 다 realm keycloak-patterns 를 본다
빌리는 이름 app2.hyeonworks.com — 인증서가 세 이름만 덮어서 B-7 이 Grafana 에서 빌렸다
브라우저 같은 창의 새 탭으로 app2 를 연다. 시크릿 창은 SSO 쿠키가 없어 다른 결과가 나온다
주입 수단 브라우저로 app1 에 로그인하고 app2 를 방문한다
파괴 시작할 때 Keycloak 세션 테이블과 Redis 를 비우고 StatefulSet 을 재시작한다
도구 jq 는 이 실험대에 없다. Keycloak 이미지에는 curlwget 도 없다
전 구간 약 20분. Keycloak 재시작에만 1~2분

이 실험이 가르는 것

원래 질문은 한 줄이었다.

"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 를 쓰면서 세션을 다르게 다룬다.

절차를 끝까지 밟으면 여섯을 자기 화면에서 보게 된다. 두 번째 앱이 로그인 화면 없이 열리는 것, user session 1client session 2 가 매달린 구조, 구조가 다른 두 앱이 같은 user session 을 공유하는 것, IdP 세션을 죽여도 두 앱이 열리는 것, logout-all 이 오류 없이 아무것도 안 하는 것, realm 을 안 보고 세면 master 의 admin 세션에 속는다.

전제와 되돌리기

  • B-2 의 app1(BFF)과 B-7 의 app2(oauth2-proxy)가 둘 다 떠 있다. 없으면 SSO 가 아니라 로그인 한 번이다.
  • app2.hyeonworks.com 은 Grafana 에서 빌린 이름이다. 이 실험이 끝나면 Ingress 를 돌려준다.
  • 브라우저가 있어야 한다. curl 로는 「로그인 화면이 안 떴다」를 볼 수 없다.
  • Keycloak 이미지에는 curlwget 도 없다(exit 127). kcadm.sh 는 파드 안에 있으므로 항상 kubectl exec 로 감싼다.
  • jq 는 이 실험대에 깔려 있지 않다.
  • ~/grafana-ingress-backup.yaml 이 손에 있어야 한다. 이 파일은 이 편이 만들지 않는다 — B-7 이 Grafana Ingress 를 걷어내기 전에 떠 둔다. 지금은 그 Ingress 가 이미 없으니 파일이 없으면 다시 뜰 수도 없고, 되살리는 절차는 가이드에 없다(unknown). B-4 로 app2 를 빌린 적이 있으면 그 편은 같은 백업을 /tmp/grafana-ingress-backup.yaml 에 떠 두므로 그쪽도 본다.

:::danger

이 실험은 세션을 전부 지우고 시작한다. 비교할 상태를 만들려고 Keycloak 세션 테이블을 직접 지우고, Redis 를 비우고, Keycloak StatefulSet 을 재시작한다. 그 순간 지금 로그인해 있는 모든 사람이 끊긴다.

:::

되돌리기는 둘이고 둘 다 먼저 읽어 둔다. 하나는 세션을 다시 깨끗하게 만드는 것이라 주입 전 절차와 같은 명령이다.

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

다른 하나는 빌린 이름을 돌려준다. ②는 먼저 지우고 나중에 올리므로, 백업 파일이 비어 있으면 Grafana 가 안 열리는 채로 끝난다. 실제로 칠 때는 복구 3 절의 확인 두 줄을 먼저 친다.

kubectl -n keycloak-lab delete ingress oauth2-proxy
kubectl apply -f ~/grafana-ingress-backup.yaml

:::warning

C-2 를 이어서 할 생각이면 아직 돌려주지 않는다. C-2 가 두 앱을 그대로 쓴다.

:::

지운 세션은 안 돌아온다. 이 실험의 파괴에는 되돌리기가 없고, 다시 로그인하는 것이 복구다.

주입 전에 같은 명령으로 먼저 본다

넓은 것부터 좁혀 간다. 이 층에서는 그 경로가 한 번 꺾인다. 세션을 지우려다 안 지워지는 것을 먼저 보고, 그다음에 세는 법을 고친다.

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

1. 앱 둘과 Ingress 둘이 살아 있는가

목적 — SSO 가 성립할 조건을 확인한다. 앱이 하나면 이 실험은 로그인 한 번이다.

행동 — 클러스터 안을 먼저 보고, 밖에서 두 이름을 두드린다.

kubectl -n keycloak-lab get pods -o wide
kubectl -n keycloak-lab get ingress
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/

예상 결과 — ① 은 bffoauth2-proxy 가 둘 다 Running 이고 Ingress 에 app1.hyeonworks.comapp2.hyeonworks.com 이 둘 다 있다. ② 의 실측은 이렇다(observed, 01-baseline.txt).

  app1 HTTP 200 / app2 HTTP 200

왜 필요한가 — 두 앱이 같은 realm 을 보고 있어야 client session 이 하나의 user session 아래 붙는다.

문제가 생기면app2 가 Grafana 로 가면 B-7 의 Ingress 가 없다. 그쪽 백업과 적용을 먼저 한다.

2. kcadm 을 로그인시킨다

목적 — 관리 API 를 칠 수 있게 한다.

행동 — 관리자 비밀번호를 Secret 에서 읽어 명령 치환으로 넘긴다.

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)"

예상 결과 — 오류 없이 끝나고 다음 kcadm 호출이 401 을 안 낸다.

왜 필요한가 — 값이 명령 치환 안에서만 흐르므로 터미널에도 셸 히스토리에도 남지 않는다. 길이를 확인하는 명령은 B-0 절에 있다.

문제가 생기면 — 아래에서 Keycloak 파드를 재시작하면 이 세션이 사라지고 이후 모든 kcadm401 이 된다. 그때 이 명령을 다시 친다.

3. 가장 자연스러운 방법으로 세션을 지워 본다

목적 — realm 전체 로그아웃이 실제로 무엇을 하는지 본다.

행동 — 관리 API 를 치고 남은 세션을 센다.

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

이 실험대는 세는 쪽을 스크립트로 돌렸고 증거에 SQL 원문이 없다. 따라 하는 사람은 가이드가 손으로 치기 좋게 고친 아래 형태를 친다. 2026-09-17 에 쳐서 돌았다(observed) — 그때까지 미검증이던 줄이다.

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

예상 결과 — 실측은 이렇다(observed, 01-baseline.txt).

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

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

4 다. 0 이 아니다. logout-all 이 오류도 안 내고 세션도 안 줄였다. A-1 에서 확인한 대로 Keycloak 은 세션을 DB 에서 읽되 캐시로 답한다. 관리 API 가 무효화를 걸어도 각 노드의 캐시가 안 바뀌면 세션은 살아 있는 것처럼 보인다.

왜 필요한가 — 해설 문서의 이 값은 한 번 정정됐다. 처음에는 0 으로 인쇄됐는데 증거 01-baseline.txt4 이고, 0 은 다음 단계의 값이었다. 여기서는 4 가 나온다.

문제가 생기면 — 다음 단계로 간다. 이 단계에서 0 을 만들려고 애쓰지 않는다.

4. DB 를 직접 지우고 캐시를 버린다

목적keycloak-patterns 세션 수와 Redis 키를 둘 다 0 으로 만든다.

2026-09-17 실측(observed) — logout-all 을 친 뒤에도 세션이 그대로 6 건이었다. 이 방법으로는 안 지워진다는 것이 이 절의 결론이고, 그래서 아래 4절로 간다.

행동 — 자식 테이블부터 지우고, 앱 세션을 비우고, 프로세스를 새로 띄운다.

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

2026-09-17 실측이다(observed). 자식 쪽이 훨씬 많다 — 부모부터 지우려 했다면 외래키에 걸렸을 것이다.

DELETE 1633
DELETE 6

원래 실행의 모양은 이렇다(observed).

DELETE 2
DELETE 4
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

③ 이 끝나면 keycloak-0 파드가 새로 뜨고 그 안에 있던 kcadm 세션 파일이 함께 사라진다. 2026-09-17 에 그대로 겪었다(observed) — 롤아웃 직후 kcadm 이 이렇게 끝난다.

No server specified. Use --server, or 'kcadm.sh config credentials'.

그러니 롤아웃이 끝나는 대로 주입 전 2 절의 config credentials 를 다시 친다. 건너뛰면 뒤에 나오는 kcadm 이 전부 401 을 내는데, 그 사실은 관찰 1 절에 가서야 보인다.

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 '*'

예상 결과 — 실측은 이렇다(observed, 01-baseline.txt).

  Redis 키: 0

Redis 키 0, 세션 수 0 이어야 한다.

왜 필요한가 — 캐시가 답하는 한 관리 API 로는 0 을 만들 수 없다. 행을 지우는 명령과 캐시를 버리는 명령이 따로 있다.

:::danger

flushall 은 이 Redis 전체를 지운다. BFF 세션과 oauth2-proxy 세션은 물론 B-5 가 남긴 b5:pvc 까지 전부다. 깨끗한 상태를 만드는 이 단계에서만 치고, 실험 도중에는 쓰지 않는다(B-7a 참고).

:::

문제가 생기면 — 여기서도 0 이 아니면 재시작이 안 끝났거나 누가 로그인 중이다. 그리고 재시작으로 kcadm 세션이 날아갔으므로 주입 전 2 절의 config credentials 를 다시 친다.

5. 세는 법을 고친다

목적 — 숫자에 realm 을 붙인다.

행동 — 두 형태를 나란히 친다. 이 실험대가 쓴 쪽이 틀린 방법이다.

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

따라 하는 사람은 realm 을 조인한다. 가이드가 미검증으로 표시한 형태다(unknown).

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 행은 4 절 뒤에 config credentials 를 다시 친 사람에게만 보인다 — 그 한 줄이 admin 세션을 새로 만든다. 4 절이 master 세션까지 지우고 재시작이 kcadm 세션을 날렸으므로, 아직 다시 안 쳤으면 아무 행도 안 나오고 그것도 맞는 상태다.

왜 필요한가offline_user_session 에는 모든 realm 의 세션이 들어 있고, kcadm 을 쓰는 순간 master realm 에 admin 세션이 생긴다. 그냥 세면 내가 만든 잡음을 남의 세션으로 읽는다. 원래 실행은 이 한 열 때문에 「안 지워졌다」로 오독할 뻔했다. 여기서부터 세션 수를 말할 때는 항상 realm 을 붙인다. 「세션 1개」가 아니라 「keycloak-patterns 세션 0개, master 1개」다.

문제가 생기면 — 이 단계를 건너뛰면 관찰 절의 결론을 반대로 읽는다.

주입

주입은 브라우저로 한다. 두 앱에 차례로 들어가면 SSO 상태가 된다. 파괴적인 조작은 관찰 절에 있고 여기까지는 초기화를 다시 하면 되돌아온다.

1. app1 에 로그인한다

목적 — 첫 로그인으로 IdP 세션을 만든다.

행동 — 브라우저에서 https://app1.hyeonworks.com/ 을 열고 B-0 에서 만든 계정 labuser 로 로그인한다. 가이드는 이 줄에 계정 이름과 비밀번호를 나란히 적지만 여기에는 이름만 옮긴다. 그 비밀번호는 B-0 의 set-password 로 따라 하는 사람이 정하는 값이다.

예상 결과 — Keycloak 로그인 화면이 뜨고 주소창이 이렇게 바뀐다(observed, 해설 문서에 남은 형태).

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

왜 필요한가 — 첫 앱에서는 로그인 화면이 나온다. 이것이 둘째 단계의 대조군이고, 이걸 안 보면 app2 에서 안 뜬 것이 특별한 일인지 알 수 없다.

문제가 생기면 — 화면이 안 뜨면 이전 실험의 쿠키가 남아 있다. auth.hyeonworks.com 의 쿠키를 지우고 다시 연다.

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

목적 — app2 를 방문하기 전의 대조값을 잡는다.

행동 — 주입 전 5 절에서 고친 조인 쿼리에 realm 조건을 붙여 친다. 같은 이유로 미검증이다(unknown).

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'"

실측은 이렇다(observed, 02-after-app1-login.txt).

=== app1 로그인 직후 Keycloak 세션 ===
     user_session_id      | client_sessions 
--------------------------+-----------------
 oqOjHekin4JU-BZjgQLjUByW |               1
(1 row)
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'

실측은 이렇다(observed).

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

authorized client 를 세는 줄도 미검증이다(unknown).

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

예상 결과client_sessions 가 1 이고 Redis 키가 하나다. Redis 키 이름의 접두사 bff:session:sessions: 는 BFF 가 만든 세션이라는 뜻이고, 뒤에서 프록시 것과 갈라진다.

왜 필요한가user_session_id 를 적어 둔다. 뒤에서 계속 쓴다. 한 번 로그인했는데 상태가 세 곳에 생겼다. Keycloak 세션, Redis 세션, PostgreSQL 토큰이고 관찰 절에서 이 셋의 운명이 갈린다.

문제가 생기면 — 행이 0 이면 로그인이 아직 안 끝났다. 브라우저에서 app1 이 실제로 열렸는지 본다.

3. 같은 창의 새 탭에서 app2 를 연다

목적 — SSO 상태를 만든다.

행동 — 같은 브라우저의 새 탭에서 https://app2.hyeonworks.com/api/echo 를 연다.

예상 결과 — 로그인 화면이 뜨지 않는다(observed, c1-sso-app2-no-login-screen.png). app2 는 Keycloak 으로 리다이렉트했지만 Keycloak 에 이미 세션이 있어서 묻지 않고 바로 돌려보냈다.

왜 필요한가 — SSO 를 만드는 것은 auth.hyeonworks.com 에 붙은 브라우저 쿠키다.

문제가 생기면 — 다른 브라우저나 시크릿 창에서 열면 안 된다. 창이 다르면 그 쿠키가 없어 로그인 화면이 뜬다.

주입 검증

결과를 해석하기 전에 주입이 의도한 것을 정확히 했는지 먼저 본다. 여기서는 「두 번째 로그인」이 아니라 「같은 로그인에 앱이 하나 붙은 것」인지를 가른다.

1. 같은 user session 인가

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'"

실측은 이렇다(observed, 03-after-app2-visit.txt).

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

user_session_id 가 앞과 같고 client_sessions 만 1 에서 2 로 늘었다. SSO 의 데이터 구조가 이렇게 생겼다.

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

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

조인해야 이름이 나온다. 가이드는 이 줄도 미검증으로 표시한다. 증거에 SQL 원문이 없고 출력만 있다(unknown).

아래 쿼리의 oqOjHekin4JU-BZjgQLjUByW 는 원래 실행의 세션 id 다. 치기 전에 주입 2 절에서 적어 둔 자기 값으로 갈아 끼운다. 그대로 치면 조건에 걸리는 행이 없어 (0 rows) 가 나오고, 그것을 「두 앱이 안 붙었다」로 읽으면 판정이 뒤집힌다.

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'"

실측은 이렇다(observed, 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 은 앱이 세션을 어떻게 다루는지 모른다.

user_session_id 는 따라 하는 사람의 환경에서 다르다. 2 절에서 적어 둔 값으로 바꿔 친다.

3. 저장소에는 무엇이 늘었는가

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

실측은 이렇다(observed, 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 를 금지한 이유가 여기 있다.

4. 왜 두 층으로 나뉘어 있는가

Keycloak 은 세션을 user session(사람 하나)과 client session(그 사람이 쓰는 앱 하나)으로 나눠 둔다. A층과 B층에서 본 두 사건이 서로 다른 층을 건드렸다.

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

두 층이 나뉘어 있는 까닭이 SSO 다. 앱 하나의 사고가 다른 앱으로 번지지 않게 하려면 client session 이 따로 있어야 한다. 한 층뿐이었다면 B-3 의 재사용 탐지 한 번에 모든 앱이 끊긴다.

관찰

지우는 대상은 ①(IdP 세션) 하나다. ②(앱 세션)와 ③(토큰)은 손대지 않는다. 되돌리기는 다시 로그인하는 것이라 파괴적이지만 회복은 쉽다.

1. 세션 id 를 지목하는 방법은 안 먹는다

지우는 방법을 고르는 데서 하나가 걸러진다. 가이드가 미검증으로 표시했다(unknown).

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

이 id 는 이 실험대의 값이다. 위 출력에서 자기 세션 id 로 갈아 끼운다. 안 바꾸면 남의 세션을 지워 아무 일도 안 일어나는데, 이 절의 판정이 「오류도 안 나고 세션도 안 줄어든다」라 그 둘이 화면에서 구별되지 않는다.

오류도 안 나고 세션도 안 줄어든다. 앞의 logout-all 과 같은 유형이다.

2. 사용자 단위로 끊는다

목적 — IdP 세션만 끊는다.

행동 — 사용자 id 를 먼저 잡아 눈으로 확인하고, 그다음에 로그아웃을 건다.

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 가 다른 것을 잡은 것이고, 그 상태로 다음 명령을 치면 엉뚱한 경로를 부른다. 가이드가 자리표시자를 두지 않으려고 두 단계로 나눴다고 적는다. 한 줄로 이어 붙이면 $USERID 가 비었을 때 그 사실이 안 보인다.

문제가 생기면 — 출력이 여러 줄이면 --fields id 가 다른 열을 함께 줬다. 먼저 echo 로 확인하고 다음 명령으로 넘어간다.

3. IdP 세션이 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'"

실측은 이렇다(observed, 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 세션이다.

:::warning

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

:::

4. 앱 세션을 주입 검증과 같은 명령으로 본다

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

실측은 이렇다(observed, 04-sso-session-killed.txt).

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

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

키 이름이 앞과 글자 하나까지 같다. 아무것도 안 지워졌다. 로그아웃은 ①만 지웠고 ②도 ③도 그대로다.

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

아까 그 브라우저에서 https://app1.hyeonworks.com/https://app2.hyeonworks.com/api/echo 를 연다. 둘 다 로그인 화면 없이 그대로 열린다(observed, c1-apps-alive-after-idp-logout.png).

:::warning

이 스크린샷과 app2 첫 방문 때의 스크린샷은 바이트 단위로 동일한 파일이다(md5 2c703176…). 두 시점의 화면이 실제로 같은 내용이었기 때문이고 조작은 아니지만, 그래서 두 시점을 구별하는 증거가 되지 못한다. 구별은 03-after-app2-visit.txt04-sso-session-killed.txt 의 터미널 출력이 한다.

:::

화면이 같아 보인다는 것 자체가 이 실험의 결론이라 화면만으로는 증명이 안 된다. 판정은 client_sessions 가 1 에서 2 로 늘어난 출력과, IdP 세션을 지운 뒤에도 그대로인 Redis 키 두 줄이 한다.

6. 그러면 언제 끊기는가

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

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

즉시가 아니라 지연되어 끊긴다. 최대 지연은 access token 수명(60초)이 아니라 앱이 다음에 IdP 를 부를 때까지다. B-2 에서 「로그아웃했는데 다시 들어가진다」를 겪은 것의 반대편이다. 거기서는 앱 세션을 지웠는데 IdP 세션이 남아 재로그인이 됐고, 두 방향 모두 「한쪽만 지우면 다른 쪽이 안 지워진다」다.

실제로 끊기는 순간을 보려면 수명 두 값을 읽고 기다린다. 가이드는 이 줄을 미검증으로 표시했고 기다려서 확인하지도 않았다(unknown).

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

7. SSO 의 대가

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

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

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

장애가 곧바로 전면에 드러나지 않고 앱 세션 수명만큼 늦게 몰려온다. 전 앱을 끊으려면 백채널 로그아웃이 필요하고, 그게 되는지는 C-2 가 잰다.

복구와 원상복구 확인표

1. 세션을 정리한다

목적 — 다음 실험을 깨끗한 상태에서 시작한다.

행동 — 주입 전 절차와 같은 명령이다.

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

예상 결과 — 롤아웃이 끝나고 realm 조인 쿼리가 keycloak-patterns 0 을 준다.

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

문제가 생기면 — 재시작 뒤 kcadm401 이면 config credentials 를 다시 친다.

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

auth.hyeonworks.comapp1app2 의 쿠키를 지우거나 시크릿 창을 새로 연다. 서버 세션을 다 지워도 브라우저에 낡은 쿠키가 남고, 다음 실험에서 「왜 로그인 화면이 안 뜨지」로 헤매는 원인이 대개 그것이다.

3. 빌린 이름을 돌려준다

목적 — Grafana 가 쓰던 app2.hyeonworks.com 을 원래 주인에게 돌린다.

행동 — 빌린 Ingress 를 먼저 지우고 백업을 올린 뒤 밖에서 확인한다.

지우기 전에 백업 파일이 쓸 만한지 본다. 이 파일은 이 편이 만들지 않는다 — B-7 이 Grafana Ingress 를 걷어내기 전에 떠 둔다. 지금은 그 Ingress 가 이미 없으니 파일이 비어 있으면 다시 뜰 원본도 없고, 되살리는 절차는 가이드에 없다(unknown).

wc -l ~/grafana-ingress-backup.yaml
grep -c 'app2.hyeonworks.com' ~/grafana-ingress-backup.yaml

줄 수가 나오고 둘째 줄이 0 이 아니면 그 파일로 돌려줄 수 있다. No such file or directory 가 나오면 아래 delete 를 치지 않는다. B-4 로 app2 를 빌린 적이 있으면 그 편은 같은 백업을 /tmp/grafana-ingress-backup.yaml 에 떠 두므로 그쪽을 본다.

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 로 돌아간다.

왜 필요한가 — 인증서가 authapp1app2 세 이름만 덮어서 네 번째 이름을 만들 수 없었고, 그래서 B-7 이 Grafana 의 이름을 잠시 빌렸다. 돌려주지 않으면 실험이 끝나도 Grafana 가 안 열린다.

문제가 생기면 — C-2 를 이어서 할 생각이면 이 단계를 건너뛰고 C-2 가 끝난 뒤에 친다. ⓪이 No such file or directory 를 냈다면 delete 를 치기 전이므로 Grafana 는 아직 살아 있다. B-7 의 백업 단계를 다시 읽고, 양쪽 경로에 다 없으면 Ingress 를 손대지 않은 채로 둔다.

항목 명령 돌아왔을 때
Keycloak 세션 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

막히면

원래 실행이 실제로 겪은 증상이고 지어낸 것은 없다고 가이드가 적는다.

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

이 실험대에서 아직 못 밟은 단계

2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. 못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.

  • 남은 것 — app1·app2 로그인과 두 앱의 Redis 키 대조. 지금까지 밟은 것 — 세션 정리 구간 전부(logout-all 이 안 듣는 것, 자식 1633·부모 6, 롤아웃이 kcadm 세션을 날리는 것).
  • 막는 것https://auth.hyeonworks.com 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt guest_input 구멍 — A-4 에서 확인한 ExecStartPost 누락)이 둘 다 있어야 한다.
  • 그때까지 이 편의 실측 가운데 (observed) 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다. 나머지는 원래 실행의 값이다.

무엇이 관측이고 무엇이 아닌가

이 절차의 숫자는 2026-09-04 14:4414:48 KST 에 돈 한 번의 실행에서 나왔다(observed).

  • (observed) 앱 둘의 app1 HTTP 200 / app2 HTTP 200, logout-all 뒤의 「Keycloak 온라인 세션: 4 · Redis 키: 0」, DB 삭제와 재시작 뒤의 「Redis 키: 0」, app1 로그인 직후의 user_session_idclient_sessions 1, app2 방문 뒤의 같은 id 와 client_sessions 2, 클라이언트 UUID 둘과 이름, Redis 키 두 줄, 사용자 단위 로그아웃 뒤의 「남은 세션 1」과 그 세션의 realm master, 그 뒤에도 Redis 키 두 줄이 글자 하나까지 같은 것.
  • (observed) 브라우저 화면 둘. 하나는 app2 가 로그인 화면 없이 열렸고, 다른 하나는 IdP 세션을 지운 뒤에도 두 앱이 열렸다. 두 파일은 md5 2c703176… 로 동일하다. 그래서 두 시점을 구별하는 증거로는 못 쓰고, 구별은 터미널 출력이 한다.
  • (unknown) realm 을 조인해 세션을 세는 쿼리, 클라이언트 이름을 조인하는 쿼리, oauth2_authorized_client 를 세는 줄, kcadm delete sessions/<id>, 수명 두 값을 읽는 get realms … --fields. 가이드가 전부 미검증으로 표시했다. 원래 실행은 스크립트로 돌렸고 증거에 SQL 원문이 없다.
  • 비밀은 옮기지 않았다. 관리자 비밀번호는 명령 치환으로만 넘어가고 화면에 안 찍힌다. 브라우저 로그인 줄에서는 계정 이름 labuser 만 옮겼고 비밀번호는 안 옮겼다. 세션 id 와 Redis 키 이름과 클라이언트 UUID 는 식별자라 그대로 적었다.
  • 버전은 이 편이 직접 잰 값이 아니다(inferred). C-1 출력에는 판 번호가 한 번도 안 찍혔고, C층은 B층 위에서 이어 돌았으므로 판을 물을 때는 같은 실험대의 B층 출력을 본다.
  • 이 절차가 재지 않은 것 — 「언제 끊기는가」를 실제로 기다려서 확인하지 않았다. IdP 세션을 지운 뒤 access token 수명이 지날 때까지 두고 app1 을 새로고침하면 Session not active 가 나와야 한다는 것은 추론이고, 재려면 그렇게 한다고 가이드는 적는다.