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>
15 KiB
B-2 — 인스턴스를 늘렸을 때 무엇이 깨지고 무엇이 남는가 → Q1
브랜치 feature/keycloak-b2-multi-instance-session ·
증거 docs/evidence/b2-multi-instance-session/ ·
2026-09-04 15:05–15:15 KST
대응 질문 — Q1 · 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가
0. 결론부터
B-1 이 남긴 문제(세션만 공유되고 토큰은 안 됨)를 JDBC 로 옮겨 해결했다. 그러자 다른 두 문제가 남았다.
| Q1 검증 | 결과 |
|---|---|
| ① 다른 인스턴스로 요청해도 되는가 | 된다 — 세션 Redis + 토큰 PostgreSQL |
| ② 재시작 후 로그인 유지 | 된다 |
| ③ 같은 사용자의 다른 브라우저가 덮어쓰는가 | ★ 덮어쓴다. 기본키가 그렇게 되어 있다 |
| ④ 로그아웃하면 두 저장소가 다 정리되는가 | ★ 아니다. 한쪽만 정리된다 |
로그아웃 후:
Redis 세션 : 0 키 ← 정리됨
PostgreSQL 토큰 : 1 행 ← 평문 refresh token 이 그대로 남는다
Keycloak SSO : 2 세션 ← 남아 있다
1. 설계 — 왜 JDBC 를 골랐나
B-1 에서 컨트롤러가 OAuth2AuthorizedClientService 를 직접 쓰는 것을 확인했다.
private final OAuth2AuthorizedClientService authorizedClientService;
...
OAuth2AuthorizedClient client = authorizedClientService.loadAuthorizedClient(...);
| 후보 | 컨트롤러 변경 | 조회 키 문제 |
|---|---|---|
JdbcOAuth2AuthorizedClientService |
불필요 (같은 인터페이스) | 안 고쳐짐 |
| Redis 직접 구현 | 불필요 | 안 고쳐짐 |
HttpSessionOAuth2AuthorizedClientRepository |
필요 (Repository 로 바꿔야) | 고쳐짐 |
Q3 가 "Redis 와 JDBC 중 무엇" 을 물었으므로 JDBC 를 골랐다. PostgreSQL 이 이미 있어 새 인프라가 필요 없고, 세션(Redis) + 토큰(JDBC) 분리 저장을 그대로 시험할 수 있다.
@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 다).
access_token_value blob NOT NULL, -- 기본 판본
access_token_value bytea NOT NULL, -- postgres 판본
그리고 내가 continue-on-error: true 를 켜둬서 그 실패가 삼켜졌다.
schema-locations: classpath:org/springframework/security/oauth2/client/oauth2-client-schema-postgres.sql
continue-on-error는 "없어도 되는 초기화"에만 쓴다. 여기서는 그것 때문에 "테이블이 조용히 안 생기는" 상태가 됐고, 파드는 정상으로 보였다. A층에서 반복해서 만난 "실패가 조용한" 유형이다.
그리고 DDL 자체가 Q1 의 답을 담고 있었다
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)
{"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번
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에[모의 두 번째 브라우저] 세션만 지우고라고 적혀 있다. 세션을 지우고 같은 사용자로 다시 로그인시킨 것이며, 조회 키가(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 세션으로 해결됨 |
개념
조회 키는 저장소와 독립이다
PRIMARY KEY (client_registration_id, principal_name)
저장소를 메모리에서 DB 로 옮겨도 이 키는 그대로다. "공유 저장소로 바꾼다" 와 "세션별로 구분한다" 는 다른 문제이며, 전자만 하면 인스턴스 간 공유는 되고 브라우저 간 격리는 안 된다.
스키마 DDL 의 방언 차이
정정 — 이 절의 제목은 처음에 "Liquibase 스키마의 방언 차이" 였다. Liquibase 가 아니다. 여기서 스키마를 태우는 것은 Spring Boot 의
spring.sql.init이고, DDL 은spring-security-oauth2-clientjar 가 번들한 파일이다. (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 |
터미널 원문 |
02-schema.txt |
터미널 원문 |
03-plaintext-tokens.txt |
터미널 원문 |
04-overwrite-test.txt |
터미널 원문 |
05-logout-cleanup.txt |
터미널 원문 |
b2-before-relogin.png |
스크린샷 |
b2-tokens-shared-across-instances.png |
스크린샷 |
파일별 상세는 evidence/b2-multi-instance-session/README.md.
8. 재현 절차 (명령어)
# 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 과 로그아웃 후 잔존 — 둘 다 코드로 막아야 한다 |
