# B-1 재현 가이드 — Redis 를 붙이고, 무엇이 옮겨졌고 무엇이 안 옮겨졌는지 찍어서 확인한다 해설 문서: [`docs/experiment-b1-redis-session-store.md`](../../experiment-b1-redis-session-store.md) · 증거 원문: [`docs/evidence/b1-redis-session-store/`](../../evidence/b1-redis-session-store/) ## 이 가이드가 끝나면 당신 터미널과 브라우저에서 이것들을 **직접 본다.** | 보게 되는 것 | 어디서 | |---|---| | **쿠버네티스가 넣지도 않은 환경변수로 파드를 죽이는 것** | `logs` · `printenv` | | 그것이 `enableServiceLinks: false` 로 고쳐지는 것 | 롤아웃 성공 | | 빈이 **321 → 402 (+81)** 로 늘어나는 것 | `/actuator/beans` | | **그런데 authorized client 는 하나도 안 바뀐 것** | 같은 곳 | | Redis 안의 키·필드·TTL, 그리고 **토큰이 없는 것** | `redis-cli` | | 세션이 **Java 네이티브 직렬화**인 것 | `\xac\xed` | | **「로그인은 되어 있는데 아무것도 못 하는」 상태** | 브라우저 | ## 전제 - [`B-0`](b0-bff-redis-deploy.md) 이 끝나 있다. **B-0 의 답(빈 세 개의 이름)을 손에 들고 시작한다** — 이 실험은 그 값들이 어떻게 바뀌는지를 재는 것이다. - **브라우저가 필요하다.** 인가 코드 흐름은 왕복이 두 번이라 `curl` 로 대신할 수 없다. - 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다. - `jq` 는 이 실험대에 **깔려 있지 않다.** 이 가이드는 `grep` 과 `redis-cli` 로 읽는다. ## 주의 — 이건 애플리케이션 구성을 바꾸는 실험이다 의존성과 설정을 바꿔 **다시 빌드하고 다시 배포한다.** 되돌리려면 소스 변경을 되돌리고 다시 빌드해야 하므로, **`git status` 가 깨끗한 상태에서 시작한다.** 전 구간 약 40분(빌드 시간 포함). 되돌리는 방법은 매 단계에 적어 두었다. **★ 2-3 은 일부러 고장 난 상태로 배포한다.** 함정을 직접 보기 위해서다. 건너뛰고 싶으면 [2-4](#2-4-고침--enableservicelinks-false) 부터 시작해도 결과는 같다. ## 표시 규약 | 표시 | 뜻 | |---|---| | **실측** | 2026-09-04 13:59–14:03 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 | | **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 | | **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 다른 방법을 썼다 | 파드 이름·세션 ID·Service IP·TTL 은 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. --- # 0. 왜 이 실험을 하는가 B-0 이 답을 냈다. 세션도 토큰도 **인스턴스 메모리**에 있고, 그래서 replica 2 에서는 로그인조차 안 된다. **처방은 뻔해 보인다 — 공유 저장소를 붙이면 된다.** ``` Redis 를 붙인다 → 상태가 공유된다 → 다중 인스턴스가 된다 ↑ 정말 그런가? ``` 이 실험이 재는 것은 **「붙였다」와 「공유된다」 사이의 거리**다. | | 물어볼 것 | |---|---| | 무엇이 옮겨졌나 | `/actuator/beans` 를 다시 찍는다 | | **무엇이 안 옮겨졌나** | **같은 곳.** 안 바뀐 것을 확인하는 게 더 중요하다 | | 옮겨진 것 안에 무엇이 들었나 | Redis 를 직접 연다 | | 사용자에게는 어떻게 보이나 | 브라우저 | 그리고 배포 첫 시도에서 **쿠버네티스가 내 설정을 덮어쓰는** 함정을 만난다. 그게 1절과 2절의 절반이다. --- # 1. 기준선 — 붙이기 전에 넓은 것부터 좁혀 간다. ``` BFF 가 돌고 있나 → B-0 의 답 세 개 → Redis 가 비어 있나 → ★ 파드 안 환경변수 ``` ## 1-1. BFF 가 B-0 구성으로 돌고 있나 **확인** ```bash sudo kubectl -n keycloak-lab get pods -o wide -l app=bff sudo kubectl -n keycloak-lab get pods -o wide -l app=redis ``` **형태** ``` bff-574c6d658b-8cz4x 1/1 Running 0 20m 10.42.0.51 kc-lab-1 bff-574c6d658b-zpkbp 1/1 Running 0 20m 10.42.1.52 kc-lab-2 redis-568bd7c4-5c5vc 1/1 Running 0 20m 10.42.1.53 kc-lab-2 ``` **어디를 봐야 하는가** — BFF 두 개가 **서로 다른 노드**에 있고, Redis 가 떠 있는 것. **이 결과가 의미하는 것** — Redis 는 **배포만 되어 있고 아직 연결되지 않았다.** B-0 이 그렇게 만들어 뒀다. 이제 연결한다. ## 1-2. B-0 의 답을 다시 확인한다 — before 값 **하기** ```bash BFF=$(sudo kubectl -n keycloak-lab get pod -l app=bff \ --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') sudo kubectl -n keycloak-lab exec "$BFF" -- \ wget -qO- http://localhost:8083/actuator/beans > /tmp/beans-before.json wc -c /tmp/beans-before.json ``` **확인** — 빈 수와 관련 빈 세 개. **미검증** ```bash grep -o '"aliases":\[' /tmp/beans-before.json | wc -l grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-before.json \ | sed 's/{"aliases".*"type":"/ -> /' \ | grep -iE 'authorizedclient|sessionRepository' ``` **실측** — [`02-autoconfig-after.txt`](../../evidence/b1-redis-session-store/02-autoconfig-after.txt) ``` 빈 수: 321 → 402 (+81) ``` ``` authorizedClientService before: InMemoryOAuth2AuthorizedClientService authorizedClientRepository before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository authorizedClientManager before: AuthorizedClientServiceOAuth2AuthorizedClientManager ``` **어디를 봐야 하는가** — 빈 수 **321**, `sessionRepository` 는 **아예 없다.** **★ 이 세 줄과 숫자를 적어 둔다.** 4-1 의 비교 대상이 이것이고, **비교 없이는 「안 바뀌었다」를 말할 수 없다.** ## 1-3. Redis 가 비어 있는지 본다 **확인** — 먼저 살아 있는지 ```bash sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli info server | head ``` **형태** ``` PONG # Server redis_version:7.4.x ... ``` **확인** — 키가 있나 ```bash sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan ``` **형태** ``` (integer) 0 ``` **어디를 봐야 하는가** — **`0`.** 비어 있어야 4-2 에서 「내가 만든 것」이라고 말할 수 있다. > **`KEYS *` 대신 `--scan` 을 쓴다.** `KEYS` 는 서버를 블로킹한다. 지금은 키가 > 0개라 차이가 없지만, 습관이 되면 운영에서 사고가 난다. ## 1-4. ★ 파드 안 환경변수를 미리 본다 — 함정이 여기 있다 **아직 아무것도 안 바꿨는데** 파드 안에 Redis 관련 환경변수가 이미 있다. **확인** ```bash sudo kubectl -n keycloak-lab exec "$BFF" -- printenv | sort ``` 한 번은 통째로 본다. 그다음 걸러 본다. ```bash sudo kubectl -n keycloak-lab exec "$BFF" -- printenv | grep -i 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 REDIS_PORT_6379_TCP_PORT=6379 REDIS_PORT_6379_TCP_PROTO=tcp ``` **어디를 봐야 하는가** — **`REDIS_PORT` 의 값이 포트 번호가 아니라 URL 이다.** ### 개념 — Service Links **무엇인가.** 쿠버네티스는 같은 네임스페이스의 **모든 Service 마다** Docker link 시절의 환경변수를 파드에 자동으로 넣는다. 옛 Docker 링크 호환을 위한 기능이고, **기본값이 켜짐**이다. **왜 여기 나오나.** Service 이름이 `redis` 이므로 `REDIS_*` 가 들어온다. 그리고 애플리케이션 설정도 `${REDIS_PORT:6379}` 를 읽는다. **이름이 겹친다.** ``` Service 이름이 redis 이면 REDIS_SERVICE_HOST=10.43.57.116 REDIS_SERVICE_PORT=6379 REDIS_PORT=tcp://10.43.57.116:6379 ← 이게 문제 ``` **`_PORT` 는 포트 번호가 아니라 URL 형태다.** **없거나 틀리면.** 매니페스트에 `REDIS_PORT: "6379"` 를 명시하면 그게 이긴다 — **그런데 명시를 안 하면 자동 주입이 이긴다.** 그리고 오류 메시지는 당신이 쓰지도 않은 값을 지목한다. **이 결과가 의미하는 것** — **지금은 아무 일도 안 일어난다.** 애플리케이션이 그 변수를 안 읽기 때문이다. **다음 절에서 읽기 시작하는 순간 파드가 죽는다.** > `REDIS`, `POSTGRES`, `MYSQL` 처럼 **흔한 Service 이름일수록 위험하다.** --- # 2. 주입 — Redis 를 붙인다 **되돌리기** — 먼저 읽어 둔다 ```bash git checkout -- bff/pom.xml bff/src/main/resources/application.yml \ deploy/lab/k8s/bff-redis.yaml ``` ## 2-1. 의존성 **두 개**를 함께 넣는다 ```bash vim bff/pom.xml ``` ```xml org.springframework.session spring-session-data-redis org.springframework.boot spring-boot-starter-data-redis ``` **★ 하나만 넣으면 조용히 in-memory 로 남는다.** 오류도 안 난다. 그래서 4-1 에서 **찍어서 확인**하는 절차가 필요하다. ## 2-2. 설정을 넣는다 ```bash vim bff/src/main/resources/application.yml ``` ```yaml spring: data: redis: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} session: store-type: ${SPRING_SESSION_STORE_TYPE:redis} timeout: ${SPRING_SESSION_TIMEOUT:30m} redis: namespace: bff:session ``` ### 문제 ② — 테스트가 Redis 를 찾다가 죽는다 `spring-session-data-redis` 를 넣으면 **컨텍스트 기동 시 Redis 에 붙으려 한다.** 테스트에는 Redis 가 없다. ```bash vim bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java ``` ```java @SpringBootTest(properties = { "KEYCLOAK_CLIENT_SECRET=test-only-secret", // 테스트는 Redis 를 띄우지 않는다 "spring.session.store-type=none", }) ``` **이 한 줄이 없으면 빌드가 테스트 단계에서 죽는다.** 그리고 그 실패 메시지는 Redis 연결 오류라서 **「배포 환경 문제」로 읽히기 쉽다.** 실패한 곳은 빌드다. ## 2-3. ★ 일부러 `enableServiceLinks` 없이 배포한다 **함정을 직접 본다.** 이미 아는 함정을 문서에서 읽는 것과, 자기 터미널에서 그 오류 메시지를 만나는 것은 다르다. ```bash vim deploy/lab/k8s/bff-redis.yaml ``` ```yaml spec: # enableServiceLinks: false ← 아직 넣지 않는다 containers: - name: bff env: - name: SPRING_SESSION_STORE_TYPE value: redis - name: REDIS_HOST value: redis.keycloak-lab.svc # REDIS_PORT 를 일부러 안 준다 — 자동 주입이 어떻게 이기는지 본다 ``` **하기** — 빌드하고 두 노드에 밀어 넣고 배포한다 ```bash docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1 echo "exit=$?" docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'" docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'" sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml sudo kubectl -n keycloak-lab rollout restart deployment/bff ``` **되돌리기** — 2-4 가 곧 되돌리기다. 지금 멈추려면: ```bash sudo kubectl -n keycloak-lab rollout undo deployment/bff ``` ### 무엇이 일어나는지 순서대로 본다 **확인 ①** — 넓게 ```bash sudo kubectl -n keycloak-lab get pods -l app=bff ``` **형태** ``` NAME READY STATUS RESTARTS AGE bff-695646ddb-kzs9k 0/1 CrashLoopBackOff 3 (20s ago) 90s ``` **확인 ②** — 왜인지 물어본다. **로그보다 먼저 이벤트를 본다** ```bash sudo kubectl -n keycloak-lab describe pod -l app=bff | tail -20 ``` **확인 ③** — 로그. 죽은 뒤라면 `--previous` ```bash sudo kubectl -n keycloak-lab logs -l app=bff --tail=40 sudo kubectl -n keycloak-lab logs -l app=bff --previous --tail=40 ``` **실측** — [`experiment-b1-redis-session-store.md`](../../experiment-b1-redis-session-store.md) 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") ``` **어디를 봐야 하는가** — 마지막 줄의 **`"tcp://10.43.57.116:6379"`.** **이 결과가 의미하는 것** — **내가 쓴 적 없는 값이 오류에 나온다.** 1-4 에서 미리 본 그 환경변수다. 쿠버네티스가 넣었다. > **이 오류를 「Redis 가 안 떠서」로 읽기 쉽다.** 실제로 Redis 는 멀쩡하다. > **파드가 Redis 에 붙어 보지도 못하고 설정 바인딩에서 죽었다.** 메시지가 > `Failed to bind properties` 라고 말하고 있다 — 연결 오류가 아니다. **확인** — Redis 는 멀쩡한지 확인해 본다 ```bash sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping ``` **형태** ``` PONG ``` ## 2-4. 고침 — `enableServiceLinks: false` **두 가지 처방이 있다.** | 처방 | 문제 | |---|---| | 환경변수 이름을 바꾼다 (`BFF_REDIS_PORT` 등) | **다음 사람이 같은 함정에 다시 빠진다** | | **주입 자체를 끈다** | 근본 처방 | ```bash vim deploy/lab/k8s/bff-redis.yaml ``` ```yaml spec: enableServiceLinks: false # 근본 처방 containers: - name: bff env: - name: SPRING_SESSION_STORE_TYPE value: redis - name: REDIS_HOST value: redis.keycloak-lab.svc - name: REDIS_PORT value: "6379" ``` **하기** ```bash sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s ``` **실측** — [`01-servicelinks-trap.txt`](../../evidence/b1-redis-session-store/01-servicelinks-trap.txt) ``` deployment.apps/bff configured deployment "bff" successfully rolled out bff-576d869c6d-bshvl true kc-lab-2 bff-695646ddb-kzs9k true kc-lab-1 bff-695646ddb-vjqzf true kc-lab-2 ``` **어디를 봐야 하는가** — **세 줄이다.** replica 는 2인데 파드가 3개 보인다. **롤아웃 전환 중에 찍은 것**이고, 옛 ReplicaSet 의 파드가 아직 종료 전이다. 잠시 뒤 두 개가 된다. **확인** — 주입이 정말 사라졌나 ```bash BFF=$(sudo kubectl -n keycloak-lab get pod -l app=bff \ --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') sudo kubectl -n keycloak-lab exec "$BFF" -- printenv | grep -i redis ``` **형태** ``` REDIS_HOST=redis.keycloak-lab.svc REDIS_PORT=6379 ``` **어디를 봐야 하는가** — **`REDIS_SERVICE_HOST` 계열이 전부 사라졌고**, 내가 준 두 개만 남은 것. `REDIS_PORT` 가 `6379` 다. ## 2-5. 문제 ③ — 리소스 서버가 아예 없었다 API 호출이 `500` 이었다. **원인은 토큰이 아니었다.** **확인** — 로그를 본다 ```bash sudo kubectl -n keycloak-lab logs "$BFF" --tail=100 | grep -iE 'exception|error' ``` **실측** — [`experiment-b1-redis-session-store.md`](../../experiment-b1-redis-session-store.md) 1절 ``` java.nio.channels.UnresolvedAddressException ``` **어디를 봐야 하는가** — **`UnresolvedAddressException`.** DNS 다. `RESOURCE_API_BASE_URL=http://echo.keycloak-lab.svc:8080` 이었는데 `echo` 는 **`header-lab` 네임스페이스의 8081** 이었다. 배포조차 되어 있지 않았다. ```yaml # 다른 네임스페이스의 서비스는 ..svc 로 부른다 - name: RESOURCE_API_BASE_URL value: http://echo.header-lab.svc:8081 ``` **확인** — 그 서비스가 실제로 있나 ```bash sudo kubectl -n header-lab get svc echo ``` > **`500` 을 보고 「토큰이 없어서」라고 읽을 뻔했다.** 로그를 보니 DNS 였다. > **증상과 원인을 붙이기 전에 로그를 본다.** --- # 3. 주입이 실제로 걸렸는지 확인한다 ## 3-1. 파드가 떴고 Redis 에 붙었나 **확인** ```bash sudo kubectl -n keycloak-lab get pods -o wide -l app=bff sudo kubectl -n keycloak-lab exec "$BFF" -- \ wget -qO- http://localhost:8083/actuator/health ``` **형태** ```json {"status":"UP","components":{"redis":{"status":"UP","details":{"version":"7.4.x"}},...}} ``` **어디를 봐야 하는가** — **`redis` 컴포넌트가 있고 `UP` 인 것.** **이 결과가 의미하는 것** — B-0 에서는 이 컴포넌트가 **아예 없었다.** `spring-boot-starter-data-redis` 가 헬스 인디케이터를 같이 들고 왔다. **건강 체크에 새 항목이 생긴 것 자체가 자동구성이 걸렸다는 신호다.** ## 3-2. 로그인이 되나 — replica 2 에서 **하기** — 브라우저에서 `https://app1.hyeonworks.com/` 로 로그인한다. **쿠키를 먼저 지운다.** **실측** — [`b1-login-works-two-replicas.png`](../../evidence/b1-redis-session-store/b1-login-works-two-replicas.png) **어디를 봐야 하는가** — **로그인이 된다.** B-0 에서 `replica 2` 로는 `/login?error` 였던 그 자리다. **이 결과가 의미하는 것** — 인가 요청(state·PKCE verifier)이 이제 **Redis** 에 있으므로 콜백이 다른 인스턴스로 가도 찾을 수 있다. **B-0 이 replica 를 1로 줄여야 했던 문제는 고쳐졌다.** **여기서 멈추면 「Redis 를 붙였더니 다 해결됐다」로 끝난다. 그게 이 실험이 막으려는 결론이다.** --- # 4. 관찰 ## 4-1. ★ 자동구성이 실제로 무엇을 바꿨나 — B-0 의 방법을 그대로 **하기** ```bash sudo kubectl -n keycloak-lab exec "$BFF" -- \ wget -qO- http://localhost:8083/actuator/beans > /tmp/beans-after.json grep -o '"aliases":\[' /tmp/beans-after.json | wc -l ``` **실측** — [`02-autoconfig-after.txt`](../../evidence/b1-redis-session-store/02-autoconfig-after.txt) ``` 빈 수: 321 → 402 (+81) ``` **확인** — 세션 저장소 계열. **미검증** ```bash grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-after.json \ | sed 's/{"aliases".*"type":"/ -> /' \ | grep -iE 'session|redis' ``` **실측** — 같은 파일 ``` --- 세션 저장소 관련 (새로 생긴 것) --- ★ cookieSerializer -> DefaultCookieSerializer ★ org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration -> RedisHttpSessionConfiguration ★ sessionRepository -> RedisSessionRepository ★ springSessionRepositoryFilter -> SessionRepositoryFilter ★ redisConnectionFactory -> LettuceConnectionFactory ★ redisTemplate -> RedisTemplate ``` **확인** — ★ **안 바뀐 것.** 이쪽이 핵심이다. **미검증** ```bash grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-after.json \ | sed 's/{"aliases".*"type":"/ -> /' \ | grep -i authorizedclient ``` **실측** — 같은 파일 ``` --- OAuth2 authorized client — 바뀌었는가? --- authorizedClientService before: InMemoryOAuth2AuthorizedClientService after : InMemoryOAuth2AuthorizedClientService 그대로 — Redis 로 안 옮겨졌다 authorizedClientRepository before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository after : AuthenticatedPrincipalOAuth2AuthorizedClientRepository 그대로 — Redis 로 안 옮겨졌다 authorizedClientManager before: AuthorizedClientServiceOAuth2AuthorizedClientManager after : AuthorizedClientServiceOAuth2AuthorizedClientManager 그대로 — Redis 로 안 옮겨졌다 ``` **어디를 봐야 하는가** — **빈 81개가 늘었는데 authorized client 는 하나도 안 바뀌었다.** **이 결과가 의미하는 것** ``` Application Session ──▶ Redis (인증 상태, principal, 인가 요청) OAuth2AuthorizedClient ──▶ 프로세스 메모리 (access token, refresh token) ``` **「Redis 를 붙였다」가 「상태가 공유된다」를 뜻하지 않는다.** `spring.session.store-type` 은 **HttpSession** 을 갈아끼우는 설정이고, `OAuth2AuthorizedClient` 는 **그 설정과 무관한 다른 저장소**다. > **찍어서 확인하지 않으면 이 사실을 알 방법이 없다.** 로그인은 되고, 화면도 > 뜨고, 파드도 건강하다. **B-0 을 실험으로 만든 이유가 이것이다** — before 가 > 있어야 after 를 읽는다. ## 4-2. Redis 안에 무엇이 들어갔나 **확인** — 키가 생겼나 ```bash sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan ``` **실측** — [`03-redis-contents.txt`](../../evidence/b1-redis-session-store/03-redis-contents.txt) ``` === Redis 에 무엇이 들어 있는가 === bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae 총 키 수: 1 ``` **어디를 봐야 하는가** — 네임스페이스가 **`bff:session`** 이다. `application.yml` 의 `spring.session.redis.namespace` 가 그대로 접두어가 됐다. **하기** — 키 이름을 변수로 잡는다 ```bash KEY=$(sudo kubectl -n keycloak-lab exec deploy/redis -- \ redis-cli --scan --pattern 'bff:session:sessions:*' | grep -v expires | head -1 | tr -d '\r') echo "$KEY" ``` **★ `grep -v expires` 가 필요한 이유** — Spring Session 은 만료 추적용 키 (`bff:session:expirations:*` · `bff:session:sessions:expires:*`)도 만든다. 그것을 잡으면 다음 명령이 빈 결과를 낸다. **★ `tr -d '\r'`** — `redis-cli` 출력이 CR 을 달고 올 수 있다. 그대로 쓰면 키가 안 맞는데 오류는 안 난다. **확인** — 타입과 필드 ```bash sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli type "$KEY" sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli hkeys "$KEY" ``` **실측** — [`03-redis-contents.txt`](../../evidence/b1-redis-session-store/03-redis-contents.txt) ``` 타입: 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 ``` **어디를 봐야 하는가** — **필드 목록에 토큰이 없다.** ### ★ refresh token 은 Redis 에 **없다** 「저장소를 직접 열어 refresh token 이 평문으로 남는지 확인한다」가 검증 항목이었다. **답은 더 앞에 있었다 — 애초에 들어가지 않는다.** **「토큰 암호화를 어떻게 할까」를 고민하기 전에, 토큰이 그 저장소에 가지도 않는다는 것을 먼저 알아야 한다.** **확인** — TTL ```bash sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ttl "$KEY" ``` **실측** — 같은 파일 ``` === TTL (Q3 검증 3번 — session TTL) === TTL: 1772 초 ``` **어디를 봐야 하는가** — `1772`. `spring.session.timeout=30m`(1800초)에서 방금 지난 만큼 줄어든 값이다. **이 결과가 의미하는 것** — **세션 TTL 1772초와 access token 수명 60초가 처음부터 어긋나 있다.** 어느 쪽에 맞출지는 선택이 아니라 **이미 어긋나 있고 그 간극을 누가 메우는가**의 문제다. B-3 의 주제다. ## 4-3. 직렬화는 Java 네이티브다 **확인** — 값의 바이트를 본다 ```bash sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --no-raw hgetall "$KEY" | head -4 ``` **실측** — [`03-redis-contents.txt`](../../evidence/b1-redis-session-store/03-redis-contents.txt) ``` 1) "sessionAttr:SPRING_SECURITY_CONTEXT" 2) "\xac\xed\x00\x05sr\x00=org.springframework.security.core.context.SecurityContextImpl\x00\x00\x00\x00\x00\x00\x02l\x02\x00\x01L\x00\x0eauthenticationt\x002Lorg/springframework/security/core/Authentication;xpsr\x00Sorg.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken... ``` **어디를 봐야 하는가** — **`\xac\xed` 로 시작한다.** > **`--no-raw` 를 쓰는 이유** — 바이너리를 이스케이프해서 보여준다. 안 쓰면 > 터미널이 제어문자를 먹고 화면이 깨진다. **이 결과가 의미하는 것** — `\xac\xed` 는 **Java 직렬화 매직 넘버**다. JSON 이 아니다. | 결과 | | |---|---| | 사람이 못 읽는다 | 운영 중 디버깅이 어렵다 | | **클래스 버전에 묶인다** | 애플리케이션을 올리면 **기존 세션이 역직렬화에 실패**할 수 있다 | | 역직렬화 취약점 | 신뢰할 수 없는 데이터가 들어오면 위험한 형식이다 | **D-2(버전 업그레이드)에서 이것이 다시 나온다** — Spring Security 버전이 바뀌면 Redis 에 남은 세션이 깨질 수 있다. ## 4-4. ★ 사용자에게는 어떻게 보이나 — 가장 중요한 부분 **하기** — 파드를 전부 교체한다. Redis 덕을 보는지 확인하는 것이다 ```bash sudo kubectl -n keycloak-lab rollout restart deployment/bff sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s ``` **되돌리기** — 롤링 재시작은 정상 작업이라 되돌릴 것이 없다. **하기** — **로그인은 그대로 둔 채** 브라우저에서 `https://app1.hyeonworks.com/bff/token-boundary` 를 연다. **실측** — [`b1-token-boundary-after-redis.png`](../../evidence/b1-redis-session-store/b1-token-boundary-after-redis.png) ```json {"pattern":"AP3-backend-for-frontend", "principal":"labuser", ← 세션은 Redis 에서 복원되었다 "accessTokenStoredOnServer":false, ← 토큰은 사라졌다 "refreshTokenStoredOnServer":false, "browserTokenCount":0, "csrfProtectionEnabled":true} ``` **어디를 봐야 하는가** — **`principal` 은 살아 있는데 두 토큰이 `false` 다.** **이 결과가 의미하는 것** ``` 사용자 관점: 로그인되어 있다고 나온다 실제: BFF 가 사용자를 대신해 아무것도 못 한다 ``` **파드가 전부 교체됐는데 로그인 상태는 살아남았다.** Redis 덕분이다. **그런데 토큰은 같이 살아남지 못했다.** 인스턴스 메모리에 있었으니까. > **이것이 「부분적으로만 공유했을 때」의 실패 모양이다.** > **완전히 로그아웃되는 편이 차라리 낫다** — 적어도 사용자가 다시 로그인한다. > 지금은 화면상 로그인 상태라 사용자가 아무것도 안 한다. ### B-0 과 나란히 놓으면 | | B-0 (Redis 없음, replica 1) | **B-1 (Redis 세션, replica 2)** | |---|---|---| | `principal` | labuser | labuser | | `accessTokenStoredOnServer` | **true** | **false** | | 파드 재시작 후 | 로그아웃 | **로그인 상태만 남고 토큰은 소실** | > **★ 스크린샷으로 시점을 구별하지 않는다.** > [`README.md`](../../evidence/b1-redis-session-store/README.md) 가 적어 둔 대로, > `b1-login-works-two-replicas.png` 와 `b1-token-boundary-after-redis.png` 는 > **동일 파일**이다. 세 시점 모두 `accessTokenStoredOnServer: false` 인 같은 > 화면이었기 때문이다. **시점 구별은 터미널 출력과 Redis/DB 조회가 한다.** ## 4-5. 그래서 무엇을 해야 하는가 `OAuth2AuthorizedClientService` 를 공유 저장소로 옮기는 구현이 **따로** 필요하다. | 후보 | | |---|---| | `JdbcOAuth2AuthorizedClientService` | Spring Security 기본 제공. **PostgreSQL 이 이미 있다** | | 직접 구현 (Redis) | `OAuth2AuthorizedClientService` 인터페이스를 Redis 로 구현 | | 세션 안에 넣기 | `HttpSessionOAuth2AuthorizedClientRepository` 를 쓰면 세션과 함께 Redis 로 간다 | **세 번째가 흥미롭다** — 조회 키 문제(principal 기준)까지 같이 해결된다. 세션 단위로 저장되므로 **같은 사용자의 다른 브라우저가 서로를 덮어쓰지 않는다.** 대신 세션이 커진다. **[B-2](b2-multi-instance-session.md) 에서 이 선택지를 비교한다.** **「두 상태를 같은 저장소에 둘지 나눌지」는 선택지가 아니다 — 이미 나뉘어 있고, 나뉜 채로 두면 깨진다.** --- # 5. 복구 ## 5-1. B-2 로 이어갈 것이면 그대로 둔다 이 구성이 B-2 의 출발점이다. **아무것도 안 되돌린다.** ## 5-2. B-0 상태로 되돌릴 때 **하기** ```bash git checkout -- bff/pom.xml bff/src/main/resources/application.yml \ bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java \ deploy/lab/k8s/bff-redis.yaml git status --short ``` **★ 되돌린 뒤에는 다시 빌드해서 다시 밀어 넣어야 한다.** 소스만 되돌리면 클러스터에는 여전히 옛 이미지가 돈다. ```bash docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1 docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'" docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'" sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml sudo kubectl -n keycloak-lab rollout restart deployment/bff sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s ``` ## 5-3. Redis 를 비운다 **하기** ```bash sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushdb sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize ``` **되돌리기** — **없다.** 지운 세션은 돌아오지 않는다. 로그인한 사용자는 전부 로그아웃된다. **실험대라서 하는 일이다.** 세션 하나만 지우고 싶으면: ```bash sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli del "$KEY" ``` ## 5-4. 원상복구 확인표 | 항목 | 명령 | 돌아왔을 때 | |---|---|---| | 소스 | `git status --short` | 출력 없음 (B-0 로 되돌릴 때) | | 파드 | `sudo kubectl -n keycloak-lab get pods -o wide -l app=bff` | `2/2`, 두 노드에 하나씩 | | Redis | `sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping` | `PONG` | | Redis 키 | `... redis-cli dbsize` | 의도한 값 | | Keycloak | `sudo kubectl -n keycloak-lab get pods \| grep keycloak` | 둘 다 `1/1 Running` | | 밖 | `curl -I https://app1.hyeonworks.com/` | `200` | | 임시 파일 | `rm -f /tmp/beans-before.json /tmp/beans-after.json /tmp/build.log` | — | > **이 실험이 재지 않은 것 셋** > - **Redis 를 끊었을 때 무엇이 나는지** — B-5 의 주제다 > - **로그아웃 뒤 두 저장소에 무엇이 남는지** — B-2 로 넘긴다 > - **저장소 지연이 화면 지연으로 얼마나 번역되는지** — B-2 이후 --- # 막히면 전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다. | 증상 | 원인 | 확인 | |---|---|---| | **파드가 `CrashLoopBackOff`, 오류에 `tcp://...:6379`** | **쿠버네티스가 `REDIS_PORT` 를 주입했다** | `printenv \| grep -i redis` — 1-4 · 2-3 | | 위 오류를 「Redis 가 죽어서」로 읽었다 | 메시지가 `Failed to bind properties` 다 | `redis-cli ping` 으로 Redis 를 따로 확인 | | `enableServiceLinks` 를 넣었는데 그대로 | 파드가 아직 옛 것이다 | `rollout restart` 후 `printenv` 다시 | | 빌드가 Redis 연결 오류로 죽는다 | **테스트가 Redis 를 찾는다** | `spring.session.store-type=none` — 2-2 | | `sessionRepository` 가 안 생긴다 | **의존성을 하나만 넣었다.** 오류 없이 in-memory 로 남는다 | 두 개 다 있는지 `pom.xml` — 2-1 | | `redis-cli --scan` 이 비어 있다 | 아직 로그인 안 했다 | 브라우저로 로그인 후 다시 | | `hkeys` 가 빈 결과 | **만료 추적 키를 잡았다** | `grep -v expires` — 4-2 | | 키가 맞는데 명령이 안 먹는다 | 출력에 CR 이 붙었다 | `tr -d '\r'` — 4-2 | | 값이 깨져서 터미널이 이상해진다 | 바이너리를 그대로 찍었다 | `--no-raw` — 4-3 | | API 호출이 `500` 인데 토큰은 멀쩡 | **DNS 다.** 다른 네임스페이스의 서비스 | 로그의 `UnresolvedAddressException` — 2-5 | | `/actuator/beans` 가 `Bad Gateway` | 응답이 커서 프록시가 못 넘긴다 | 파드 안에서 받는다 — 4-1 | | `jq: command not found` | **이 실험대에 `jq` 가 없다** | `grep` 으로 읽는다 — 4-1 | | 로그인은 되는데 API 가 전부 실패 | **이게 이 실험의 결론이다** | `token-boundary` 의 두 `false` — 4-4 | | 스크린샷으로 시점을 구별하려다 헷갈린다 | **두 파일이 동일하다** | 터미널 출력과 Redis 조회로 구별 — 4-4 | --- # 다음 | 실험 | B-1 이 남긴 것 | |---|---| | [B-2](b2-multi-instance-session.md) 다중 인스턴스 | **authorized client 를 어디로 옮길지**가 남았다. 4-5 의 세 후보를 비교한다 | | [B-3](b3-refresh-token-contention.md) refresh 경쟁 | 토큰이 공유되어야 경쟁이 재현된다 — **아직 공유되지 않았다** | | [B-5](../../experiment-b5-redis-loss-persistence.md) Redis 소실 | 이제 잃을 것이 생겼다. `/data` 가 볼륨인지부터 본다 | | [D-2](d2-version-upgrade.md) 업그레이드 | **Java 직렬화된 세션**이 버전 변경에 견디는가 | | 운영 | `enableServiceLinks: false` — Service 이름과 환경변수 충돌 |