Files
keycloak-pattern/docs/guides/experiments/b1-redis-session-store.md
T
DongHyeonkaandClaude Opus 5 6f6ab86345 docs(guides): reproduction guides for all 26 experiments
Written by subagents running under the writing-practitioner-guides skill,
one guide per experiment, 22,566 lines. Each walks a reader from baseline
capture through injection, injection verification, observation and recovery.

Section 3 carries the weight in most of them. Injection failed silently nine
times in this lab, and a failed injection looks exactly like no effect — so
the guides verify the target is actually in the intended state before
reading any result. A-4 makes virsh list the only proof because the node
reads Ready for 40 seconds after the machine is off; A-5 makes the packet
counter the sole go/no-go because a rule on the wrong node produces an empty
result that reads like a finding; A-6 quotes the run where 적용완료 was
printed between four Cannot find device "eth0" lines.

The traps the guides are built around are ones that invert a conclusion
rather than merely annoy:

  A-0   emptying the session table without a restart leaves cache entries
        that get counted as replication arriving
  A-2   dropping -o /dev/null fuses body and status into one string
  A-3   presence of "ready to accept connections" instead of its timestamp
  B-2   row count alone reads an UPDATE as nothing having happened
  B-4   tr ',' '\n' splits ["admin","editor"] so only admin is seen
  B-7   no login screen means the cookie died and SSO re-authenticated
  C-1   counting sessions without joining realm counts your own kcadm one
  D-1   kubectl exec without -i restores nothing and still exits 0
  D-4a  "ran with error output" is what success looks like

Every quoted block is copied from docs/evidence/ and marked 실측; reshaped
commands are marked 미검증 rather than passed off as measured. Where a source
document carries a ★ correction the guides follow the corrected claim — A-7's
REVOKED_TOKEN hypothesis, C-1's session count, B-2's schema attribution.

Two hazards are stated rather than smoothed over: B-6 deletes a key that
cannot be recreated, and D-1/D-4 need host sudo, which asks for a password,
so those steps say a person must type them.

Audit over all 26: 672 interpretation pairs, 486 evidence citations, 117
undo sections, and zero occurrences of the patterns the skill forbids —
no python data processing, no deprecated kubectl get endpoints, no
placeholders, no bare kcadm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:29:00 +09:00

34 KiB
Raw Blame History

B-1 재현 가이드 — Redis 를 붙이고, 무엇이 옮겨졌고 무엇이 안 옮겨졌는지 찍어서 확인한다

해설 문서: docs/experiment-b1-redis-session-store.md · 증거 원문: docs/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 이 끝나 있다. B-0 의 답(빈 세 개의 이름)을 손에 들고 시작한다 — 이 실험은 그 값들이 어떻게 바뀌는지를 재는 것이다.
  • 브라우저가 필요하다. 인가 코드 흐름은 왕복이 두 번이라 curl 로 대신할 수 없다.
  • 명령은 kc-lab-1 에서 친다. kubectlsudo 로 쓴다.
  • jq 는 이 실험대에 깔려 있지 않다. 이 가이드는 grepredis-cli 로 읽는다.

주의 — 이건 애플리케이션 구성을 바꾸는 실험이다

의존성과 설정을 바꿔 다시 빌드하고 다시 배포한다. 되돌리려면 소스 변경을 되돌리고 다시 빌드해야 하므로, git status 가 깨끗한 상태에서 시작한다.

전 구간 약 40분(빌드 시간 포함). 되돌리는 방법은 매 단계에 적어 두었다.

★ 2-3 은 일부러 고장 난 상태로 배포한다. 함정을 직접 보기 위해서다. 건너뛰고 싶으면 2-4 부터 시작해도 결과는 같다.

표시 규약

표시
실측 2026-09-04 13:5914: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 구성으로 돌고 있나

확인

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 값

하기

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

확인 — 빈 수와 관련 빈 세 개. 미검증

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

  빈 수: 321 → 402  (+81)
    authorizedClientService
      before: InMemoryOAuth2AuthorizedClientService
    authorizedClientRepository
      before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
    authorizedClientManager
      before: AuthorizedClientServiceOAuth2AuthorizedClientManager

어디를 봐야 하는가 — 빈 수 321, sessionRepository아예 없다.

★ 이 세 줄과 숫자를 적어 둔다. 4-1 의 비교 대상이 이것이고, 비교 없이는 「안 바뀌었다」를 말할 수 없다.

