An independent audit found ten documents printing values their evidence files do not contain. C-1 printed a session count of 0 where the evidence says 4, C-2 printed a success readback for a command that exited 1, and A-1 credited the conntrack flush with a split that the timestamps attribute to a pod restart four seconds earlier. Also measured wal_writer_delay, which A-3 had asserted as matching without ever querying it, relabelled the A-6 control that moved 41 percent, noted A-8's nine-sample resolution, corrected D-1's RTO to the 41 seconds its own timeline shows, and added a correction banner to D-2. Every experiment document now links its evidence files with their real collection times, and the duplicate screenshots are documented as duplicates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
382 lines
15 KiB
Markdown
382 lines
15 KiB
Markdown
# 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)
|
||
```
|
||
|
||

|
||
|
||
```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 <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** 과 **로그아웃 후 잔존** — 둘 다 코드로 막아야 한다 |
|