Compare commits

...
Author SHA1 Message Date
DongHyeonkaandClaude Opus 5 aa2c3907f5 docs: B-6 — rotation is safe, retiring the old key is not
Adding a higher-priority RSA provider leaves both kids in JWKS, so tokens signed before and after the rotation both validate. Deleting the old provider makes its tokens 401 immediately, and the resource server's JWKS cache does not buy a grace period because an unknown kid triggers a refetch.

The encryption key Q3 asks about does not exist yet, since B-2 showed the tokens are stored as plaintext JWTs, so the measured signing-key rotation is what its design has to copy: write with one key, read with several, and keep the overlap longer than the lifetime of anything signed with the old one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:33:42 +09:00
DongHyeonkaandClaude Opus 5 f45a2a2aaa docs: B-5 — the pod stays Ready while every request hangs
Stopping Redis returns HTTP 000 rather than an error because the client waits on reconnect, and the pod keeps serving traffic because the redis health indicator is not in the readiness group even though /actuator/health returns 503. That is the mirror image of A-2, where Keycloak put its database check in readiness and the pods left the Service.

Turning on AOF with config set created the appendonlydir and still lost everything on pod deletion, because /data was the container filesystem; adding a PVC makes the same setting work. Volume first, persistence setting second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:29:17 +09:00
12 changed files with 670 additions and 4 deletions
+28 -4
View File
@@ -24,9 +24,24 @@ stringData:
# Base64 in etcd is not encryption — see D-3. # Base64 in etcd is not encryption — see D-3.
KEYCLOAK_CLIENT_SECRET: bff-lab-secret KEYCLOAK_CLIENT_SECRET: bff-lab-secret
--- ---
# Redis. No persistence yet: `--save ""` and no appendonly, so a restart loses # Redis. B-5 measured that turning on AOF with `redis-cli config set` changes
# everything. B-5 and B-6 compare that against RDB and AOF, which is easier to # nothing here, because /data is the container filesystem and dies with the
# reason about when the starting point is "nothing survives". # container — the appendonlydir was created and then thrown away. Persistence
# configuration without a volume is decoration.
#
# So the volume comes first, and only then does `--appendonly yes` mean anything.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-data
namespace: keycloak-lab
spec:
accessModes: [ReadWriteOnce]
storageClassName: local-path
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
@@ -47,16 +62,25 @@ spec:
containers: containers:
- name: redis - name: redis
image: redis:7.4-alpine image: redis:7.4-alpine
args: ["redis-server", "--save", "", "--appendonly", "no"] # appendfsync everysec 이 기본값이다 — 1초 분량을 잃을 수 있다.
# Keycloak 의 synchronous_commit OFF(A-3)와 같은 모양의 트레이드오프다.
args: ["redis-server", "--appendonly", "yes", "--dir", "/data"]
ports: ports:
- containerPort: 6379 - containerPort: 6379
name: redis name: redis
readinessProbe: readinessProbe:
exec: { command: ["redis-cli", "ping"] } exec: { command: ["redis-cli", "ping"] }
initialDelaySeconds: 3 initialDelaySeconds: 3
volumeMounts:
- name: data
mountPath: /data
resources: resources:
requests: { memory: 32Mi, cpu: 20m } requests: { memory: 32Mi, cpu: 20m }
limits: { memory: 128Mi } limits: { memory: 128Mi }
volumes:
- name: data
persistentVolumeClaim:
claimName: redis-data
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
@@ -0,0 +1,9 @@
=== 기준선 ===
Redis 키: 1
PostgreSQL 토큰: 1 행
Redis 영속화 설정:
save = save
appendonly no
=== 외부 진입점 정상 확인 ===
https://app1.hyeonworks.com/ HTTP 200
@@ -0,0 +1,25 @@
=== ① Redis 정지 ===
정지: 14:26:30
deployment.apps/redis scaled
삭제 완료
=== 로그인한 사용자의 다음 요청은 어떻게 되는가 ===
/ HTTP 200
/bff/token-boundary HTTP 000
/actuator/health HTTP 503
--- token-boundary 응답 본문 ---
=== 파드 상태 — readiness 가 Redis 를 보는가 ===
bff-555df79c97-6j86w 1/1 Running 0 17m
bff-555df79c97-vgg6g 1/1 Running 0 16m
=== health 상세 ===
=== BFF 로그 ===
at java.base/sun.nio.ch.Net.pollConnect(Native Method) ~[na:na]
at java.base/sun.nio.ch.Net.pollConnectNow(Unknown Source) ~[na:na]
at java.base/sun.nio.ch.SocketChannelImpl.finishConnect(Unknown Source) ~[na:na]
at io.netty.channel.socket.nio.NioSocketChannel.doFinishConnect(NioSocketChannel.java:336) ~[netty-transport-4.1.135.Final.jar!/:4.1.135.Final]
at io.netty.channel.nio.AbstractNioChannel$AbstractNioUnsafe.finishConnect(AbstractNioChannel.java:339) ~[netty-transport-4.1.135.Final.jar!/:4.1.135.Final]
@@ -0,0 +1,13 @@
=== health 그룹별 응답 — 왜 파드는 Ready 인가 ===
/actuator/health HTTP server
/actuator/health/readiness HTTP 200
/actuator/health/liveness HTTP 200
=== /actuator/health 본문 (Redis 항목이 있는가) ===
=== /actuator/health/readiness 본문 ===
{"status":"UP"}
=== Service 엔드포인트 — 트래픽을 계속 받는가 ===
ready: [10.42.0.52 10.42.1.124]
@@ -0,0 +1,39 @@
=== 복구 ===
deployment.apps/redis scaled
deployment "redis" successfully rolled out
/actuator/health HTTP 200
/bff/token-boundary HTTP 302
BFF 재시작 필요했나: 0,0 회 재시작
=== ② 영속화 — 지금 설정으로 재시작하면 무엇이 남는가 ===
키 심음: before-restart
dbsize: 4
--- AOF 를 켜고 다시 심는다 (영속화가 켜져 있으면 살아남는가) ---
appendonly yes
total 12
drwxr-xr-x 3 redis redis 4096 Sep 4 05:26 .
drwxr-xr-x 1 root root 4096 Sep 4 05:26 ..
drwx------ 2 redis redis 4096 Sep 4 05:26 appendonlydir
--- 파드를 지운다 ---
deployment "redis" successfully rolled out
재기동 후:
dbsize: 0
b5:probe
b5:aof
appendonly no
persistentvolumeclaim/redis-data created
deployment.apps/redis configured
deployment "redis" successfully rolled out
=== 영속 볼륨 위에서 다시 시험 ===
appendonly yes
키 심음: written-on-pvc
sed: -e expression #1, char 8: unknown option to 's'
--- 파드를 지운다 ---
deployment "redis" successfully rolled out
재기동 후:
dbsize: 1
b5:pvc written-on-pvc
+17
View File
@@ -0,0 +1,17 @@
# B-5 — Redis 상실과 영속화 증거
2026-09-04 15:3515:50 KST
해설: [`docs/experiment-b5-redis-loss-persistence.md`](../../experiment-b5-redis-loss-persistence.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-baseline.txt` | 정지 전 — Redis 1키, PostgreSQL 1행, `save`/`appendonly no`, 외부 200 |
| `02-redis-down.txt` | 정지 후 — `/bff/token-boundary` **`HTTP 000`(멈춤)**, `/actuator/health` 503, **파드는 1/1 Ready 유지**, Lettuce 재연결 스택 |
| `03-health-groups.txt` | **핵심**`/actuator/health` 503 인데 `/actuator/health/readiness``{"status":"UP"}`. Service 엔드포인트에 두 파드 모두 남아 있다 |
| `04-persistence.txt` | 복구는 자동(재시작 0회) · **AOF 를 켰는데 파드 삭제 후 `dbsize 0`** · PVC 를 붙인 뒤 `written-on-pvc` **생존** |
## 핵심 세 줄
1. **파드가 Ready 를 유지한 채 계속 실패한다.** `redis` 헬스 지표가 readiness 그룹에 없기 때문이며, A-2 에서 Keycloak 이 NotReady 가 된 것과 정반대다.
2. **오류가 아니라 멈춤이다.** `HTTP 000` — 빠른 실패가 안 되어 있어 사용자는 멈춘 화면을 본다.
3. **볼륨 없이 AOF 만 켜는 것은 장식이다.** `appendonlydir` 까지 만들어지지만 컨테이너와 함께 사라진다.
@@ -0,0 +1,9 @@
=== [1] 회전 전: 토큰 발급 + JWKS 상태 ===
발급 토큰의 kid: OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM
JWKS 의 RS256 키 수: 1
JWKS kid 목록:
{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
=== [2] 그 토큰이 지금 통하는가 (리소스 서버) ===
/api/me HTTP 200
@@ -0,0 +1,16 @@
=== [3] 키 회전 — 우선순위가 더 높은 RSA 공급자를 추가한다 ===
Created new component with id '7902af43-a0cc-4ebd-ad25-04d563854d16'
=== [4] 회전 후 JWKS — 옛 키가 남아 있는가 ===
RS256 키 수: 2
kid 목록:
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
=== [5] 새 토큰은 어느 키로 서명되는가 ===
새 토큰의 kid: 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84
=== [6] ★ 회전 전에 발급된 토큰은 아직 통하는가 ===
옛 토큰 /api/me HTTP 200
새 토큰 /api/me HTTP 200
@@ -0,0 +1,16 @@
=== [7] 옛 RSA 공급자(980ee9b7 = OY-caYDN 키) 제거 ===
제거 완료
=== [8] JWKS 에서 사라졌는가 ===
RS256 키 수: 1
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
=== [9] ★ 옛 키로 서명된 토큰은 이제 어떻게 되는가 ===
옛 토큰 /api/me HTTP 401 (캐시가 살아 있으면 아직 통할 수 있다)
새 토큰 /api/me HTTP 200
=== [10] 리소스 서버를 재시작해 JWKS 캐시를 비우면 ===
deployment "echo" successfully rolled out
옛 토큰 /api/me HTTP 401
새 토큰 /api/me HTTP 200
+16
View File
@@ -0,0 +1,16 @@
# B-6 — key 회전 증거
2026-09-04 15:5016:00 KST
해설: [`docs/experiment-b6-key-rotation.md`](../../experiment-b6-key-rotation.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-before-rotation.txt` | 회전 전 — 토큰 `kid=OY-caYDN...`, JWKS RS256 1개, `/api/me` 200 |
| `02-rotation.txt` | 우선순위 200 공급자 추가 → **JWKS RS256 2개**, 새 토큰은 새 kid, **옛 토큰도 새 토큰도 200** (무중단) |
| `03-old-key-removed.txt` | 옛 공급자 제거 → JWKS 1개, **옛 토큰 즉시 401**. 리소스 서버 재시작 후에도 동일 |
## 핵심 세 줄
1. **Keycloak 의 키 회전은 "바꾸기"가 아니라 "더 높은 우선순위로 추가하기"** 다. 추가만으로는 아무것도 안 깨진다.
2. **위험한 것은 옛 키를 버리는 시점이다.** 제거 즉시 그 키로 서명된 토큰이 401 이 된다.
3. **캐시는 유예가 아니다.** 모르는 `kid` 를 만나면 JWKS 를 다시 받으므로 제거가 곧바로 반영된다. 유예는 옛 키를 남겨두는 기간으로 만들어야 한다.
@@ -0,0 +1,254 @@
# B-5 — Redis 가 죽으면, 그리고 재시작하면 무엇이 남는가
브랜치 `feature/keycloak-b5-redis-loss-persistence` ·
증거 [`docs/evidence/b5-redis-loss/`](evidence/b5-redis-loss/) ·
2026-09-04 15:3515:50 KST
선행: [`B-2`](experiment-b2-multi-instance-session.md) — 세션(Redis)과 토큰(PostgreSQL)이 나뉘어 있어야 각각 죽여볼 수 있다
---
## 0. 결론부터
| | 결과 |
|---|---|
| Redis 정지 시 요청 | **오류가 아니라 멈춘다** (`HTTP 000`) |
| `/actuator/health` | **503** |
| **파드 readiness** | **`UP` 유지 — 트래픽을 계속 받으며 계속 실패한다** |
| 복구 | 자동. **BFF 재시작 0회** |
| **AOF 를 켰는데 재시작 후 전부 소실** | **볼륨이 없었다.** 영속화 설정만으로는 아무것도 안 남는다 |
| PVC 를 붙인 뒤 | **살아남는다** |
**A-2(Keycloak DB 상실)와 정반대의 실패 모양이다.** 거기서는 헬스체크가
파드를 트래픽에서 빼줬는데, 여기서는 안 빼준다.
---
## 1. Redis 정지 — 오류가 아니라 정지다
```bash
kubectl -n keycloak-lab scale deployment/redis --replicas=0
```
```
/ HTTP 200 ← permitAll 정적 페이지
/bff/token-boundary HTTP 000 ← ★ 응답이 없다
/actuator/health HTTP 503
```
`000` 은 curl 이 응답을 못 받았다는 뜻이다. **오류를 돌려주는 것이 아니라
매달려 있다.** Lettuce 가 재연결을 시도하며 타임아웃을 기다리기 때문이다.
```
io.netty.channel.socket.nio.NioSocketChannel.doFinishConnect
java.base/sun.nio.ch.Net.pollConnect
```
> **"빨리 실패하기(fail fast)"가 안 되어 있다.** 사용자는 오류 화면 대신
> **멈춘 화면**을 본다. 이것이 A-6(지연 주입)에서 본 것과 같은 문제다 —
> **느린 실패가 빠른 실패보다 나쁘다.**
---
## 2. 그런데 파드는 Ready 를 유지한다 — 가장 중요한 발견
```
/actuator/health HTTP 503
/actuator/health/readiness HTTP 200 {"status":"UP"}
/actuator/health/liveness HTTP 200
Service ready: [10.42.0.52 10.42.1.124] ← 둘 다 트래픽을 받는다
```
### 개념 — health group
Spring Boot 는 헬스 지표를 **그룹**으로 나눈다.
```
/actuator/health 모든 지표의 합 ← redis 지표가 여기 있다
/actuator/health/readiness readiness 그룹 ← 기본값은 readinessState 뿐
/actuator/health/liveness liveness 그룹
```
**`redis` 헬스 지표는 자동으로 readiness 그룹에 들어가지 않는다.**
그래서 전체 상태는 `DOWN` 인데 readiness 는 `UP` 이다.
kubelet 은 `/actuator/health/readiness` 를 보므로 **파드를 빼지 않는다.**
### A-2 와의 대비
| | A-2 (Keycloak · DB 상실) | **B-5 (BFF · Redis 상실)** |
|---|---|---|
| 의존 대상 헬스 지표 | **readiness 에 포함** | **포함 안 됨** |
| 파드 상태 | **NotReady** | **Ready 유지** |
| Service 엔드포인트 | **비었다** | 둘 다 남는다 |
| 외부 응답 | **503** (즉시, 명확) | **000** (멈춤) |
**Keycloak 은 자기 의존성을 readiness 에 넣었고, 이 BFF 는 안 넣었다.**
어느 쪽이 옳은지는 상황에 달렸다.
| readiness 에 넣으면 | 넣지 않으면 |
|---|---|
| 의존 대상이 죽으면 **전 파드가 빠진다** → 전면 장애 | 파드가 남아 **실패를 계속 서빙한다** |
| 부분 기능이라도 살릴 수 없다 | 부분 기능(정적 페이지 등)은 살아 있다 |
| A-2 처럼 **명확한 503** | **멈춤** — 진단이 어렵다 |
**의도적으로 골라야 하는 설정이며, 기본값에 맡기면 후자가 된다.**
```yaml
management:
endpoint:
health:
group:
readiness:
include: readinessState, redis # 넣으려면 명시해야 한다
```
---
## 3. 복구는 자동이다
```
/actuator/health HTTP 200
/bff/token-boundary HTTP 302 (세션이 사라져 로그인으로 보냄)
BFF 재시작: 0, 0 회
```
**Lettuce 가 스스로 재연결했다.** A-2 에서 Keycloak 의 커넥션 풀이 그랬던
것과 같다. **liveness 를 Redis 에 걸었다면 파드가 재시작됐을 것**이고,
회복이 더 늦어졌을 것이다.
`302` 는 세션이 사라졌기 때문이다 — Redis 가 비었으므로 로그인 상태가 없다.
**사용자는 로그아웃된다.**
---
## 4. 영속화 — 설정만으로는 아무것도 안 남는다
### 시도 ① AOF 를 켜고 파드를 지운다
```bash
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config set appendonly yes
kubectl -n keycloak-lab exec deploy/redis -- redis-cli set b5:aof "written-with-aof"
```
```
appendonly yes
/data 내용: appendonlydir ← 파일이 실제로 만들어졌다
```
파드를 지운 뒤
```
dbsize: 0
b5:probe (없음)
b5:aof (없음)
appendonly no ← 설정도 되돌아갔다
```
**전부 사라졌다.**
| 왜 | |
|---|---|
| `/data`**컨테이너 파일시스템** | 볼륨이 없으므로 컨테이너와 함께 사라진다 |
| `CONFIG SET`**런타임 전용** | 재기동하면 매니페스트의 `args` 가 이긴다 |
> **쿠버네티스에서 영속화 설정만 켜는 것은 장식이다.**
> `appendonly yes` 를 켜고 안심하는 것이 가장 위험하다 — **파일은 만들어지고
> 로그도 정상이며, 사라지는 것은 재시작 순간뿐**이다.
### 시도 ② PVC 를 붙인다
```yaml
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: redis-data
args: ["redis-server", "--appendonly", "yes", "--dir", "/data"]
```
```
appendonly yes
키 심음: written-on-pvc
--- 파드 삭제 후 ---
dbsize: 1
b5:pvc written-on-pvc ← 살아남았다
```
### 비교
| 구성 | 파드 삭제 후 |
|---|---|
| AOF **끔**, 볼륨 없음 | 전부 소실 |
| AOF **켬**, 볼륨 없음 | **전부 소실** (설정은 켰는데) |
| AOF **켬**, **PVC** | **생존** |
**순서가 있다 — 볼륨이 먼저고 설정이 나중이다.**
### `appendfsync` 는 여전히 트레이드오프다
```
appendfsync everysec ← 기본값
```
**1초 분량을 잃을 수 있다.** A-3 에서 본 PostgreSQL 의
`synchronous_commit OFF` 와 **같은 모양의 맞바꿈**이다.
| 설정 | 잃는 양 | 비용 |
|---|---|---|
| `always` | 없음 | 쓰기마다 fsync — 느리다 |
| **`everysec`** | **최대 1초** | 기본값 |
| `no` | OS 에 맡김 | 가장 빠름 |
**세션 저장소에서 1초를 잃는다는 것은 그 사이 로그인한 사용자가
다시 로그인해야 한다는 뜻이다.** A-3 에서 Keycloak 이 같은 판단을 했다.
### PVC 도 노드에 못박힌다
`local-path` PVC 이므로 **A-4 에서 본 것과 같다** — 노드가 죽으면
볼륨도 함께 접근 불가가 된다. **영속화는 재시작을 견디게 하지만
노드 상실을 견디게 하지는 않는다.**
---
## 5. 재현 절차 (명령어)
```bash
# ① 정지
kubectl -n keycloak-lab scale deployment/redis --replicas=0
curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/bff/token-boundary # 000
# ② 왜 파드가 안 빠지는가 — 그룹별로 본다
kubectl -n keycloak-lab exec <bff-pod> -- wget -qO- http://localhost:8083/actuator/health
kubectl -n keycloak-lab exec <bff-pod> -- wget -qO- http://localhost:8083/actuator/health/readiness
kubectl -n keycloak-lab get endpoints bff -o jsonpath='{.subsets[*].addresses[*].ip}'
# ③ 복구
kubectl -n keycloak-lab scale deployment/redis --replicas=1
# ④ 영속화 — 볼륨 없이 AOF 만 켜본다
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config set appendonly yes
kubectl -n keycloak-lab exec deploy/redis -- redis-cli set k v
kubectl -n keycloak-lab delete pod -l app=redis
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize # 0
# ⑤ PVC 를 붙이고 다시
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab exec deploy/redis -- redis-cli set k v
kubectl -n keycloak-lab delete pod -l app=redis
kubectl -n keycloak-lab exec deploy/redis -- redis-cli get k # v
```
---
## 6. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **B-6** 암호화 key 교체 | Redis 가 이제 영속적이므로 **key 를 바꾸면 옛 데이터가 남아 있다** |
| **D-1** 백업·복구 | `local-path` PVC 는 **노드에 묶여 있다** — 노드가 안 돌아오면 백업뿐 |
| 구성 | **readiness 그룹에 무엇을 넣을지 명시적으로 정한다** |
| 구성 | Redis 클라이언트에 **타임아웃**을 걸어 빠르게 실패시킨다 |
+228
View File
@@ -0,0 +1,228 @@
# B-6 — key 를 교체하면 옛 것으로 만든 것은 어떻게 되는가 → Q3 미지수 3
브랜치 `feature/keycloak-b6-key-rotation` ·
증거 [`docs/evidence/b6-key-rotation/`](evidence/b6-key-rotation/) ·
2026-09-04 15:5016:00 KST
선행: [`B-2`](experiment-b2-multi-instance-session.md) — 토큰이 **평문**임을 확인했다
**대응 질문** — Q3 미지수 3
> *"암호화 key 를 어디에 두고 어떻게 교체하게 되는가. 교체하는 동안 이전 key 로
> 저장된 값은 어떻게 읽는가."*
---
## 0. 결론부터
**질문이 두 갈래로 나뉜다.**
| | 상태 |
|---|---|
| **① 토큰 저장소의 암호화 key** | **★ 존재하지 않는다.** B-2 에서 평문임을 확인했다 |
| **② 토큰 서명 key (Keycloak realm)** | 존재하고 회전 가능하다 — **측정했다** |
②를 측정한 결과가 ①을 설계할 때 그대로 쓰인다.
```
키 추가 → 무중단. JWKS 에 옛 키와 새 키가 함께 남는다
키 제거 → ★ 즉시 파괴적. 옛 키로 서명된 토큰이 곧바로 401
```
**"교체하는 동안 이전 key 로 저장된 값은 어떻게 읽는가" 의 답은
"두 key 를 동시에 들고 있으면 읽힌다" 이고, 위험한 것은 교체가 아니라
옛 key 를 언제 버리느냐다.**
---
## 1. ① 암호화 key 는 애초에 없다
B-2 에서 확인했다.
```sql
select convert_from(refresh_token_value, 'UTF8') from oauth2_authorized_client;
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlMmUz...
```
**`bytea` 안이 JWT 문자열 그대로다.** 암호화가 없으므로 **교체할 key 도 없다.**
> Q3 는 *"Access Token 과 Refresh Token 을 Redis 에 저장할 경우 Token 을 어떤
> 방식으로 암호화할지"* 를 미지수로 뒀다. **먼저 확인해야 할 것은 "지금은
> 암호화가 없다" 이고, 그것을 B-2 에서 측정했다.**
암호화를 넣는다면 회전 설계는 ②에서 본 모양을 따라야 한다 —
**읽기는 여러 key 를 받아들이고, 쓰기는 한 key 만 쓴다.**
---
## 2. ② 서명 key 회전을 실측한다
### 회전 전
```
발급 토큰의 kid: OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM
JWKS 의 RS256 키 수: 1
/api/me HTTP 200
```
### 회전 — 우선순위가 높은 공급자를 추가한다
```bash
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh create components \
-r keycloak-patterns \
-s name=rsa-rotated -s providerId=rsa-generated \
-s providerType=org.keycloak.keys.KeyProvider \
-s 'config.priority=["200"]' -s 'config.algorithm=["RS256"]' -s 'config.keySize=["2048"]'
```
**Keycloak 의 키 회전은 "바꾸기"가 아니라 "더 높은 우선순위로 추가하기"다.**
```
RS256 키 수: 1 → 2
kid 목록:
1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84 ← 새 키 (우선순위 200)
OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM ← 옛 키 (우선순위 100, 남아 있다)
새 토큰의 kid: 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84
```
### 결과 — 무중단이다
```
옛 토큰 /api/me HTTP 200
새 토큰 /api/me HTTP 200
```
**둘 다 통한다.** 새 토큰은 새 키로 서명되고, 옛 토큰은 **JWKS 에 아직 있는
옛 키로 검증**된다.
---
## 3. 옛 키를 제거하면 — 여기가 진짜 질문이다
```bash
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
delete components/980ee9b7-... -r keycloak-patterns
```
```
RS256 키 수: 2 → 1
옛 토큰 /api/me HTTP 401 ← ★ 즉시 깨진다
새 토큰 /api/me HTTP 200
```
**리소스 서버를 재시작해 JWKS 캐시를 비운 뒤에도 같다.**
```
옛 토큰 /api/me HTTP 401
새 토큰 /api/me HTTP 200
```
### 캐시가 구해주지 않는다
처음에는 "리소스 서버가 JWKS 를 캐시하고 있으니 한동안은 통하겠지" 라고
예상했다. **아니었다. 제거 직후 바로 401 이다.**
이유는 Spring 의 `NimbusJwtDecoder` 동작에 있다 —
**모르는 `kid` 를 만나면 JWKS 를 다시 가져온다.** 그래서 캐시가 오히려
빨리 갱신되고, 결과적으로 **제거가 즉시 반영된다.**
> **캐시를 유예 기간으로 기대하면 안 된다.**
> 유예는 **옛 키를 JWKS 에 남겨두는 기간**으로 만들어야 한다.
---
## 4. 개념
### `kid` — key ID
```json
{"alg":"RS256","typ":"JWT","kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}
```
토큰 헤더에 **어느 키로 서명했는지**가 적혀 있다. 검증하는 쪽은 JWKS 에서
`kid` 를 찾아 공개키를 얻는다.
**`kid` 가 있기 때문에 여러 키를 동시에 운용할 수 있다.**
### 안전한 회전의 모양
```
t0 키 A 만 있다. 발급: A, 검증: A
t1 키 B 추가. 발급: B, 검증: A + B ← 겹치는 구간
t2 키 A 제거. 발급: B, 검증: B
★ t1 ~ t2 사이가 "A 로 서명된 것이 살아 있는 기간" 보다 길어야 한다
```
**겹치는 구간의 최소 길이 = 옛 키로 서명된 것 중 가장 오래 사는 것의 수명.**
| 이 실험대에서 | |
|---|---|
| access token | 60초 |
| refresh token | 1800초 (30분) |
| **필요한 겹침** | **최소 30분** |
**Q3 가 물은 "교체하는 동안" 이 바로 이 구간**이고, 길이를 정하는 것은
key 가 아니라 **그 key 로 만든 것의 수명**이다.
### ①에 적용하면
토큰 저장소를 암호화한다면 같은 구조가 필요하다.
```
쓰기: 새 key 하나로만
읽기: 새 key + 옛 key(들) ← key 에도 식별자가 필요하다
제거: 옛 key 로 암호화된 마지막 항목이 만료된 뒤
```
**저장된 값에 `kid` 에 해당하는 표시가 없으면 회전이 불가능하다.**
암호화를 설계할 때 **key 식별자를 값과 함께 저장**해야 하는 이유다.
---
## 5. 겪은 문제
### `kcadm get components` 가 조용히 빈 결과
```bash
kcadm.sh get components -r keycloak-patterns -q type=org.keycloak.keys.KeyProvider
```
`-q type=...` 로는 아무것도 안 나왔다. `--fields` 를 붙여 전체를 받아야 보였다.
**필터가 안 먹었는데 오류도 없었다** — A층에서 반복해 만난 "조용한 실패"다.
---
## 6. 재현 절차 (명령어)
```bash
# 1. 현재 키와 토큰의 kid
kcadm.sh get keys -r keycloak-patterns
curl -s $KC/protocol/openid-connect/certs | tr ',' '\n' | grep kid
# 2. 회전 — 더 높은 우선순위로 추가한다 (바꾸는 것이 아니다)
kcadm.sh create components -r keycloak-patterns \
-s name=rsa-rotated -s providerId=rsa-generated \
-s providerType=org.keycloak.keys.KeyProvider \
-s 'config.priority=["200"]' -s 'config.algorithm=["RS256"]' -s 'config.keySize=["2048"]'
# 3. 옛 토큰이 아직 통하는지 (통해야 정상)
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $OLD" http://echo...:8081/api/me
# 4. 옛 공급자 목록 — -q 필터는 안 먹는다. --fields 로 전체를 본다
kcadm.sh get components -r keycloak-patterns --fields id,name,providerId
# 5. 제거하고 다시 확인 — 여기서 401 이 나와야 정상이다
kcadm.sh delete components/<old-id> -r keycloak-patterns
```
---
## 7. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **B-7** oauth2-proxy cookie secret | **같은 모양의 문제.** 다만 거기는 겹침 구간을 만들 수 있는가가 관건 |
| **D-2** 버전 업그레이드 | Redis 의 **Java 직렬화 세션**(B-1)도 같은 "옛 형식을 읽을 수 있는가" 문제다 |
| 설계 | 암호화를 넣는다면 **값과 함께 key 식별자를 저장**해야 회전할 수 있다 |