Files
keycloak-pattern/docs/experiment-b2-multi-instance-session.md
T
DongHyeonkaandClaude Opus 5 74c9b3cea7 docs: replace prose placeholders in reproduction steps with executable commands
The audit found ~80 placeholders, and the damaging ones were where the
measuring apparatus itself was prose rather than a command:

  a6  "( curl ... ) & 를 20개 띄우고 wait"  — the 22.2s headline came from this
  a3  "<로그인 반복, sid 를 /tmp/sids 에>"  — the whole RPO measurement
  a3  "<sid 목록>"                          — the control it is compared against
  a5  "<수신 파드IP>"                       — the injection
  a8  writes /tmp/tok, reads /tmp/rt        — self-inconsistent, sent an empty token
  b3  $KC / $RT / $NEW never assigned
  c2  bare kcadm.sh with no kubectl exec
  a1  conntrack tuples written by hand, though the direction flips per restart

Each is now a shell-expandable form: pod IPs from jsonpath, the admin password
from the secret, ids from kcadm --format csv, conntrack tuples derived from
"conntrack -L" with awk rather than transcribed.

Then the rewritten commands were executed against the live cluster, and one
of them failed — the 20-way load generator, written as "kubectl run --rm -i",
lost its output stream twice in a row. That is a trap this series already hit
once, and the rewrite reintroduced it. A-6 now uses a resident probe pod that
collects into a file and is cat-ed once; verified 20/20 lines.

Evidence: docs/evidence/followup/05-command-reproducibility.txt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:01:54 +09:00

15 KiB
Raw Blame History

B-2 — 인스턴스를 늘렸을 때 무엇이 깨지고 무엇이 남는가 → Q1

브랜치 feature/keycloak-b2-multi-instance-session · 증거 docs/evidence/b2-multi-instance-session/ · 2026-09-04 15:0515:15 KST

선행: B-0 · B-1

대응 질문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 logoutOidcClientInitiatedLogoutSuccessHandler
두 곳을 원자적으로 한쪽이 실패하면? — 정리 순서와 실패 처리를 정해야 한다

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-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 터미널 원문
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 $(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로그아웃 후 잔존 — 둘 다 코드로 막아야 한다