# B-2 — 인스턴스를 늘렸을 때 무엇이 깨지고 무엇이 남는가 → Q1 브랜치 `feature/keycloak-b2-multi-instance-session` · 증거 [`docs/evidence/b2-multi-instance-session/`](evidence/b2-multi-instance-session/) · 2026-09-04 15:05–15: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번 같은 사용자로 다시 로그인시키고 행을 비교했다. > **실제로는 브라우저를 두 개 쓰지 않았다.** 증거 > [`04-overwrite-test.txt`](evidence/b2-multi-instance-session/04-overwrite-test.txt) > 에 `[모의 두 번째 브라우저] 세션만 지우고` 라고 적혀 있다. > **세션을 지우고 같은 사용자로 다시 로그인시킨 것**이며, 조회 키가 > `(clientRegistrationId, principalName)` 이므로 브라우저가 둘이든 하나든 > **같은 행을 쓴다는 점에서 등가**다. 다만 "두 브라우저에서" 라고 쓴 것은 > 측정하지 않은 것을 측정한 것처럼 적은 것이다. ``` === 재로그인 전 === 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 세션으로 **해결됨** | --- --- ## 개념 ### 조회 키는 저장소와 독립이다 ```sql PRIMARY KEY (client_registration_id, principal_name) ``` **저장소를 메모리에서 DB 로 옮겨도 이 키는 그대로다.** "공유 저장소로 바꾼다" 와 "세션별로 구분한다" 는 다른 문제이며, 전자만 하면 인스턴스 간 공유는 되고 브라우저 간 격리는 안 된다. ### 스키마 DDL 의 방언 차이 > **정정** — 이 절의 제목은 처음에 "Liquibase 스키마의 방언 차이" 였다. > **Liquibase 가 아니다.** 여기서 스키마를 태우는 것은 Spring Boot 의 > `spring.sql.init` 이고, DDL 은 `spring-security-oauth2-client` jar 가 > 번들한 파일이다. (Liquibase 는 Keycloak 이 자기 스키마에 쓰며, D-2 의 주제다.) Spring Security 는 DDL 을 두 벌 제공한다. | 파일 | 타입 | |---|---| | `oauth2-client-schema.sql` | `blob` — PostgreSQL 에 **없는 타입** | | `oauth2-client-schema-postgres.sql` | `bytea` | `spring.sql.init.continue-on-error: true` 는 이 실패를 삼킨다. **"없어도 되는 초기화" 에만 써야 하는 이유다.** ### 로그아웃이 지워야 하는 것은 셋이다 ``` ① HttpSession (Spring Security 가 지운다) ② OAuth2AuthorizedClient ★ 아무도 안 지운다 ③ IdP SSO 세션 ★ RP-initiated logout 을 보내야 한다 ``` --- --- ## 증거 파일 **증거 수집 시각: 2026-09-04 14:09 – 14:13 KST** (파일 mtime 기준. 문서 상단의 시각 표기는 작성 시점이라 다를 수 있다.) | 파일 | 종류 | |---|---| | [`01-jdbc-store-deploy.txt`](evidence/b2-multi-instance-session/01-jdbc-store-deploy.txt) | 터미널 원문 | | [`02-schema.txt`](evidence/b2-multi-instance-session/02-schema.txt) | 터미널 원문 | | [`03-plaintext-tokens.txt`](evidence/b2-multi-instance-session/03-plaintext-tokens.txt) | 터미널 원문 | | [`04-overwrite-test.txt`](evidence/b2-multi-instance-session/04-overwrite-test.txt) | 터미널 원문 | | [`05-logout-cleanup.txt`](evidence/b2-multi-instance-session/05-logout-cleanup.txt) | 터미널 원문 | | [`b2-before-relogin.png`](evidence/b2-multi-instance-session/b2-before-relogin.png) | 스크린샷 | | [`b2-tokens-shared-across-instances.png`](evidence/b2-multi-instance-session/b2-tokens-shared-across-instances.png) | 스크린샷 | 파일별 상세는 [`evidence/b2-multi-instance-session/README.md`](evidence/b2-multi-instance-session/README.md). ## 8. 재현 절차 (명령어) ```bash # 1. JDBC authorized client service 빈 추가 (SecurityConfig) # + spring-boot-starter-jdbc, postgresql 의존성 # 2. 스키마 — PostgreSQL 판본을 써야 한다 kubectl -n keycloak-lab exec $(kubectl -n keycloak-lab get pod -l app=bff --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') -- 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** 과 **로그아웃 후 잔존** — 둘 다 코드로 막아야 한다 |