An audit against the standard the series set — concepts, procedure, commands, architecture diagram, evidence table, terminal output — found the three new experiments met it while twelve of the original ones had no diagram at all: A-0, A-1, A-3, A-4, A-5, A-6, A-8, B-0, B-2, B-7, C-2, D-2. Each now has one drawn from what that experiment actually found, not filler: A-0 shows sharing going through PostgreSQL rather than between the caches; A-3 the gap between the 200 and the WAL flush, with both failed injections; A-5 the three silent injection failures; A-6 the two places latency is multiplied; B-0 the repository keyed by principal with no session id; B-2 the primary key that causes the overwrite; D-2 why the rolling update stopped the accident halfway. Also corrected the index's stale claim of 11 experiments without a screenshot — it is 14, and the reason is recorded: those experiments were measured from terminals, the database and logs, and the observability stack does not scrape Redis, the BFF or PostgreSQL, so there is no console to photograph. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 KiB
C-2 — 로그아웃이 전 앱에 퍼지는가 (백채널 로그아웃)
브랜치 feature/keycloak-c2-backchannel-logout ·
증거 docs/evidence/c2-backchannel-logout/ ·
2026-09-04 16:30–16:55 KST
선행: C-1 — IdP 로그아웃이 앱에 전파되지 않는다를 관측했다
구조
다이어그램 규약은
diagrams/_style.md. 실험대 전체 구조는diagrams/lab-topology.svg.
0. 결론부터
| 확인 | 결과 |
|---|---|
| 백채널 로그아웃이 설정되어 있었는가 | 아니다. 두 클라이언트 모두 backchannelLogoutUrl 없음 |
| BFF 에 그 엔드포인트가 있는가 | 아니다. 소스에 oidcLogout 설정이 없다 |
| IdP 쪽만 설정하면 되는가 | ★ 안 된다. 앱 세션이 그대로 남았다 |
| Keycloak 이 앱 URL 에 닿기는 하는가 | 닿는다 (HTTP 200) — 네트워크 문제가 아니다 |
C-1 이 관측한 "로그아웃이 안 퍼진다" 의 원인은 단순했다 — 아무도 구현하지 않았다.
1. 현재 상태 — 어느 쪽에도 없다
IdP 쪽
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
frontchannelLogout : false
(backchannel.logout.url 없음)
oauth2-proxy 클라이언트도 마찬가지였다.
앱 쪽
grep -rn "oidcLogout\|backchannel" bff/src/main/java/
아무것도 안 나온다.
Spring Security 6.2+ 는 백채널 로그아웃을 지원하지만 명시적으로 켜야 한다.
.oidcLogout(oidc -> oidc.backChannel(Customizer.withDefaults()))
이 설정이 없으면 /logout/connect/back-channel/{registrationId} 경로가
생기지 않는다.
/logout/connect/back-channel/keycloak HTTP 302 ← 로그인으로 리다이렉트
/backchannel-logout HTTP 302
/oauth2/sign_out HTTP 302
302 는 "그런 핸들러가 없어서 인증 요구로 떨어졌다"는 뜻이다. 엔드포인트가 있었다면 POST 를 받아 200 이나 400 을 돌려줬을 것이다.
2. IdP 쪽만 설정하고 시험했다
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh update clients/$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh get clients -r keycloak-patterns -q clientId=bff --fields id --format csv --noquotes | tail -1) -r keycloak-patterns \
-s 'attributes={"backchannel.logout.url":"https://app1.hyeonworks.com/logout/connect/back-channel/keycloak",
"backchannel.logout.session.required":"true"}'
backchannel.logout.session.required = true
backchannel.logout.url = https://app1.hyeonworks.com/logout/connect/back-channel/keycloak
출처 주의 — 위 확인 출력은
02-configure-idp.txt가 아니라 그 뒤 별도로 실행한 조회에서 나온 것이다. 그 파일에는command terminated with exit code 1이 남아 있다 —-s "attributes.backchannel.logout.url=..."의 점 표기가 실패한 첫 시도이며, JSON 으로 다시 넣어 성공했다. 실패한 시도의 파일에 성공 출력을 붙여 인쇄한 것은 잘못이었다.
살아 있는 세션에 로그아웃을 걸었다
=== 로그아웃 전 ===
keycloak-patterns 세션: 1
Redis: 1 키
=== IdP 로그아웃 ===
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh create users/$(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)/logout -r keycloak-patterns
=== 결과 ===
keycloak-patterns 세션: 0 ← IdP 쪽은 끊겼다
Redis: 1 키 ← ★ 앱 세션은 그대로다
IdP 세션만 사라지고 앱 세션은 남았다. C-1 과 같은 결과다.
네트워크 문제가 아님을 확인했다
=== Keycloak 파드가 app1.hyeonworks.com 에 닿는가 ===
DNS 해석: Address: 100.83.212.4
HTTPS 도달: HTTP 200
클러스터 안에서 공개 이름으로 앱에 닿는다. 이 실험대는 tailnet + split DNS 구성이라 헤어핀이 되는데, 운영에서는 안 되는 경우가 흔하다.
백채널 로그아웃의 숨은 전제 — IdP 가 앱의 공개 URL 로 서버에서 서버로 요청을 보낼 수 있어야 한다. 앱이 사설망에 있고 IdP 가 밖에 있으면 설정을 해도 도달하지 못한다. 그때는 로그도 안 남고 조용히 실패한다.
3. 그래서 왜 안 퍼졌는가
IdP 로그아웃
├─ ① Keycloak 이 backchannel.logout.url 로 POST 를 보낸다 (설정함)
├─ ② 앱이 그 POST 를 받는 엔드포인트를 갖고 있다 ★ 없다
└─ ③ 앱이 logout token 을 검증하고 sid 로 세션을 찾아 지운다 ★ 없다
②와 ③이 없다. ①만 설정해도 받을 사람이 없다.
Keycloak 로그에 backchannel 문자열이 0줄이었다 — 다만 이것만으로
"보내지 않았다"고 단정할 수는 없다 (DEBUG 레벨일 수 있다).
확실한 것은 앱 세션이 남았다는 관측이다.
4. 개념 — 백채널 로그아웃의 구조
사용자가 어느 앱에서든 로그아웃
│
▼
Keycloak 이 SSO 세션에 붙은 client session 목록을 본다 (C-1 의 그 구조)
│
├──POST──▶ app1 의 backchannel.logout.url
└──POST──▶ app2 의 backchannel.logout.url
본문: logout_token (JWT)
{ "sid": "...", "sub": "...", "events": {...} }
sid 가 여기서 쓰인다
A-0 에서 확인한 그 sid 다 — JWT·DB·관리 API 에서 같은 문자열이었던.
logout_token 의 sid → 앱이 "그 sid 로 만든 내 세션"을 찾아 지운다
그래서 앱은 sid → 자기 세션 ID 역인덱스를 갖고 있어야 한다.
Spring Security 는 이를 위해 OidcSessionRegistry 를 쓰며,
여러 인스턴스가 있으면 그 레지스트리도 공유 저장소여야 한다 —
B-1·B-2 에서 겪은 것과 같은 문제가 한 겹 더 있다.
부분 실패는 어떻게 되는가
app1 로그아웃 성공, app2 는 응답 없음
└─ Keycloak 은 재시도하는가? 얼마나?
└─ 사용자는 app2 에서 여전히 로그인 상태다
로그아웃은 원자적이지 않다. 앱이 늘어날수록 "일부만 로그아웃된 상태"가 생길 확률이 올라간다.
5. 겪은 문제
| 문제 | |
|---|---|
kcadm -s "attributes.backchannel.logout.url=..." 이 exit 1 |
점 표기가 안 먹는다. JSON 으로 통째로 줘야 한다 |
| 첫 시험이 무의미했다 | 로그아웃 전 IdP 세션이 이미 0 이었다. 끊을 대상이 없었다 |
| 세션 수를 realm 없이 셌다 | C-1 과 같은 실수 — master 의 admin 세션이 섞인다 |
| Keycloak 재시작 후 로그인 폼이 안 넘어갔다 | 인증 세션 쿠키가 무효화된 상태에서 폼을 재사용했다 |
두 번째가 A층에서 반복한 교훈이다 — 주입 대상이 실제로 존재하는지 먼저 확인한다. 세션이 없는 상태에서 로그아웃을 걸고 "전파가 안 된다"고 결론지을 뻔했다.
6. 구현하려면 무엇이 필요한가
| 계층 | 할 일 |
|---|---|
| IdP | 클라이언트마다 backchannel.logout.url 설정 (완료) |
| 앱 | .oidcLogout(oidc -> oidc.backChannel(...)) 활성화 |
| 앱 | OidcSessionRegistry 를 공유 저장소로 (인스턴스가 여럿이므로) |
| 네트워크 | IdP → 앱 공개 URL 도달 (이 실험대는 됨, 운영은 확인 필요) |
| oauth2-proxy | 지원하지 않는다. 별도 방안이 필요하다 |
마지막이 C-1 과 맞물린다 — app1(BFF)은 구현할 수 있지만 app2(oauth2-proxy)는 못 한다. 한 SSO 안에서 로그아웃 전파가 앱마다 다르게 동작하게 된다.
증거 파일
증거 수집 시각: 2026-09-04 14:50 – 14:53 KST (파일 mtime 기준. 문서 상단의 시각 표기는 작성 시점이라 다를 수 있다.)
| 파일 | 종류 |
|---|---|
01-current-state.txt |
터미널 원문 |
02-configure-idp.txt |
터미널 원문 |
03-logout-attempt.txt |
터미널 원문 |
04-reachability.txt |
터미널 원문 |
파일별 상세는 evidence/c2-backchannel-logout/README.md.
7. 재현 절차 (명령어)
# 1. 현재 설정 확인 — 어느 쪽에도 없다
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
grep -rn "oidcLogout\|backchannel" bff/src/main/java/
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://app1.hyeonworks.com/logout/connect/back-channel/keycloak
# 2. IdP 쪽 설정 — 점 표기는 안 먹는다. JSON 으로
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh update clients/$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh get clients -r keycloak-patterns -q clientId=bff --fields id --format csv --noquotes | tail -1) -r keycloak-patterns \
-s 'attributes={"backchannel.logout.url":"...","backchannel.logout.session.required":"true"}'
# 3. ★ 살아 있는 세션이 있는지 먼저 확인한다 (realm 을 join 해서)
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from offline_user_session us join realm r on r.id=us.realm_id
where r.name='keycloak-patterns' and us.offline_flag='0'"
# 4. 로그아웃하고 앱 세션을 본다
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh create users/$(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)/logout -r keycloak-patterns
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
# 5. 도달성 확인 — 클러스터 안에서 앱 공개 URL 로
kubectl -n keycloak-lab run t --rm -i --restart=Never --image=curlimages/curl:8.11.1 \
--command -- curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/
8. 다음에 남기는 것
| 구현 | .oidcLogout() 활성화 + OidcSessionRegistry 공유 |
| oauth2-proxy | 백채널 로그아웃 미지원 — SSO 안에서 앱마다 동작이 갈린다 |
| 운영 | IdP → 앱 도달성이 전제다. 안 되면 조용히 실패한다 |
| B-2 와 연결 | 로그아웃이 지우는 것은 지금도 세 곳 중 하나뿐이다 |