1-3. Redis 가 비어 있는지 본다

확인 — 먼저 살아 있는지

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
...

확인 — 키가 있나

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 관련 환경변수가 이미 있다.

확인

sudo kubectl -n keycloak-lab exec "$BFF" -- printenv | sort

한 번은 통째로 본다. 그다음 걸러 본다.

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 마다 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        ← 이게 문제

<SVCNAME>_PORT 는 포트 번호가 아니라 URL 형태다.

없거나 틀리면. 매니페스트에 REDIS_PORT: "6379" 를 명시하면 그게 이긴다 — 그런데 명시를 안 하면 자동 주입이 이긴다. 그리고 오류 메시지는 당신이 쓰지도 않은 값을 지목한다.

이 결과가 의미하는 것지금은 아무 일도 안 일어난다. 애플리케이션이 그 변수를 안 읽기 때문이다. 다음 절에서 읽기 시작하는 순간 파드가 죽는다.

REDIS, POSTGRES, MYSQL 처럼 흔한 Service 이름일수록 위험하다.


2. 주입 — Redis 를 붙인다

되돌리기 — 먼저 읽어 둔다

git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
  deploy/lab/k8s/bff-redis.yaml

2-1. 의존성 두 개를 함께 넣는다

vim bff/pom.xml
<!-- spring-session-data-redis 가 SessionRepository 를 갈아끼우고,
     spring-boot-starter-data-redis 가 연결(Lettuce)을 제공한다.
     둘 다 있어야 자동구성이 걸린다 -->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

★ 하나만 넣으면 조용히 in-memory 로 남는다. 오류도 안 난다. 그래서 4-1 에서 찍어서 확인하는 절차가 필요하다.

2-2. 설정을 넣는다

vim bff/src/main/resources/application.yml
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 가 없다.

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

이 한 줄이 없으면 빌드가 테스트 단계에서 죽는다. 그리고 그 실패 메시지는 Redis 연결 오류라서 「배포 환경 문제」로 읽히기 쉽다. 실패한 곳은 빌드다.

함정을 직접 본다. 이미 아는 함정을 문서에서 읽는 것과, 자기 터미널에서 그 오류 메시지를 만나는 것은 다르다.

vim deploy/lab/k8s/bff-redis.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 를 일부러 안 준다 — 자동 주입이 어떻게 이기는지 본다

하기 — 빌드하고 두 노드에 밀어 넣고 배포한다

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 가 곧 되돌리기다. 지금 멈추려면:

sudo kubectl -n keycloak-lab rollout undo deployment/bff

무엇이 일어나는지 순서대로 본다

확인 ① — 넓게

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

확인 ② — 왜인지 물어본다. 로그보다 먼저 이벤트를 본다

sudo kubectl -n keycloak-lab describe pod -l app=bff | tail -20

확인 ③ — 로그. 죽은 뒤라면 --previous

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 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 는 멀쩡한지 확인해 본다

sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping

형태

PONG

두 가지 처방이 있다.

처방 문제
환경변수 이름을 바꾼다 (BFF_REDIS_PORT 등) 다음 사람이 같은 함정에 다시 빠진다
주입 자체를 끈다 근본 처방
vim deploy/lab/k8s/bff-redis.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"

하기

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

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 의 파드가 아직 종료 전이다. 잠시 뒤 두 개가 된다.

확인 — 주입이 정말 사라졌나

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_PORT6379 다.

2-5. 문제 ③ — 리소스 서버가 아예 없었다

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

확인 — 로그를 본다

sudo kubectl -n keycloak-lab logs "$BFF" --tail=100 | grep -iE 'exception|error'

실측experiment-b1-redis-session-store.md 1절

java.nio.channels.UnresolvedAddressException

어디를 봐야 하는가UnresolvedAddressException. DNS 다.

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

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

확인 — 그 서비스가 실제로 있나

sudo kubectl -n header-lab get svc echo

500 을 보고 「토큰이 없어서」라고 읽을 뻔했다. 로그를 보니 DNS 였다. 증상과 원인을 붙이기 전에 로그를 본다.


3. 주입이 실제로 걸렸는지 확인한다

3-1. 파드가 떴고 Redis 에 붙었나

확인

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

형태

