Files
document-haness/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b6-key-rotation.md
T
DongHyeonkaandClaude Opus 5 2109f726fe feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다
기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만
있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문
종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다.

SSOT 결함 둘을 고쳤다.

- kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이
  「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽
  `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다.
- virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데
  원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다.

기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은
둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더
든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다).

계약을 셋 고쳤다.

- kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/**
  28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는
  것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온
  것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수
  대조했을 때 가장 가까운 커밋도 28개가 어긋났다).
- virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고
  재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다.
- kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고
  넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다.

style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져
(실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고,
engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고
있었다(Question 기록에서 11.94 → 3.86).

verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:01:55 +09:00

35 KiB
Raw Blame History

id, kind, slug, title, topic, topicName, project, status, studio, pinnedVersions, source, sourceRevision
id kind slug title topic topicName project status studio pinnedVersions source sourceRevision
7447ccbf-1800-43a4-a9d2-8ac774965c4b SETUP reproduce-b6-key-rotation 서명 키를 더한 뒤 옛 키를 지우고 옛 토큰이 언제 끊기는지 본다 where-application-state-lives 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기 keycloak-session-store 게시 전 https://hyeonworks.com/studio/documents/7447ccbf-1800-43a4-a9d2-8ac774965c4b/edit
name version
curl 8.5.0
final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-6
cdac9b8178391311d8eca1ebc6cac15bb62d79af

서명 키를 더한 뒤 옛 키를 지우고 옛 토큰이 언제 끊기는지 본다

realm 에 RSA 서명 키를 하나 더해 겹치는 구간을 만들고, 옛 공급자를 지운 뒤 두 토큰을 같은 두 줄로 다시 치는 절차다. 지운 키는 개인키와 함께 사라져 돌아오지 않으므로 실험대 전용 realm 에서만 한다. 약 15분.

관계

  • 볼륨 없는 영속화와 유예 없는 키 회전 이 절차가 만드는 옛 401 · 새 200 을 그 기록이 결론으로 적는다. 결론이 필요하면 그쪽을 읽는다.
  • 주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다 여기서 -q 필터가 오류도 종료코드도 없이 빈 결과를 주는 대목이 그 아홉 건 중 하나다.
  • 예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다 이 절차는 주입 검증에서 old 200 을 보기 전에는 관찰로 넘어가지 않는다. 그것을 안 보고 지우면 뒤에 나온 401 의 원인을 못 가른다.
  • cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다 같은 회전을 식별자가 없는 쪽에서 치는 편이다. 여기서 kid 가 겹침을 가능하게 하는 것을 보고 나면 그쪽에서 겹침이 왜 불가능한지가 한 줄로 끝난다.

본문

읽기 전에 — 어디서 치는가

명령은 전부 [kc-lab-1] 에서 친다. Keycloak 이미지에는 curlwget 도 없어서(exit 127) 파드 안에서 HTTP 요청을 보낼 수 없다. JWKS(JSON Web Key Set, 서버가 공개키를 싣는 목록)와 토큰은 호스트에서 공개 이름으로 치고, kcadm.shkubectl exec 로 감싸 파드 안에서 돌린다.

터미널은 하나면 된다. 붙잡아 두어야 하는 셸이 없고, 대신 OLDNEW 두 변수를 끝까지 들고 가므로 중간에 터미널을 닫지 않는다.

무엇
네임스페이스 keycloak-lab · 리소스 서버는 header-lab
realm keycloak-patterns — 클라이언트 bff-confidential, 사용자 labuser
주입 수단 kcadm.sh create componentspriority 가 더 높은 RSA 공급자를 하나 더 만든다
판정하는 쪽 리소스 서버 echo. 이 절차의 401 과 200 은 전부 그 앱이 낸다
시간 제약 access token 수명 60초. 토큰을 받고 1분 안에 그 토큰으로 친다
전 구간 약 15분. 주입 검증까지는 아무것도 안 깨진다
도구 jq 가 이 실험대에 없다. JSON 은 trgrep 으로 자른다

이 실험이 가르는 것

암호화 키를 어디에 두고 어떻게 교체하며, 교체하는 동안 옛 키로 저장된 값을 어떻게 읽는가. B층이 들고 온 이 물음이 두 갈래로 갈린다.

어느 키인가 지금 상태
토큰 저장소의 암호화 키 존재하지 않는다. B-2 에서 bytea 안이 JWT 문자열 그대로였다
토큰 서명 키 (Keycloak realm) 존재하고 회전할 수 있다 — 이 절차가 잰다

앞의 것이 없으므로 교체할 것도 없다. 그래서 이 절차는 뒤의 것만 치고, 거기서 본 모양이 나중에 앞의 것을 설계할 때 쓰인다.

원래 실행은 예측이 빗나간 실험이었다. 리소스 서버가 JWKS 를 캐시하니 옛 키를 지워도 한동안은 통할 것이라고 적어 두었는데, 제거 직후 바로 401 이 나왔다. 「교체」라는 한 단어가 성질이 정반대인 두 조작을 가리킨다.

   키 추가   →  무중단.  JWKS 에 옛 키와 새 키가 함께 남는다
   키 제거   →  ★ 즉시 파괴적.  옛 키로 서명된 토큰이 곧바로 401

절차를 끝까지 밟으면 RS256 키 수가 1 → 2 → 1 로 움직이는 것, 겹치는 구간에서 옛 토큰과 새 토큰이 둘 다 200 인 것, 제거 뒤에 옛 토큰만 401 이 되는 것, 리소스 서버를 재시작해 캐시를 비워도 그 401 이 그대로인 것을 자기 화면에서 보게 된다.

전제와 되돌리기

  • 05-keycloak 이 끝나 있고 realm keycloak-patterns 에 클라이언트 bff-confidential 과 사용자 labuser 가 있다.
  • B-0 이 끝나 BFF 가 떠 있다.
  • 리소스 서버(echo, 네임스페이스 header-lab)가 떠 있다. 이 절차의 401 과 200 은 전부 그 앱이 판정한다.

이건 되돌릴 수 없는 실험이다. 지우는 것은 서명 키 공급자이고 그 안의 개인키가 함께 사라진다. 같은 이름으로 공급자를 다시 만들어도 새 키 쌍이 생기고 kid 가 달라지므로, 옛 키로 서명된 토큰은 영구히 검증되지 않는다. 실험대에서만 한다.

되돌릴 수 있는 것은 주입 하나다. 방금 만든 공급자를 지우면 원래대로 돌아간다. id 는 주입이 화면에 찍어 주는 값이고 그 줄을 그대로 옮겨 친다 — 원래 실행에서는 7902af43-a0cc-4ebd-ad25-04d563854d16 이었다. 아래 블록에 박힌 값이 그 원래 실행의 id 라, 8 절을 친 뒤에 그 출력이 찍어 준 자기 id 로 바꿔야 지워진다. 8 절을 치기 전에는 지울 공급자가 없다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  delete components/7902af43-a0cc-4ebd-ad25-04d563854d16 -r keycloak-patterns

주입 전에 같은 명령으로 먼저 본다

제거 후에 볼 것을 제거 전에 똑같은 명령으로 먼저 봐 둔다. 넓은 것부터 좁혀 간다.

kcadm 로그인 → 키 공급자 목록 → JWKS 원문 → 토큰의 kid → 그 토큰이 통하는가

1. kcadm 세션을 파드 안에 만든다

목적 — 뒤의 모든 kcadm.sh 명령이 관리 API 로 인증되게 한다.

행동 — 관리자 자격증명으로 로그인하고, 값이 넘어갔는지는 길이로만 본다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  config credentials --server http://localhost:8080 --realm master --user admin \
  --password "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
                -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
  -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c

예상 결과 — ① 은 아무것도 안 나오면 성공이다. 실패하면 한 줄짜리 오류가 뜬다. ② 는 0 이 아닌 수를 낸다.

왜 필요한가kcadm.sh 는 파드 안 파일에 세션을 저장한다. 파드가 재시작되면 그 파일이 사라지고 그다음 모든 명령이 401 로 떨어지므로 맨 앞에서 한 번 해 둔다. 비밀번호는 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 남지 않는다.

문제가 생기면 — 중간에 kcadm 이 전부 Unauthorized 로 바뀌면 파드가 재시작된 것이다. ① 을 다시 친다.

2. 키 공급자 목록을 통째로 받는다

무엇을 보는가 — 이 realm 에 어떤 키 공급자가 있고 각각의 id 가 무엇인지.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get components -r keycloak-patterns --fields id,name,providerId

어디를 보나 — JSON 배열이 여러 줄로 나온다. providerIdrsa-generated 인 항목이 서명 키 공급자이고 hmac-generated · aes-generated 등이 함께 나온다. "name" : "rsa-generated" 인 항목의 "id" 를 지금 적어 둔다. 관찰 절에서 지울 대상이다.

이 값이 뜻하는 것 — 여기서 「키 공급자만 걸러 보자」는 시도가 빈 결과를 준다. 가이드가 이 줄을 미검증으로 표시했다(unknown) — 원래 실행에서 이렇게 치고 아무것도 못 받았다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get components -r keycloak-patterns -q type=org.keycloak.keys.KeyProvider

오류도 종료코드도 없이 비어 있다. 「키 공급자가 하나도 없구나」로 읽으면 이 절차 전체가 무너진다. 빈 출력은 「없다」가 아니라 「이 명령으로는 안 보인다」일 수 있고, --fields 로 전체를 받아 눈으로 고른다.

3. JWKS 원문을 한 번 통째로 본다

무엇을 보는가 — 어떤 필드가 실려 있는지. 다음부터 무엇으로 걸를지가 여기서 정해진다.

curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs

줄바꿈 없이 한 줄로 길게 나온다. 실측의 첫머리는 이렇다(observed, 01-before-rotation.txt).

{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"

그 뒤로 kty · alg · use · n · e 가 이어지고 다음 키가 온다. kid 마다 alg 가 따로 붙는다. 읽을 만하게 자를 때는 jq 가 없으므로 tr 로 쉼표를 줄바꿈으로 바꾼다.

curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
  | tr ',' '\n' | grep kid

어디를 보나 — 실측은 이렇다(observed, 01-before-rotation.txt).

  JWKS kid 목록:
    {"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
    {"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"

이 값이 뜻하는 것kid 는 둘인데 같은 파일의 윗줄은 RS256 키가 하나라고 적는다(observed).

  JWKS 의 RS256 키 수: 1

세는 단위가 다르다. JWKS 에는 서명 키만 실리지 않는다. 이 realm 에서는 암호화용 키(RSA-OAEP 계열)가 함께 실려 있고 그것도 kid 를 갖는다. grep kid | wc -l 로 세면 서명 키 수를 과다 계산한다.

4. RS256 만 세는 두 형태를 알아 둔다

무엇을 보는가 — 알고리즘까지 보고 세는 방법. 아래 두 줄은 가이드가 미검증으로 표시했다(unknown).

JWKS 는 키 하나가 } 로 끝나므로 tr '}' 로 자르면 한 줄이 한 키가 된다.

curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
  | tr '}' '\n' | grep -c RS256

Keycloak 자신에게 묻는 쪽이 확실하고 그쪽이 1순위 도구다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get keys -r keycloak-patterns

어디를 보나 — 키마다 붙는 algorithmstatus 를 보고, RS256 이면서 ACTIVE 인 것이 지금 서명에 쓰이는 키다.

이 값이 뜻하는 것 — 이 두 명령은 원래 실행 기록에 출력이 없다. 위에 인용한 「RS256 키 수: 1」만이 실측이다.

5. 시험체가 될 옛 토큰을 하나 받아 둔다

목적 — 회전 전에 발급된 토큰을 확보한다. 이 토큰 하나가 이 절차의 시험체다.

행동 — 토큰 엔드포인트와 클라이언트 비밀을 변수에 담고 direct grant 로 받는다.

KC=https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/token
CS=$(kubectl -n keycloak-lab get secret bff-secrets \
     -o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d)
OLD=$(curl -s -X POST "$KC" \
  -d grant_type=password -d client_id=bff-confidential -d "client_secret=$CS" \
  -d username=labuser -d password=labpass -d scope=openid \
  | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "${#OLD}자"

예상 결과 — 길이만 나온다. 원래 실행의 모양은 이렇다(observed).

2043자

왜 필요한가 — 변수 이름이 OLD 인 까닭은 회전이 끝난 뒤에도 이 값이 「옛 키로 서명된 토큰」으로 남아 있어야 하기 때문이다. 중간에 다시 받으면 새 키로 서명되어 실험이 성립하지 않는다. 토큰 값도 클라이언트 비밀도 화면에 찍지 않고 길이만 본다.

문제가 생기면0자 가 나오면 토큰을 못 받았다. 변수에 담지 말고 같은 curl 을 그대로 쳐서 응답 본문을 읽는다.

6. 그 토큰이 어느 키로 서명됐는지 읽는다

무엇을 보는가 — JWT 의 첫 토막이 헤더이고 거기 kid 가 있다.

echo "$OLD" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo

어디를 보나 — 원래 실행의 모양은 이렇다(observed).

{"alg":"RS256","typ":"JWT","kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}

증거 파일에는 이렇게 남아 있다(observed, 01-before-rotation.txt).

  발급 토큰의 kid: OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM

이 값이 뜻하는 것kid 는 key ID 이고, 서명한 쪽이 어느 키를 썼는지 토큰 헤더에 적어 준다. 검증하는 쪽은 JWKS 에서 그 kid 를 찾아 공개키를 얻는다. kid 가 없다면 검증자는 「지금 유효한 키」 하나만 알 수 있고, 키가 바뀌는 순간 옛 토큰이 전부 죽는다. 겹치는 구간을 가능하게 하는 것이 이 kid 다. 여기서 본 값이 3 절의 목록에 있는지 대조한다. base64 패딩 때문에 끝이 깨져 보일 수 있고(2>/dev/null 이 그 불평을 지운다) 헤더는 짧아서 대개 온전히 보인다.

7. 그 토큰이 지금 통하는지 본다

무엇을 보는가 — 대조군. 이 확인을 건너뛰면 뒤의 401 이 아무 의미가 없다.

curl -s -i -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me

200 이면 subject 같은 클레임이 돌아오고, 401 이면 WWW-Authenticate 헤더에 이유가 붙는다. 이 헤더를 한 번 봐 두면 뒤에서 401 이 났을 때 왜인지 물을 근거가 생긴다. 여러 번 비교할 때부터는 코드만 뽑는다.

curl -s -o /dev/null -w 'old %{http_code}\n' \
  -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me

어디를 보나 — 실측은 이렇다(observed, 01-before-rotation.txt).

=== [2] 그 토큰이 지금 통하는가 (리소스 서버) ===
  /api/me  HTTP 200

이 값이 뜻하는 것 — 회전 전에는 통한다. 원래 실행은 클러스터 안에서 http://echo.header-lab.svc:8081/api/me 를 쳤고, 이 절차가 공개 이름을 쓰는 까닭은 kc-lab-1 에서 클러스터 DNS 가 안 풀리기 때문이다. app1.hyeonworks.com/api 는 Ingress 가 같은 echo 로 보내므로 도달하는 앱은 같다. 그리고 access token 은 60초짜리다(이 realm 은 accessTokenLifespan=60). 1분을 넘기면 회전과 무관하게 401 이 나온다.

주입

8. 우선순위가 더 높은 RSA 공급자를 추가한다

목적 — 발급은 새 키로 가고 검증은 옛 키와 새 키를 둘 다 받는 상태를 만든다.

Keycloak 의 키 회전은 바꾸기가 아니라 더 높은 우선순위로 추가하기다. 기존 공급자는 그대로 두고 priority 가 더 큰 공급자를 하나 더 만든다.

   t0   키 A 만 있다.  발급: A,  검증: A
   t1   키 B 추가.     발급: B,  검증: A + B      ← 겹치는 구간
   t2   키 A 제거.     발급: B,  검증: B

행동 — 공급자를 만들고 시각을 남긴다.

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"]'
date '+%H:%M:%S 추가'

예상 결과 — 실측은 이렇다(observed, 02-rotation.txt).

=== [3] 키 회전 — 우선순위가 더 높은 RSA 공급자를 추가한다 ===
Created new component with id '7902af43-a0cc-4ebd-ad25-04d563854d16'

돌아온 id 를 적어 둔다. 전제 절의 되돌리기가 그 값을 쓴다.

왜 필요한가config.priority 가 기존 공급자보다 커야 발급이 새 키로 간다. 기본값은 100 이고 여기서는 200 을 줬다. 낮게 주면 새 키는 만들어지지만 발급에 쓰이지 않아 주입 검증에서 kid 가 안 바뀐다. config.* 값이 대괄호로 감싼 배열인 것에도 주의한다 — -s config.priority=200 처럼 쓰면 형이 안 맞는다. Keycloak 컴포넌트 설정은 값이 전부 문자열 목록이다.

문제가 생기면 — 생성이 거절되면 config.* 의 대괄호부터 본다.

주입 검증

결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 본다. 주입 전과 똑같은 명령을 다시 친다.

9. JWKS 에 옛 키가 남아 있는가

curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
  | tr ',' '\n' | grep kid

실측은 이렇다(observed, 02-rotation.txt).

=== [4] 회전 후 JWKS — 옛 키가 남아 있는가 ===
  RS256 키 수: 2
  kid 목록:
    {"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
    {"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
    {"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"

kid(OY-caYDN…)가 목록에서 빠지지 않았다. 새 것이 하나 늘었고 아무것도 사라지지 않았다. JWKS 는 지금 검증에 쓸 수 있는 키 전부를 싣는 목록이고, 추가는 그 목록을 늘린다.

10. 새 토큰은 어느 키로 서명되는가

OLD 은 건드리지 않는다.

NEW=$(curl -s -X POST "$KC" \
  -d grant_type=password -d client_id=bff-confidential -d "client_secret=$CS" \
  -d username=labuser -d password=labpass -d scope=openid \
  | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "$NEW" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo

실측은 이렇다(observed, 02-rotation.txt).

=== [5] 새 토큰은 어느 키로 서명되는가 ===
  새 토큰의 kid: 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84

kid 가 우선순위 200 짜리 새 키로 바뀌었다. 발급은 우선순위가 가장 높은 키로 간다. 여기서 kid 가 안 바뀌었다면 priority 를 낮게 준 것이다.

11. 둘 다 통해야 겹치는 구간이 무중단이다

curl -s -o /dev/null -w 'old %{http_code}\n' \
  -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
curl -s -o /dev/null -w 'new %{http_code}\n' \
  -H "Authorization: Bearer $NEW" https://app1.hyeonworks.com/api/me

실측은 이렇다(observed, 02-rotation.txt).

=== [6] ★ 회전 전에 발급된 토큰은 아직 통하는가 ===
  옛 토큰  /api/me  HTTP 200
  새 토큰  /api/me  HTTP 200

둘 다 200 이므로 키 추가는 무중단이다. 새 토큰은 새 키로 서명되고 옛 토큰은 JWKS 에 아직 있는 옛 키로 검증되며 사용자는 아무것도 못 느낀다.

여기서 old 가 401 이면 둘 중 하나다. 토큰이 만료됐거나(60초), 추가 말고 다른 것을 건드렸다. 가르는 법은 옛 토큰의 exp 를 보는 것이고 JWT 의 가운데 토막이 클레임이다.

echo "$OLD" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
date +%s

exp 가 지금보다 작으면 만료다. 다시 받아야 하는데, 순서 그대로 5 절만 다시 치면 8 절이 만든 rsa-rotated 가 이미 priority 200 이라 새로 받은 토큰도 새 키로 서명된다. 8 절이 화면에 찍어 준 id 로 그 공급자부터 지우고(전제 절의 되돌리기 한 줄), 5 절로 토큰을 받고, 8 절을 다시 쳐서 추가한 뒤 11 절로 온다.

관찰

여기서부터 되돌릴 수 없다. 계속하기 전에 셋을 확인한다 — 이 realm 이 실험대 전용인가, 지금 살아 있는 세션 중에 잃으면 곤란한 것이 있는가, 11 절의 old 200 을 실제로 봤는가. 마지막 것을 안 봤다면 401 이 나와도 원인을 못 가른다.

넷째로 $OLD 에 시간이 얼마나 남았는지 본다. access token 이 60초짜리라, 12 절에서 목록을 받아 눈으로 id 를 고르고 13 절에서 그 id 를 손으로 옮겨 적는 동안 만료된다. 그러면 15 절의 old 401 이 키 제거 때문인지 만료 때문인지 안 갈린다 — 이 절차의 결론이 바로 그 401 이라, 여기서 못 가르면 아무것도 못 잰다. 11 절의 만료 확인 두 줄을 지금 한 번 쳐서 expdate +%s 의 차이를 보고, 12·13 절을 칠 만큼 안 남았으면 11 절이 안내한 대로 $OLD 를 다시 받고 온다.

12. 지울 대상을 정확히 고른다

무엇을 보는가 — 남길 것과 지울 것의 id. -q 는 여전히 안 먹는다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get components -r keycloak-patterns --fields id,name,providerId

목록이 길면 그 항목 둘레만 잘라 본다. "id""name" 보다 위에 나온다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get components -r keycloak-patterns --fields id,name,providerId \
  | grep -B2 '"name" : "rsa-generated"'

어디를 보나 — 남길 것이 "name" : "rsa-rotated" 이고 지울 것이 "name" : "rsa-generated" 다.

이 값이 뜻하는 것 — 둘을 바꿔 지우면 실험이 뒤집힌다. 원래 실행에서 옛 공급자의 id 는 980ee9b7… 로 시작했고 그것이 OY-caYDN… 키를 갖고 있었다. 환경마다 id 가 다르고 증거에 남은 것도 앞 8자뿐이니 전체 id 는 위 출력에서 그대로 옮겨 온다.

13. 옛 공급자를 지운다

목적 — 옛 서명 키를 JWKS 에서 없앤다.

행동 — 위 출력의 id 를 변수에 옮기고 지운다. 아래 블록은 그대로 붙여넣으면 안 된다. 첫 줄의 980ee9b7-... 은 원래 실행의 값이므로 12 절 출력에서 읽은 자기 id 로 바꾼다. 안 바꾸고 치면 없는 컴포넌트를 지우라는 요청이 되어 옛 공급자는 살아 있고, 15 절이 옛 200 을 내 결론이 뒤집힌다.

OLDID=980ee9b7-...        # ← 위 출력에서 그대로 옮긴다. 환경마다 다르다

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  delete components/"$OLDID" -r keycloak-patterns
date '+%H:%M:%S 제거'

예상 결과 — 조용히 끝나면 성공이다. 증거 파일에는 이렇게 남아 있다(observed, 03-old-key-removed.txt).

=== [7] 옛 RSA 공급자(980ee9b7 = OY-caYDN 키) 제거 ===
  제거 완료

왜 필요한가 — 시각을 적어 두면 뒤에 나온 401 을 이 조작에 귀속할 수 있다. 그리고 지워진 것은 공급자이므로 그 안의 개인키도 함께 사라진다.

문제가 생기면 — 지운 뒤 새 토큰까지 401 이면 새 공급자를 지운 것이다. 14 절과 15 절을 먼저 치고 kid 를 대조한다.

14. JWKS 에서 사라졌는지 본다

curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
  | tr ',' '\n' | grep kid

실측은 이렇다(observed, 03-old-key-removed.txt).

=== [8] JWKS 에서 사라졌는가 ===
  RS256 키 수: 1
    {"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
    {"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"

OY-caYDN… 이 없고 RS256 은 다시 하나다. gokjn0… 은 처음부터 끝까지 목록에 있는데, 서명 키가 아니라 암호화 키이기 때문이다.

15. 두 토큰을 같은 두 줄로 다시 친다

치기 전에 $OLD 가 아직 안 죽었는지 본다. 11 절의 만료 확인 두 줄을 그대로 다시 친다. expdate +%s 보다 작으면 아래에서 나올 old 401 은 키 제거가 아니라 만료이고, 두 401 은 화면에서 똑같이 보인다.

curl -s -o /dev/null -w 'old %{http_code}\n' \
  -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
curl -s -o /dev/null -w 'new %{http_code}\n' \
  -H "Authorization: Bearer $NEW" https://app1.hyeonworks.com/api/me

실측은 이렇다(observed, 03-old-key-removed.txt).

=== [9] ★ 옛 키로 서명된 토큰은 이제 어떻게 되는가 ===
  옛 토큰  /api/me  HTTP 401  (캐시가 살아 있으면 아직 통할 수 있다)
  새 토큰  /api/me  HTTP 200

제거는 즉시 반영된다. 괄호 안의 「캐시가 살아 있으면 아직 통할 수 있다」는 측정하기 전에 적어 둔 예상이고, 옆의 401 이 그 예상을 부정한 값이다. 증거 파일에 예상과 결과가 나란히 남아 있다.

$OLD 가 만료된 뒤였다면 이 실행은 15·16 절의 판정을 못 낸다. 옛 키는 13 절에서 개인키와 함께 사라져 옛 키로 서명된 토큰을 새로 만들 방법이 없다. 그때는 401 을 키 제거에 귀속하지 말고, 실험대를 5 절부터 다시 밟되 8 절에서 15 절까지를 토큰 수명 안에 끝낸다. 원래 실행이 그 구간을 얼마 만에 끝냈는지는 원본 가이드에 없다(unknown).

16. 리소스 서버를 재시작해 캐시를 비운다

목적 — 401 이 캐시 상태 때문인지 가른다.

kubectl -n header-lab rollout restart deploy/echo
kubectl -n header-lab rollout status deploy/echo --timeout=180s

그다음 15 절의 두 줄을 다시 친다. 실측은 이렇다(observed, 03-old-key-removed.txt).

=== [10] 리소스 서버를 재시작해 JWKS 캐시를 비우면 ===
deployment "echo" successfully rolled out
  옛 토큰  /api/me  HTTP 401
  새 토큰  /api/me  HTTP 200

왜 필요한가 — 재시작 전과 후가 같으므로 401 은 캐시 상태와 무관하고 캐시는 유예를 주지 않았다. Spring 의 NimbusJwtDecoder 는 모르는 kid 를 만나면 JWKS 를 다시 가져온다. 캐시는 이미 아는 키를 다시 안 받으려는 장치이지 옛 키를 붙잡아 두는 장치가 아니다.

   옛 토큰 도착
      │
      ├─▶ kid = OY-caYDN…  →  캐시에 없다
      │                          │
      │                          └─▶ JWKS 를 다시 가져온다 (여기서 오히려 빨리 갱신된다)
      │
      └─▶ 새로 받은 JWKS 에도 없다  →  401

캐시가 오히려 제거를 빨리 반영시킨다. 유예는 캐시로 만드는 것이 아니라 옛 키를 JWKS 에 남겨 두는 기간으로 만든다.

문제가 생기면 — 재시작 뒤 옛 토큰이 200 이면 지운 것이 그 토큰의 키가 아니다. 12 절의 목록과 6 절의 kid 를 대조한다.

겹치는 구간은 얼마나 길어야 하나

이 절차는 그 길이를 재지 않았다. 추가와 제거가 연달아 일어났고 전 구간이 약 15분이다. 아래 값은 realm 설정에서 따라 나온 추론이다.

이 실험대에서 수명
access token 60초
refresh token 1800초 (30분)
필요한 겹침 최소 30분 — 앞 두 값에서 따라 나온 추론이고 측정하지 않았다

겹침의 최소 길이는 옛 키로 서명된 것 중 가장 오래 사는 것의 수명과 같다. 실제로 재려면 추가와 제거 사이를 30분 이상 벌리고, 그 사이에 받은 refresh token 으로 제거 뒤에 갱신을 시도한다. 이 절차에는 그 단계가 없다.

수명 세 값은 realm 설정이므로 직접 볼 수 있다. 가이드가 이 줄을 미검증으로 표시했다(unknown).

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get realms/keycloak-patterns --fields accessTokenLifespan,ssoSessionIdleTimeout,ssoSessionMaxLifespan

겹침 길이를 정하는 것은 키가 아니라 그 키로 만든 것의 수명이다. 30분짜리 refresh token 을 발급하면서 겹침을 5분만 두면 25분어치의 토큰을 죽인다. 토큰 저장소의 암호화 키를 나중에 설계할 때도 같은 모양이 된다.

   쓰기: 새 key 하나로만
   읽기: 새 key + 옛 key(들)      ← key 에도 식별자가 필요하다
   제거: 옛 key 로 암호화된 마지막 항목이 만료된 뒤

저장된 값에 kid 에 해당하는 표시가 없으면 회전이 불가능하다. 그것이 없을 때 무슨 일이 나는지는 B-7 이 잰다.

복구와 원상복구 확인표

이 절차에는 원상복구가 없다. 지운 키 공급자는 개인키와 함께 사라졌고, 같은 이름으로 다시 만들면 새 키 쌍이 생기고 kid 가 다르므로 옛 토큰은 그래도 401 이다. 정상 상태는 새 키 하나만 남은 상태이고, 실험 전과 다르지만 깨진 상태가 아니다.

남은 것 어떻게
rsa-rotated 공급자 그냥 둔다. 지금 유일한 RS256 서명 키다 지우면 realm 이 서명할 키를 잃는다
셸 변수 OLD NEW CS 터미널을 닫으면 사라진다 unset OLD NEW CS
실험 중 발급한 토큰 60초 뒤 만료된다 별도 조치 없음

이름이 거슬리면 새 공급자를 하나 더 만들고 rsa-rotated 를 지운다. 다만 그것 역시 또 한 번의 회전이고 또 하나의 새 키다.

항목 명령 돌아왔을 때
서명 키 curl -s …/protocol/openid-connect/certs | tr ',' '\n' | grep kid RS256 이 하나
새 토큰 토큰 발급 + /api/me 200
리소스 서버 kubectl -n header-lab get pods echo1/1 Running
Keycloak kubectl -n keycloak-lab get pods 둘 다 1/1 Running
공급자 목록 kcadm get components --fields id,name,providerId rsa-generated 가 없고 rsa-rotated 가 있다

막히면

원래 실행이 실제로 겪은 증상이고 지어낸 것은 없다고 가이드가 적는다.

증상 원인 확인
kcadm get components -q type=... 가 빈 결과 -q 필터가 안 먹는다. 오류도 없다 --fields id,name,providerId 로 전체를 받는다
kcadm 이 전부 401/Unauthorized 파드가 재시작되어 kcadm 세션이 사라졌다 config credentials 를 다시
kubectl exec keycloak-0 -- curlexit 127 Keycloak 이미지에 curl 도 wget 도 없다 JWKS 와 토큰은 kc-lab-1 호스트에서 친다
jq: command not found 이 실험대에는 jq 가 없다 tr ',' '\n' | grep 로 자른다
kid 를 세니 2개인데 문서는 1개라고 한다 RS256 이 아닌 암호화 키가 섞여 있다 tr '}' '\n' | grep -c RS256 또는 kcadm get keys
공급자를 추가했는데 새 토큰의 kid 가 그대로 config.priority 가 기존보다 낮다 값이 ["200"] 처럼 배열인지
추가만 했는데 옛 토큰이 401 추가가 아니라 토큰이 만료됐다(60초) 클레임의 expdate +%s 비교
제거했는데 새 토큰이 401 지운 것이 새 공급자다 kid 를 다시 확인하고 남은 공급자 목록을 본다
제거했는데 옛 토큰이 200 지운 것이 그 토큰의 키가 아니다 토큰 헤더의 kid 와 지운 공급자의 키를 대조
「캐시 때문일 것」이라 재시작을 기다린다 캐시는 유예를 주지 않는다 재시작 전후가 같다
지운 키를 되살리려 한다 되살릴 수 없다. 같은 이름과 같은 키는 다르다 새 키 하나만 남은 상태가 정상이다

무엇이 관측이고 무엇이 아닌가

이 절차의 숫자는 2026-09-04 14:3014:32 KST 에 돈 한 번의 실행에서 나왔다(observed). 해설 문서 머리의 15:5016:00 KST 는 문서를 쓴 시각이고 증거 파일의 mtime 이 앞의 값이라, 실측으로 인용하는 것은 뒤쪽이라고 가이드가 적는다.

  • (observed) 회전 전 JWKS 의 kid 두 개(gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4 · OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM)와 RS256 키 수 1, 발급 토큰의 kidOY-caYDN… 인 것, 그 토큰의 /api/me HTTP 200, 추가한 공급자의 id 7902af43-a0cc-4ebd-ad25-04d563854d16, 회전 후 RS256 키 수 2 와 늘어난 kid 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84, 새 토큰의 kid 가 그것인 것, 겹치는 구간의 옛 200 · 새 200, 제거 후 RS256 키 수 1 과 옛 401 · 새 200, echo 를 재시작한 뒤에도 옛 401 · 새 200 인 것.
  • 비밀은 길이와 존재만 적었다. 클라이언트 비밀은 CS 변수에 명령 치환으로만 넘겨 화면에 찍지 않고, admin 비밀번호도 wc -c 로 길이만 본다. 토큰은 2043자 라는 길이만 옮겼고 값은 증거 파일에 있다. kid 와 공급자 id 는 공개 식별자라 그대로 적었다.
  • (unknown) -q type=org.keycloak.keys.KeyProvider 로 거르는 줄(조용히 빈 결과를 준다), tr '}' '\n' | grep -c RS256 로 RS256 만 세는 줄, kcadm get keys 로 알고리즘과 상태를 보는 줄, realm 의 수명 세 값을 한 번에 받는 줄. 가이드가 전부 미검증으로 표시했고 원래 실행 기록에 이 명령들의 출력이 없다.
  • 판 번호는 이 편의 출력에 하나도 안 찍혔다. B층 아홉 편 가운데 판 번호가 남은 것은 다섯 편이고 B-6 은 거기 없다(observed). curl 8.5.0 은 같은 실험대의 B-4 가 echo 앱에서 되돌려받은 user-agent 이지 이 편이 잰 값이 아니다(inferred).
  • 추론이지 측정이 아닌 것 — 「겹침은 최소 30분」은 access token 60초와 refresh token 1800초라는 설정에서 따라 나온 값이다. 겹침을 실제로 30분 유지하며 그 사이에 발급된 refresh token 이 제거 뒤에 어떻게 되는지는 측정하지 않았다.