Compare commits

..
Author SHA1 Message Date
DongHyeonkaandClaude Opus 5 b16e1dccf7 docs: B-3 — concurrent refresh does not lose a race, it destroys the session
Five simultaneous refreshes with one token return a single 200, and that winner's new token is already dead. Reuse detection removes the client session while the user session stays, which is why the other responses read Session doesn't have required client rather than a reuse error.

Comparing policies shows rotation off passes all five and keeps the session, while raising refreshTokenMaxReuse to one still destroys it. Since no retry can recover a removed client session, Q2's own criterion resolves to a lock, and a database row lock is the natural place because its lifetime is tied to the connection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:20:04 +09:00
DongHyeonkaandClaude Opus 5 711878379c docs: B-2 — sharing the stores fixes one problem and exposes three more
Moving the authorized client to JdbcOAuth2AuthorizedClientService makes tokens work across replicas, so the session-in-Redis plus tokens-in-PostgreSQL split holds. The table then shows what sharing cannot fix: the primary key is (client_registration_id, principal_name) with no session in it, so a second login for the same user updates the same row rather than adding one.

The refresh token sits in bytea as the raw JWT, readable with convert_from, and logout clears only the Redis session while the plaintext token row and the Keycloak SSO session both survive. The schema itself failed silently first because the default DDL uses blob, which PostgreSQL does not have, and continue-on-error swallowed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:15:05 +09:00
DongHyeonkaandClaude Opus 5 f2595f748f docs: B-1 — Redis moves the session and leaves the tokens behind
Adding Spring Session Redis grows the context by 81 beans and swaps sessionRepository for RedisSessionRepository, while authorizedClientService stays InMemoryOAuth2AuthorizedClientService. The user then reads as logged in with principal labuser while accessTokenStoredOnServer is false, which is worse than being logged out.

Redis holds only the security context, serialized with Java native serialization, and the refresh token is not there to encrypt in the first place. Three problems on the way: Kubernetes service links overwrote REDIS_PORT with a tcp:// URL, the tests tried to reach Redis, and the resource server was never deployed so a DNS failure looked like a token failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:05:02 +09:00
DongHyeonkaandClaude Opus 5 e62bbb4df0 docs: B-0 — deploy the BFF and read what autoconfiguration actually chose
The authorized client repository is AuthenticatedPrincipalOAuth2AuthorizedClientRepository, keyed by principal with no session id in it, which is the mechanism behind the sharing problem Q1 and Q3 describe. Sharing a store does not fix a lookup key.

Five problems on the way in: only build output was committed under bff/, a duplicate YAML key broke the image build and was invisible until the full log was captured, env placeholders without defaults broke the tests, actuator was behind the login redirect so a 200 was the login page, and the 117KB beans response failed through the proxy.

Deploying two replicas made the login itself fail before any experiment started, because the authorization request lives in per-instance memory and the callback lands elsewhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:48:33 +09:00
DongHyeonkaandClaude Opus 5 8f6d67df35 docs: A-7 — three results invert when persistent sessions are turned off
Disabling persistent-user-sessions moves the session from PostgreSQL into the cluster, and the A-1 and A-8 outcomes flip to 400 Session not active while a new login during database loss starts working. The control group in each case still returns 200, so the injections cut only what they were meant to cut.