{"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

어디를 봐야 하는가로그인이 된다. B-0 에서 replica 2 로는 /login?error 였던 그 자리다.

이 결과가 의미하는 것 — 인가 요청(state·PKCE verifier)이 이제 Redis 에 있으므로 콜백이 다른 인스턴스로 가도 찾을 수 있다. B-0 이 replica 를 1로 줄여야 했던 문제는 고쳐졌다.

여기서 멈추면 「Redis 를 붙였더니 다 해결됐다」로 끝난다. 그게 이 실험이 막으려는 결론이다.


4. 관찰

4-1. ★ 자동구성이 실제로 무엇을 바꿨나 — B-0 의 방법을 그대로

하기

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

  빈 수: 321 → 402  (+81)

확인 — 세션 저장소 계열. 미검증

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

확인 — ★ 안 바뀐 것. 이쪽이 핵심이다. 미검증

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-typeHttpSession 을 갈아끼우는 설정이고, OAuth2AuthorizedClient그 설정과 무관한 다른 저장소다.

찍어서 확인하지 않으면 이 사실을 알 방법이 없다. 로그인은 되고, 화면도 뜨고, 파드도 건강하다. B-0 을 실험으로 만든 이유가 이것이다 — before 가 있어야 after 를 읽는다.

4-2. Redis 안에 무엇이 들어갔나

확인 — 키가 생겼나

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

=== Redis 에 무엇이 들어 있는가 ===
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
  총 키 수: 1

어디를 봐야 하는가 — 네임스페이스가 bff:session 이다. application.ymlspring.session.redis.namespace 가 그대로 접두어가 됐다.

하기 — 키 이름을 변수로 잡는다

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 을 달고 올 수 있다. 그대로 쓰면 키가 안 맞는데 오류는 안 난다.

확인 — 타입과 필드

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

  타입: 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

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 네이티브다

확인 — 값의 바이트를 본다

sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --no-raw hgetall "$KEY" | head -4

실측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\xedJava 직렬화 매직 넘버다. JSON 이 아니다.

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

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

4-4. ★ 사용자에게는 어떻게 보이나 — 가장 중요한 부분

하기 — 파드를 전부 교체한다. Redis 덕을 보는지 확인하는 것이다

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

{"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 가 적어 둔 대로, b1-login-works-two-replicas.pngb1-token-boundary-after-redis.png동일 파일이다. 세 시점 모두 accessTokenStoredOnServer: false 인 같은 화면이었기 때문이다. 시점 구별은 터미널 출력과 Redis/DB 조회가 한다.

4-5. 그래서 무엇을 해야 하는가

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

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

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

「두 상태를 같은 저장소에 둘지 나눌지」는 선택지가 아니다 — 이미 나뉘어 있고, 나뉜 채로 두면 깨진다.


5. 복구

5-1. B-2 로 이어갈 것이면 그대로 둔다

이 구성이 B-2 의 출발점이다. 아무것도 안 되돌린다.

5-2. B-0 상태로 되돌릴 때

하기

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

★ 되돌린 뒤에는 다시 빌드해서 다시 밀어 넣어야 한다. 소스만 되돌리면 클러스터에는 여전히 옛 이미지가 돈다.

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 를 비운다

하기

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

되돌리기없다. 지운 세션은 돌아오지 않는다. 로그인한 사용자는 전부 로그아웃된다. 실험대라서 하는 일이다.

세션 하나만 지우고 싶으면:

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 restartprintenv 다시
빌드가 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/beansBad Gateway 응답이 커서 프록시가 못 넘긴다 파드 안에서 받는다 — 4-1
jq: command not found 이 실험대에 jq 가 없다 grep 으로 읽는다 — 4-1
로그인은 되는데 API 가 전부 실패 이게 이 실험의 결론이다 token-boundary 의 두 false — 4-4
스크린샷으로 시점을 구별하려다 헷갈린다 두 파일이 동일하다 터미널 출력과 Redis 조회로 구별 — 4-4

다음

실험 B-1 이 남긴 것
B-2 다중 인스턴스 authorized client 를 어디로 옮길지가 남았다. 4-5 의 세 후보를 비교한다
B-3 refresh 경쟁 토큰이 공유되어야 경쟁이 재현된다 — 아직 공유되지 않았다
B-5 Redis 소실 이제 잃을 것이 생겼다. /data 가 볼륨인지부터 본다
D-2 업그레이드 Java 직렬화된 세션이 버전 변경에 견디는가
운영 enableServiceLinks: false — Service 이름과 환경변수 충돌