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>
34 KiB
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에서 친다.kubectl은sudo로 쓴다. jq는 이 실험대에 깔려 있지 않다. 이 가이드는grep과redis-cli로 읽는다.
주의 — 이건 애플리케이션 구성을 바꾸는 실험이다
의존성과 설정을 바꿔 다시 빌드하고 다시 배포한다. 되돌리려면 소스 변경을
되돌리고 다시 빌드해야 하므로, git status 가 깨끗한 상태에서 시작한다.
전 구간 약 40분(빌드 시간 포함). 되돌리는 방법은 매 단계에 적어 두었다.
★ 2-3 은 일부러 고장 난 상태로 배포한다. 함정을 직접 보기 위해서다. 건너뛰고 싶으면 2-4 부터 시작해도 결과는 같다.
표시 규약
| 표시 | 뜻 |
|---|---|
| 실측 | 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 구성으로 돌고 있나
확인
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'
빈 수: 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 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 ← 이게 문제
<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 연결 오류라서 「배포 환경 문제」로 읽히기 쉽다. 실패한 곳은 빌드다.
2-3. ★ 일부러 enableServiceLinks 없이 배포한다
함정을 직접 본다. 이미 아는 함정을 문서에서 읽는 것과, 자기 터미널에서 그 오류 메시지를 만나는 것은 다르다.
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
2-4. 고침 — enableServiceLinks: false
두 가지 처방이 있다.
| 처방 | 문제 |
|---|---|
환경변수 이름을 바꾼다 (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
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_PORT 가 6379 다.
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 이었는데 echo 는
header-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
빈 수: 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-type 은 HttpSession 을 갈아끼우는 설정이고,
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
=== Redis 에 무엇이 들어 있는가 ===
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
총 키 수: 1
어디를 봐야 하는가 — 네임스페이스가 bff:session 이다. application.yml
의 spring.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"
타입: 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
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 덕을 보는지 확인하는 것이다
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.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 에서 이 선택지를 비교한다.
「두 상태를 같은 저장소에 둘지 나눌지」는 선택지가 아니다 — 이미 나뉘어 있고, 나뉜 채로 두면 깨진다.
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 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 다중 인스턴스 | authorized client 를 어디로 옮길지가 남았다. 4-5 의 세 후보를 비교한다 |
| B-3 refresh 경쟁 | 토큰이 공유되어야 경쟁이 재현된다 — 아직 공유되지 않았다 |
| B-5 Redis 소실 | 이제 잃을 것이 생겼다. /data 가 볼륨인지부터 본다 |
| D-2 업그레이드 | Java 직렬화된 세션이 버전 변경에 견디는가 |
| 운영 | enableServiceLinks: false — Service 이름과 환경변수 충돌 |