Files
keycloak-pattern/docs/experiment-b2-multi-instance-session.md
T
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

12 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번

같은 사용자로 다시 로그인시키고 행을 비교했다.

=== 재로그인 전 ===
 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 세션으로 해결됨

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