Files
keycloak-pattern/docs/experiment-b1-redis-session-store.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

13 KiB
Raw Blame History

B-1 — Redis 를 붙이면 무엇이 옮겨지고 무엇이 안 옮겨지는가 → Q3

브랜치 feature/keycloak-b1-redis-session-store · 증거 docs/evidence/b1-redis-session-store/ · 2026-09-04 14:5015:05 KST

선행: B-0

대응 질문Q3 · BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가


구조

B-1 구조 — 세션만 Redis 로, 토큰은 프로세스 메모리에

다이어그램 규약은 diagrams/_style.md. 실험대 전체 구조는 diagrams/lab-topology.svg.


0. 결론부터

before after
sessionRepository (없음, Tomcat 기본) RedisSessionRepository 옮겨졌다
authorizedClientService InMemoryOAuth2AuthorizedClientService InMemoryOAuth2AuthorizedClientService 그대로다
authorizedClientRepository AuthenticatedPrincipalOAuth2AuthorizedClientRepository 동일 그대로다

그 결과 사용자에게는 이렇게 보인다.

{"principal":"labuser",                      로그인은 되어 있다
 "accessTokenStoredOnServer":false,          그런데 토큰이 없다
 "refreshTokenStoredOnServer":false,
 "browserTokenCount":0}

"로그인은 되어 있는데 아무것도 못 하는" 상태가 만들어진다. Q1 이 "Session Store 를 공유 저장소로 변경하는 것만으로는 충분하지 않다" 고 쓴 것의 실물이다.


1. 문제 ① — 쿠버네티스가 내 환경변수를 덮어썼다

배포하자마자 파드가 안 떴다.

Failed to bind properties under 'spring.data.redis.port' to int:
    Property: spring.data.redis.port
    Value: "${REDIS_PORT:6379}"
    Reason: failed to convert java.lang.String to int
            (caused by NumberFormatException: For input string: "tcp://10.43.57.116:6379")

쿠버네티스가 REDIS_PORT=tcp://10.43.57.116:6379 를 주입했다.

쿠버네티스는 같은 네임스페이스의 모든 Service 마다 Docker link 시절의 환경변수를 파드에 자동으로 넣는다.

   Service 이름이 redis 이면
     REDIS_SERVICE_HOST=10.43.57.116
     REDIS_SERVICE_PORT=6379
     REDIS_PORT=tcp://10.43.57.116:6379        ← 이게 문제
     REDIS_PORT_6379_TCP=tcp://10.43.57.116:6379
     REDIS_PORT_6379_TCP_ADDR=10.43.57.116
     ...

<SVCNAME>_PORT 는 포트 번호가 아니라 URL 형태다. 이름이 겹치면 애플리케이션 설정이 조용히 오염된다.

spec:
  enableServiceLinks: false      # 근본 처방

환경변수 이름을 바꿔 피할 수도 있다. 그러면 다음 사람이 같은 함정에 다시 빠진다. 주입 자체를 끄는 쪽을 골랐다.

이 함정은 Service 이름과 환경변수 이름이 겹칠 때만 나타나므로, REDIS, POSTGRES, MYSQL 처럼 흔한 이름일수록 위험하다.

문제 ② — 테스트가 Redis 를 찾다가 죽었다

spring-session-data-redis 를 넣으면 컨텍스트 기동 시 Redis 에 붙으려 한다. 테스트에는 Redis 가 없다.

@SpringBootTest(properties = {
    "KEYCLOAK_CLIENT_SECRET=test-only-secret",
    // 테스트는 Redis 를 띄우지 않는다
    "spring.session.store-type=none",
})

문제 ③ — 리소스 서버가 아예 없었다

API 호출이 500 이었다. 원인은 토큰이 아니었다.

java.nio.channels.UnresolvedAddressException

RESOURCE_API_BASE_URL=http://echo.keycloak-lab.svc:8080 인데 echoheader-lab 네임스페이스의 8081 이었다. 배포조차 되어 있지 않았다.

500 을 보고 "토큰이 없어서"라고 읽을 뻔했다. 로그를 보니 DNS 였다. A층에서 반복해서 배운 것 — 증상과 원인을 붙이기 전에 로그를 본다.

