--- kind: CASE slug: moving-the-session-left-the-tokens-behind title: 세션만 Redis 로 옮기자 토큰이 따라오지 않았다 topic: where-application-state-lives topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기 project: keycloak-session-store status: 게시 전 lastVerifiedOn: 2026-09-04 sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af source: - final/document.md#선택이-코드와-흐름에-반영되는-방식-b0 - final/document.md#선택이-코드와-흐름에-반영되는-방식-b1 evidence: - ../../../final/evidence/raw/b0-bff-redis-deploy__03-beans-analysis.txt - ../../../final/evidence/raw/b1-redis-session-store__03-redis-contents.txt --- # 세션만 Redis 로 옮기자 토큰이 따라오지 않았다 세션 저장소만 Redis 로 바꿨더니 파드를 재시작해도 로그인은 유지됐는데 토큰은 살아남지 못했다. 세션은 세션 id 로 찾고 토큰은 principal 이름으로 찾으므로, 세션 저장소를 바꾸는 설정은 토큰 쪽 경로를 건드리지 않는다. ## 관계 - **세션과 인가된 클라이언트는 조회 키가 다르다** 이 실험이 관측한 결과를 만든 구조를 그 기록이 설명한다. - **기본키에 세션 id 가 없어서 두 번째 로그인이 첫 토큰을 덮어썼다** 세션과 따로 옮긴 토큰 저장소에서 무엇이 남았는지를 그 실험이 이어서 쟀다. - **세션과 토큰의 저장소를 나눠 각각 설계한다** 이 관측이 그 결정의 근거다. ## 문제 BFF 인스턴스가 둘이고 요청이 어느 쪽으로 갈지 모르면 로그인 상태가 인스턴스 안에 있어서는 안 된다. 그래서 세션 저장소를 Redis 로 옮기는 것이 첫 수였다. 문제는 무엇이 옮겨지는지가 코드에 안 적혀 있다는 것이었다. 이 BFF 에는 세션 저장소나 토큰 저장소를 직접 만드는 빈이 없고 전부 자동구성이 고른다. 무엇이 실제로 쓰이는지 모르는 채로 저장소를 붙이면 옮겨진 것과 안 옮겨진 것을 가를 수 없다. ## 결론 자동구성이 고른 것 세션 저장소 : 없음. 서블릿 컨테이너 메모리 토큰 저장소 : InMemoryOAuth2AuthorizedClientService 토큰 조회를 맡는 것 : AuthenticatedPrincipalOAuth2AuthorizedClientRepository Redis 와 Spring Session : x 세션만 Redis 로 옮긴 뒤 파드 재시작 후 로그인 유지 : o 파드 재시작 후 토큰 생존 : x Redis 에 들어간 키 : 1개. 타입은 hash 그 해시에 인가된 클라이언트 필드 : x 세션은 세션 id 로 찾고 토큰은 principal 이름으로 찾는다. 조회 경로가 갈라져 있어서 세션 저장소를 무엇으로 바꾸든 토큰은 따라오지 않는다. ## 검증 환경 실험대 : 베어메탈 한 대(test-server, Arch Linux, 12GB, WiFi only) 위의 VM 두 대 kc-lab-1 : k3s server (컨트롤 플레인) · keycloak-1 kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis 애플리케이션 : BFF(Spring Boot) 두 인스턴스 세션 저장소 전환 : 환경변수 SPRING_SESSION_STORE_TYPE 을 redis 로 빈 목록 조회 : BFF 의 actuator beans 엔드포인트 Keycloak 패치 버전 : 이 측정 기록에 적혀 있지 않다 Spring Boot 버전 : 이 측정 기록에 적혀 있지 않다 ## 재현 조건 1. 저장소를 아무것도 붙이지 않은 상태로 BFF 를 띄우고 로그인을 한 번 한다. 2. 빈 목록을 조회해 세션과 토큰에 관계된 구현이 무엇인지 확인한다. authorizedClientService · authorizedClientRepository · SessionRepository 세 이름을 본다. 3. 환경변수 SPRING_SESSION_STORE_TYPE 을 redis 로 두고 다시 배포한 뒤 로그인한다. 4. Redis 의 키를 세고 그 키의 타입과 필드 이름을 확인한다. 5. BFF 파드를 재시작하고 같은 브라우저로 요청한다. 로그인이 유지되는지와 토큰이 필요한 요청이 되는지를 따로 본다. ## 본문 ## 저장소를 붙이기 전에 무엇이 골라져 있었나 이 BFF 의 코드에는 세션 저장소도 토큰 저장소도 직접 만드는 빈이 없다. 전부 자동구성이 고르므로 Redis 를 붙이기 전에 실행 중인 애플리케이션의 빈 목록을 먼저 읽었다. 전체 빈은 321개였고 그중 세션·토큰 저장소에 관계된 빈은 다섯 줄이었다. 그 다섯 줄에서 토큰을 담는 구현과 토큰을 조회하는 구현을 찾았고, 세션 쪽과 Redis 쪽에는 해당하는 빈이 없었다. ```text label="B-0 · 자동구성이 실제로 고른 구현체" authorizedClientService → InMemoryOAuth2AuthorizedClientService authorizedClientRepository → AuthenticatedPrincipalOAuth2AuthorizedClientRepository SessionRepository → 없음 (서블릿 컨테이너 in-memory) Redis / Spring Session → 없음 ``` `InMemoryOAuth2AuthorizedClientService` 는 발급받은 access token 과 refresh token 을 JVM 메모리에 담아 두는 기본 구현이다. `SessionRepository` 가 하나도 없다는 것은 Spring Session 이 구성되지 않았다는 뜻이므로, 로그인 상태는 서블릿 컨테이너가 자기 메모리에 들고 있었다. 두 가지 모두 인스턴스 안에 있었다. 빈 목록은 이렇게 뽑는다. ```bash label="무엇이 토큰을 맡고 있는지 확인" kubectl exec -- wget -qO- localhost:8083/actuator/beans \ | grep -o '"[a-zA-Z]*OAuth2AuthorizedClient[a-zA-Z]*"' | sort -u ``` ## 이름 하나가 조회 키를 말해 준다 `AuthenticatedPrincipalOAuth2AuthorizedClientRepository` 는 요청에 딸린 인증 주체(principal)의 이름으로 인가된 클라이언트를 찾아 주는 구현이다. 이름에 적힌 그대로 principal 을 기준으로 찾으므로 조회 키에 세션 id 가 들어가지 않는다. | | 무엇을 담나 | 무엇으로 찾나 | |---|---|---| | Application Session | 누가 로그인했는지 | 세션 id | | OAuth2AuthorizedClient | access · refresh token | principal 이름 | 같은 요청 하나가 세션은 세션 id 로, 토큰은 principal 이름으로 두 갈래로 조회된다. 이름이 비슷해서 하나로 읽히지만 저장되는 것도 다르고 찾아오는 경로도 다르다. ## 세션만 옮기고 재시작했을 때 배포하자마자 파드가 안 떴다. 쿠버네티스는 같은 네임스페이스의 Service 마다 환경변수를 자동으로 넣는데, 그중 `REDIS_PORT=tcp://10.43.57.116:6379` 가 `application.yml` 의 `${REDIS_PORT:6379}` 를 덮어써 `spring.data.redis.port` 를 int 로 바꾸지 못했다. 환경변수 이름을 바꿔 피할 수도 있었지만 그러면 다음 사람이 같은 함정에 다시 빠지므로, 주입 자체를 끄는 `enableServiceLinks: false` 를 골랐다. 환경변수 `SPRING_SESSION_STORE_TYPE` 을 `redis` 로 두어 Application Session 을 Redis 로 옮겼다. 파드를 재시작해도 로그인은 유지됐다. 브라우저는 다시 로그인 화면을 보지 않았고 같은 세션으로 요청이 이어졌다. 그런데 같은 재시작에서 토큰은 살아남지 못했다. `OAuth2AuthorizedClient` 를 찾는 경로가 principal 이름을 쓰는 인메모리 구현 그대로였기 때문이다. 세션 저장소를 Redis 로 바꾸는 설정은 세션 쪽 경로만 갈아 끼우므로 토큰 쪽은 건드리지 않는다. ## Redis 안에 들어간 것 옮긴 뒤 Redis 를 열어 보니 키가 하나였고 타입은 hash 였다. ```text label="B-1 · Redis 에 들어 있던 세션 키" bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae 총 키 수: 1 타입: hash 필드: sessionAttr:SPRING_SECURITY_CONTEXT 필드: sessionAttr:SPRING_SECURITY_SAVED_REQUEST 필드: sessionAttr:SPRING_SECURITY_LAST_EXCEPTION 필드: sessionAttr:org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST 필드: lastAccessedTime 필드: maxInactiveInterval 필드: creationTime TTL: 1772 초 ``` 필드 이름에 토큰이 안 보이는 것과 값 안에 토큰이 없는 것은 다른 말이라, 필드 값도 직접 꺼내 토큰 문자열이 섞여 있는지 봤다. `sessionAttr:SPRING_SECURITY_CONTEXT` 의 값은 `\xac\xed` 두 바이트로 시작한다. Java 기본 직렬화의 매직 넘버라, Redis 에 들어간 값은 같은 클래스패스의 JVM 이 있어야 읽힌다. 그 안에 든 것은 누가 로그인했는지를 말하는 인증 객체다. 일곱 필드 어느 이름도 access token 이나 refresh token 을 가리키지 않는다. 값을 꺼내 본 것은 `sessionAttr:SPRING_SECURITY_CONTEXT` 하나뿐이다. 이름에 OAuth2 가 들어간 `…HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST` 는 이름만 보고 넘어갔고 값 안을 열어 보지 않았다. 로그인 상태가 Redis 로 나간 것은 맞지만 토큰은 아직 인스턴스 메모리에 있었다. 여기서 「무상태가 됐다」고 판단하면 다른 인스턴스로 간 요청이 토큰을 못 찾는 이유를 설명할 수 없게 된다. ## 확인하지 않은 것 Redis 를 붙인 상태에서 토큰 저장소만 따로 두는 조합은 B-2 에서 다시 쟀다. 여기서는 세션 쪽만 확인했다. 그 실험이 확인한 네 가지 가운데 둘은 저장소를 밖으로 빼는 것으로 풀렸고 둘은 풀리지 않았는데, 남은 둘의 원인은 저장소가 아니라 토큰 테이블의 기본키였다. 파드 재시작 한 번으로 본 결과이고, 두 인스턴스 사이로 요청을 번갈아 보내 토큰이 어느 쪽에서 사라지는지까지는 이 실험에서 재지 않았다.