Files
keycloak-pattern/docs/experiment-b1-redis-session-store.md
DongHyeonkaandClaude Opus 5 f2595f748f docs: B-1 — Redis moves the session and leaves the tokens behind
Adding Spring Session Redis grows the context by 81 beans and swaps sessionRepository for RedisSessionRepository, while authorizedClientService stays InMemoryOAuth2AuthorizedClientService. The user then reads as logged in with principal labuser while accessTokenStoredOnServer is false, which is worse than being logged out.

Redis holds only the security context, serialized with Java native serialization, and the refresh token is not there to encrypt in the first place. Three problems on the way: Kubernetes service links overwrote REDIS_PORT with a tcp:// URL, the tests tried to reach Redis, and the resource server was never deployed so a DNS failure looked like a token failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:05:02 +09:00

11 KiB
Raw Permalink 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를 어디에 저장할 것인가


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 <bff-pod> -- 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 에서 이 선택지를 비교한다.


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 <bff-pod> -- 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:<id>"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli ttl  "bff:session:sessions:<id>"

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

8. 다음 실험에 남기는 것

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