# 다른 네임스페이스의 서비스는 <svc>.<ns>.svc 로 부른다
value: http://echo.header-lab.svc:8081

2. 자동구성이 실제로 바뀌었는가 — B-0 의 방법을 다시 쓴다

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}') -- wget -qO- http://localhost:8083/actuator/beans
  빈 수: 321 → 402  (+81)

  --- 세션 저장소 (새로 생긴 것) ---
    ★ sessionRepository                    -> RedisSessionRepository
    ★ springSessionRepositoryFilter        -> SessionRepositoryFilter
    ★ RedisHttpSessionConfiguration
    ★ cookieSerializer                     -> DefaultCookieSerializer

  --- OAuth2 authorized client ---
    authorizedClientService
      before: InMemoryOAuth2AuthorizedClientService
      after : InMemoryOAuth2AuthorizedClientService    그대로 — Redis 로 안 옮겨졌다
    authorizedClientRepository
      before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
      after : AuthenticatedPrincipalOAuth2AuthorizedClientRepository   그대로

빈 81개가 늘었는데 authorized client 는 하나도 안 바뀌었다.

"Redis 를 붙였다"가 "상태가 공유된다"를 뜻하지 않는다. 무엇이 옮겨졌는지 찍어서 확인해야 한다. B-0 을 실험으로 만든 이유다.


3. Redis 안에 무엇이 들어갔는가 → Q3 검증 2번

=== Redis 키 ===
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
  dbsize: 1

=== 필드 ===
  sessionAttr:SPRING_SECURITY_CONTEXT
  sessionAttr:SPRING_SECURITY_SAVED_REQUEST
  sessionAttr:SPRING_SECURITY_LAST_EXCEPTION
  sessionAttr:...HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST
  lastAccessedTime / maxInactiveInterval / creationTime

=== TTL ===
  1772 초        ← spring.session.timeout=30m 과 일치

refresh token 은 Redis 에 없다

Q3 는 "저장소를 직접 열어 refresh token 이 평문으로 남는지 확인한다" 를 검증 항목으로 두었다. 답은 더 앞에 있었다 — 애초에 들어가지 않는다.

   Application Session  ──▶ Redis        (인증 상태, principal, 인가 요청)
   OAuth2AuthorizedClient ─▶ 프로세스 메모리  (access token, refresh token)

"토큰 암호화를 어떻게 할까"를 고민하기 전에, 토큰이 그 저장소에 가지도 않는다는 것을 먼저 알아야 한다.

직렬화는 Java 네이티브다

\xac\xed\x00\x05sr\x00=org.springframework.security.core.context.SecurityContextImpl

\xac\xedJava 직렬화 매직 넘버다. JSON 이 아니다.

결과
사람이 못 읽는다 운영 중 디버깅이 어렵다
클래스 버전에 묶인다 애플리케이션을 올리면 기존 세션이 역직렬화에 실패할 수 있다
역직렬화 취약점 신뢰할 수 없는 데이터가 들어오면 위험한 형식이다

D-2(버전 업그레이드)에서 이것이 다시 나온다 — Spring Security 버전이 바뀌면 Redis 에 남은 세션이 깨질 수 있다.


4. 사용자에게 보이는 결과 — 가장 중요한 부분

Redis 전환 후 token-boundary

{"pattern":"AP3-backend-for-frontend",
 "principal":"labuser",                   세션은 Redis 에서 복원되었다
 "accessTokenStoredOnServer":false,       토큰은 사라졌다
 "refreshTokenStoredOnServer":false,
 "browserTokenCount":0,
 "csrfProtectionEnabled":true}

파드가 전부 교체됐는데 로그인 상태는 살아남았다. Redis 덕분이다. 그런데 토큰은 같이 살아남지 못했다. 인스턴스 메모리에 있었으니까.

   사용자 관점:  로그인되어 있다고 나온다
   실제:        BFF 가 사용자를 대신해 아무것도 못 한다

이것이 "부분적으로만 공유했을 때"의 실패 모양이다. 완전히 로그아웃되는 편이 차라리 낫다 — 적어도 사용자가 다시 로그인한다.

B-0 과 나란히 놓으면