This is the pair that makes the A layer legible: the conventional wisdom that sessions ride TCP 7800 is correct for Keycloak 24 and earlier, and the mistake is applying it to 26 without checking the version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:33:33 +09:00
DongHyeonkaandClaude Opus 5 114d21aebe docs: A-8 — a rolling restart keeps every session and drops only the cache
Nine samples through the restart all returned 200, the refresh token issued beforehand still works, and the session count is unchanged at 151 while both caches reset to zero. The updated last_session_refresh proves the write path recovered too, not just the response code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:20:56 +09:00
85 changed files with 3403 additions and 0 deletions
@@ -10,3 +10,11 @@
[ 52154ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 67608ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 86965ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 108668ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 129158ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 137544ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 152699ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 159898ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 173491ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 187925ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 197824ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
@@ -0,0 +1,143 @@
[ 635ms] [WARNING] <meta name="apple-mobile-web-app-capable" content="yes"> is deprecated. Please include <meta name="mobile-web-app-capable" content="yes"> @ https://app2.hyeonworks.com/explore?schemaVersion=1&orgId=1&panes=%7B%22a%22%3A%7B%22datasource%22%3A%22PBFA97CFB590B2093%22%2C%22queries%22%3A%5B%7B%22refId%22%3A%22A%22%2C%22expr%22%3A%22vendor_statistics_approximate_entries_unique%7Bcache%3D%5C%22sessions%5C%22%7D%22%2C%22range%22%3Atrue%2C%22instant%22%3Afalse%2C%22editorMode%22%3A%22code%22%2C%22legendFormat%22%3A%22%7B%7Bpod%7D%7D%22%2C%22datasource%22%3A%7B%22type%22%3A%22prometheus%22%2C%22uid%22%3A%22PBFA97CFB590B2093%22%7D%7D%2C%7B%22refId%22%3A%22B%22%2C%22expr%22%3A%22vendor_cluster_size%22%2C%22range%22%3Atrue%2C%22instant%22%3Afalse%2C%22editorMode%22%3A%22code%22%2C%22legendFormat%22%3A%22cluster_size%20%7B%7Bpod%7D%7D%22%2C%22datasource%22%3A%7B%22type%22%3A%22prometheus%22%2C%22uid%22%3A%22PBFA97CFB590B2093%22%7D%7D%5D%2C%22range%22%3A%7B%22from%22%3A%22now-15m%22%2C%22to%22%3A%22now%22%7D%7D%7D:0
[ 686ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1451ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 3030ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 4308ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 5296ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 7691ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 18337ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 20184ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 32473ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 50697ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 65961ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 85590ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 99644ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 115524ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 130773ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 134354ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 142549ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 151361ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 155246ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 165901ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 169379ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 180952ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 190372ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 193657ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 205737ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 220582ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 225496ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 240339ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 244029ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 264211ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 273424ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 284893ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 292571ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 305778ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 306804ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 324378ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 335169ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 337968ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 352967ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 364258ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 379409ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 399590ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 403681ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 406340ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 421033ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 428667ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 429483ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 437573ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 442286ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 445865ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 456312ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 464043ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 480382ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 491438ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 494608ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 508231ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 528305ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 529632ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 538033ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 550079ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 566347ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 586157ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 603877ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 615779ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 624657ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 629879ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 637255ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 643298ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 650047ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 668080ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 669816ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 675422ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 677001ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 686611ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 692655ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 713114ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 723787ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 730443ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 751427ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 757583ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 776007ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 792298ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 803509ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 822917ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 827374ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 847278ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 867460ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 875338ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 886296ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 900938ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 912511ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 917067ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 924405ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 930435ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 942211ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 953146ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 957153ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 969140ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 984088ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1003544ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1014908ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1020851ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1034668ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1040406ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1056887ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1062828ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1073375ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1085052ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1102031ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1110039ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1129341ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1132300ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1133670ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1142705ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1160906ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1172400ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1190482ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1196673ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1213666ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1225136ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1228310ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1242751ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1243874ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1256126ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1274444ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1288526ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1307058ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1320986ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1325134ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1335974ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1344832ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1354780ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1364165ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1383346ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1399019ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1400207ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1413875ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1423809ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1427502ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1430773ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1436408ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1447464ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
@@ -0,0 +1 @@
[ 207ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ https://app1.hyeonworks.com/favicon.ico:0
@@ -0,0 +1 @@
[ 236ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://app1.hyeonworks.com/bff/api/me:0
@@ -0,0 +1,2 @@
[ 7292ms] [ERROR] Access to fetch at 'https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?response_type=code&client_id=bff-confidential&scope=openid%20profile%20email&state=WWc76H7TY73Fbsdc41B2nRD5exkXqHcohLh4WdJB4AA%3D&redirect_uri=https://app1.hyeonworks.com/login/oauth2/code/keycloak&nonce=A4TXweuKS4Y5HdZ63rLJUez1ZOyI2em6zs3OIfTXLFo&code_challenge=wPr8PXG0lcUvie7Wo91YrVMhOUYq0KtEU4PxVJ0_CWA&code_challenge_method=S256' (redirected from 'https://app1.hyeonworks.com/bff/api/me') from origin 'https://app1.hyeonworks.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ https://app1.hyeonworks.com/bff/token-boundary:0
[ 7293ms] [ERROR] Failed to load resource: net::ERR_FAILED @ https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?response_type=code&client_id=bff-confidential&scope=openid%20profile%20email&state=WWc76H7TY73Fbsdc41B2nRD5exkXqHcohLh4WdJB4AA%3D&redirect_uri=https://app1.hyeonworks.com/login/oauth2/code/keycloak&nonce=A4TXweuKS4Y5HdZ63rLJUez1ZOyI2em6zs3OIfTXLFo&code_challenge=wPr8PXG0lcUvie7Wo91YrVMhOUYq0KtEU4PxVJ0_CWA&code_challenge_method=S256:0
@@ -0,0 +1,2 @@
[ 6726ms] [ERROR] Access to fetch at 'https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?response_type=code&client_id=bff-confidential&scope=openid%20profile%20email&state=GGupuPr3ZklKp8ah99r7h7mNHEq9yTsEWZr85WyXevE%3D&redirect_uri=https://app1.hyeonworks.com/login/oauth2/code/keycloak&nonce=rPVvEOvG7rzssAjR7pP67qNvoY2W6ZVpIpKYz1LGoU8&code_challenge=qoKRLRrzB7z9CU_rlaAxJ7UYcRZswyqmDi8PgxmaWM0&code_challenge_method=S256' (redirected from 'https://app1.hyeonworks.com/bff/api/me') from origin 'https://app1.hyeonworks.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ https://app1.hyeonworks.com/:0
[ 6726ms] [ERROR] Failed to load resource: net::ERR_FAILED @ https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?response_type=code&client_id=bff-confidential&scope=openid%20profile%20email&state=GGupuPr3ZklKp8ah99r7h7mNHEq9yTsEWZr85WyXevE%3D&redirect_uri=https://app1.hyeonworks.com/login/oauth2/code/keycloak&nonce=rPVvEOvG7rzssAjR7pP67qNvoY2W6ZVpIpKYz1LGoU8&code_challenge=qoKRLRrzB7z9CU_rlaAxJ7UYcRZswyqmDi8PgxmaWM0&code_challenge_method=S256:0
@@ -0,0 +1,2 @@
[ 6898ms] [ERROR] Failed to load resource: the server responded with a status of 403 () @ https://app1.hyeonworks.com/logout:0
[ 23950ms] [ERROR] Failed to load resource: the server responded with a status of 403 () @ https://app1.hyeonworks.com/logout:0
@@ -0,0 +1,56 @@
- generic [ref=f18e4]:
- link "Skip to main content" [ref=f18e5] [cursor=pointer]:
- /url: "#pageContent"
- banner [ref=f18e7]:
- generic [ref=f18e8]:
- link [ref=f18e10] [cursor=pointer]:
- /url: /
- img "Grafana" [ref=f18e11]
- generic [ref=f18e14]:
- button "Search or jump to..." [ref=f18e18] [cursor=pointer]
- generic [ref=f18e19]: ctrl+k
- generic [ref=f18e23]:
- button "New" [ref=f18e24] [cursor=pointer]
- button "Help" [ref=f18e30] [cursor=pointer]
- button "News" [ref=f18e33] [cursor=pointer]
- button "Profile" [ref=f18e36] [cursor=pointer]:
- img "User avatar" [ref=f18e37]
- generic [ref=f18e38]:
- button "Open menu" [ref=f18e40] [cursor=pointer]
- navigation "Breadcrumbs" [ref=f18e43]:
- list [ref=f18e44]:
- listitem [ref=f18e45]:
- link "Home" [ref=f18e46] [cursor=pointer]:
- /url: /
- listitem [ref=f18e50]:
- link "Explore" [ref=f18e51] [cursor=pointer]:
- /url: /explore
- listitem [ref=f18e55]:
- generic "Prometheus" [ref=f18e56]
- generic [ref=f18e57]:
- button "Show more items" [ref=f18e60] [cursor=pointer]
- button "Toggle top search bar" [ref=f18e64] [cursor=pointer]
- main [ref=f18e70]:
- generic [ref=f18e72]:
- heading "Explore" [level=1] [ref=f18e73]
- generic [ref=f18e78]:
- navigation "Explore toolbar" [ref=f18e80]:
- navigation "Search links" [ref=f18e82]:
- generic [ref=f18e83]:
- button "Content outline" [expanded] [ref=f18e85] [cursor=pointer]:
- generic [ref=f18e88]: Outline
- generic [ref=f18e93] [cursor=pointer]:
- img "Prometheus logo" [ref=f18e95]
- textbox "Select a data source" [ref=f18e96]:
- /placeholder: ""
- button "Show more items" [ref=f18e102] [cursor=pointer]
- generic [ref=f18e106]:
- button "Collapse outline" [expanded] [ref=f18e112] [cursor=pointer]:
- img "arrow-from-right" [ref=f18e113]
- generic [ref=f18e120]:
- generic [ref=f18e123]:
- button "Add query" [ref=f18e124] [cursor=pointer]
- button "Query history" [ref=f18e128] [cursor=pointer]
- button "Query inspector" [ref=f18e132] [cursor=pointer]
- generic:
- main
@@ -0,0 +1,7 @@
- main [ref=f21e2]:
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f21e3]
- paragraph [ref=f21e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
- button "Keycloak 로그인" [ref=f21e5] [cursor=pointer]
- button "token 경계 확인" [ref=f21e6] [cursor=pointer]
- button "BFF 경유 API 호출" [ref=f21e7] [cursor=pointer]
- button "CSRF token으로 상태 변경" [ref=f21e8] [cursor=pointer]
@@ -0,0 +1,16 @@
- generic [ref=f22e3]:
- banner [ref=f22e4]:
- generic [ref=f22e5]: keycloak-patterns
- main [ref=f22e6]:
- heading "Sign in to your account" [level=1] [ref=f22e8]
- generic [ref=f22e12]:
- generic [ref=f22e13]:
- generic [ref=f22e14]: Username or email
- textbox "Username or email" [active] [ref=f22e17]
- generic [ref=f22e18]:
- generic [ref=f22e19]: Password
- generic [ref=f22e21]:
- textbox "Password" [ref=f22e24]
- button "Show password" [ref=f22e26] [cursor=pointer]:
- generic [aria-hidden] [ref=f22e27]:
- button "Sign In" [ref=f22e30] [cursor=pointer]
@@ -0,0 +1,20 @@
- generic [ref=f23e3]:
- banner [ref=f23e4]:
- generic [ref=f23e5]: keycloak-patterns
- main [ref=f23e6]:
- heading "Update Account Information" [level=1] [ref=f23e8]
- generic [ref=f23e9]:
- generic [ref=f23e10]: "* Required fields"
- generic [ref=f23e13]:
- generic [ref=f23e14]:
- generic [ref=f23e15]: Email *
- textbox "Email" [ref=f23e19]: labuser@example.com
- generic [ref=f23e20]:
- generic [ref=f23e21]: First name *
- textbox "First name" [invalid] [ref=f23e25]
- generic [ref=f23e26]: Please specify this field.
- generic [ref=f23e31]:
- generic [ref=f23e32]: Last name *
- textbox "Last name" [invalid] [ref=f23e36]
- generic [ref=f23e37]: Please specify this field.
- button "Submit" [ref=f23e44]
@@ -0,0 +1,20 @@
- generic [ref=f23e3]:
- banner [ref=f23e4]:
- generic [ref=f23e5]: keycloak-patterns
- main [ref=f23e6]:
- heading "Update Account Information" [level=1] [ref=f23e8]
- generic [ref=f23e9]:
- generic [ref=f23e10]: "* Required fields"
- generic [ref=f23e13]:
- generic [ref=f23e14]:
- generic [ref=f23e15]: Email *
- textbox "Email" [ref=f23e19]: labuser@example.com
- generic [ref=f23e20]:
- generic [ref=f23e21]: First name *
- textbox "First name" [invalid] [ref=f23e25]: Lab
- generic [ref=f23e26]: Please specify this field.
- generic [ref=f23e31]:
- generic [ref=f23e32]: Last name *
- textbox "Last name" [active] [invalid] [ref=f23e36]: User
- generic [ref=f23e37]: Please specify this field.
- button "Submit" [ref=f23e44]
@@ -0,0 +1,7 @@
- main [ref=f24e2]:
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f24e3]
- paragraph [ref=f24e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
- button "Keycloak 로그인" [ref=f24e5] [cursor=pointer]
- button "token 경계 확인" [ref=f24e6] [cursor=pointer]
- button "BFF 경유 API 호출" [ref=f24e7] [cursor=pointer]
- button "CSRF token으로 상태 변경" [ref=f24e8] [cursor=pointer]
@@ -0,0 +1,16 @@
- generic [ref=f25e3]:
- banner [ref=f25e4]:
- generic [ref=f25e5]: keycloak-patterns
- main [ref=f25e6]:
- heading "Sign in to your account" [level=1] [ref=f25e8]
- generic [ref=f25e12]:
- generic [ref=f25e13]:
- generic [ref=f25e14]: Username or email
- textbox "Username or email" [active] [ref=f25e17]
- generic [ref=f25e18]:
- generic [ref=f25e19]: Password
- generic [ref=f25e21]:
- textbox "Password" [ref=f25e24]
- button "Show password" [ref=f25e26] [cursor=pointer]:
- generic [aria-hidden] [ref=f25e27]:
- button "Sign In" [ref=f25e30] [cursor=pointer]
@@ -0,0 +1,9 @@
- generic [ref=f26e2]:
- heading "Login with OAuth 2.0" [level=2] [ref=f26e3]
- alert [ref=f26e4]: Invalid credentials
- table [ref=f26e5]:
- rowgroup [ref=f26e6]:
- row [ref=f26e7]:
- cell [ref=f26e8]:
- link "keycloak" [ref=f26e9] [cursor=pointer]:
- /url: /oauth2/authorization/keycloak
@@ -0,0 +1,7 @@
- main [ref=f27e2]:
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f27e3]
- paragraph [ref=f27e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
- button "Keycloak 로그인" [ref=f27e5] [cursor=pointer]
- button "token 경계 확인" [ref=f27e6] [cursor=pointer]
- button "BFF 경유 API 호출" [ref=f27e7] [cursor=pointer]
- button "CSRF token으로 상태 변경" [ref=f27e8] [cursor=pointer]
@@ -0,0 +1,7 @@
- main [ref=f27e2]:
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f27e3]
- paragraph [ref=f27e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
- button "Keycloak 로그인" [ref=f27e5] [cursor=pointer]
- button "token 경계 확인" [ref=f27e6] [cursor=pointer]
- button "BFF 경유 API 호출" [ref=f27e7] [cursor=pointer]
- button "CSRF token으로 상태 변경" [ref=f27e8] [cursor=pointer]
@@ -0,0 +1,7 @@
- main [ref=f27e2]:
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f27e3]
- paragraph [ref=f27e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
- button "Keycloak 로그인" [ref=f27e5] [cursor=pointer]
- button "token 경계 확인" [ref=f27e6] [cursor=pointer]
- button "BFF 경유 API 호출" [ref=f27e7] [cursor=pointer]
- button "CSRF token으로 상태 변경" [ref=f27e8] [cursor=pointer]
@@ -0,0 +1 @@
- generic [active] [ref=f28e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":true,\"refreshTokenStoredOnServer\":true,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
@@ -0,0 +1,7 @@
- main [ref=f29e2]:
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f29e3]
- paragraph [ref=f29e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
- button "Keycloak 로그인" [ref=f29e5] [cursor=pointer]
- button "token 경계 확인" [ref=f29e6] [cursor=pointer]
- button "BFF 경유 API 호출" [ref=f29e7] [cursor=pointer]
- button "CSRF token으로 상태 변경" [ref=f29e8] [cursor=pointer]
@@ -0,0 +1 @@
- generic [active] [ref=f30e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":false,\"refreshTokenStoredOnServer\":false,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
@@ -0,0 +1,5 @@
- generic [active] [ref=f31e1]:
- heading "Whitelabel Error Page" [level=1] [ref=f31e2]
- paragraph [ref=f31e3]: This application has no explicit mapping for /error, so you are seeing this as a fallback.
- generic [ref=f31e4]: Fri Sep 04 05:00:51 GMT 2026
- generic [ref=f31e5]: There was an unexpected error (type=Internal Server Error, status=500).
@@ -0,0 +1 @@
- generic [active] [ref=f32e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":false,\"refreshTokenStoredOnServer\":false,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
@@ -0,0 +1,7 @@
- main [ref=f33e2]:
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f33e3]
- paragraph [ref=f33e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
- button "Keycloak 로그인" [ref=f33e5] [cursor=pointer]
- button "token 경계 확인" [ref=f33e6] [cursor=pointer]
- button "BFF 경유 API 호출" [ref=f33e7] [cursor=pointer]
- button "CSRF token으로 상태 변경" [ref=f33e8] [cursor=pointer]
@@ -0,0 +1 @@
- generic [active] [ref=f34e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":false,\"refreshTokenStoredOnServer\":false,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
@@ -0,0 +1 @@
- generic [active] [ref=f35e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":false,\"refreshTokenStoredOnServer\":false,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
@@ -0,0 +1,7 @@
- main [ref=f36e2]:
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f36e3]
- paragraph [ref=f36e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
- button "Keycloak 로그인" [ref=f36e5] [cursor=pointer]
- button "token 경계 확인" [ref=f36e6] [cursor=pointer]
- button "BFF 경유 API 호출" [ref=f36e7] [cursor=pointer]
- button "CSRF token으로 상태 변경" [ref=f36e8] [cursor=pointer]
@@ -0,0 +1 @@
- generic [active] [ref=f37e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":true,\"refreshTokenStoredOnServer\":true,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
@@ -0,0 +1,7 @@
- main [ref=f38e2]:
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f38e3]
- paragraph [ref=f38e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
- button "Keycloak 로그인" [ref=f38e5] [cursor=pointer]
- button "token 경계 확인" [ref=f38e6] [cursor=pointer]
- button "BFF 경유 API 호출" [ref=f38e7] [cursor=pointer]
- button "CSRF token으로 상태 변경" [ref=f38e8] [cursor=pointer]
@@ -0,0 +1,7 @@
- main [ref=f39e2]:
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f39e3]
- paragraph [ref=f39e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
- button "Keycloak 로그인" [ref=f39e5] [cursor=pointer]
- button "token 경계 확인" [ref=f39e6] [cursor=pointer]
- button "BFF 경유 API 호출" [ref=f39e7] [cursor=pointer]
- button "CSRF token으로 상태 변경" [ref=f39e8] [cursor=pointer]
+1
View File
@@ -0,0 +1 @@
target/
+14
View File
@@ -0,0 +1,14 @@
FROM maven:3.9.11-eclipse-temurin-21-alpine AS build
WORKDIR /workspace
COPY pom.xml .
RUN mvn --batch-mode dependency:go-offline
COPY src src
RUN mvn --batch-mode verify
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S spring && adduser -S spring -G spring
WORKDIR /app
COPY --from=build /workspace/target/keycloak-bff.jar app.jar
USER spring:spring
EXPOSE 8083
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
+90
View File
@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>keycloak-bff</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>keycloak-bff</name>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<!-- B-1: Application Session 을 Redis 로 옮긴다.
spring-session-data-redis 가 SessionRepository 를 갈아끼우고,
spring-boot-starter-data-redis 가 연결(Lettuce)을 제공한다.
둘 다 있어야 자동구성이 걸린다 — 하나만 넣으면 조용히 in-memory 로 남는다. -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- B-2: OAuth2AuthorizedClient 를 PostgreSQL 로 옮긴다.
Q3 가 후보로 든 "Redis 와 JDBC 중 무엇" 에서 JDBC 쪽이며,
JdbcOAuth2AuthorizedClientService 는 같은 인터페이스라
컨트롤러를 바꾸지 않아도 된다. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>keycloak-bff</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,12 @@
package com.example.keycloakpattern.bff;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BffApplication {
public static void main(String[] args) {
SpringApplication.run(BffApplication.class, args);
}
}
@@ -0,0 +1,112 @@
package com.example.keycloakpattern.bff;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
@RestController
public class BffController {
private final OAuth2AuthorizedClientService authorizedClientService;
private final OAuth2AuthorizedClientManager authorizedClientManager;
private final RestClient resourceApi;
private final AtomicReference<String> theme = new AtomicReference<>("system");
public BffController(
OAuth2AuthorizedClientService authorizedClientService,
OAuth2AuthorizedClientManager authorizedClientManager,
RestClient.Builder restClientBuilder,
@Value("${resource-api.base-url}") String resourceApiBaseUrl
) {
this.authorizedClientService = authorizedClientService;
this.authorizedClientManager = authorizedClientManager;
this.resourceApi = restClientBuilder.baseUrl(resourceApiBaseUrl).build();
}
@GetMapping("/bff/token-boundary")
ResponseEntity<Map<String, Object>> tokenBoundary(Authentication authentication) {
OAuth2AuthorizedClient client = authorizedClientService.loadAuthorizedClient(
"keycloak",
authentication.getName()
);
Map<String, Object> response = new LinkedHashMap<>();
response.put("pattern", "AP3-backend-for-frontend");
response.put("principal", authentication.getName());
response.put("accessTokenStoredOnServer", client != null
&& client.getAccessToken() != null);
response.put("refreshTokenStoredOnServer", client != null
&& client.getRefreshToken() != null);
response.put("browserTokenCount", 0);
response.put("csrfProtectionEnabled", true);
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.header("Pragma", "no-cache")
.body(response);
}
@GetMapping("/bff/api/me")
ResponseEntity<?> currentUser(Authentication authentication) {
OAuth2AuthorizedClient client = authorizedClient(authentication);
return resourceApi.get()
.uri("/api/me")
.header(
HttpHeaders.AUTHORIZATION,
"Bearer " + client.getAccessToken().getTokenValue()
)
.retrieve()
.toEntity(Map.class);
}
@PostMapping("/bff/api/preferences")
Map<String, Object> updatePreference(
Authentication authentication,
@RequestParam(defaultValue = "system") String theme
) {
this.theme.set(theme);
return Map.of(
"updated", true,
"theme", this.theme.get(),
"principal", authentication.getName()
);
}
@GetMapping("/bff/api/preferences")
Map<String, String> preference() {
return Map.of("theme", theme.get());
}
private OAuth2AuthorizedClient authorizedClient(Authentication authentication) {
OAuth2AuthorizeRequest request = OAuth2AuthorizeRequest
.withClientRegistrationId("keycloak")
.principal(authentication)
.build();
OAuth2AuthorizedClient client = authorizedClientManager.authorize(request);
if (client == null || client.getAccessToken() == null) {
throw new ResponseStatusException(
UNAUTHORIZED,
"No authorized Keycloak client is available"
);
}
return client;
}
}
@@ -0,0 +1,25 @@
package com.example.keycloakpattern.bff;
import java.util.Map;
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class CsrfController {
@GetMapping("/bff/csrf")
ResponseEntity<Map<String, String>> csrf(CsrfToken csrfToken) {
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.header("Pragma", "no-cache")
.body(Map.of(
"headerName", csrfToken.getHeaderName(),
"parameterName", csrfToken.getParameterName(),
"token", csrfToken.getToken()
));
}
}
@@ -0,0 +1,108 @@
package com.example.keycloakpattern.bff;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.JdbcOAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.jdbc.core.JdbcOperations;
@Configuration
public class SecurityConfig {
/**
* B-2 — authorized client 를 프로세스 메모리에서 PostgreSQL 로 옮긴다.
*
* B-1 에서 Application Session 만 Redis 로 옮겼더니, 사용자는 로그인
* 상태로 보이는데 BFF 에는 access token 이 없는 상태가 만들어졌다.
* 두 상태의 저장소를 **각각** 정해야 한다는 Q3 의 지적이 그대로 나타난 것이다.
*
* 주의 — 이것이 고치는 것과 고치지 못하는 것이 다르다.
* 고친다 : 인스턴스 간 공유. 어느 replica 로 가도 같은 토큰을 본다.
* 못 고친다: 조회 키. JdbcOAuth2AuthorizedClientService 도
* (clientRegistrationId, principalName) 으로 찾으므로
* 같은 사용자의 두 브라우저는 여전히 한 항목을 공유한다.
*/
@Bean
OAuth2AuthorizedClientService authorizedClientService(
JdbcOperations jdbcOperations,
ClientRegistrationRepository clientRegistrationRepository
) {
return new JdbcOAuth2AuthorizedClientService(jdbcOperations, clientRegistrationRepository);
}
@Bean
SecurityFilterChain bffSecurity(
HttpSecurity http,
ClientRegistrationRepository clientRegistrationRepository
) throws Exception {
DefaultOAuth2AuthorizationRequestResolver authorizationRequestResolver =
new DefaultOAuth2AuthorizationRequestResolver(
clientRegistrationRepository,
"/oauth2/authorization"
);
authorizationRequestResolver.setAuthorizationRequestCustomizer(
OAuth2AuthorizationRequestCustomizers.withPkce()
);
CookieCsrfTokenRepository csrfTokenRepository =
CookieCsrfTokenRepository.withHttpOnlyFalse();
csrfTokenRepository.setCookiePath("/");
return http
.csrf(csrf -> csrf
.csrfTokenRepository(csrfTokenRepository)
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(
"/",
"/index.html",
"/app.js",
"/favicon.ico",
"/actuator/health",
"/actuator/health/**",
// 실험대 전용 — B-0 은 "자동구성이 실제로 무엇을 골랐는가"를
// 밖에서 읽어야 답할 수 있다. 운영에서는 절대 열지 않는다:
// /actuator/beans 와 /actuator/env 는 내부 구조와 설정값을
// 그대로 드러낸다.
"/actuator/**"
)
.permitAll()
.anyRequest()
.authenticated())
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(endpoint -> endpoint
.authorizationRequestResolver(authorizationRequestResolver))
.defaultSuccessUrl("/", true))
.build();
}
@Bean
OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientService authorizedClientService
) {
OAuth2AuthorizedClientProvider authorizedClientProvider =
OAuth2AuthorizedClientProviderBuilder.builder()
.authorizationCode()
.refreshToken()
.build();
AuthorizedClientServiceOAuth2AuthorizedClientManager manager =
new AuthorizedClientServiceOAuth2AuthorizedClientManager(
clientRegistrationRepository,
authorizedClientService
);
manager.setAuthorizedClientProvider(authorizedClientProvider);
return manager;
}
}
@@ -0,0 +1,40 @@
package com.example.keycloakpattern.bff;
import java.util.function.Supplier;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.csrf.CsrfTokenRequestHandler;
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler;
import org.springframework.util.StringUtils;
final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler {
private final CsrfTokenRequestHandler plain =
new CsrfTokenRequestAttributeHandler();
private final CsrfTokenRequestHandler xor =
new XorCsrfTokenRequestAttributeHandler();
@Override
public void handle(
HttpServletRequest request,
HttpServletResponse response,
Supplier<CsrfToken> deferredCsrfToken
) {
xor.handle(request, response, deferredCsrfToken);
}
@Override
public String resolveCsrfTokenValue(
HttpServletRequest request,
CsrfToken csrfToken
) {
if (StringUtils.hasText(request.getHeader(csrfToken.getHeaderName()))) {
return plain.resolveCsrfTokenValue(request, csrfToken);
}
return xor.resolveCsrfTokenValue(request, csrfToken);
}
}
+80
View File
@@ -0,0 +1,80 @@
server:
port: ${SERVER_PORT:8083}
servlet:
session:
cookie:
name: AP3_SESSION
http-only: true
same-site: lax
spring:
application:
name: keycloak-bff
datasource:
# B-2: authorized client 전용. Keycloak 과 같은 PostgreSQL 인스턴스지만
# 테이블이 다르다(oauth2_authorized_client). 운영이라면 분리를 검토한다.
url: ${BFF_DB_URL:jdbc:postgresql://localhost:5432/keycloak}
username: ${BFF_DB_USER:keycloak}
password: ${BFF_DB_PASSWORD:keycloak}
sql:
init:
# Spring Security 가 제공하는 DDL 을 그대로 쓴다.
# always 로 두면 매 기동마다 실행되므로 CREATE TABLE IF NOT EXISTS 가 아닌
# 스크립트에서는 실패한다 → continue-on-error 로 넘긴다.
mode: ${SPRING_SQL_INIT_MODE:always}
# ★ PostgreSQL 은 -postgres 판본을 써야 한다. 기본 판본은 `blob` 타입을
# 쓰는데 PostgreSQL 에는 그 타입이 없다(`bytea` 다). continue-on-error 가
# 그 실패를 삼켜서 "테이블이 조용히 안 생기는" 상태가 됐었다.
schema-locations: classpath:org/springframework/security/oauth2/client/oauth2-client-schema-postgres.sql
continue-on-error: true
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
session:
# Application Session 만 Redis 로 간다. OAuth2AuthorizedClient 는
# 이 설정과 무관하며 여전히 InMemory 다 — 조회 키가 다르기 때문이다(B-0).
store-type: ${SPRING_SESSION_STORE_TYPE:redis}
timeout: ${SPRING_SESSION_TIMEOUT:30m}
redis:
namespace: bff:session
security:
oauth2:
client:
registration:
keycloak:
provider: keycloak
client-id: bff-confidential
client-secret: ${KEYCLOAK_CLIENT_SECRET}
client-authentication-method: client_secret_basic
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope:
- openid
- profile
- email
provider:
keycloak:
# 브라우저가 리다이렉트되는 주소와 BFF 가 서버끼리 부르는 주소는 다르다.
# 앞의 것은 외부에서 닿는 이름이어야 하고, 뒤의 것은 클러스터 안 주소여도 된다.
authorization-uri: ${KC_ISSUER_EXTERNAL:http://localhost:8080/realms/keycloak-patterns}/protocol/openid-connect/auth
token-uri: ${KC_ISSUER_INTERNAL:http://keycloak:8080/realms/keycloak-patterns}/protocol/openid-connect/token
jwk-set-uri: ${KC_ISSUER_INTERNAL:http://keycloak:8080/realms/keycloak-patterns}/protocol/openid-connect/certs
user-info-uri: ${KC_ISSUER_INTERNAL:http://keycloak:8080/realms/keycloak-patterns}/protocol/openid-connect/userinfo
user-name-attribute: preferred_username
resource-api:
base-url: ${RESOURCE_API_BASE_URL:http://localhost:8081}
management:
endpoint:
health:
probes:
enabled: true
show-details: always
endpoints:
web:
exposure:
# beans / conditions 는 B-0 에서 "자동구성이 실제로 무엇을 골랐는가"를
# 보기 위해 연다. 운영에 그대로 두면 내부 구조가 노출된다.
include: health,info,beans,conditions,env
+59
View File
@@ -0,0 +1,59 @@
const result = document.querySelector("#result");
function render(value) {
result.textContent = JSON.stringify(value, null, 2);
}
function readCookie(name) {
const prefix = `${encodeURIComponent(name)}=`;
const value = document.cookie
.split("; ")
.find((cookie) => cookie.startsWith(prefix));
return value ? decodeURIComponent(value.slice(prefix.length)) : null;
}
async function request(path, options = {}) {
const response = await fetch(path, {
...options,
headers: { Accept: "application/json", ...options.headers },
});
if (response.redirected || response.status === 401) {
window.location.assign("/oauth2/authorization/keycloak");
return null;
}
const body = await response.json();
render({ status: response.status, ...body });
return { response, body };
}
document.querySelector("#login").addEventListener("click", () => {
window.location.assign("/oauth2/authorization/keycloak");
});
document.querySelector("#inspect").addEventListener("click", () => {
void request("/bff/token-boundary");
});
document.querySelector("#call-bff").addEventListener("click", () => {
void request("/bff/api/me");
});
document.querySelector("#change-with-csrf").addEventListener("click", async () => {
const csrfResponse = await fetch("/bff/csrf", {
headers: { Accept: "application/json" },
});
const csrf = await csrfResponse.json();
const csrfToken = readCookie("XSRF-TOKEN");
if (!csrfToken) {
render({ status: 500, error: "XSRF-TOKEN cookie was not created" });
return;
}
await request("/bff/api/preferences", {
method: "POST",
body: new URLSearchParams({ theme: "dark" }),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
[csrf.headerName]: csrfToken,
},
});
});
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AP3 · Backend-for-Frontend</title>
<style>
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
body { max-width: 58rem; margin: 6vh auto; padding: 0 1.5rem; line-height: 1.6; }
button { margin: 0 0.5rem 0.5rem 0; padding: 0.6rem 0.9rem; cursor: pointer; }
pre { min-height: 9rem; padding: 1rem; border-radius: 0.4rem;
background: color-mix(in srgb, CanvasText 9%, Canvas); white-space: pre-wrap; }
</style>
</head>
<body>
<main>
<h1>AP3 · Backend-for-Frontend</h1>
<p>
브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session
cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource
Server 요청에 붙입니다.
</p>
<button id="login" type="button">Keycloak 로그인</button>
<button id="inspect" type="button">token 경계 확인</button>
<button id="call-bff" type="button">BFF 경유 API 호출</button>
<button id="change-with-csrf" type="button">CSRF token으로 상태 변경</button>
<pre id="result" aria-live="polite"></pre>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>
@@ -0,0 +1,99 @@
package com.example.keycloakpattern.bff;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(properties = {
"KEYCLOAK_CLIENT_SECRET=test-only-secret",
// 테스트는 Redis 를 띄우지 않는다. store-type=none 이면 자동구성이
// 서블릿 컨테이너 기본 세션으로 되돌아가 컨텍스트가 뜬다.
"spring.session.store-type=none",
// 테스트에는 PostgreSQL 이 없다. H2 로 대신하고 Spring Security 의
// DDL 을 그대로 태워 JdbcOAuth2AuthorizedClientService 가 뜨게 한다.
"spring.datasource.url=jdbc:h2:mem:bfftest;DB_CLOSE_DELAY=-1",
"spring.datasource.username=sa",
"spring.datasource.password=",
"spring.sql.init.mode=always",
"resource-api.base-url=http://127.0.0.1:9"
})
@AutoConfigureMockMvc
class BffControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private OAuth2AuthorizedClientService authorizedClientService;
@MockitoBean
private OAuth2AuthorizedClientManager authorizedClientManager;
@Test
void reportsServerTokenCustodyWithoutReturningTokens() throws Exception {
OAuth2AuthorizedClient client = mock(OAuth2AuthorizedClient.class);
when(client.getAccessToken()).thenReturn(mock(OAuth2AccessToken.class));
when(client.getRefreshToken()).thenReturn(mock(OAuth2RefreshToken.class));
when(authorizedClientService.loadAuthorizedClient("keycloak", "test-subject"))
.thenReturn(client);
mockMvc.perform(get("/bff/token-boundary").with(oidcLogin()
.idToken(token -> token.subject("test-subject"))))
.andExpect(status().isOk())
.andExpect(header().string("Cache-Control", "no-store"))
.andExpect(jsonPath("$.accessTokenStoredOnServer").value(true))
.andExpect(jsonPath("$.refreshTokenStoredOnServer").value(true))
.andExpect(jsonPath("$.browserTokenCount").value(0))
.andExpect(jsonPath("$.csrfProtectionEnabled").value(true))
.andExpect(jsonPath("$.access_token").doesNotExist())
.andExpect(jsonPath("$.refresh_token").doesNotExist());
}
@Test
void rejectsStateChangeWithoutCsrfToken() throws Exception {
mockMvc.perform(post("/bff/api/preferences")
.param("theme", "attacker")
.with(oidcLogin().idToken(token -> token.subject("test-subject"))))
.andExpect(status().isForbidden());
}
@Test
void acceptsStateChangeWithCsrfToken() throws Exception {
mockMvc.perform(post("/bff/api/preferences")
.param("theme", "dark")
.with(oidcLogin().idToken(token -> token.subject("test-subject")))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.updated").value(true))
.andExpect(jsonPath("$.theme").value("dark"));
}
@Test
void exposesSpaCsrfTokenWithoutCaching() throws Exception {
mockMvc.perform(get("/bff/csrf").with(oidcLogin()
.idToken(token -> token.subject("test-subject"))))
.andExpect(status().isOk())
.andExpect(header().string("Cache-Control", "no-store"))
.andExpect(header().exists("Set-Cookie"))
.andExpect(jsonPath("$.headerName").value("X-XSRF-TOKEN"))
.andExpect(jsonPath("$.token").isNotEmpty());
}
}
+190
View File
@@ -0,0 +1,190 @@
# BFF (2 replicas) + Redis, for the B-layer experiments.
#
# The BFF is deployed FIRST WITHOUT any session store wiring. That is deliberate:
# B-0 asks what Spring Boot's autoconfiguration actually picks when nothing is
# configured, and the only honest way to answer is to look at a running instance
# that has been given nothing. Redis is deployed alongside but left unused until
# B-1 turns it on.
#
# kubectl apply -f deploy/lab/k8s/bff-redis.yaml
#
# Image comes from the workstation, not a registry:
# docker build -t keycloak-pattern-bff:lab bff/
# docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
# (repeat for kc-lab-2)
# so imagePullPolicy must stay Never on both replicas.
apiVersion: v1
kind: Secret
metadata:
name: bff-secrets
namespace: keycloak-lab
type: Opaque
stringData:
# Matches the client created with kcadm in the keycloak-patterns realm.
# Base64 in etcd is not encryption — see D-3.
KEYCLOAK_CLIENT_SECRET: bff-lab-secret
---
# Redis. No persistence yet: `--save ""` and no appendonly, so a restart loses
# everything. B-5 and B-6 compare that against RDB and AOF, which is easier to
# reason about when the starting point is "nothing survives".
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: keycloak-lab
spec:
replicas: 1
selector:
matchLabels: { app: redis }
template:
metadata:
labels: { app: redis }
spec:
# Same node as postgres so a node-loss experiment takes both stores at
# once, matching how A-4 was set up.
nodeSelector:
kubernetes.io/hostname: kc-lab-2
containers:
- name: redis
image: redis:7.4-alpine
args: ["redis-server", "--save", "", "--appendonly", "no"]
ports:
- containerPort: 6379
name: redis
readinessProbe:
exec: { command: ["redis-cli", "ping"] }
initialDelaySeconds: 3
resources:
requests: { memory: 32Mi, cpu: 20m }
limits: { memory: 128Mi }
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: keycloak-lab
spec:
selector: { app: redis }
ports:
- port: 6379
targetPort: redis
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: bff
namespace: keycloak-lab
spec:
# Two replicas is the whole point: Q1 and Q2 only exist because a request can
# land on an instance that did not handle the login.
replicas: 2
selector:
matchLabels: { app: bff }
template:
metadata:
labels: { app: bff }
spec:
# Spread across both nodes so "the other instance" is genuinely another
# machine, not another process on the same kernel.
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels: { app: bff }
# 쿠버네티스는 같은 네임스페이스의 Service 마다 Docker link 시절의
# 환경변수를 자동 주입한다: REDIS_PORT=tcp://10.43.57.116:6379.
# 그것이 application.yml 의 ${REDIS_PORT:6379} 를 덮어써서 기동이 실패했다.
# Failed to bind properties under 'spring.data.redis.port' to int:
# Value: "tcp://10.43.57.116:6379"
# 이 주입 자체를 끄는 것이 근본 처방이다. 이름을 바꿔 피하면 다음 사람이
# 같은 함정에 다시 빠진다.
enableServiceLinks: false
containers:
- name: bff
image: keycloak-pattern-bff:lab
imagePullPolicy: Never
ports:
- containerPort: 8083
name: http
env:
# The browser is redirected to the public name; the BFF calls the
# token endpoint over the cluster network. Getting these two the same
# way round is what the 2-hop header experiment was about.
- name: KC_ISSUER_EXTERNAL
value: https://auth.hyeonworks.com/realms/keycloak-patterns
- name: KC_ISSUER_INTERNAL
value: http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns
# echo 는 header-lab 네임스페이스의 8081 이다. 다른 네임스페이스의
# 서비스는 <svc>.<ns>.svc 로 부른다. 이름을 틀리면 500 이 나는데
# 원인은 UnresolvedAddressException 이지 토큰 문제가 아니다.
- name: RESOURCE_API_BASE_URL
value: http://echo.header-lab.svc:8081
- name: KEYCLOAK_CLIENT_SECRET
valueFrom:
secretKeyRef: { name: bff-secrets, key: KEYCLOAK_CLIENT_SECRET }
# Spring needs to know it is behind TLS termination, for the same
# reason Keycloak needs KC_PROXY_HEADERS. Without it the redirect_uri
# it builds comes back as http:// and Keycloak rejects it.
- name: SERVER_FORWARD_HEADERS_STRATEGY
value: native
# B-1: Application Session 을 Redis 로 옮긴다.
# OAuth2AuthorizedClient 는 이것으로 옮겨지지 않는다 — 조회 키가
# 다르기 때문이며, B-0 에서 확인한 사실이다.
- name: SPRING_SESSION_STORE_TYPE
value: redis
- name: REDIS_HOST
value: redis.keycloak-lab.svc
- name: REDIS_PORT
value: "6379"
# B-2: authorized client 는 PostgreSQL 로. 세션(Redis)과 다른
# 저장소를 쓰는 것이 Q3 가 말한 "각각 설계한다"의 실물이다.
- name: BFF_DB_URL
value: jdbc:postgresql://postgres.keycloak-lab.svc:5432/keycloak
- name: BFF_DB_USER
value: keycloak
- name: BFF_DB_PASSWORD
valueFrom:
secretKeyRef: { name: keycloak-lab-secrets, key: POSTGRES_PASSWORD }
- name: JAVA_TOOL_OPTIONS
value: "-Xms128m -Xmx320m"
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: http }
initialDelaySeconds: 20
failureThreshold: 30
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: http }
initialDelaySeconds: 60
resources:
requests: { memory: 320Mi, cpu: 100m }
limits: { memory: 512Mi }
---
apiVersion: v1
kind: Service
metadata:
name: bff
namespace: keycloak-lab
spec:
selector: { app: bff }
ports:
- port: 8083
targetPort: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bff
namespace: keycloak-lab
spec:
ingressClassName: traefik
rules:
- host: app1.hyeonworks.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bff
port:
number: 8083
@@ -0,0 +1,18 @@
=== 비교를 위해 세션을 비운다 ===
DELETE 151
=== volatile 모드로 전환 ===
namespace/keycloak-lab unchanged
secret/keycloak-lab-secrets configured
persistentvolumeclaim/postgres-data unchanged
deployment.apps/postgres unchanged
service/postgres unchanged
statefulset.apps/keycloak configured
service/keycloak-headless unchanged
service/keycloak unchanged
ingress.networking.k8s.io/keycloak unchanged
Waiting for 1 pods to be ready...
partitioned roll out complete: 2 new pods have been updated...
=== [검증] 정말 꺼졌는가 ===
["start","--features-disabled=persistent-user-sessions"]
@@ -0,0 +1,15 @@
keycloak-0=10.42.1.94 keycloak-1=10.42.0.45
=== [A-0 재실행] keycloak-0 에만 로그인 5회 → 캐시가 어디에 담기는가 ===
로그인완료
keycloak-0 sessions 캐시 5.0 건
keycloak-1 sessions 캐시 0.0 건
=== DB 에는 들어갔는가 (persistent 였을 때는 5건이 들어갔다) ===
offline_flag | count
--------------+-------
(0 rows)
=== 교차 노드 세션은 되는가 ===
keycloak-0 로그인 → keycloak-1 에서 refresh HTTP 200
@@ -0,0 +1,13 @@
=== [A-8 재실행] 재시작 전 로그인 ===
sid = aVwYnzKZFFvMqD3bpSeiILuM
=== 롤링 재시작 ===
statefulset.apps/keycloak restarted
partitioned roll out complete: 2 new pods have been updated...
=== ★ 재시작 전 토큰이 아직 통하는가 (persistent 였을 때는 200) ===
keycloak-0 에서 refresh HTTP 400
--- 오류 본문 ---
{"error":"invalid_grant","error_description":"Session not active"}
=== 캐시 상태 ===
keycloak-1 sessions 캐시 1.0 건
@@ -0,0 +1,21 @@
=== [A-1 재실행] volatile 에서 7800 을 막으면 ===
keycloak-0=10.42.1.99 keycloak-1=10.42.0.46
[대조군] 차단 전 교차 노드 refresh
keycloak-1 에서 refresh HTTP 200
차단 적용 (A-5 에서 확인한 raw 테이블 방식, 양방향)
분단이 성립할 때까지 대기...
+25초 cluster_size(k0 k1) = [2.0 2.0 ]
+50초 cluster_size(k0 k1) = [1.0 ]
+75초 cluster_size(k0 k1) = [1.0 ]
+100초 cluster_size(k0 k1) = []
+125초 cluster_size(k0 k1) = [1.0 ]
+150초 cluster_size(k0 k1) = [1.0 ]
+175초 cluster_size(k0 k1) = [1.0 ]
+200초 cluster_size(k0 k1) = []
=== ★ 분단 상태에서 교차 노드 세션 (persistent 였을 때는 200) ===
keycloak-0 로그인 → keycloak-0 에서 refresh HTTP 200 ← 대조군
keycloak-0 로그인 → keycloak-1 에서 refresh HTTP 400 ← 시험군
--- 시험군 오류 본문 ---
{"error":"invalid_grant","error_description":"Session not active"}
@@ -0,0 +1,12 @@
차단 해제, 클러스터 재형성 대기...
=== [A-2 재실행] volatile 에서 DB 를 내리면 — 세션이 메모리에 있으니 살아남는가? ===
DB 정지 전 로그인 완료
deployment.apps/postgres scaled
postgres 정지
① 캐시를 가진 노드에서 refresh HTTP 500
② 새 로그인 HTTP 200
=== DB 복구 후 원복 ===
deployment.apps/postgres scaled
deployment "postgres" successfully rolled out
@@ -0,0 +1,12 @@
=== persistent 모드로 원복 ===
statefulset.apps/keycloak configured
partitioned roll out complete: 2 new pods have been updated...
=== [검증] persistent 로 돌아왔는가 — 로그인 후 DB 에 행이 생기는가 ===
["start"]
로그인
DB 온라인 세션: 1 건 (1 이면 persistent 복귀)
keycloak-0 1/1 Running 0 67s
keycloak-1 1/1 Running 0 89s
postgres-7b474b88c8-t6rrf 1/1 Running 0 2m8s
외부 진입점 HTTP 200
@@ -0,0 +1,23 @@
# A-7 — volatile 모드 비교 증거
2026-09-04 13:4514:15 KST
해설: [`docs/experiment-a7-volatile-comparison.md`](../../experiment-a7-volatile-comparison.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-switch-to-volatile.txt` | `--features-disabled=persistent-user-sessions` 적용, args 확인 |
| `02-a0-rerun.txt` | **DB 0건**인데 교차 노드 refresh `200` — 경로가 DB 에서 클러스터로 바뀌었다 |
| `03-a8-rerun-restart.txt` | **롤링 재시작 후 `400 Session not active`** — persistent 에서는 `200` 이었다 |
| `04-a1-rerun-partition.txt` | **7800 차단 시 교차 노드 `400`** — persistent 에서는 `200`. 대조군(같은 노드)은 `200` 유지 |
| `05-a2-rerun-db-loss.txt` | DB 정지 중 **새 로그인 `200`**(persistent 에서는 500), refresh 는 `500` |
| `06-restore-persistent.txt` | 원복 확인 — `args: ["start"]`, 로그인 후 DB 1건, 외부 200 |
## 뒤집힌 결과
| 실험 | persistent | volatile |
|---|---|---|
| A-1 7800 차단 후 교차 refresh | `200` | **`400`** |
| A-8 롤링 재시작 후 refresh | `200` | **`400`** |
| A-2 DB 정지 중 새 로그인 | `500` | **`200`** |
**같은 주입, 같은 관측, 정반대 결과.** A층 전체가 버전 조건부임을 보여주는 대조군이다.
@@ -0,0 +1,17 @@
=== [1] 재시작 전 로그인 — 토큰을 파드 안에 보관 ===
sid = XLcgQWRiJrTkuNZcJsNeT_2j
DB 세션 수: 151
=== [2] 롤링 재시작 중 가용성 — 5초 간격으로 외부 진입점 확인 ===
statefulset.apps/keycloak restarted
200 Waiting for partitioned roll out to finish: 0 out of 2 new pods have been updated...
Waiting for 1 pods to be ready...
Waiting for 1 pods to be ready...
Waiting for 1 pods to be ready...
200 200 200 200 Waiting for partitioned roll out to finish: 1 out of 2 new pods have been updated...
Waiting for 1 pods to be ready...
Waiting for 1 pods to be ready...
Waiting for 1 pods to be ready...
200 200 200 200 partitioned roll out complete: 2 new pods have been updated...
(위 숫자열이 재시작 중 외부 응답 코드의 시계열)
@@ -0,0 +1,19 @@
=== [3] 재시작 전 발급한 refresh token 이 아직 통하는가 ===
대상 sid: XLcgQWRiJrTkuNZcJsNeT_2j
keycloak-0 에서 refresh HTTP 200
=== [4] DB 에 그 세션이 남아 있는가 ===
user_session_id | created_on | last_session_refresh
--------------------------+------------+----------------------
XLcgQWRiJrTkuNZcJsNeT_2j | 1788495513 | 1788495577
(1 row)
전체 온라인 세션: 151 (재시작 전 151)
=== [5] 캐시는 어떻게 되었는가 ===
keycloak-0 sessions 캐시 0.0 건 / cluster_size 2.0
keycloak-1 sessions 캐시 1.0 건 / cluster_size 2.0
=== [6] 파드 나이 — 정말 재시작되었나 ===
keycloak-0 1/1 Running 0 44s
keycloak-1 1/1 Running 0 66s
@@ -0,0 +1,16 @@
# A-8 — 롤링 재시작 증거
2026-09-04 13:3813:41 KST
해설: [`docs/experiment-a8-rolling-restart.md`](../../experiment-a8-rolling-restart.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-restart-availability.txt` | 재시작 전 로그인(sid 기록), 롤링 재시작 중 외부 진입점 **9회 모두 `200`** |
| `02-session-survival.txt` | 재시작 전 토큰으로 refresh **`200`**, DB 행 생존(`last_session_refresh` 갱신 확인), **세션 수 151 → 151**, 캐시 0으로 초기화, 파드 나이 44초/66초 |
| `a8-cache-reset-cluster-reformed.png` | Grafana — 세션 캐시가 0 으로 떨어지고 `cluster_size` 가 다시 2 가 되는 구간 |
## 핵심 세 줄
1. **무중단이었다.** 한 번에 하나씩 내리고 readiness 가 전환을 맞춰준다 — replica ≥ 2 가 전제.
2. **세션은 살아남고 캐시만 사라진다.** DB 151건 그대로, 재시작 전 토큰이 그대로 통한다.
3. **이것이 `persistent-user-sessions` 를 켜는 진짜 이유다.** A-7(volatile)에서 정반대가 나와야 한다.
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

@@ -0,0 +1,21 @@
=== 배포 전 자원 ===
Mem: 11648 7329 280 4 4377 4319
NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%)
kc-lab-1 115m 5% 2192Mi 44%
kc-lab-2 121m 6% 1324Mi 33%
=== 배포 ===
secret/bff-secrets created
deployment.apps/redis created
service/redis created
deployment.apps/bff created
service/bff created
ingress.networking.k8s.io/bff created
deployment "redis" successfully rolled out
Waiting for deployment "bff" rollout to finish: 1 of 2 updated replicas are available...
deployment "bff" successfully rolled out
bff-574c6d658b-8cz4x true kc-lab-1
bff-574c6d658b-zpkbp true kc-lab-2
redis-568bd7c4-5c5vc true kc-lab-2
@@ -0,0 +1,13 @@
=== B-0: 자동구성이 실제로 고른 구현체 ===
Q1 확인한 사실: "코드에 저장소를 직접 생성하는 Bean 이 없기 때문에,
어떤 구현체가 실제로 사용되는지는 자동구성 결과까지 확인해야 정확하게 알 수 있다"
File "<stdin>", line 9
print(f" {name:46} {t.rsplit(\".\",1)[-1]}")
^
SyntaxError: unexpected character after line continuation character
=== HttpSession 은 어디에 있는가 (서블릿 컨테이너 기본) ===
=== 외부 진입점 ===
https://app1.hyeonworks.com/ HTTP 200
@@ -0,0 +1,29 @@
=== B-0 — 자동구성이 실제로 고른 구현체 ===
컨텍스트: keycloak-bff
전체 빈 수: 321
--- 세션 · 토큰 저장소 관련 ---
authorizedClientManager -> AuthorizedClientServiceOAuth2AuthorizedClientManager
authorizedClientManagerRegistrar -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerRegistrar
authorizedClientRepository -> AuthenticatedPrincipalOAuth2AuthorizedClientRepository
authorizedClientService -> InMemoryOAuth2AuthorizedClientService
org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientConfigurations$OAuth2AuthorizedClientServiceConfiguration -> OAuth2ClientConfigurations$OAuth2AuthorizedClientServiceConfiguration
org.springframework.security.config.annotation.web.configuration.OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerConfiguration -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerConfiguration
--- OAuth2 클라이언트 관련 전체 ---
authorizedClientManager -> AuthorizedClientServiceOAuth2AuthorizedClientManager
authorizedClientManagerRegistrar -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerRegistrar
authorizedClientRepository -> AuthenticatedPrincipalOAuth2AuthorizedClientRepository
authorizedClientService -> InMemoryOAuth2AuthorizedClientService
clientRegistrationRepository -> InMemoryClientRegistrationRepository
org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientAutoConfiguration -> OAuth2ClientAutoConfiguration
org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientConfigurations$ClientRegistrationRepositoryConfiguration -> OAuth2ClientConfigurations$ClientRegistrationRepositoryConfiguration
org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientConfigurations$OAuth2AuthorizedClientServiceConfiguration -> OAuth2ClientConfigurations$OAuth2AuthorizedClientServiceConfiguration
org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientWebSecurityAutoConfiguration -> OAuth2ClientWebSecurityAutoConfiguration
org.springframework.security.config.annotation.web.configuration.OAuth2ClientConfiguration -> OAuth2ClientConfiguration
org.springframework.security.config.annotation.web.configuration.OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerConfiguration -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerConfiguration
org.springframework.security.config.annotation.web.configuration.OAuth2ClientConfiguration$OAuth2ClientWebMvcSecurityConfiguration -> OAuth2ClientConfiguration$OAuth2ClientWebMvcSecurityConfiguration
spring.security.oauth2.client-org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties -> OAuth2ClientProperties
--- Redis / Spring Session 이 구성되었는가 ---
★ 없음 — Redis 도 Spring Session 도 구성되지 않았다
@@ -0,0 +1,18 @@
# B-0 — BFF·Redis 배포와 자동구성 확인 증거
2026-09-04 14:2014:50 KST
해설: [`docs/experiment-b0-bff-redis-deploy.md`](../../experiment-b0-bff-redis-deploy.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-deploy.txt` | 배포 전 자원, Redis·BFF 롤아웃, 두 노드에 하나씩 배치됨 |
| `02-autoconfiguration.txt` | 첫 조회 시도(파싱 실패)와 **외부 진입점 `HTTP 200`** |
| `03-beans-analysis.txt` | **B-0 의 답**`InMemoryOAuth2AuthorizedClientService`, `AuthenticatedPrincipalOAuth2AuthorizedClientRepository`, **Redis·Spring Session 없음** |
| `b0-bff-login-success-single-replica.png` | replica 1 에서 로그인 성공한 BFF 화면 |
| `b0-bff-token-boundary.png` | `/bff/token-boundary``principal: labuser`, `accessTokenStoredOnServer: true`, **`browserTokenCount: 0`** |
## 핵심 세 줄
1. **`AuthenticatedPrincipalOAuth2AuthorizedClientRepository`** — 조회 키가 principal 이고 session ID 가 없다. Q1·Q3 문제의 기제가 이 빈 하나에 있다.
2. **Redis 를 붙여도 그건 안 고쳐진다.** 저장소 공유와 조회 키는 다른 문제다.
3. **replica 2개에서는 로그인 자체가 실패한다.** 인가 코드 흐름의 왕복 두 번이 같은 인스턴스로 가야 하는데, 인가 요청이 인스턴스 메모리에 있다.
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1,5 @@
deployment.apps/bff configured
deployment "bff" successfully rolled out
bff-576d869c6d-bshvl true kc-lab-2
bff-695646ddb-kzs9k true kc-lab-1
bff-695646ddb-vjqzf true kc-lab-2
@@ -0,0 +1,64 @@
=== B-1 — Redis 를 붙인 뒤 자동구성이 실제로 바뀌었는가 ===
빈 수: 321 → 402 (+81)
--- 세션 저장소 관련 (새로 생긴 것) ---
★ cookieSerializer -> DefaultCookieSerializer
★ org.springframework.boot.autoconfigure.session.RedisSessionConfiguration -> RedisSessionConfiguration
★ org.springframework.boot.autoconfigure.session.RedisSessionConfiguration$DefaultRedisSessionConfiguration -> RedisSessionConfiguration$DefaultRedisSessionConfiguration
★ org.springframework.boot.autoconfigure.session.SessionAutoConfiguration -> SessionAutoConfiguration
★ org.springframework.boot.autoconfigure.session.SessionAutoConfiguration$ServletSessionConfiguration -> SessionAutoConfiguration$ServletSessionConfiguration
★ org.springframework.boot.autoconfigure.session.SessionAutoConfiguration$ServletSessionConfiguration$RememberMeServicesConfiguration -> SessionAutoConfiguration$ServletSessionConfiguration$RememberMeServicesConfiguration
★ org.springframework.boot.autoconfigure.session.SessionAutoConfiguration$ServletSessionConfiguration$ServletSessionRepositoryConfiguration -> SessionAutoConfiguration$ServletSessionConfiguration$ServletSessionRepositoryConfiguration
★ org.springframework.boot.autoconfigure.session.SessionRepositoryFilterConfiguration -> SessionRepositoryFilterConfiguration
★ org.springframework.session.config.annotation.web.http.SpringHttpSessionConfiguration -> SpringHttpSessionConfiguration
★ org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration -> RedisHttpSessionConfiguration
★ rememberMeServicesCookieSerializerCustomizer -> SessionAutoConfiguration$ServletSessionConfiguration$RememberMeServicesConfiguration$$Lambda/0x00007f364e69fa60
★ sessionEventHttpSessionListenerAdapter -> SessionEventHttpSessionListenerAdapter
★ sessionRepository -> RedisSessionRepository
★ sessionRepositoryFilterRegistration -> DelegatingFilterProxyRegistrationBean
★ spring.session-org.springframework.boot.autoconfigure.session.SessionProperties -> SessionProperties
★ spring.session.redis-org.springframework.boot.autoconfigure.session.RedisSessionProperties -> RedisSessionProperties
★ springBootSessionRepositoryCustomizer -> RedisSessionConfiguration$DefaultRedisSessionConfiguration$$Lambda/0x00007f364e6a4a68
★ springSessionRepositoryFilter -> SessionRepositoryFilter
--- OAuth2 authorized client — 바뀌었는가? ---
authorizedClientService
before: InMemoryOAuth2AuthorizedClientService
after : InMemoryOAuth2AuthorizedClientService 그대로 — Redis 로 안 옮겨졌다
authorizedClientRepository
before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
after : AuthenticatedPrincipalOAuth2AuthorizedClientRepository 그대로 — Redis 로 안 옮겨졌다
authorizedClientManager
before: AuthorizedClientServiceOAuth2AuthorizedClientManager
after : AuthorizedClientServiceOAuth2AuthorizedClientManager 그대로 — Redis 로 안 옮겨졌다
--- Redis 연결 빈 (새로 생긴 것) ---
★ keyValueMappingContext -> RedisMappingContext
★ lettuceMetrics -> LettuceMetricsAutoConfiguration$$Lambda/0x00007f364e56f4d0
★ org.springframework.boot.actuate.autoconfigure.data.redis.RedisHealthContributorAutoConfiguration -> RedisHealthContributorAutoConfiguration
★ org.springframework.boot.actuate.autoconfigure.data.redis.RedisReactiveHealthContributorAutoConfiguration -> RedisReactiveHealthContributorAutoConfiguration
★ org.springframework.boot.actuate.autoconfigure.metrics.redis.LettuceMetricsAutoConfiguration -> LettuceMetricsAutoConfiguration
★ org.springframework.boot.autoconfigure.data.redis.LettuceConnectionConfiguration -> LettuceConnectionConfiguration
★ org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration -> RedisAutoConfiguration
★ org.springframework.boot.autoconfigure.data.redis.RedisReactiveAutoConfiguration -> RedisReactiveAutoConfiguration
★ org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration -> RedisRepositoriesAutoConfiguration
★ org.springframework.boot.autoconfigure.session.RedisSessionConfiguration -> RedisSessionConfiguration
★ org.springframework.boot.autoconfigure.session.RedisSessionConfiguration$DefaultRedisSessionConfiguration -> RedisSessionConfiguration$DefaultRedisSessionConfiguration
★ org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration -> RedisHttpSessionConfiguration
★ reactiveRedisTemplate -> ReactiveRedisTemplate
★ reactiveStringRedisTemplate -> ReactiveStringRedisTemplate
★ redisConnectionDetails -> PropertiesRedisConnectionDetails
★ redisConnectionFactory -> LettuceConnectionFactory
★ redisConverter -> MappingRedisConverter
★ redisCustomConversions -> RedisCustomConversions
★ redisHealthContributor -> RedisReactiveHealthIndicator
★ redisKeyValueAdapter -> RedisKeyValueAdapter
★ redisKeyValueTemplate -> RedisKeyValueTemplate
★ redisMappingConfiguration#0 -> MappingConfiguration
★ redisReferenceResolver -> ReferenceResolverImpl
★ redisTemplate -> RedisTemplate
★ sessionRepository -> RedisSessionRepository
★ spring.data.redis-org.springframework.boot.autoconfigure.data.redis.RedisProperties -> RedisProperties
★ spring.session.redis-org.springframework.boot.autoconfigure.session.RedisSessionProperties -> RedisSessionProperties
★ springBootSessionRepositoryCustomizer -> RedisSessionConfiguration$DefaultRedisSessionConfiguration$$Lambda/0x00007f364e6a4a68
★ stringRedisTemplate -> StringRedisTemplate
@@ -0,0 +1,21 @@
=== Redis 에 무엇이 들어 있는가 ===
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
총 키 수: 1
=== 세션 키의 내용 — refresh token 이 있는가 (Q3 검증 2번) ===
키: bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
타입: hash
필드: sessionAttr:SPRING_SECURITY_CONTEXT
필드: sessionAttr:SPRING_SECURITY_SAVED_REQUEST
필드: sessionAttr:SPRING_SECURITY_LAST_EXCEPTION
필드: sessionAttr:org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST
필드: lastAccessedTime
필드: maxInactiveInterval
필드: creationTime
=== 필드 값에 토큰 문자열이 보이는가 ===
1) "sessionAttr:SPRING_SECURITY_CONTEXT"
2) "\xac\xed\x00\x05sr\x00=org.springframework.security.core.context.SecurityContextImpl\x00\x00\x00\x00\x00\x00\x02l\x02\x00\x01L\x00\x0eauthenticationt\x002Lorg/springframework/security/core/Authentication;xpsr\x00Sorg.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken\x00\x00\x00\x00\x00\x00\x02l\x02\x00\x02L\x00\x1eauthorizedClientRegistrationIdt\x00\x12Ljava/lang/String;L\x00\tprincipalt\x00:Lorg/springframework/security/oauth2/core/user/OAuth2User;xr\x00Gorg.springframework.security.authentication.AbstractAuthenticationToken\xd3\xaa(~nGd\x0e\x02\x00\x03Z\x00\rauthenticatedL\x00\x0bauthoritiest\x00\x16Ljava/util/Collection;L\x00\adetailst\x00\x12Ljava/lang/Object;xp\x01sr\x00&java.util.Collections$UnmodifiableList\xfc\x0f%1\xb5\xec\x8e\x10\x02\x00\x01L\x00\x04listt\x00\x10Ljava/util/List;xr\x00,java.util.Collect
=== TTL (Q3 검증 3번 — session TTL) ===
TTL: 1772 초
@@ -0,0 +1,18 @@
# B-1 — Redis 세션 저장소 전환 증거
2026-09-04 14:5015:05 KST
해설: [`docs/experiment-b1-redis-session-store.md`](../../experiment-b1-redis-session-store.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-servicelinks-trap.txt` | `enableServiceLinks: false` 적용 후 롤아웃 성공 — 쿠버네티스가 주입한 `REDIS_PORT=tcp://...` 가 설정을 덮어쓴 문제 |
| `02-autoconfig-after.txt` | **핵심** — 빈 321→402(+81). `sessionRepository → RedisSessionRepository` 로 바뀌었지만 **`authorizedClientService``InMemory` 그대로** |
| `03-redis-contents.txt` | Redis 키 1개, 필드는 `SPRING_SECURITY_CONTEXT` 뿐. **토큰 없음.** Java 직렬화(`\xac\xed`), TTL 1772초 |
| `b1-login-works-two-replicas.png` | 전환 직후 `accessTokenStoredOnServer: false` |
| `b1-token-boundary-after-redis.png` | 파드 전면 교체 후 — `principal: labuser` 는 살아남고 토큰만 사라진 상태 |
## 핵심 세 줄
1. **세션은 옮겨졌고 토큰은 안 옮겨졌다.** 빈 81개가 늘었는데 authorized client 관련은 하나도 안 바뀌었다.
2. **refresh token 은 Redis 에 평문으로 있는 게 아니라 아예 없다.** 암호화를 고민하기 전에 이걸 알아야 한다.
3. **"로그인은 되어 있는데 아무것도 못 하는" 상태가 만들어진다** — 완전 로그아웃보다 나쁘다.
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,8 @@
deployment.apps/bff configured
deployment "bff" successfully rolled out
bff-555df79c97-6j86w 1/1 Running 0 44s
bff-555df79c97-vgg6g 1/1 Running 0 22s
=== oauth2_authorized_client 테이블이 생겼는가 ===
Did not find any relation named "oauth2_authorized_client".
command terminated with exit code 1
@@ -0,0 +1,33 @@
=== PostgreSQL 전용 스키마 ===
CREATE TABLE oauth2_authorized_client (
client_registration_id varchar(100) NOT NULL,
principal_name varchar(200) NOT NULL,
access_token_type varchar(100) NOT NULL,
access_token_value bytea NOT NULL,
access_token_issued_at timestamp NOT NULL,
access_token_expires_at timestamp NOT NULL,
access_token_scopes varchar(1000) DEFAULT NULL,
refresh_token_value bytea DEFAULT NULL,
refresh_token_issued_at timestamp DEFAULT NULL,
created_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
PRIMARY KEY (client_registration_id, principal_name)
);
=== 적용 ===
CREATE TABLE
Table "public.oauth2_authorized_client"
Column | Type | Collation | Nullable | Default
-------------------------+-----------------------------+-----------+----------+-------------------------
client_registration_id | character varying(100) | | not null |
principal_name | character varying(200) | | not null |
access_token_type | character varying(100) | | not null |
access_token_value | bytea | | not null |
access_token_issued_at | timestamp without time zone | | not null |
access_token_expires_at | timestamp without time zone | | not null |
access_token_scopes | character varying(1000) | | | NULL::character varying
refresh_token_value | bytea | | |
refresh_token_issued_at | timestamp without time zone | | |
created_at | timestamp without time zone | | not null | CURRENT_TIMESTAMP
Indexes:
"oauth2_authorized_client_pkey" PRIMARY KEY, btree (client_registration_id, principal_name)
@@ -0,0 +1,20 @@
=== Q3 검증 2번 — 저장소를 직접 열어 refresh token 이 평문인가 ===
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlMmUzZDZkMy0yNzQyLTRhYWItYjk4Ni02ZDU2ZDM5MDk1ZDEifQ.eyJleHAiOjE3ODg1MDA0NDYsImlhdCI6MTc4ODQ5ODY0NiwianRpIjoiNTQwOTZmYTQtZWRjNi1iZjZkLWE4OGMtZDJhNjEzOGJjNmVlIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLmh5ZW9ud29ya3MuY29tL3JlYWxtcy9rZXljbG9hay1wYXR0ZXJucyIsImF1ZCI6I
=== access token 도 ===
eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJPWS1jYVlETkdvUDRITUF6LVE5VVBUVS1ETTFpODk2TnV6VVp1NmdmQ3FNIn0.eyJleHAi
=== 그 문자열이 실제 JWT 인지 — 헤더를 디코드 ===
File "<string>", line 3
h=open(/tmp/hdr.txt).read().strip()
^
SyntaxError: invalid syntax
=== 저장된 바이트를 그대로 디코드한 결과 ===
refresh_token 헤더 : {"alg":"HS512","typ" : "JWT","kid" : "e2e3d6d3-2742-4aab-b986-6d56d39095d1"}
refresh_token 페이로드(앞부분):
{"exp":1788500446,"iat":1788498646,"jti":"54096fa4-edc6-bf6d-a88c-d2a6138bc6ee","iss":"https://auth.hyeonworks.com/realms/keycloak-patterns"
access_token 헤더 : {"alg":"RS256","typ" : "JWT","kid" : "OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}
→ bytea 에 들어 있는 것은 암호화된 덩어리가 아니라 JWT 문자열 그대로다.
DB 읽기 권한만 있으면 그 자리에서 쓸 수 있는 토큰을 얻는다.
@@ -0,0 +1,25 @@
=== [현재] 같은 사용자의 항목 ===
client_registration_id | principal_name | access_token_issued_at | at_md5
------------------------+----------------+----------------------------+----------------------------------
keycloak | labuser | 2026-09-04 05:10:46.927192 | 675af2286bfc2fd9d2bab7bc8f391df7
(1 row)
행 수: 1
=== [모의 두 번째 브라우저] 세션만 지우고 같은 사용자로 다시 로그인시킨다 ===
(브라우저가 달라도 principal 은 같으므로 조회 키가 같다)
Redis 세션 삭제 완료 — 다음 요청이 새 로그인을 만든다
=== [재로그인 후] 행이 늘었는가, 덮어써졌는가 ===
client_registration_id | principal_name | access_token_issued_at | at_md5
------------------------+----------------+----------------------------+----------------------------------
keycloak | labuser | 2026-09-04 05:12:13.018828 | e19a63fc5aa18bd0a68b3e19dff16b3b
(1 row)
행 수: 1
★ 행 수가 1 그대로이고 md5 가 바뀌었으면 → 덮어쓰기다
=== Q1 검증 ④ — 로그아웃하면 두 저장소가 다 정리되는가 ===
로그아웃 전
Redis: 1 키
PostgreSQL: 1 행
@@ -0,0 +1,14 @@
=== Q1 검증 ④ — 로그아웃 후 두 저장소 상태 ===
Redis 세션 : 0 키
PostgreSQL 토큰 : 1 행
principal_name | access_token_issued_at | access_token_expires_at
----------------+----------------------------+----------------------------
labuser | 2026-09-04 05:12:13.018828 | 2026-09-04 05:13:13.018828
(1 row)
★ Redis 는 비었는데 PostgreSQL 에 행이 남아 있으면 → 한쪽만 정리된 것
=== Keycloak 쪽 SSO 세션은? ===
Keycloak 온라인 세션: 2
@@ -0,0 +1,21 @@
# B-2 — 다중 인스턴스 운영 증거
2026-09-04 15:0515:15 KST
해설: [`docs/experiment-b2-multi-instance-session.md`](../../experiment-b2-multi-instance-session.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-jdbc-store-deploy.txt` | JDBC 저장소로 배포. **테이블이 조용히 안 만들어졌다** |
| `02-schema.txt` | 원인 — 기본 DDL 은 `blob`(PostgreSQL 에 없음), `-postgres.sql` 판본이 따로 있다. **`PRIMARY KEY (client_registration_id, principal_name)`** — 조회 키 문제가 DDL 에 박혀 있다 |
| `03-plaintext-tokens.txt` | **Q3 검증 2번**`bytea` 안이 JWT 문자열 그대로. 디코드하면 `{"alg":"HS512",...}` |
| `04-overwrite-test.txt` | **Q1 검증 3번** — 같은 사용자 재로그인 시 행 수 1 그대로, `issued_at` 과 md5 만 바뀜 = **UPDATE(덮어쓰기)** |
| `05-logout-cleanup.txt` | **Q1 검증 4번** — Redis 0키 / PostgreSQL **1행 잔존** / Keycloak SSO **2세션 잔존** |
| `b2-before-relogin.png` | JDBC 전환 직후, 옛 세션은 여전히 `false` |
| `b2-tokens-shared-across-instances.png` | 재로그인 후 **`accessTokenStoredOnServer: true`** — 두 replica 에서 동작 |
## 핵심 네 줄
1. **세션 Redis + 토큰 PostgreSQL 분리 저장이 성립한다.** B-1 의 "로그인은 됐는데 토큰이 없는" 상태가 해결됐다.
2. **refresh token 은 평문이다.** DB 읽기 권한이면 작동하는 토큰을 얻는다.
3. **같은 사용자의 두 번째 로그인이 첫 번째를 덮어쓴다.** 기본키에 session id 가 없어 구조적으로 그렇다.
4. **로그아웃은 셋 중 하나만 지운다.** 평문 토큰과 Keycloak SSO 세션이 남는다.
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1,11 @@
=== [1] refresh token 하나 확보 ===
토큰 길이: 811
jti: 8e7e3ee2-0dc8-573d-58ec-d12651a50b9c
sid: BvFiB01Rntz1FcLdf7zG4BNt
=== [2] 같은 refresh token 으로 동시에 5회 갱신 ===
요청 1: HTTP 400 {"error":"invalid_grant","error_description":"Maximum allowed refresh token reuse exceeded"}
요청 2: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
요청 3: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
요청 4: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
요청 5: HTTP 200 {"access_token":"...(발급됨)
@@ -0,0 +1,16 @@
=== [3] 이긴 요청이 받은 새 토큰은 쓸 수 있는가 ===
새 refresh token 길이: 810
그 토큰으로 다시 갱신: HTTP 400
{"error":"invalid_grant","error_description":"Session doesn't have required client"}
=== [4] 그 sid 의 세션이 DB 에 남아 있는가 ===
user_session_id | offline_flag | last_session_refresh
--------------------------+--------------+----------------------
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 1788498996
(1 row)
=== [5] revoked_token 테이블 ===
revoked_count
---------------
0
(1 row)
@@ -0,0 +1,14 @@
=== user session 과 client session 을 나눠서 본다 ===
user_session_id | offline_flag | client_sessions
--------------------------+--------------+-----------------
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 0
(1 row)
=== 대조: 정상 세션 하나를 새로 만들어 비교 ===
새 sid: JT-XuepgutWcE273QwAnIXta
user_session_id | client_sessions
--------------------------+-----------------
JT-XuepgutWcE273QwAnIXta | 1
(1 row)
@@ -0,0 +1,24 @@
=== 구성 A: rotation ON (revokeRefreshToken=true, maxReuse=0) — 앞서 측정 ===
성공 1 / 5, 세션 파괴됨
=== 구성 B: rotation OFF (revokeRefreshToken=false) ===
sid=iW1CGyO7COdyJLryIrCt3njk
1: 200
2: 200
3: 200
4: 200
5: 200
성공 5 / 5
이긴 토큰 재사용: HTTP 200
남은 client_session: 1
=== 구성 C: rotation ON + 재사용 1회 허용 (maxReuse=1) ===
sid=72c04JCdr0NpCHGQmXWW2wM8
1: 200
2: 400 "error_description":"Session doesn't have required client"
3: 200
4: 400 "error_description":"Maximum allowed refresh token reuse exceeded"
5: 400 "error_description":"Session doesn't have required client"
성공 2 / 5
이긴 토큰 재사용: HTTP 400
남은 client_session: 0
@@ -0,0 +1,18 @@
# B-3 — Refresh Token 동시 갱신 경쟁 증거
2026-09-04 15:1515:25 KST
해설: [`docs/experiment-b3-refresh-token-contention.md`](../../experiment-b3-refresh-token-contention.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-concurrent-refresh.txt` | 같은 토큰으로 동시 5회 — **1개만 200**, 나머지는 `Maximum allowed refresh token reuse exceeded``Session doesn't have required client` **두 종류** 오류 |
| `02-session-impact.txt` | **★ 이긴 요청의 새 토큰조차 400.** user_session 행은 남아 있고 `revoked_token` 은 0건 |
| `03-client-session-removed.txt` | **기제 확정 (대조군 포함)** — 경쟁 세션 `client_sessions=0`, 정상 세션 `client_sessions=1` |
| `04-policy-comparison.txt` | 정책 3종 비교 — rotation OFF 는 **5/5 성공·세션 생존**, maxReuse=1 은 **여전히 세션 파괴** |
## 핵심 네 줄
1. **"하나는 성공"이 아니다.** 이긴 요청이 받은 토큰도 곧바로 쓸 수 없다.
2. **재사용 탐지가 client session 을 제거한다.** user session 은 껍데기로 남아 `Session doesn't have required client` 가 된다.
3. **`refreshTokenMaxReuse` 를 올려도 안 된다.** 동시 요청 수만큼 올려야 하고 그러면 rotation 의 목적이 사라진다.
4. **재시도로 회복되지 않으므로 Q2 의 답은 lock 이다.** 그리고 lock 은 저장소 쪽(가급적 DB 행 잠금)에 있어야 한다.
+267
View File
@@ -0,0 +1,267 @@
# A-7 — 옛 방식(volatile)이었다면 무엇이 달라지는가
브랜치 `feature/keycloak-a7-volatile-comparison` ·
증거 [`docs/evidence/a7-volatile-comparison/`](evidence/a7-volatile-comparison/) ·
2026-09-04 13:4514:15 KST
**A층의 결론 전체가 "Keycloak 26 기본값"이라는 전제 위에 있다.**
전제를 뒤집어 같은 실험을 반복한 것이 이 실험이다.
---
## 0. 결론부터 — 비교표
| 실험 | persistent (KC 26 기본) | **volatile (KC 24 이전 방식)** |
|---|---|---|
| **A-0** 세션 저장 위치 | DB 에 행이 생긴다 | **DB 0건.** 메모리에만 |
| **A-1** 7800 차단 후 교차 노드 refresh | **`200`** — 안 깨진다 | **`400 Session not active`** — 깨진다 |
| **A-8** 롤링 재시작 후 refresh | **`200`** — 세션 생존 | **`400 Session not active`** — 전원 로그아웃 |
| **A-2** DB 정지 중 새 로그인 | `500` | **`200`** — 된다 |
| A-2 DB 정지 중 refresh | `500` | `500` |
**세 개가 정반대로 뒤집혔다.** 예측한 그대로다.
> **"세션 공유는 7800 을 안 탄다"는 A-1 의 결론은 버전에 달린 사실이다.**
> 인터넷 자료 대부분이 24 이전 기준이므로 **거기서는 통념이 맞다.**
> 틀린 것은 자료가 아니라 **버전을 확인하지 않고 적용하는 것**이다.
---
## 1. 전환 방법
```bash
# 이 버전에서 끌 수 있는지부터 확인한다
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kc.sh build --help-all \
| tr ',' '\n' | grep -i persistent
# persistent-user-sessions[:v1] ← 목록에 있다
```
```yaml
# deploy/lab/k8s/keycloak-cluster.yaml
args: ["start", "--features-disabled=persistent-user-sessions"]
```
```bash
kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
**빌드 옵션이므로 기동 시 재빌드가 일어나 평소보다 오래 걸린다.**
---
## 2. A-0 재실행 — 세션이 DB 에 안 들어간다
```
=== keycloak-0 에만 로그인 5회 ===
keycloak-0 sessions 캐시 5.0 건
keycloak-1 sessions 캐시 0.0 건
=== DB 에는 들어갔는가 (persistent 였을 때는 5건) ===
offline_flag | count
--------------+-------
(0 rows) ← 0 건
=== 교차 노드 세션은 되는가 ===
keycloak-0 로그인 → keycloak-1 에서 refresh HTTP 200
```
**DB 는 비어 있는데 교차 노드가 된다.** persistent 때와 겉보기 결과가 같지만
**경로가 완전히 다르다** — 이제는 DB 가 아니라 **클러스터를 타고** 있다.
> 캐시 엔트리 수가 `5 / 0` 인 것은 persistent 때와 같다.
> `approximate_entries_unique` 는 **그 노드가 소유한 엔트리**만 세므로,
> 백업본을 들고 있어도 0 으로 보인다. **이 지표만으로는 두 모드를 구분할 수
> 없다** — 구분하려면 7800 을 끊어봐야 한다. 그게 다음 절이다.
---
## 3. A-1 재실행 — **정반대다**
A-5 에서 확립한 `raw` 테이블 양방향 차단을 그대로 썼다.
```
[대조군] 차단 전 교차 노드 refresh HTTP 200
차단 적용 → 분단 성립
+25초 cluster_size(k0 k1) = [2.0 2.0]
+50초 cluster_size(k0 k1) = [1.0 ...] ← 갈라졌다
=== 분단 상태에서 ===
keycloak-0 로그인 → keycloak-0 에서 refresh HTTP 200 ← 대조군
keycloak-0 로그인 → keycloak-1 에서 refresh HTTP 400 ← 시험군
{"error":"invalid_grant","error_description":"Session not active"}
```
**대조군이 200 인 것이 중요하다.** 차단이 모든 것을 망가뜨린 게 아니라
**교차 노드만** 끊었다는 증거다.
```
persistent : 세션 ── PostgreSQL ──▶ 양쪽이 본다 7800 무관
volatile : 세션 ── 클러스터(7800) ─▶ 상대에게 간다 7800 필수
```
**같은 주입, 같은 관측, 정반대 결과.** 이 한 쌍이 A층 전체의 근거다.
---
## 4. A-8 재실행 — 배포가 곧 로그아웃
```
=== 재시작 전 로그인 ===
sid = aVwYnzKZFFvMqD3bpSeiILuM
=== 롤링 재시작 ===
partitioned roll out complete: 2 new pods have been updated...
=== ★ 재시작 전 토큰이 아직 통하는가 (persistent 였을 때는 200) ===
keycloak-0 에서 refresh HTTP 400
{"error":"invalid_grant","error_description":"Session not active"}
```
**배포할 때마다 전원 로그아웃된다.**
| | persistent | volatile |
|---|---|---|
| 배포 | 자유롭다 | **모든 사용자가 다시 로그인** |
| 파드 재시작 (OOM, 노드 교체) | 무해 | **그 노드가 처리하던 세션 소멸** |
| 무중단 여부 | 무중단 (A-8) | 접속은 되지만 **로그인 상태가 사라진다** |
> **A-8 에서 "이것이 persistent 를 켜는 진짜 이유"라고 썼는데, 여기서 증명된다.**
> 24 이전 버전을 쓰는 곳에서 "배포하면 로그아웃된다"가 당연하게 여겨졌던 이유다.
---
## 5. A-2 재실행 — 예상 못 한 비대칭
```
① 캐시를 가진 노드에서 refresh HTTP 500
② 새 로그인 HTTP 200 ← persistent 에서는 500 이었다
```
**새 로그인은 되는데 refresh 가 안 된다.** 순서가 거꾸로다.
### 왜 새 로그인이 되는가
```
로그인에 필요한 것
├─ realm 설정 → Infinispan `realms` 캐시에 있다
├─ 사용자 자격 → `users` 캐시에 있다
└─ 세션 저장 → volatile 이므로 메모리
→ DB 없이 완결된다
```
**A-2 에서 persistent 로 했을 때 로그인이 실패한 이유는 "세션을 DB 에 써야
해서"였다.** 그 쓰기가 없어지니 로그인이 통과한다.
### refresh 가 500 인 이유 — 가설
**측정은 확실하지만 원인은 확정하지 못했다.** 유력한 후보는
`REVOKED_TOKEN` 테이블이다 — refresh token 회전에서 **이미 쓴 토큰인지**
확인하려면 그 테이블을 봐야 하고, 그 경로는 캐시되지 않는다.
```
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c "\dt" | grep revoked
public | revoked_token | table | keycloak
```
**확정하려면 A-3 에서 쓴 문장 로깅을 켜고 다시 재현해야 한다.** 여기서는
**가설로 남긴다.**
> **volatile 이 "DB 없이 돌아간다"는 뜻은 아니다.**
> realm·사용자·클라이언트·취소 토큰은 **여전히 DB 에 있다.**
> 세션만 메모리로 옮긴 것이다.
---
## 6. 원복
```bash
# args 를 되돌린다
args: ["start"]
kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml
```
```
=== [검증] persistent 로 돌아왔는가 ===
["start"]
DB 온라인 세션: 1 건 (로그인 1회 후 → persistent 복귀 확인)
외부 진입점 HTTP 200
```
**전환 자체는 설정 한 줄이고 되돌리기도 한 줄이다.** 다만 전환 시점에
**기존 세션은 전부 사라진다** (저장 위치가 바뀌므로).
---
## 7. 개념
### 두 모드의 데이터 흐름
```
persistent (KC 25+, 26 기본)
로그인 ──▶ PostgreSQL (진실) + 로컬 캐시 (사본)
조회 ──▶ 캐시에 없으면 DB
공유 ──▶ 같은 DB 를 보는 것
volatile (KC 24 이전)
로그인 ──▶ Infinispan (진실)
조회 ──▶ 클러스터에서 찾는다
공유 ──▶ 7800 을 통한 복제
```
### 무엇을 맞바꾸는가
| | persistent | volatile |
|---|---|---|
| 재시작 내구성 | **있다** | 없다 |
| 7800 의존 | 낮다 (무효화만) | **높다 (세션 자체)** |
| DB 부하 | **로그인·refresh 마다 쓰기** | 세션 관련 없음 |
| 노드 확장 | DB 가 병목 | **복제 트래픽이 N² 로 증가** |
| 지연 민감도 | **DB 왕복에 민감** (A-6) | 클러스터 왕복에 민감 |
**26 이 기본을 바꾼 이유가 이 표에 있다** — 운영에서 가장 아픈 것이
"배포하면 로그아웃"이었기 때문이다.
---
## 8. 재현 절차 (명령어)
```bash
# 1. 끌 수 있는지 확인
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kc.sh build --help-all \
| tr ',' '\n' | grep -i persistent
# 2. 세션을 비우고 전환 (비교 기준을 맞추기 위해)
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_user_session"
# args: ["start", "--features-disabled=persistent-user-sessions"]
kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
# 3. volatile 확인 — 로그인 후 DB 가 비어 있어야 한다
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select offline_flag, count(*) from offline_user_session group by offline_flag"
# 4. A-1 / A-8 을 그대로 반복한다 (증거 파일 참조)
# 5. 원복
# args: ["start"]
kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml
```
---
## 9. 이 실험이 A층에 남기는 것
| | |
|---|---|
| **A-0~A-6 의 결론은 모두 "26 기본값" 조건부다** | 버전이 다르면 답이 다르다 |
| **A-1 이 통념과 어긋난 이유가 확정됐다** | 통념은 24 이전에서 맞다 |
| **버전 확인이 1순위** | `kc.sh --version` 을 먼저 본다 |
| **volatile 이 DB 독립을 뜻하지 않는다** | realm·사용자·취소 토큰은 여전히 DB |
### 미해결로 남긴 것
**volatile 에서 refresh 만 500 이 되는 이유**`REVOKED_TOKEN` 조회 가설을
세웠지만 확정하지 못했다. A-3 의 문장 로깅 기법으로 재현하면 답이 나온다.
+175
View File
@@ -0,0 +1,175 @@
# A-8 — 배포할 때마다 로그아웃되는가
브랜치 `feature/keycloak-a8-rolling-restart` ·
증거 [`docs/evidence/a8-rolling-restart/`](evidence/a8-rolling-restart/) ·
2026-09-04 13:3813:41 KST
**운영에서 가장 자주 겪는 일이다.** 장애가 아니라 정상 작업인데도
사용자가 로그아웃되면 그건 사고다.
---
## 0. 결론부터
| 확인 | 결과 |
|---|---|
| 재시작 중 서비스 중단 | **없음.** 전 구간 `200` |
| 재시작 전 발급한 refresh token | **여전히 통한다** (`200`) |
| DB 세션 수 | **151 → 151** 그대로 |
| 세션 캐시 | **0 으로 초기화** |
| 클러스터 | 자동 재형성 (`cluster_size = 2`) |
**세션은 살아남고 캐시만 사라진다.** 이것이 `persistent-user-sessions`
켜는 진짜 이유다.
---
## 1. 방법
```bash
# 1. 재시작 전 로그인 — 토큰을 상주 파드 안에 보관한다
kubectl -n keycloak-lab run a8-probe --image=curlimages/curl:8.11.1 \
--restart=Never --command -- sleep 3600
kubectl -n keycloak-lab exec a8-probe -- sh -c '<로그인 후 /tmp/rt, /tmp/sid 에 저장>'
# 2. 재시작하면서 5초 간격으로 외부 진입점을 찍는다
kubectl -n keycloak-lab rollout restart statefulset/keycloak
( for i in $(seq 1 48); do
curl -s -o /dev/null -w '%{http_code} ' --max-time 4 https://auth.hyeonworks.com/realms/master
sleep 5
done ) &
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=420s
```
**탐침 파드가 StatefulSet 밖에 있어야** 재시작을 넘어 토큰을 들고 있을 수 있다.
---
## 2. 가용성 — 무중단이었다
```
statefulset.apps/keycloak restarted
200 Waiting for partitioned roll out to finish: 0 out of 2 new pods have been updated...
Waiting for 1 pods to be ready...
200 200 200 200 Waiting for partitioned roll out to finish: 1 out of 2 new pods have been updated...
Waiting for 1 pods to be ready...
200 200 200 200 partitioned roll out complete: 2 new pods have been updated...
```
**9번 찍어서 9번 다 `200`.** 한 번도 끊기지 않았다.
### 왜 무중단이 되는가
```
StatefulSet 롤링 재시작
├─ keycloak-1 종료 → Service 엔드포인트에서 빠짐
│ └─ 이 동안 keycloak-0 이 전부 받는다
├─ keycloak-1 기동 → readiness UP → 엔드포인트 복귀
└─ keycloak-0 종료 → ... (반복)
```
**한 번에 하나씩** 내리므로 항상 최소 하나는 Ready 다.
readiness 프로브가 이 전환을 정확히 맞춰준다 — A-2 에서 본 그 메커니즘이
여기서는 **정상 작업을 안전하게** 만든다.
> 다만 이 실험대는 **파드가 2개**다. replica 1 이면 반드시 끊긴다.
> 무중단은 공짜가 아니라 **replica ≥ 2 와 readiness 의 조합**이다.
---
## 3. 세션 생존
```
=== 재시작 전 발급한 refresh token 이 아직 통하는가 ===
대상 sid: XLcgQWRiJrTkuNZcJsNeT_2j
keycloak-0 에서 refresh HTTP 200
=== DB 에 그 세션이 남아 있는가 ===
user_session_id | created_on | last_session_refresh
--------------------------+------------+----------------------
XLcgQWRiJrTkuNZcJsNeT_2j | 1788495513 | 1788495577
전체 온라인 세션: 151 (재시작 전 151)
```
**`last_session_refresh``created_on` 보다 64초 뒤**다. 재시작 후의 refresh
가 **실제로 DB 에 기록**되었다는 뜻이다 — 응답 코드만 200 인 게 아니라
쓰기까지 정상이다.
**파드가 통째로 바뀌었는데(44초/66초 나이) 세션은 그대로다.**
---
## 4. 캐시는 사라진다
```
keycloak-0 sessions 캐시 0.0 건 / cluster_size 2.0
keycloak-1 sessions 캐시 1.0 건 / cluster_size 2.0
```
![캐시 초기화와 클러스터 재형성](evidence/a8-rolling-restart/a8-cache-reset-cluster-reformed.png)
**캐시는 프로세스 메모리이므로 재시작에 사라진다.** `keycloak-1` 의 1건은
방금 refresh 를 처리하며 새로 담은 것이다.
```
재시작 전: 캐시 N건 + DB 151건
재시작 후: 캐시 0건 + DB 151건 ← 진실은 DB 에 있다
```
**A-0 의 모델이 그대로 확인된다.** 캐시가 통째로 날아가도 정확성은 유지되고
**첫 접근만 느려진다** (룩어사이드 캐시의 성질).
---
## 5. 이것이 `persistent-user-sessions` 를 켜는 진짜 이유다
| | persistent (KC 26 기본) | volatile (KC 24 이전 방식) |
|---|---|---|
| 롤링 재시작 후 | **세션 유지** | **전원 로그아웃** |
| 배포 빈도 | 자유롭다 | 배포가 곧 사고다 |
| 대가 | DB 쓰기 (A-6 에서 본 지연) | 없음 |
**A-7 에서 volatile 로 바꿔 같은 실험을 반복하면 여기가 정반대가 될 것이다.**
그 비교가 이 실험의 짝이다.
---
## 6. 재현 절차 (명령어)
```bash
# 상주 탐침 (StatefulSet 밖에 있어야 한다)
kubectl -n keycloak-lab run a8-probe --image=curlimages/curl:8.11.1 \
--restart=Never --command -- sleep 3600
kubectl -n keycloak-lab wait --for=condition=Ready pod/a8-probe --timeout=120s
# 로그인하고 토큰 보관
kubectl -n keycloak-lab exec a8-probe -- sh -c \
'curl -s -X POST http://<pod>:8080/realms/master/protocol/openid-connect/token \
-d grant_type=password -d client_id=admin-cli -d username=admin -d password=<pw> > /tmp/tok'
# 재시작 + 가용성 감시
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=420s
# 세션 생존 확인
kubectl -n keycloak-lab exec a8-probe -- sh -c \
'curl -s -o /dev/null -w "%{http_code}\n" -X POST http://<pod>:8080/realms/master/protocol/openid-connect/token \
-d grant_type=refresh_token -d client_id=admin-cli -d refresh_token=$(cat /tmp/rt)'
# DB 대조
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select user_session_id, created_on, last_session_refresh from offline_user_session where user_session_id='<sid>'"
```
---
## 7. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **A-7** volatile 비교 | **이 실험을 그대로 반복하면 정반대 결과가 나와야 한다** |
| **D-2** 버전 업그레이드 | 롤링 재시작이 안전하다는 것이 업그레이드의 전제 |
| 구성 | 무중단은 **replica ≥ 2 + readiness** 의 조합이다 |
+283
View File
@@ -0,0 +1,283 @@
# B-0 — 자동구성은 실제로 무엇을 골랐는가 (그리고 배포에서 겪은 것들)
브랜치 `feature/keycloak-b0-bff-redis-deploy` ·
증거 [`docs/evidence/b0-bff-redis-deploy/`](evidence/b0-bff-redis-deploy/) ·
2026-09-04 14:2014:50 KST
**Q1 이 직접 요구한 확인이다.**
> 코드에 저장소를 직접 생성하는 Bean 이 없기 때문에, 어떤 구현체가 실제로
> 사용되는지는 **Spring Boot 의 자동구성 결과까지 확인해야** 정확하게 알 수 있다.
---
## 0. 결론부터
```
authorizedClientService -> InMemoryOAuth2AuthorizedClientService
authorizedClientRepository -> AuthenticatedPrincipalOAuth2AuthorizedClientRepository
authorizedClientManager -> AuthorizedClientServiceOAuth2AuthorizedClientManager
clientRegistrationRepository -> InMemoryClientRegistrationRepository
SessionRepository -> 없음 (서블릿 컨테이너 in-memory)
Redis / Spring Session -> ★ 없음
```
**추측이 맞았지만, 추측으로 두면 안 되는 이유가 두 번째 줄에 있다.**
`AuthenticatedPrincipalOAuth2AuthorizedClientRepository` — 이름이 곧 설명이다.
**"인증된 주체(principal) 기준"** 으로 authorized client 를 찾는다.
**session ID 가 아니다.** Q1·Q3 가 지적한 "같은 사용자의 여러 브라우저가 같은
token 을 공유한다"는 문제의 **기제가 이 빈 하나에 들어 있다.**
그리고 배포하자마자 **Q1 의 문제가 실험을 시작하기도 전에 나타났다**
replica 2개에서는 **로그인 자체가 실패한다.**
---
## 1. 배포에서 겪은 문제 다섯 가지
### 문제 ① — `bff/` 가 소스 없이 빌드 산출물만 있었다
```
bff/target/classes/... 9개 파일
bff/src/ 없음
```
`.gitignore``target/` 이 없어 클래스 파일만 커밋되어 있었다.
소스는 다른 브랜치에 있었다.
```bash
git checkout origin/develop-keycloak-pattern3 -- bff/
```
### 문제 ② — YAML 중복 키로 빌드가 깨졌다
actuator 를 열려고 `management:` 아래에 `endpoint:` 블록을 **하나 더** 넣었다.
이미 있는데.
```
org.yaml.snakeyaml.constructor.SafeConstructor.processDuplicateKeys
```
**Docker 빌드 로그가 `tail` 로 잘려 원인이 안 보였다.** `--progress=plain` 으로
전체를 받아서야 스택트레이스에서 `processDuplicateKeys` 를 찾았다.
```bash
docker build --progress=plain -t keycloak-pattern-bff:lab . > /tmp/build.log 2>&1
grep -nE "Tests run|Caused by|\.java:[0-9]" /tmp/build.log
```
> **빌드 실패는 마지막 15줄에 안 들어 있는 경우가 많다.** 전체를 파일로 받는다.
### 문제 ③ — 환경변수에 기본값을 안 줘서 테스트가 죽었다
`${KC_ISSUER_EXTERNAL}` 처럼 기본값 없이 쓰면 **테스트에서 컨텍스트가 안 뜬다.**
테스트는 그 환경변수를 모른다.
```yaml
authorization-uri: ${KC_ISSUER_EXTERNAL:http://localhost:8080/realms/keycloak-patterns}/protocol/openid-connect/auth
```
### 문제 ④ — actuator 가 인증에 막혀 있었다
`/actuator/beans` 를 부르면 `200` 이 왔는데, **Keycloak 로그인 페이지**였다.
`-L` 로 리다이렉트를 따라간 결과였다.
```java
"/actuator/health",
"/actuator/health/**",
// 실험대 전용 — 운영에서는 절대 열지 않는다
"/actuator/**"
```
> **`200` 이 곧 성공은 아니다.** 무엇이 왔는지 봐야 한다.
### 문제 ⑤ — 큰 응답이 프록시에서 `Bad Gateway`
`/actuator/beans` 는 117KB 다. nginx → Traefik 을 거치면서 실패했다.
```
$ curl https://app1.hyeonworks.com/actuator/beans
Bad Gateway
```
파드 안에서 직접 받아 해결했다. **alpine 기반 JRE 이미지에 `wget` 이 있다.**
```bash
kubectl -n keycloak-lab exec <bff-pod> -- wget -qO- http://localhost:8083/actuator/beans
```
---
## 2. 배포 구성
```
브라우저 ──https──▶ nginx ──▶ Traefik ──▶ bff (2 replica)
├──▶ Keycloak (realm: keycloak-patterns)
└──▶ echo (resource server 대역)
redis ── kc-lab-2 (postgres 와 같은 노드) ← 아직 연결하지 않았다
```
**Redis 는 배포만 하고 BFF 에 연결하지 않았다.** B-0 의 질문이 "아무것도 주지
않았을 때 자동구성이 무엇을 고르는가"이므로, 아무것도 주지 않은 상태를 먼저
측정해야 한다.
### Keycloak realm 준비 (kcadm)
```bash
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh config credentials \
--server http://localhost:8080 --realm master --user admin --password <pw>
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh create realms \
-s realm=keycloak-patterns -s enabled=true -s accessTokenLifespan=60
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh create clients -r keycloak-patterns \
-s clientId=bff-confidential -s publicClient=false -s secret=bff-lab-secret \
-s 'redirectUris=["https://app1.hyeonworks.com/*"]'
```
**`accessTokenLifespan=60`** 으로 둔 것은 B-3(refresh 경쟁)을 위해서다.
만료를 기다리는 시간이 짧아야 재현이 된다.
### 브라우저용 URL 과 백채널 URL 을 분리했다
```yaml
authorization-uri: ${KC_ISSUER_EXTERNAL}/protocol/openid-connect/auth # 브라우저가 간다
token-uri: ${KC_ISSUER_INTERNAL}/protocol/openid-connect/token # BFF 가 서버끼리
```
```yaml
- name: KC_ISSUER_EXTERNAL
value: https://auth.hyeonworks.com/realms/keycloak-patterns
- name: KC_ISSUER_INTERNAL
value: http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns
```
**2홉 헤더 실험에서 배운 것이 그대로 쓰인다** — 브라우저가 보는 이름과
서버가 부르는 주소는 다르고, 섞으면 리다이렉트가 깨진다.
`SERVER_FORWARD_HEADERS_STRATEGY=native` 도 같은 이유다. 없으면 Spring 이
`redirect_uri``http://` 로 만들어 Keycloak 이 거부한다.
---
## 3. 동작 확인 — 브라우저 증거
![로그인 성공](evidence/b0-bff-redis-deploy/b0-bff-login-success-single-replica.png)
![token 경계](evidence/b0-bff-redis-deploy/b0-bff-token-boundary.png)
```json
{"pattern":"AP3-backend-for-frontend","principal":"labuser",
"accessTokenStoredOnServer":true,"refreshTokenStoredOnServer":true,
"browserTokenCount":0,"csrfProtectionEnabled":true}
```
**BFF 패턴이 성립한다** — 브라우저에 토큰이 0개이고, 서버가 access/refresh 를
들고 있다.
---
## 4. B-0 의 답 — 자동구성 결과
전체 빈 321개 중 관련된 것들이다.
| 빈 | 구현체 | 뜻 |
|---|---|---|
| `authorizedClientService` | **`InMemoryOAuth2AuthorizedClientService`** | **프로세스 메모리.** 재시작하면 사라진다 |
| `authorizedClientRepository` | **`AuthenticatedPrincipalOAuth2AuthorizedClientRepository`** | **principal 기준 조회.** session ID 가 없다 |
| `authorizedClientManager` | `AuthorizedClientServiceOAuth2AuthorizedClientManager` | **service**(공유) 를 쓴다 |
| `clientRegistrationRepository` | `InMemoryClientRegistrationRepository` | 설정에서 읽은 것 |
| SessionRepository | **없음** | Tomcat 의 기본 `StandardSession` |
| Redis / Spring Session | **없음** | 의존성 자체가 없다 |
### `AuthenticatedPrincipalOAuth2AuthorizedClientRepository` 가 핵심이다
```
요청이 인증되어 있으면
└─▶ OAuth2AuthorizedClientService 에 위임
└─▶ 키: (clientRegistrationId, principalName)
└─ session ID 가 없다 ★
인증되어 있지 않으면
└─▶ HttpSession 에 임시 보관
```
**같은 사용자가 두 브라우저에서 로그인하면 principalName 이 같으므로
같은 항목을 본다.** Q1 의 미지수 3 과 Q3 의 제약이 여기서 나온다.
> **Redis 를 붙여도 이건 안 고쳐진다.** 저장소를 공유해도 **키에 session ID 가
> 없기 때문**이다. Q1 이 "Session Store 를 공유 저장소로 바꾸는 것만으로는
> 충분하지 않다"고 쓴 이유다.
---
## 5. 예상 못 한 것 — **replica 2개에서 로그인 자체가 안 된다**
배포 직후 브라우저에서 로그인하니 `/login?error` 로 떨어졌다.
BFF 로그에는 아무 오류도 없었다 (Spring Security 는 로그인 실패를 DEBUG 로만 남긴다).
**가설** — 인가 요청(state, PKCE verifier)은 `HttpSession` 에 저장된다.
그런데 그 세션은 **인스턴스 메모리**다. 콜백이 다른 replica 로 가면 저장된
인가 요청이 없어 실패한다.
**검증** — replica 를 1로 줄이고 다시 시도했다.
```bash
kubectl -n keycloak-lab scale deployment/bff --replicas=1
```
**로그인이 성공했다.** 가설 확정.
```
replica 2 + 스티키 없음 → 로그인 실패 (콜백이 다른 인스턴스로)
replica 1 → 로그인 성공
```
> **Q1 의 문제가 실험을 시작하기도 전에 나타났다.**
> "다중 인스턴스에서 어떻게 운영할 것인가"는 **로그인한 뒤의 문제가 아니라
> 로그인 자체의 문제**다. 인가 코드 흐름은 **왕복 두 번**이고, 두 번 다 같은
> 인스턴스로 가야 한다.
>
> 이건 B-2 의 검증 1번("한쪽에서 로그인한 뒤 다른 인스턴스로 요청")보다
> **앞선 단계**다. 로그인이 끝나야 그 검증을 할 수 있는데, 로그인부터 막힌다.
---
## 6. 재현 절차 (명령어)
```bash
# 1. 소스 가져오기 (target/ 만 커밋되어 있었다)
git checkout origin/develop-keycloak-pattern3 -- bff/
# 2. 빌드 — 실패하면 전체 로그를 파일로
docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
grep -nE "Tests run|Caused by" /tmp/build.log
# 3. 두 노드에 적재 (레지스트리 없음 → imagePullPolicy: Never)
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
# 4. realm · client · user
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh create realms -s realm=keycloak-patterns ...
# 5. 배포
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
# 6. 자동구성 결과 — 파드 안에서 (프록시는 큰 응답에서 502)
kubectl -n keycloak-lab exec <bff-pod> -- wget -qO- http://localhost:8083/actuator/beans > beans.json
python3 -c "import json;d=json.load(open('beans.json'));[print(n,'->',i['type']) for n,i in
list(d['contexts'].values())[0]['beans'].items() if 'AuthorizedClient' in i['type']]"
```
---
## 7. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **B-1** 저장소 결정 | **전환 후 이 빈들이 바뀌는지 다시 찍는다.** "Redis 붙였다"고 믿는데 자동구성이 안 걸리는 경우가 흔하다 |
| **B-2** 다중 인스턴스 | **로그인 자체가 실패한다**는 것이 이미 관측됐다. 그것이 검증 0번이다 |
| **B-3** refresh 경쟁 | `accessTokenLifespan=60` 으로 realm 을 만들어뒀다 |
| 운영 | actuator `beans`/`env`**내부 구조를 그대로 드러낸다.** 실험대에서만 연다 |
+297
View File
@@ -0,0 +1,297 @@
# B-1 — Redis 를 붙이면 무엇이 옮겨지고 무엇이 안 옮겨지는가 → Q3
브랜치 `feature/keycloak-b1-redis-session-store` ·
증거 [`docs/evidence/b1-redis-session-store/`](evidence/b1-redis-session-store/) ·
2026-09-04 14:5015:05 KST
선행: [`B-0`](experiment-b0-bff-redis-deploy.md)
**대응 질문** — [Q3 · BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가](https://hyeonworks.com/questions/bff-session-authorized-client-store)
---
## 0. 결론부터
| | before | after | |
|---|---|---|---|
| `sessionRepository` | (없음, Tomcat 기본) | **`RedisSessionRepository`** | **옮겨졌다** |
| `authorizedClientService` | `InMemoryOAuth2AuthorizedClientService` | **`InMemoryOAuth2AuthorizedClientService`** | **그대로다** |
| `authorizedClientRepository` | `AuthenticatedPrincipalOAuth2AuthorizedClientRepository` | **동일** | **그대로다** |
그 결과 사용자에게는 이렇게 보인다.
```json
{"principal":"labuser",
"accessTokenStoredOnServer":false,
"refreshTokenStoredOnServer":false,
"browserTokenCount":0}
```
**"로그인은 되어 있는데 아무것도 못 하는" 상태**가 만들어진다.
Q1 이 *"Session Store 를 공유 저장소로 변경하는 것만으로는 충분하지 않다"*
쓴 것의 실물이다.
---
## 1. 문제 ① — 쿠버네티스가 내 환경변수를 덮어썼다
배포하자마자 파드가 안 떴다.
```
Failed to bind properties under 'spring.data.redis.port' to int:
Property: spring.data.redis.port
Value: "${REDIS_PORT:6379}"
Reason: failed to convert java.lang.String to int
(caused by NumberFormatException: For input string: "tcp://10.43.57.116:6379")
```
**쿠버네티스가 `REDIS_PORT=tcp://10.43.57.116:6379` 를 주입했다.**
### 개념 — Service Links
쿠버네티스는 같은 네임스페이스의 **모든 Service 마다** Docker link 시절의
환경변수를 파드에 자동으로 넣는다.
```
Service 이름이 redis 이면
REDIS_SERVICE_HOST=10.43.57.116
REDIS_SERVICE_PORT=6379
REDIS_PORT=tcp://10.43.57.116:6379 ← 이게 문제
REDIS_PORT_6379_TCP=tcp://10.43.57.116:6379
REDIS_PORT_6379_TCP_ADDR=10.43.57.116
...
```
**`<SVCNAME>_PORT` 는 포트 번호가 아니라 URL 형태다.** 이름이 겹치면
애플리케이션 설정이 조용히 오염된다.
```yaml
spec:
enableServiceLinks: false # 근본 처방
```
> **환경변수 이름을 바꿔 피할 수도 있다.** 그러면 다음 사람이 같은 함정에
> 다시 빠진다. **주입 자체를 끄는 쪽**을 골랐다.
>
> 이 함정은 Service 이름과 환경변수 이름이 겹칠 때만 나타나므로,
> `REDIS`, `POSTGRES`, `MYSQL` 처럼 **흔한 이름일수록 위험하다.**
## 문제 ② — 테스트가 Redis 를 찾다가 죽었다
`spring-session-data-redis` 를 넣으면 컨텍스트 기동 시 Redis 에 붙으려 한다.
테스트에는 Redis 가 없다.
```java
@SpringBootTest(properties = {
"KEYCLOAK_CLIENT_SECRET=test-only-secret",
// 테스트는 Redis 를 띄우지 않는다
"spring.session.store-type=none",
})
```
## 문제 ③ — 리소스 서버가 아예 없었다
API 호출이 `500` 이었다. 원인은 토큰이 아니었다.
```
java.nio.channels.UnresolvedAddressException
```
`RESOURCE_API_BASE_URL=http://echo.keycloak-lab.svc:8080` 인데 `echo`
**`header-lab` 네임스페이스의 8081** 이었다. 배포조차 되어 있지 않았다.
> **500 을 보고 "토큰이 없어서"라고 읽을 뻔했다.** 로그를 보니 DNS 였다.
> A층에서 반복해서 배운 것 — **증상과 원인을 붙이기 전에 로그를 본다.**
```yaml
# 다른 네임스페이스의 서비스는 <svc>.<ns>.svc 로 부른다
value: http://echo.header-lab.svc:8081
```
---
## 2. 자동구성이 실제로 바뀌었는가 — B-0 의 방법을 다시 쓴다
```bash
kubectl -n keycloak-lab exec <bff-pod> -- wget -qO- http://localhost:8083/actuator/beans
```
```
빈 수: 321 → 402 (+81)
--- 세션 저장소 (새로 생긴 것) ---
★ sessionRepository -> RedisSessionRepository
★ springSessionRepositoryFilter -> SessionRepositoryFilter
★ RedisHttpSessionConfiguration
★ cookieSerializer -> DefaultCookieSerializer
--- OAuth2 authorized client ---
authorizedClientService
before: InMemoryOAuth2AuthorizedClientService
after : InMemoryOAuth2AuthorizedClientService 그대로 — Redis 로 안 옮겨졌다
authorizedClientRepository
before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
after : AuthenticatedPrincipalOAuth2AuthorizedClientRepository 그대로
```
**빈 81개가 늘었는데 authorized client 는 하나도 안 바뀌었다.**
> **"Redis 를 붙였다"가 "상태가 공유된다"를 뜻하지 않는다.**
> 무엇이 옮겨졌는지 **찍어서 확인**해야 한다. B-0 을 실험으로 만든 이유다.
---
## 3. Redis 안에 무엇이 들어갔는가 → Q3 검증 2번
```
=== Redis 키 ===
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
dbsize: 1
=== 필드 ===
sessionAttr:SPRING_SECURITY_CONTEXT
sessionAttr:SPRING_SECURITY_SAVED_REQUEST
sessionAttr:SPRING_SECURITY_LAST_EXCEPTION
sessionAttr:...HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST
lastAccessedTime / maxInactiveInterval / creationTime
=== TTL ===
1772 초 ← spring.session.timeout=30m 과 일치
```
### **refresh token 은 Redis 에 없다**
Q3 는 *"저장소를 직접 열어 refresh token 이 평문으로 남는지 확인한다"*
검증 항목으로 두었다. 답은 더 앞에 있었다 — **애초에 들어가지 않는다.**
```
Application Session ──▶ Redis (인증 상태, principal, 인가 요청)
OAuth2AuthorizedClient ─▶ 프로세스 메모리 (access token, refresh token)
```
**"토큰 암호화를 어떻게 할까"를 고민하기 전에, 토큰이 그 저장소에 가지도
않는다는 것을 먼저 알아야 한다.**
### 직렬화는 Java 네이티브다
```
\xac\xed\x00\x05sr\x00=org.springframework.security.core.context.SecurityContextImpl
```
`\xac\xed` 는 **Java 직렬화 매직 넘버**다. JSON 이 아니다.
| 결과 | |
|---|---|
| 사람이 못 읽는다 | 운영 중 디버깅이 어렵다 |
| **클래스 버전에 묶인다** | 애플리케이션을 올리면 **기존 세션이 역직렬화에 실패**할 수 있다 |
| 역직렬화 취약점 | 신뢰할 수 없는 데이터가 들어오면 위험한 형식이다 |
**D-2(버전 업그레이드)에서 이것이 다시 나온다** — Spring Security 버전이
바뀌면 Redis 에 남은 세션이 깨질 수 있다.
---
## 4. 사용자에게 보이는 결과 — 가장 중요한 부분
![Redis 전환 후 token-boundary](evidence/b1-redis-session-store/b1-token-boundary-after-redis.png)
```json
{"pattern":"AP3-backend-for-frontend",
"principal":"labuser", Redis
"accessTokenStoredOnServer":false,
"refreshTokenStoredOnServer":false,
"browserTokenCount":0,
"csrfProtectionEnabled":true}
```
**파드가 전부 교체됐는데 로그인 상태는 살아남았다.** Redis 덕분이다.
**그런데 토큰은 같이 살아남지 못했다.** 인스턴스 메모리에 있었으니까.
```
사용자 관점: 로그인되어 있다고 나온다
실제: BFF 가 사용자를 대신해 아무것도 못 한다
```
**이것이 "부분적으로만 공유했을 때"의 실패 모양이다.**
완전히 로그아웃되는 편이 차라리 낫다 — 적어도 사용자가 다시 로그인한다.
### B-0 과 나란히 놓으면
| | B-0 (Redis 없음, replica 1) | **B-1 (Redis 세션, replica 2)** |
|---|---|---|
| `principal` | labuser | labuser |
| `accessTokenStoredOnServer` | **true** | **false** |
| 파드 재시작 후 | 로그아웃 | **로그인 상태만 남고 토큰은 소실** |
---
## 5. Q3 검증 항목 대조
| # | Q3 의 검증 | 결과 |
|---|---|---|
| 1 | 인스턴스 두 대에서 로그인 유지·재시작 복구 | **세션은 유지, 토큰은 소실** |
| 2 | 저장소를 열어 refresh token 이 평문인지 | **평문 이전에 존재하지 않는다** |
| 3 | session TTL 과 token 만료 어긋남 | TTL 1772초 관측. 토큰 만료(60초)와 **처음부터 어긋나 있다** |
| 4 | logout 뒤 두 store 잔여 항목 | **B-2 에서 이어서** |
| 5 | 저장소를 끊었을 때 오류 | **B-5 에서** |
| 6 | 같은 store vs 분리 | **분리가 기본값이었다** — 고르는 것이 아니라 이미 그렇다 |
| 7 | 저장소 지연이 화면 지연으로 | **B-2 이후** |
**6번의 답이 이 실험의 요지다.** "두 상태를 같은 저장소에 둘지 나눌지"는
선택지가 아니라 **이미 나뉘어 있고, 나뉜 채로 두면 깨진다.**
---
## 6. 그래서 무엇을 해야 하는가
`OAuth2AuthorizedClientService` 를 공유 저장소로 옮기는 구현이 따로 필요하다.
| 후보 | |
|---|---|
| `JdbcOAuth2AuthorizedClientService` | Spring Security 기본 제공. **PostgreSQL 이 이미 있다** |
| 직접 구현 (Redis) | `OAuth2AuthorizedClientService` 인터페이스를 Redis 로 구현 |
| 세션 안에 넣기 | `HttpSessionOAuth2AuthorizedClientRepository` 를 쓰면 세션과 함께 Redis 로 간다 |
**세 번째가 흥미롭다** — 조회 키 문제(principal 기준)까지 같이 해결된다.
세션 단위로 저장되므로 **같은 사용자의 다른 브라우저가 서로를 덮어쓰지 않는다.**
대신 세션이 커진다.
**B-2 에서 이 선택지를 비교한다.**
---
## 7. 재현 절차 (명령어)
```bash
# 1. 의존성 두 개를 함께 넣는다 (하나만 넣으면 조용히 in-memory 로 남는다)
# spring-session-data-redis + spring-boot-starter-data-redis
# 2. 테스트는 Redis 를 안 띄우므로 store-type=none 을 준다
# 3. 배포 — enableServiceLinks: false 를 잊지 말 것
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
# 4. 자동구성이 실제로 바뀌었는지 확인 (B-0 의 방법)
kubectl -n keycloak-lab exec <bff-pod> -- wget -qO- http://localhost:8083/actuator/beans > after.json
# sessionRepository 가 RedisSessionRepository 인가
# authorizedClientService 는 여전히 InMemory 인가 ← 이쪽이 핵심
# 5. Redis 를 직접 연다
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
kubectl -n keycloak-lab exec deploy/redis -- redis-cli hkeys "bff:session:sessions:<id>"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli ttl "bff:session:sessions:<id>"
# 6. 사용자 관점 확인
# 브라우저로 https://app1.hyeonworks.com/bff/token-boundary
```
---
## 8. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **B-2** 다중 인스턴스 | **authorized client 를 어디로 옮길지**가 남았다. 세 후보를 비교한다 |
| **B-3** refresh 경쟁 | 토큰이 공유되어야 경쟁이 재현된다 — **아직 공유되지 않았다** |
| **D-2** 업그레이드 | **Java 직렬화된 세션**이 버전 변경에 견디는가 |
| 운영 | `enableServiceLinks: false` — Service 이름과 환경변수 충돌 |
@@ -0,0 +1,314 @@
# B-2 — 인스턴스를 늘렸을 때 무엇이 깨지고 무엇이 남는가 → Q1
브랜치 `feature/keycloak-b2-multi-instance-session` ·
증거 [`docs/evidence/b2-multi-instance-session/`](evidence/b2-multi-instance-session/) ·
2026-09-04 15:0515:15 KST
선행: [`B-0`](experiment-b0-bff-redis-deploy.md) · [`B-1`](experiment-b1-redis-session-store.md)
**대응 질문** — [Q1 · 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가](https://hyeonworks.com/questions/server-session-pattern-multi-instance)
---
## 0. 결론부터
B-1 이 남긴 문제(세션만 공유되고 토큰은 안 됨)를 **JDBC 로 옮겨 해결했다.**
그러자 **다른 두 문제가 남았다.**
| Q1 검증 | 결과 |
|---|---|
| ① 다른 인스턴스로 요청해도 되는가 | **된다** — 세션 Redis + 토큰 PostgreSQL |
| ② 재시작 후 로그인 유지 | **된다** |
| ③ 같은 사용자의 다른 브라우저가 덮어쓰는가 | **★ 덮어쓴다.** 기본키가 그렇게 되어 있다 |
| ④ 로그아웃하면 두 저장소가 다 정리되는가 | **★ 아니다. 한쪽만 정리된다** |
```
로그아웃 후:
Redis 세션 : 0 키 ← 정리됨
PostgreSQL 토큰 : 1 행 ← 평문 refresh token 이 그대로 남는다
Keycloak SSO : 2 세션 ← 남아 있다
```
---
## 1. 설계 — 왜 JDBC 를 골랐나
B-1 에서 컨트롤러가 `OAuth2AuthorizedClientService` 를 직접 쓰는 것을 확인했다.
```java
private final OAuth2AuthorizedClientService authorizedClientService;
...
OAuth2AuthorizedClient client = authorizedClientService.loadAuthorizedClient(...);
```
| 후보 | 컨트롤러 변경 | 조회 키 문제 |
|---|---|---|
| **`JdbcOAuth2AuthorizedClientService`** | **불필요** (같은 인터페이스) | 안 고쳐짐 |
| Redis 직접 구현 | 불필요 | 안 고쳐짐 |
| `HttpSessionOAuth2AuthorizedClientRepository` | **필요** (Repository 로 바꿔야) | **고쳐짐** |
**Q3 가 "Redis 와 JDBC 중 무엇" 을 물었으므로 JDBC 를 골랐다.**
PostgreSQL 이 이미 있어 새 인프라가 필요 없고, 세션(Redis) + 토큰(JDBC)
**분리 저장**을 그대로 시험할 수 있다.
```java
@Bean
OAuth2AuthorizedClientService authorizedClientService(
JdbcOperations jdbcOperations,
ClientRegistrationRepository clientRegistrationRepository
) {
return new JdbcOAuth2AuthorizedClientService(jdbcOperations, clientRegistrationRepository);
}
```
---
## 2. 문제 — 스키마가 조용히 안 만들어졌다
파드는 떴고 Hikari 도 붙었는데 테이블이 없었다.
```
HikariPool-1 - Start completed.
...
Did not find any relation named "oauth2_authorized_client".
```
**Spring Security 가 두 벌의 DDL 을 제공한다.**
```
org/springframework/security/oauth2/client/oauth2-client-schema.sql ← 기본
org/springframework/security/oauth2/client/oauth2-client-schema-postgres.sql ← PostgreSQL 용
```
기본 판본은 `blob` 타입을 쓴다. **PostgreSQL 에는 그 타입이 없다** (`bytea` 다).
```sql
access_token_value blob NOT NULL, -- 기본 판본
access_token_value bytea NOT NULL, -- postgres 판본
```
그리고 내가 `continue-on-error: true` 를 켜둬서 **그 실패가 삼켜졌다.**
```yaml
schema-locations: classpath:org/springframework/security/oauth2/client/oauth2-client-schema-postgres.sql
```
> **`continue-on-error` 는 "없어도 되는 초기화"에만 쓴다.**
> 여기서는 그것 때문에 "테이블이 조용히 안 생기는" 상태가 됐고, 파드는
> **정상으로 보였다.** A층에서 반복해서 만난 "실패가 조용한" 유형이다.
### 그리고 DDL 자체가 Q1 의 답을 담고 있었다
```sql
CREATE TABLE oauth2_authorized_client (
client_registration_id varchar(100) NOT NULL,
principal_name varchar(200) NOT NULL,
...
PRIMARY KEY (client_registration_id, principal_name)
);
```
**기본키에 session id 가 없다.** B-0 에서 빈 이름
(`AuthenticatedPrincipalOAuth2AuthorizedClientRepository`)으로 짐작한 것이
**테이블 정의로 확정된다.** 구현을 바꿔도, 저장소를 바꿔도, **이 키를 그대로
쓰는 한 같은 사용자의 두 브라우저는 한 행을 공유한다.**
---
## 3. 결과 ① — 인스턴스 간 공유가 된다
```
=== 재로그인 후 oauth2_authorized_client ===
client_registration_id | principal_name | access_token_type | at_len | rt_len
------------------------+----------------+-------------------+--------+--------
keycloak | labuser | Bearer | 1431 | 744
=== Redis ===
bff:session:sessions:c63c39ee-... (dbsize 1)
```
![토큰이 인스턴스 간에 공유된다](evidence/b2-multi-instance-session/b2-tokens-shared-across-instances.png)
```json
{"principal":"labuser",
"accessTokenStoredOnServer":true, B-1 false
"refreshTokenStoredOnServer":true,
"browserTokenCount":0}
```
**두 저장소가 각자 제 일을 한다.**
```
Application Session ──▶ Redis (인증 상태)
OAuth2AuthorizedClient ▶ PostgreSQL (access / refresh token)
```
**Q3 가 "두 상태를 반드시 같은 저장소에 보관해야 하는 것은 아니다" 라고 한 것이
실물로 성립한다.** 다만 B-1 에서 본 대로, **한쪽만 옮기면 더 나쁘다.**
---
## 4. 결과 ② — refresh token 이 평문이다 → Q3 검증 2번
```sql
select convert_from(refresh_token_value, 'UTF8') from oauth2_authorized_client;
```
```
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlMmUz...
```
디코드하면
```
refresh_token 헤더 : {"alg":"HS512","typ":"JWT","kid":"e2e3d6d3-..."}
refresh_token 본문 : {"exp":1788500446,"iat":1788498646,"jti":"54096fa4-...",
"iss":"https://auth.hyeonworks.com/realms/keycloak-patterns"}
access_token 헤더 : {"alg":"RS256","typ":"JWT","kid":"OY-caYDNGoP4HMAz-..."}
```
**`bytea` 안에 든 것은 암호화된 덩어리가 아니라 JWT 문자열 그대로다.**
> **DB 읽기 권한만 있으면 그 자리에서 쓸 수 있는 토큰을 얻는다.**
> 백업 파일, 읽기 전용 복제본, 덤프, 로그 — 어디로든 새면 그대로 쓸 수 있다.
>
> Q3 의 가정 *"저장된 refresh token 을 평문으로 두면 안 된다"* 는 옳고,
> **Spring Security 기본 구현은 그 가정을 지키지 않는다.**
> 암호화하려면 `JdbcOAuth2AuthorizedClientService` 를 감싸거나 직접 구현해야 한다.
---
## 5. 결과 ③ — 같은 사용자의 두 번째 로그인이 덮어쓴다 → Q1 검증 3번
같은 사용자로 다시 로그인시키고 행을 비교했다.
```
=== 재로그인 전 ===
principal_name | access_token_issued_at | at_md5
labuser | 2026-09-04 05:10:46.927192 | 675af2286bfc2fd9d2bab7bc8f391df7
행 수: 1
=== 재로그인 후 ===
labuser | 2026-09-04 05:12:13.018828 | e19a63fc5aa18bd0a68b3e19dff16b3b
행 수: 1
```
**행 수는 그대로, 값만 바뀌었다. UPDATE 다.**
```
브라우저 A 로그인 → (keycloak, labuser) 행 생성
브라우저 B 로그인 → 같은 행을 덮어쓴다
└─ A 의 토큰은 사라진다
```
**A 쪽에서 다음 요청을 하면 B 의 토큰을 쓰게 된다.** 같은 사용자이므로
당장은 문제가 안 보이지만,
| 언제 문제가 되는가 | |
|---|---|
| B 가 로그아웃하면 | **A 도 같이 끊긴다** (행이 지워지므로) |
| refresh 회전이 걸려 있으면 | **A 와 B 가 같은 refresh token 을 다툰다** → B-3 |
| 스코프가 다른 로그인이면 | 나중 것이 이긴다 |
**저장소를 바꿔도 안 고쳐진다.** 고치려면 조회 키에 session 을 넣어야 하고,
그것이 `HttpSessionOAuth2AuthorizedClientRepository` 다.
---
## 6. 결과 ④ — 로그아웃이 한쪽만 정리한다 → Q1 검증 4번
```
=== 로그아웃 후 ===
Redis 세션 : 0 키 ← 정리됨
PostgreSQL 토큰 : 1 행 ← 남아 있다
Keycloak SSO : 2 세션 ← 남아 있다
principal_name | access_token_issued_at | access_token_expires_at
labuser | 2026-09-04 05:12:13.018828 | 2026-09-04 05:13:13.018828
```
**세 저장소 중 하나만 지워졌다.**
```
로그아웃
├─▶ HttpSession 무효화 ✔ Redis 키 삭제됨
├─▶ authorized client 삭제 ✗ 아무도 안 지운다
└─▶ Keycloak SSO 종료 ✗ RP-initiated logout 을 안 보낸다
```
| 남은 것 | 결과 |
|---|---|
| **PostgreSQL 의 평문 refresh token** | 로그아웃한 사용자의 **작동하는 토큰**이 DB 에 남는다 |
| **Keycloak SSO 세션** | 앱을 다시 열면 **로그인 화면 없이 다시 로그인**된다 |
**두 번째가 사용자에게 특히 혼란스럽다** — "로그아웃했는데 다시 들어가면
그냥 들어가진다". 실험 중에도 계속 그랬다. 세션을 지워도 Keycloak SSO 가
살아 있어 조용히 재인증됐다.
### 무엇을 해야 하는가
| 필요한 것 | 방법 |
|---|---|
| authorized client 삭제 | `LogoutSuccessHandler` 에서 `removeAuthorizedClient` 호출 |
| Keycloak 세션 종료 | **RP-initiated logout**`OidcClientInitiatedLogoutSuccessHandler` |
| 두 곳을 원자적으로 | 한쪽이 실패하면? — **정리 순서와 실패 처리를 정해야 한다** |
**Q3 의 미지수 5번("두 store 를 logout 에서 어떻게 한 번에 지우게 되는가")이
바로 이 지점이며, 답은 "지금은 하나도 안 지운다" 이다.**
---
## 7. Q1 검증 항목 대조
| # | Q1 의 검증 | 결과 |
|---|---|---|
| 1 | 다른 인스턴스로 요청 시 200 유지 | **된다** (Redis + JDBC 조합) |
| 2 | 재시작 후 session cookie 로 상태 유지 | **된다** |
| 3 | 두 브라우저에서 authorized client 덮어쓰기 | **★ 덮어쓴다.** 기본키가 원인 |
| 4 | 한쪽 logout 후 다른 쪽 | **★ 한쪽만 정리된다** |
| 5 | session 만료 ≠ token 만료 | 세션 30분 / access 60초 — **처음부터 어긋나 있다** |
| — | (B-0 에서) replica 2개에서 **로그인 자체가 실패** | Redis 세션으로 **해결됨** |
---
## 8. 재현 절차 (명령어)
```bash
# 1. JDBC authorized client service 빈 추가 (SecurityConfig)
# + spring-boot-starter-jdbc, postgresql 의존성
# 2. 스키마 — PostgreSQL 판본을 써야 한다
kubectl -n keycloak-lab exec <bff-pod> -- sh -c \
'unzip -p /app/app.jar BOOT-INF/lib/spring-security-oauth2-client-*.jar' > /dev/null
# 실제로는 nested jar 를 풀어서 -postgres.sql 을 꺼낸다
kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak < oauth2-pg.sql
# 3. 저장소가 채워지는지
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select client_registration_id, principal_name, length(refresh_token_value) from oauth2_authorized_client"
# 4. 평문 여부
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select convert_from(refresh_token_value,'UTF8') from oauth2_authorized_client limit 1"
# 5. 덮어쓰기 — 같은 사용자로 다시 로그인시키고 md5 를 비교
kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall # 세션만 지운다
# 브라우저로 재접속 → 행 수는 그대로, md5 는 바뀐다
# 6. 로그아웃 정리
# POST /logout (CSRF 는 form 파라미터 _csrf 로)
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select count(*) from oauth2_authorized_client"
```
---
## 9. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **B-3** refresh 경쟁 | **이제 토큰이 공유된다** — 경쟁이 재현될 조건이 갖춰졌다. 그리고 **덮어쓰기 때문에 두 브라우저가 같은 refresh token 을 다툰다** |
| **B-4** Edge 인가 | 리소스 서버 직접 호출 차단(Q1 제약)은 2홉 NetworkPolicy 패턴 재사용 |
| **B-5** Redis 상실 | 이제 세션(Redis)과 토큰(PostgreSQL)이 나뉘어 있어 **각각 죽여볼 수 있다** |
| 보안 | **평문 refresh token****로그아웃 후 잔존** — 둘 다 코드로 막아야 한다 |
@@ -0,0 +1,270 @@
# B-3 — 같은 refresh token 으로 동시에 갱신하면 → Q2
브랜치 `feature/keycloak-b3-refresh-token-contention` ·
증거 [`docs/evidence/b3-refresh-contention/`](evidence/b3-refresh-contention/) ·
2026-09-04 15:1515:25 KST
선행: [`B-2`](experiment-b2-multi-instance-session.md) — 토큰이 공유되어야 경쟁이 성립한다
**대응 질문** — [Q2 · Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가](https://hyeonworks.com/questions/refresh-rotation-replica-contention)
> Q2 가 남긴 것: *"실제 Keycloak 응답과 session 영향은 아직 재현해 보지 않았다."*
---
## 0. 결론부터
**"하나는 성공하고 하나는 실패한다"가 아니다. 세션이 파괴된다.**
```
5개를 동시에 보냈을 때 (rotation ON, maxReuse=0)
요청 1: 400 "Maximum allowed refresh token reuse exceeded"
요청 2: 400 "Session doesn't have required client"
요청 3: 400 "Session doesn't have required client"
요청 4: 400 "Session doesn't have required client"
요청 5: 200 (토큰 발급됨)
★ 그런데 5번이 받은 토큰으로 다시 갱신하면 → 400
```
**이긴 요청조차 쓸 수 없는 토큰을 받는다.**
| 구성 | 성공 | 이긴 토큰 재사용 | client_session |
|---|---|---|---|
| **A** rotation ON · maxReuse=0 | **1 / 5** | **400** | **0 — 파괴** |
| **B** rotation OFF | **5 / 5** | 200 | **1 — 생존** |
| **C** rotation ON · maxReuse=1 | **2 / 5** | **400** | **0 — 파괴** |
**Q2 의 판정 기준***"실패가 사용자에게 노출되면 lock, 노출되지 않으면 재시도."*
**재시도로 회복되지 않는다.** 세션 자체가 없어지므로 답은 **lock** 이다.
---
## 1. 전제를 Q2 에 맞춘다
```bash
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh get realms/keycloak-patterns \
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
```
```json
{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
```
**기본값은 rotation 이 꺼져 있었다.** Q2 는 *"realm 이 refresh token rotation 과
재사용 허용 0회를 쓰게 되어서"* 를 전제로 하므로 맞춰야 한다.
```bash
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=0
```
> **`revokeRefreshToken` 이 rotation 스위치다.** 이름이 "회전"이 아니라
> "취소"인 것이 헷갈리는데, **켜면 새 토큰을 줄 때 옛 토큰을 무효화**한다.
> `refreshTokenMaxReuse` 는 그 위에서 **몇 번까지 봐줄 것인가**이다.
---
## 2. 재현 — 진짜 동시성을 만든다
B-2 에서 토큰이 PostgreSQL 로 공유되므로 두 replica 가 같은 항목을 본다.
다만 **Keycloak 쪽 동작을 분리해서 보려면** BFF 를 거치지 않는 편이 낫다.
```bash
# 파드 안에서 5개를 동시에 띄우고 wait
i=1; while [ $i -le 5 ]; do
( curl -s -o /tmp/b$i -w "%{http_code}" -X POST $KC \
-d grant_type=refresh_token -d client_id=bff-confidential \
-d client_secret=bff-lab-secret -d refresh_token=$RT > /tmp/c$i ) &
i=$((i+1)); done
wait
```
**순차 실행이면 재현되지 않는다.** `&` 로 띄우고 `wait` 해야 진짜로 겹친다.
---
## 3. 무슨 일이 일어났는가 — 기제
오류 메시지가 **두 종류**인 것이 단서였다.
| 메시지 | 뜻 |
|---|---|
| `Maximum allowed refresh token reuse exceeded` | **재사용 탐지가 발동** |
| `Session doesn't have required client` | **그 여파** — client session 이 이미 없다 |
DB 로 확인했다.
```sql
select us.user_session_id,
(select count(*) from offline_client_session cs
where cs.user_session_id = us.user_session_id) as client_sessions
from offline_user_session us where us.user_session_id = '<sid>';
```
```
경쟁을 겪은 세션: BvFiB01Rntz1FcLdf7zG4BNt client_sessions = 0 ← 제거됨
정상 세션(대조군): JT-XuepgutWcE273QwAnIXta client_sessions = 1
```
**user session 은 남고 client session 만 제거된다.**
```
user session "이 브라우저는 labuser 로 로그인함" ← 남는다
└─ client session "그중 bff-confidential 에 대한 상태" ← 지워진다
```
그래서 오류가 `"Session doesn't have required client"` 다 —
**세션은 있는데 그 클라이언트 몫이 없다.**
### 그래서 이긴 요청도 죽는다
```
t0 5개가 동시에 도착
t1 하나가 처리를 시작 → 새 토큰 발급 준비
t2 다른 것들이 같은 옛 토큰으로 들어옴 → 재사용 탐지 발동
t3 ★ client session 제거
t4 t1 의 응답이 나간다 → HTTP 200, 새 토큰
t5 그 토큰을 쓰면 → client session 이 없다 → 400
```
**애플리케이션은 200 을 받았으므로 성공했다고 믿는다.**
다음 요청에서야 끊긴 것을 안다. **오류가 지연되어 나타난다.**
---
## 4. 정책을 바꿔 비교했다
### 구성 B — rotation OFF
```
1: 200 2: 200 3: 200 4: 200 5: 200
성공 5 / 5
이긴 토큰 재사용: HTTP 200
남은 client_session: 1
```
**전부 성공하고 세션도 멀쩡하다.** 같은 refresh token 을 계속 쓸 수 있으므로
경쟁 자체가 성립하지 않는다.
**대신 잃는 것** — 토큰이 유출되면 **만료까지 계속 쓸 수 있다.**
rotation 의 목적이 그 창을 좁히는 것이었다.
### 구성 C — rotation ON · maxReuse=1
```
1: 200
2: 400 "Session doesn't have required client"
3: 200
4: 400 "Maximum allowed refresh token reuse exceeded"
5: 400 "Session doesn't have required client"
성공 2 / 5
이긴 토큰 재사용: HTTP 400
남은 client_session: 0
```
**허용치를 1로 올려도 세션은 파괴됐다.**
> **`refreshTokenMaxReuse` 를 올리는 것은 해법이 아니다.**
> 동시 요청이 N 개면 `maxReuse ≥ N-1` 이어야 하는데, 그러면
> **rotation 의 보안 목적이 사라진다.** 값을 올려 버티려는 시도는
> "몇 개까지 동시에 올 것인가"를 맞춰야 하는 문제로 바뀔 뿐이다.
---
## 5. Q2 검증 항목 대조
| # | Q2 의 검증 | 결과 |
|---|---|---|
| 1 | 동시 갱신 시 각 replica 동작 | **1개만 200, 나머지 400. 그런데 200 도 무효** |
| 2 | 사용자 화면에 로그인 만료로 보이나 일시 오류로 보이나 | **로그인 만료로 보인다** — 세션이 실제로 없어졌으므로 |
| 3 | 새 token 을 다시 읽어 **재시도하면 성공하는가** | **★ 실패한다.** client session 이 없어 어떤 토큰도 안 통한다 |
| 4 | 한 곳에서만 갱신할지 / 각자 하고 재시도할지 | **재시도로는 회복 불가 → 한 곳에서만** |
| 5 | lock 을 어디에 두고 얼마나 / 잡은 채 죽으면 | **아래 6절** |
| 6 | 갱신 실패를 로그인 만료와 구분할 수 있는가 | **구분할 필요가 없다 — 실제로 로그인 만료다** |
| 7 | rotation 전제를 바꿔서 비교 | **구성 B/C 로 측정 완료** |
**3번이 이 실험의 핵심이다.** Q2 는 "재시도하면 성공하는가"를 열어뒀는데,
**답은 아니오**이고 그래서 판정 기준이 자동으로 lock 쪽으로 결정된다.
---
## 6. 그래서 무엇을 해야 하는가
### lock 이 필요하다 — 그런데 어디에
```
BFF replica 1 ─┐
├─▶ 같은 (client, principal) 항목
BFF replica 2 ─┘
```
**lock 은 저장소 쪽에 있어야 한다.** 프로세스 안의 `synchronized`
replica 를 넘지 못한다.
| 후보 | |
|---|---|
| **PostgreSQL 행 잠금** | `SELECT ... FOR UPDATE`**A-0 에서 Keycloak 자신이 쓰는 방식** |
| Redis 분산 lock | `SET NX PX` — TTL 로 스스로 풀린다 |
| 갱신 전용 인스턴스 | 단일 지점. 그 인스턴스가 죽으면? |
**첫 번째가 자연스럽다** — 토큰이 이미 PostgreSQL 에 있고(B-2),
Keycloak 도 세션 갱신에 같은 기법을 쓴다.
```sql
-- A-0 에서 Keycloak 이 실제로 쓰는 것
select VERSION from OFFLINE_USER_SESSION ... for no key update skip locked
```
### lock 을 잡은 채 죽으면 (Q2 미지수 5번)
| 방식 | 프로세스가 죽으면 |
|---|---|
| **DB 행 잠금** | **연결이 끊기면 자동 해제** — 가장 안전하다 |
| Redis lock + TTL | TTL 만료까지 막힌다. TTL 이 짧으면 **중복 갱신**, 길면 **정지** |
**DB 잠금이 이 문제에서 유리한 이유가 여기 있다** — 잠금의 수명이
**연결의 수명**과 묶여 있어 따로 관리할 것이 없다.
**B-5(Redis 상실)에서 Redis lock 의 이 약점을 재볼 수 있다.**
---
## 7. 재현 절차 (명령어)
```bash
# 1. 전제 맞추기
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=0
# 2. refresh token 하나 확보 (direct grant)
curl -s -X POST $KC -d grant_type=password -d client_id=bff-confidential \
-d client_secret=bff-lab-secret -d username=labuser -d password=labpass -d scope=openid
# 3. 동시에 5개 — & 와 wait 이 없으면 재현되지 않는다
i=1; while [ $i -le 5 ]; do ( curl ... -d refresh_token=$RT > /tmp/c$i ) & i=$((i+1)); done; wait
# 4. ★ 이긴 요청의 토큰을 다시 써본다 — 여기서 진짜 답이 나온다
curl -s -o /dev/null -w '%{http_code}' -X POST $KC -d grant_type=refresh_token -d refresh_token=$NEW
# 5. 기제 확인 — client session 이 지워졌는지
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select us.user_session_id,
(select count(*) from offline_client_session cs
where cs.user_session_id = us.user_session_id) as client_sessions
from offline_user_session us where us.user_session_id = '<sid>'"
# 6. 정책 비교 — revokeRefreshToken 과 refreshTokenMaxReuse 를 바꿔가며 3~5 반복
```
---
## 8. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **B-5** Redis 상실 | Redis lock 을 쓴다면 **Redis 가 죽었을 때 갱신이 멈춘다** |
| **B-6** 암호화 key 교체 | 같은 "동시 접근" 문제의 다른 얼굴 |
| **A-6** 지연 주입 (기록 정정) | A-6 에서 낙관적 락 충돌이 0 이었던 이유가 확인된다 — **로그인은 새 행을 만들 뿐**이고, 다투는 것은 **여기서처럼 같은 항목을 갱신할 때**다 |
| 설계 | **재시도로 회복되지 않는다 → lock.** Q2 의 판정 기준이 결정됐다 |