B-0 (Redis 없음, replica 1) B-1 (Redis 세션, replica 2)
principal labuser labuser
accessTokenStoredOnServer true false
파드 재시작 후 로그아웃 로그인 상태만 남고 토큰은 소실

5. Q3 검증 항목 대조

# Q3 의 검증 결과
1 인스턴스 두 대에서 로그인 유지·재시작 복구 세션은 유지, 토큰은 소실
2 저장소를 열어 refresh token 이 평문인지 평문 이전에 존재하지 않는다
3 session TTL 과 token 만료 어긋남 TTL 1772초 관측. 토큰 만료(60초)와 처음부터 어긋나 있다
4 logout 뒤 두 store 잔여 항목 B-2 에서 이어서
5 저장소를 끊었을 때 오류 B-5 에서
6 같은 store vs 분리 분리가 기본값이었다 — 고르는 것이 아니라 이미 그렇다
7 저장소 지연이 화면 지연으로 B-2 이후

6번의 답이 이 실험의 요지다. "두 상태를 같은 저장소에 둘지 나눌지"는 선택지가 아니라 이미 나뉘어 있고, 나뉜 채로 두면 깨진다.


6. 그래서 무엇을 해야 하는가

OAuth2AuthorizedClientService 를 공유 저장소로 옮기는 구현이 따로 필요하다.

후보
JdbcOAuth2AuthorizedClientService Spring Security 기본 제공. PostgreSQL 이 이미 있다
직접 구현 (Redis) OAuth2AuthorizedClientService 인터페이스를 Redis 로 구현
세션 안에 넣기 HttpSessionOAuth2AuthorizedClientRepository 를 쓰면 세션과 함께 Redis 로 간다

세 번째가 흥미롭다 — 조회 키 문제(principal 기준)까지 같이 해결된다. 세션 단위로 저장되므로 같은 사용자의 다른 브라우저가 서로를 덮어쓰지 않는다. 대신 세션이 커진다.

B-2 에서 이 선택지를 비교한다.



증거 파일

증거 수집 시각: 2026-09-04 13:59 14:03 KST (파일 mtime 기준. 문서 상단의 시각 표기는 작성 시점이라 다를 수 있다.)

파일 종류
01-servicelinks-trap.txt 터미널 원문
02-autoconfig-after.txt 터미널 원문
03-redis-contents.txt 터미널 원문
b1-login-works-two-replicas.png 스크린샷
b1-token-boundary-after-redis.png 스크린샷

파일별 상세는 evidence/b1-redis-session-store/README.md.

7. 재현 절차 (명령어)

# 1. 의존성 두 개를 함께 넣는다 (하나만 넣으면 조용히 in-memory 로 남는다)
#    spring-session-data-redis  +  spring-boot-starter-data-redis

# 2. 테스트는 Redis 를 안 띄우므로 store-type=none 을 준다

# 3. 배포 — enableServiceLinks: false 를 잊지 말 것
kubectl apply -f deploy/lab/k8s/bff-redis.yaml

# 4. 자동구성이 실제로 바뀌었는지 확인 (B-0 의 방법)
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}') -- wget -qO- http://localhost:8083/actuator/beans > after.json
#    sessionRepository 가 RedisSessionRepository 인가
#    authorizedClientService 는 여전히 InMemory 인가   ← 이쪽이 핵심

# 5. Redis 를 직접 연다
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
kubectl -n keycloak-lab exec deploy/redis -- redis-cli hkeys "bff:session:sessions:$(kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:sessions:*' | grep -v expires | head -1 | sed 's/.*://')"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli ttl  "bff:session:sessions:$(kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:sessions:*' | grep -v expires | head -1 | sed 's/.*://')"

# 6. 사용자 관점 확인
#    브라우저로 https://app1.hyeonworks.com/bff/token-boundary

8. 다음 실험에 남기는 것

실험 이 실험이 준 것
B-2 다중 인스턴스 authorized client 를 어디로 옮길지가 남았다. 세 후보를 비교한다
B-3 refresh 경쟁 토큰이 공유되어야 경쟁이 재현된다 — 아직 공유되지 않았다
D-2 업그레이드 Java 직렬화된 세션이 버전 변경에 견디는가
운영 enableServiceLinks: false — Service 이름과 환경변수 충돌