docs(keycloak): adopt the decomposition contract, fix the redirect URI, strip evaluative prose

- 계약 채택 — 독자 질문, 후보 29건(PROMOTE 24 · MERGE_INTO 4 · KEEP_IN_SSOT 1). 게시 중
  17건은 전부 유지. 저장소 keycloak-pattern 은 패턴 넷이 브랜치로 갈라져 있어 revisions 로
  tip 넷을 적었다. keycloak-session-store 는 같은 저장소 @ cdac9b8
- 게시된 기록의 redirect_uri 가 SSOT·코드와 달랐다 — OAuth2callback.html → callback.html
  (frontend/src/app.js 에서 확인). 계약 title 이 기록과 다른 7건도 기록 쪽으로 맞췄다
- 미작성 1건 작성 — 패턴 검증을 실제로 돌릴 때의 안전한 순서(Reference)
- 리뷰 100건 반영 — 설명 뒤에 붙은 평가·차례 예고·독자 오해 가정·작성 지시를 지웠다.
  삭제가 남긴 조각 4건을 고치고, 원래부터 잘려 있던 로컬 미리보기 라벨 1건도 닫았다

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-07 12:39:20 +09:00
co-authored by Claude Fable 5.1
parent 62520a4dce
commit 4d50bb939a
26 changed files with 1947 additions and 1225 deletions
@@ -11,124 +11,130 @@ version: 32
questionStatus: OPEN
studio: "https://hyeonworks.com/studio/documents/18a5cde2-dd1e-4bff-9f1c-997577ae438f/edit"
public: "https://hyeonworks.com/questions/bff-session-authorized-client-store"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#검토한-선택지와-막힌-지점-ap3
- final/document.md#문제를-어렵게-만든-제약-학습-환경
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap3
---
# BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가
Application Session과 Authorized Client는 저장하고 조회하는 기준이 서로 다르다.
Session은 `session ID`를 기준으로 조회하지만, Authorized Client는 `client registration 이름``principal name`을 기준으로 조회한다.
그래서 두 상태를 반드시 같은 저장소에 보관해야 하는 것은 아니며, 각각의 조회 방식과 운영 요구사항에 맞게 저장 구조를 결정해야 한다.
현재 BFF의 세션과 Authorized Client는 프로세스 메모리에 저장된다. 재시작과 레플리카 이동 뒤에도 로그인 상태를 유지하려면 두 상태를 어디에 저장할지 정해야 한다.
현재 Shared Store의 후보로는 Redis를 우선 생각하고 있지만 아직 최종 저장소로 결정한 것은 아니다.
특히 Access Token과 Refresh Token을 Redis에 저장할 경우 Token을 어떤 방식으로 암호화할지, Session과 Token의 만료 시간을 어떻게 맞출지, Logout할 때 Session과 Authorized Client가 모두 정상적으로 제거되는지까지는 확인하지 않았다.
BFF(Backend for Frontend)가 서버에 들고 있는 상태는 둘이다. 하나는 브라우저의 로그인 세션이고,
다른 하나는 Keycloak에서 받은 액세스 토큰과 리프레시 토큰을 담아 두는 Authorized Client다.
세션은 `session ID`로 찾지만 Authorized Client는 `client registration 이름``principal name`으로 찾는다.
따라서 현재 단계에서는 Redis를 저장소 후보로 작성하고, Token 보호와 만료 처리, Logout 시 상태 정리까지 검증한 뒤 실제 저장 구조를 결정한다.
찾는 열쇠가 이렇게 다르니 두 상태를 반드시 한 저장소에 담아야 하는 것은 아니다.
각각의 조회 방식과 운영 요구사항에 맞게 저장 구조를 따로 정할 수 있다.
공유 저장소 후보로 Redis를 우선 보고 있지만 아직 고르지 않았다.
액세스 토큰과 리프레시 토큰을 Redis에 담는다면 토큰을 어떤 방식으로 암호화할지, 세션과 토큰의 만료 시간을 어떻게 맞출지,
로그아웃할 때 세션과 Authorized Client가 모두 지워지는지를 확인해야 하는데 아직 확인하지 않았다.
토큰 보호와 만료 처리, 로그아웃 뒤 상태 정리까지 검증한 다음에 저장 구조를 정한다.
## 관계
- **서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가**
이 질문에서 저장소 부분만 떼어 낸 것이다.
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
sessionauthorized client 열쇠가 다르다는 사실의 출처다.
세션Authorized Client를 찾는 열쇠가 다르다는 사실의 출처다.
- **BFF 인증 구조 설계 기준**
이 기준의 저장소 항목이 이 질문의 답을 기다린다.
이 기준의 저장소 항목은 여기에 답이 나와야 채울 수 있다.
- **Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가**
저장소를 공유한 에야 replica 경쟁 재현다.
저장소를 공유한 다음에야 레플리카 경쟁 재현할 수 있다.
## 사실
- 현재 구성에 Spring Session Redis, JDBC repository, 암호화 token store가 없다.
- 현재 HttpSession은 servlet container의 in-memory 구현을 사용하므로 해당 process가 종료되면 session 데이터도 유지되지 않는다.
- OAuth2AuthorizedClientService도 자동구성이 고르는 in-memory 구현이다.
- session은 session ID로 조회하고 authorized client는 registration 이름과 principal name으로 조회한다.
두 저장 구조를 shared store로 전환할 때 각각 따로 설계해야 한다.
- authorized client manager에 authorization-code와 refresh-token provider가 함께 구성돼 있어서,
저장소를 공유하게 되면 여러 인스턴스가 같은 항목을 동시에 갱신할 수 있게 된다.
- 현재 구성에 Spring Session도, Redis, JDBC 저장소도, 토큰을 암호화해 담는 저장소도 없다.
- HttpSession은 서블릿 컨테이너의 메모리 구현을 쓰기 때문에, 그 프로세스가 종료되면 세션 데이터도 같이 사라진다.
- OAuth2AuthorizedClientService도 Spring Boot 자동구성이 고르는 메모리 구현이다.
- 세션은 session ID로 조회하고 Authorized Client는 registration 이름과 principal name으로 조회한다.
두 저장 구조를 공유 저장소로 옮길 각각 따로 설계해야 한다.
- Authorized Client 매니저에는 authorization-code와 refresh-token provider가 함께 구성돼 있어서,
저장소를 공유하면 여러 인스턴스가 같은 항목을 동시에 갱신할 수 있다.
## 가정
- 두 상태를 같은 저장소에 둘 필요는 없다.
- 저장된 refresh token을 평문으로 두면 안 된다.
- session 만료와 token 만료 중 하나가 먼저 오게 되면 그 순간의 동작이 정의돼 있어야 한다.
예를 들면 token이 만료됐을 때 사용자의 로그인 상태가 여전히 유지되는지.
- 저장한 리프레시 토큰을 평문으로 두면 안 된다.
- 세션 만료와 토큰 만료 중 하나가 먼저 오면 그 순간에 무엇이 일어날지 정해 두어야 한다.
토큰이 만료됐을 때 사용자의 로그인 상태가 유지되는지가 그런 예다.
## 미지수
- Redis와 JDBC 중 무엇이 이 상태의 접근 패턴에 맞는가.
- Redis와 JDBC 중 어느 쪽이 이 상태의 접근 패턴에 맞는가.
요청마다 읽는 값과 가끔 읽는 값이 섞여 있다.
- sessionauthorized client를 같은 store에 둘지 나눌지.
- 암호화 key를 어디에 두고 어떻게 교체하게 되는가. 교체하는 동안 이전 key로 저장 값은 어떻게 읽는가.
- session TTL과 refresh token 수명 중 어느 을 기준으로 만료를 맞추게 되는가.
- session과 Authorized Client 두 store를 logout에서 어떻게 한 번에 지우게 되는가.
- sticky session이 durable store의 대안이 되는가 보완이 되는가.
- 세션Authorized Client를 한 저장소에 둘지 나눌지.
- 암호화 를 어디에 두고 어떻게 교체하는가. 교체하는 동안 이전 로 저장 값은 어떻게 읽는가.
- 세션 TTL과 리프레시 토큰 수명 중 어느 을 기준으로 만료를 맞추는가.
- 로그아웃 한 번으로 세션과 Authorized Client 두 저장소를 어떻게 같이 지우는가.
- 스티키 세션이 영속 저장소의 대안인가 보완인가.
## 제약
- authorized client의 조회 시 session ID가 없어서 session만 공유해도 같은 사용자의 여러 session이 같은 token을 보게 된다.
- 현재 테스트에는 저장소 관련 계약이 없다.
- 모든 UI 요청이 BFF를 지나기 때문에 저장소 지연이 화면 지연으로 바로 드러나게 된다.
- Authorized Client를 찾을 때는 session ID를 쓰지 않아서, 세션만 공유해도 같은 사용자의 여러 세션이 같은 토큰을 본다.
- 현재 테스트에는 저장소 계약이 없다.
- 모든 화면 요청이 BFF를 지나기 때문에 저장소가 느려지면 화면도 바로 느려진다.
## 선택지
### 1. sessionauthorized client를 모두 Redis에 둔다
### 1. 세션Authorized Client를 모두 Redis에 둔다
Spring Session Redis와 Redis 기반 Authorized Client 저장소를 사용하면 여러 애플리케이션 인스턴스가 같은 Session과 Authorized Client 정보를 조회할 수 있다.
상태가 특정 인스턴스의 메모리에 묶이지 않으므로 서버가 재시작되거나 요청이 다른 Replica로 전달되는 환경에서도 인증 상태를 공유하기 쉬워진다.
Spring Session Redis와 Redis 기반 Authorized Client 저장소를 면 여러 애플리케이션 인스턴스가 같은 세션과 Authorized Client를 조회할 수 있다.
상태가 특정 인스턴스의 메모리에 묶이지 않으 서버가 재시작되거나 요청이 다른 레플리카로 전달되는 환경에서도 인증 상태를 이어 쓰기 쉬워진다.
Session과 Authorized Client에 설정 유효 시간에 따라 Redis의 TTL을 이용해 저장 상태를 만료시키는 구조도 구성할 수 있다.
다만 어떤 상태를 얼마 동안 유지할지는 애플리케이션의 Session 정책과 OAuth Token의 수명에 맞춰 별도로 정해야 한다.
세션과 Authorized Client에 설정 유효 시간에 맞춰 Redis의 TTL 저장 상태를 만료시키는 구조도 수 있다.
다만 어떤 상태를 얼마 동안 유지할지는 애플리케이션의 세션 정책과 OAuth 토큰 수명에 맞춰 로 정한다.
이 구성에서는 인증 경로가 Redis의 가용성에 의존하게 된다.
Redis에 장애가 발생했을 때 기존 Session과 Authorized Client를 조회하지 못하는 상황을 어떻게 처리할지 정해야 하,
장애 복구와 데이터 유지 방식도 함께 고려해야 한다.
대신 인증 경로가 Redis의 가용성에 매인다.
Redis에 장애가 을 때 기존 세션과 Authorized Client를 지 못하는 상황을 어떻게 처리할지 정해야 하, 장애 복구와 데이터 유지 방식도 같이 본다.
또한 Access Token과 Refresh Token을 Redis에 저장한다면 저장된 Token을 어떤 방식으로 보호할지도 정해야 한다.
Token을 암호화해서 저장할지, 암호화한다면 Key를 어디에 보관하고 어떻게 교체할지까지 저장소 설계에 포함한다.
액세스 토큰과 리프레시 토큰을 Redis에 담는다면 담은 토큰을 어떻게 보호할지도 정해야 한다.
암호화해서 저장할지, 암호화한다면 를 어디에 보관하고 어떻게 교체할지까지 저장소 설계에 포함한다.
### 2. sessionauthorized client를 모두 JDBC에 둔다
### 2. 세션Authorized Client를 모두 JDBC에 둔다
JDBC 기반 저장소를 사용하면 이미 운영 중인 관계형 DB에 Session과 Authorized Client 정보를 저장할 수 있다.
JDBC 기반 저장소를 면 이미 운영 중인 관계형 DB에 세션과 Authorized Client를 담을 수 있다.
인증 과정에서 Session이나 Authorized Client를 조회할 때마다 DB 접근이 발생하므로, 인증 요청이 기존 관계형 DB의 가용성과 성능에 영향을 받게 된다.
요청량이 증가했을 때 Session 조회가 지연되지 않는지 확인하고, 인증 관련 조회 기존 애플리케이션 쿼리 서로 영향을 주지 않는지 확인해야 한다.
인증 과정에서 세션이나 Authorized Client를 조회할 때마다 DB 접근이 일어나므로, 인증 요청이 기존 관계형 DB의 가용성과 성능에 매이게 된다.
요청량이 늘었을 때 세션 조회가 지연되지 않는지, 인증 조회 기존 애플리케이션 쿼리 서로 영향을 주지 않는지 확인해야 한다.
또한 만료된 Session과 Authorized Client 데이터가 계속 쌓이지 않도록 정리하는 방법과 주기해야 한다.
JDBC를 고르면 기존 DB 운영 체계를 그대로 쓰면서 조회 지연과 DB 부하, 만료 데이터 정리까지 같이 관리하게 된다.
만료된 세션과 Authorized Client 데이터가 계속 쌓이지 않도록 정리하는 방법과 주기 정한다.
### 3. session만 공유하고 sticky session을 쓴다
### 3. 세션만 공유하고 스티키 세션을 쓴다
Session Affinity를 사용하면 같은 사용자의 요청을 가능한 한 동일한 애플리케이션 인스턴스로 전달할 수 있으므로 기존 구조의 변경을 줄일 수 있다.
세션 어피니티를 쓰면 같은 사용자의 요청을 되도록 같은 애플리케이션 인스턴스로 보낼 수 있어서 기존 구조를 덜 건드린다.
하지만 이것만으로 인증 상태가 여러 인스턴스에 공유되는 것은 아니다.
Authorized Client가 여전히 특정 애플리케이션 인스턴스의 메모리에 저장되어 있다면, 요청이 다른 인스턴스로 전달되거나 해당 인스턴스가 종료되었을 때 기존 Token 정보를 조회할 수 없다.
하지만 이것만으로 인증 상태가 여러 인스턴스에 공유되않는다.
Authorized Client가 여전히 특정 애플리케이션 인스턴스의 메모리에 있다면, 요청이 다른 인스턴스로 전달되거나 인스턴스가 종료을 때 기존 토큰 정보를 찾지 못한다.
Session Affinity는 요청을 특정 인스턴스로 보내는 방법이다. Session과 Authorized Client를 공유하는 문제 장애 이후에 인증 상태를 유지하는 문제는 그대로 남는다.
인스턴스 장애와 Replica 간 이동까지 고려한다면 Session과 Authorized Client의 저장 방식을 별도로 설계해야 한다.
세션과 Authorized Client를 공유하는 문제, 장애 에 인증 상태를 는 문제는 따로 풀어야 한다.
인스턴스 장애와 레플리카 간 이동까지 보려면 세션과 Authorized Client의 저장 방식을 로 설계한다.
### 4. session은 Redis, token은 암호화 JDBC에 둔다
### 4. 세션은 Redis, 토큰은 암호화 JDBC에 둔다
Session과 Authorized Client를 서로 다른 저장소에 보관하는 방법도 있다.
요청마다 자주 조회되는 Session은 Redis와 같이 빠르게 접근할 수 있는 저장소에 두고, Access Token과 Refresh Token은 암호화와 장기 보관 정책을 적용하기 쉬운 관계형 DB에 저장할 수 있다.
각 데이터의 접근 패턴과 보호 요구사항에 맞춰 저장소를 선택할 수 있다는 장점이 있다.
세션과 Authorized Client를 서로 다른 저장소에 는 방법도 있다.
요청마다 자주 조회되는 세션은 Redis처럼 빠르게 접근할 수 있는 저장소에 두고, 액세스 토큰과 리프레시 토큰은 암호화와 장기 보관 정책을 적용하기 쉬운 관계형 DB에 담는 식이다.
두 저장소의 상태 함께 관리해야 한다.
Session이 만료되었는데 Authorized Client가 남거나, 반대로 Authorized Client가 먼저 제거되어 유효한 Session에서 Token을 찾지 못하는 상황이 발생할 수 있다.
따라서 각각의 만료 정책을 어떻게 맞출지 정해야 한다.
대신 두 저장소의 상태 함께 관리해야 한다.
세션이 만료는데 Authorized Client는 아직 지워지지 않거나, 반대로 Authorized Client가 먼저 지워져서 유효한 세션인데도 토큰을 찾지 못하는 상황이 생길 수 있다.
그래서 각각의 만료 정책을 어떻게 맞출지 정해야 한다.
Logout에서도 Session과 Authorized Client가 서로 다른 저장소에 있으므로 두 상태를 모두 정리해야 한다.
한쪽을 제거하는 과정에서 실패했을 때 어떻게 처리할지도 함께 결정해야 한다.
로그아웃에서도 세션과 Authorized Client가 서로 다른 저장소에 있으 두 상태를 모두 지워야 하고, 한쪽을 지우다 실패했을 때 어떻게 할지도 같이 정한다.
또한 Redis와 관계형 DB를 모두 인증 경로에서 사용하게 되므로 모니터링, 장애 대응, 백업 운영해야 하는 저장소 늘어난다.
저장소를 나눠서 얻는 것과 늘어나는 운영 대상을 같이 놓고 정한다.
Redis와 관계형 DB를 둘 다 인증 경로에서 게 되므로 모니터링, 장애 대응, 백업까지 운영 저장소 늘어난다.
## 다음 검증
후보마다 같은 입력으로 비교한다.
1. 인스턴스 두 대에서 로그인 유지와 재시작 복구가 되는지 본다.
2. 저장소를 직접 열어 refresh token이 평문으로 는지 확인한다.
3. session TTL과 token 만료를 어긋나게 두고 그 순간의 응답과 화면을 기록한다.
4. logout 뒤 두 store에 잔여 항목이 없는지 확인한다.
2. 저장소를 직접 열어 리프레시 토큰이 평문으로 보이는지 확인한다.
3. 세션 TTL과 토큰 만료를 어긋나게 두고 그 순간의 응답과 화면을 기록한다.
4. 로그아웃 뒤 두 저장소에 잔여 항목이 없는지 확인한다.
5. 저장소를 끊은 상태에서 로그인과 API 호출이 어떤 오류를 내는지 본다.
암호화 key 교체 절차는 후보를 고른 뒤에 따로 설계한다.
암호화 교체 절차는 후보를 고른 뒤에 따로 설계한다.
@@ -11,115 +11,106 @@ version: 36
questionStatus: OPEN
studio: "https://hyeonworks.com/studio/documents/7ff40767-a00b-4db2-98f6-0cdfce8c8936/edit"
public: "https://hyeonworks.com/questions/edge-authorization-scope"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#검토한-선택지와-막힌-지점-ap4
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap4
---
# Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가
현재 Edge는 인증된 사용자의 `user``email`Header로 전달하고 있으며, Upstream 애플리케이션에서는 Role을 이용한 인가 판단하지 않는다.
지금 엣지는 인증된 사용자의 `user``email`헤더로 넘기고, 업스트림 애플리케이션은 역할(role)보고 인가 판단하지 않는다.
따라서 현재 구조만으로는 Role 기반 인가가 필요한 요구사항이 추가되었을 때 어떻게 처리할지 결정되어 있지 않다.
Edge가 사용자의 Role까지 확인해 Header로 전달할지, 아니면 애플리케이션이 Role과 권한을 확인하고 인가를 직접 판단하도록 할지 별도로 결정해야 한다.
그래서 역할 기반 인가가 필요한 요구가 들어왔을 때 어떻게 처리할지 아직 정해 두지 않다. 엣지가 사용자의 역할까지 확인해 헤더로 넘길지, 애플리케이션이 역할과 권한을 직접 조회해 인가를 판단할지를 정해야 한다.
Role이나 권한처럼 애플리케이션 기능과 밀접한 정보가 계속 늘어난다면, 이러한 정보를 Edge Header에 계속 추가하기보다 인가 책임을 애플리케이션에서 처리하는 구조가 더 적절한지도 함께 검토한다.
역할이나 권한처럼 애플리케이션 기능에 가까운 값이 계속 늘어난다면, 그 값을 엣지 헤더에 계속 더하는 대신 인가를 애플리케이션이 맡는 구조가 나은지도 함께 검토한다.
## 관계
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
edge가 user와 email만 전달한다는 사실의 출처다.
엣지가 user와 email만 넘긴다는 사실을 여기서 가져왔다.
- **Forward-Auth에서 Identity Header를 신뢰하기 위한 조건**
헤더 allowlist와 검증 조건이 이 기에 있다.
넘길 헤더를 허용 목록(allowlist)으로 두는 기준과 검증 조건이 이 기에 있다.
- **BFF 인증 구조 설계 기준**
되돌리는 선택지의 기준이 이 문서다.
네 번째 선택지로 되돌아갈 때 따를 기준이 이 기록이다.
## 사실
- 지금 edge 응답은 user와 email만 전달한다. role과 groups, tenant, 인증 방식, token 만료는 전달하지 않는다.
- upstream의 identity endpoint는 role을 확인하지 않는다. 누가 왔는지만 응답한다.
- 현재 Internal Token 검증은 특정 Controller에서만 수행하고 있으며, Security 설정에서는 해당 경로를 permitAll로 허용하고 있다.
이 구조에서는 같은 내부 경로 아래에 새로운 Endpoint를 추가하더라도 Internal Token 검증이 자동으로 적용되지 않는다.
그래서 Filter, Interceptor, 또는 Spring Security의 인증 처리 단계처럼 공통 경계에서 검증하도록 옮겨야 한다.
- Nginx는 client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다. 추가적으로 늘어나는 헤더도 같은 처리를 받아야 한다.
- upstream은 JWT를 입력으로 받지 않아서 헤더로 넘어온 값을 검증할 방법이 없다.
- 지금 엣지 응답은 user와 email만 넘긴다. 역할과 그룹, 테넌트, 인증 방식, 토큰 만료는 넘기지 않는다.
- 업스트림의 신원 응답 엔드포인트는 역할을 확인하지 않 누가 왔는지만 돌려준다.
- 내부 토큰(internal token) 검증은 컨트롤러 한 곳에서만 하고, Spring Security 설정은 그 경로를 permitAll로 열어 둔다. 검증이 그 컨트롤러 안에만 있으니 같은 내부 경로 아래에 엔드포인트를 새로 추가해도 검증이 따라붙지 않는다. 필터나 인터셉터, Spring Security의 인증 처리 단계처럼 모든 요청이 지나는 공통 경계로 이 검사를 옮겨야 한다.
- Nginx는 클라이언트가 보낸 같은 이름의 헤더를 합치지 않고 덮어쓴다. 헤더를 늘리면 늘어난 헤더도 똑같이 덮어쓰게 해야 한다.
- 업스트림은 JWT를 입력으로 받지 않아서 헤더로 넘어온 값이 맞는지 확인할 방법이 없다.
## 가정
- 헤더 종류가 늘어나면 정해야 할 계약도 늘어난다.
- role이 바뀌는 시과 요청이 오는 시이 달라서 그 사이에 들어온 요청은 바뀌기 전 값을 볼 수도 있다.
- 헤더 종류가 늘어나면 그만큼 정해야 할 계약도 늘어난다.
- 역할이 바뀌는 시과 요청이 들어오는 시이 달라서, 그 사이에 들어온 요청은 바뀌기 전 값을 볼 수도 있다.
## 미지수
- 다중 값 role을 어떤 구분자와 escaping으로 보낼지. 값 안에 그 구분자가 들어오면 어떻게 되는지.
- 헤더 크기 상한을 넘으면 어떻게 되는지. proxy가 자르는지 요청 자체가 거부되는지.
- role이 바뀌었을 때 proxy session과 downstream 인가 언제 반영되는지. 권한 변경이 몇 분 뒤에 반영되는지.
- upstream이 헤더 존재만 볼지 값과 service identity까지 볼지.
- 역할이 여러 개일 때 어떤 구분자와 이스케이프 규칙으로 보낼지. 값 안에 그 구분자가 들어오면 어떻게 되는지.
- 헤더 크기 상한을 넘으면 어떻게 되는지. 프록시가 잘라 내는지, 요청 자체가 거부되는지.
- 역할이 바뀌었을 때 프록시 세션과 다운스트림 인가 언제 반영되는지. 권한을 바꾸고 몇 분 뒤에 반영되는지.
- 업스트림이 헤더가 있는지만 볼지, 값과 내부 서비스 식별값(service identity)까지 함께 볼지.
## 제약
- 전달할 헤더는 allowlist로 해야 하고 client가 보낸 동명 헤더는 항상 덮어써야 한다.
- internal token 검사가 controller 한 곳에만 있다. 헤더를 늘리기 전에 이 검사를 공통 경계로 옮겨야 다.
- 넘길 헤더는 허용 목록으해야 하고, 클라이언트가 보낸 같은 이름의 헤더는 언제나 덮어써야 한다.
- 내부 토큰 검사가 컨트롤러 한 곳에만 있다. 헤더를 늘리기 전에 이 검사를 공통 경계로 옮겨야 다.
## 선택지
### 1. 인증만 edge에 둔다
### 1. 인증만 엣지에 둔다
Edge가 전달하는 Header를 user와 email 정도로 제한하면 Edge와 Upstream 사이의 계약을 작게 유지할 수 있다.
Role이나 Permission 정보를 Header에 계속 추가하지 않으므로 Header 크기가 커지는 문제도 줄일 수 있다.
엣지가 넘기는 헤더를 user와 email 정도로 묶어 두면 엣지와 업스트림 사이의 계약을 작게 유지할 수 있다. 역할이나 권한을 헤더에 계속 더하지 않으니 헤더가 커지는 문제도 줄일 수 있다.
이 경우 인가 판단은 각 Upstream 애플리케이션이 직접 수행한다.
애플리케이션은 전달받은 사용자 식별 정보를 기준으로 자신의 저장소에서 Role이나 Permission을 조회하고,
해당 요청을 허용할지 결정해야 한다.
대신 인가는 업스트림 애플리케이션이 직접 판단한다. 넘겨받은 사용자 식별 정보로 자기 저장소에서 역할이나 권한을 조회하고, 그 요청을 허용할지 정해야 한다.
이 구조에서는 서비스마다 권한 조회와 인가 로직을 별도로 구성해야 한다.
따라서 Edge의 책임은 단순하게 유지할 수 있지만, 서비스 수가 늘어나면 각 서비스에서 동일하거나 유사한 권한 조회 체계를 반복해서 구현하고 운영해야 할 수 있다.
인가를 서비스가 직접 판단하다 보니 권한 조회와 인가 코드를 서비스마다 따로 만들어야 한다.
### 2. role 전달까지 edge에 둔다
### 2. 역할 전달까지 엣지에 둔다
Edge가 공통 Role 정보를 확인해 Upstream에 전달하면 각 서비스가 별도로 사용자 권한을 조회해야 하는 작업을 줄일 수 있다.
엣지가 공통 역할을 확인해 업스트림에 넘기면, 서비스마다 사용자 권한을 따로 조회하는 을 줄일 수 있다.
대신 Role을 Header로 전달하기 위한 계약을 먼저 정해야 한다.
사용자가 여러 Role을 가질 때 어떤 형식으로 직렬화할지, Header에 허용할 최대 크기를 어디까지로 할지, 사용자의 Role이 변경되었을 때 언제부터 새로운 값이 요청에 반영되는지도 명확하게 정의해야 한다.
대신 역할을 헤더로 넘기는 계약을 먼저 정해야 한다. 한 사용자가 역할을 여럿 가질 때 어떤 형식으로 직렬화할지, 헤더에 허용할 최대 크기를 얼마로 둘지, 역할을 바꾼 뒤 언제부터 새 값이 요청에 실리는지를 적어 두어야 한다.
또한 Upstream은 전달받은 Role이 원래 인증 시스템의 값과 일치하는지 확인할 수 있어야 하고, 그러지 못하면 Edge가 전달한 값을 그대로 신뢰하게 된다. 따라서 Edge에서 Role을 잘못 계산하거나 오래된 값을 전달하면 Upstream의 인가 판단도 그대로 잘못될 수 있다.
업스트림은 넘겨받은 역할이 원래 인증 시스템의 값과 같은지 확인할 수 있어야 한다. 확인하지 못하면 엣지가 넘긴 값을 그대로 믿게 되는데, 엣지가 역할을 잘못 계산하거나 오래된 값을 넘기면 업스트림의 인가 판단도 그대로 틀어질 수 있다.
구조를 선택하게 되면 Edge가 Role 정보를 만드는 과정과 Header를 전달하는 경로 신뢰 경계의 일부로 보고,
Role 갱신과 전달 오류를 어떻게 검증할지도 함께 설계해야 한다.
선택지를 고르면 엣지가 역할을 만드는 과정과 헤더가 지나는 경로까지 신뢰 경계 안으로 들어온다. 역할을 언제 갱신하고 전달 오류를 어떻게 잡을지도 같이 설계해야 한다.
### 3. tenant와 인가 판단까지 edge에 둔다
### 3. 테넌트와 인가 판단까지 엣지에 둔다
Tenant 정보는 단순한 사용자 속성이 아니라 어떤 조직의 데이터에 접근할 수 있는지를 정하는 값이다.
따라서 잘못된 Tenant 값 하나가 전달되면 다른 조직의 데이터에 접근하는 문제로 바로 이어질 수 있다.
테넌트는 어느 조직의 데이터까지 볼 수 있는지를 정하는 값이다. 그래서 잘못된 테넌트 값 하나가 넘어가면 다른 조직의 데이터에 접근하는 문제로 바로 이어질 수 있다.
이 때문에 Tenant를 Edge Header로 전달하려면 Upstream에서도 해당 사용자가 실제로 그 Tenant에 속하는지 다시 확인할 수 있는 방법이 필요하다.
현재 구성에는 이러한 재검증 수단이 없으므로 Tenant까지 Edge가 책임지는 구조로 확장하기에는 위험이 크다.
테넌트를 엣지 헤더로 넘기려면 업스트림이 그 사용자가 실제로 그 테넌트에 속하는지 다시 확인할 방법이 있어야 한다. 지금 구성에는 다시 확인할 수단이 없어서 테넌트까지 엣지가 맡는 구조로 넓히기에는 위험이 크다.
더 나아가 Tenant나 Role을 이용한 실제 인가 판단까지 Edge로 옮기면 Edge가 애플리케이션의 도메인 규칙을 알아야 한다.
어떤 사용자가 어떤 조직의 어떤 기능을 사용할 수 있는지 같은 정책이 바뀔 때마다 Edge의 로직도 함께 수정하고 배포해야 한다.
테넌트나 역할로 인가 판단하는 일까지 엣지로 옮기면 엣지가 애플리케이션의 도메인 규칙을 알아야 한다. 어떤 사용자가 어느 조직의 어떤 기능을 쓸 수 있는지 같은 정책이 바뀔 때마다 엣지 코드도 함께 고쳐 배포해야 한다.
따라서 Edge는 인증된 사용자 정보를 전달하는 역할에 가깝게 유지하고, Tenant 소속 관계나 도메인에 종속된 인가 규칙은 Upstream 애플리케이션에서 검증하는 구조를 우선 검토한다.
그래서 엣지는 인증된 사용자 정보를 넘기는 쪽에 가깝게 두고, 테넌트 소속 여부나 도메인에 묶인 인가 규칙은 업스트림 애플리케이션 검증하는 구조를 먼저 검토한다.
### 4. 헤더 계약 대신 BFF가 인가와 API 호출을 맡는다
Role이나 Tenant 같은 정보를 Edge Header에 계속 추가하는 대신, BFF가 사용자에게 필요한 정보를 직접 조회하고 인가 판단 API 호출을 처리하는 구조도 선택할 수 있다.
역할이나 테넌트 같은 값을 엣지 헤더에 계속 하는 대신, BFF(Backend For Frontend)가 필요한 정보를 직접 조회 인가 판단하고 API까지 부르는 구조도 고를 수 있다. BFF는 화면에 필요한 API를 브라우저 대신 불러 조합하는 서버다.
구조에서는 Edge가 애플리케이션의 Role, Tenant, 권한 정책까지 알 필요가 없다.
BFF가 필요한 사용자와 권한 정보를 조회해 인가를 판단하고, 화면에 필요한 여러 Resource Server의 API를 호출해 결과를 조합할 수 있다. 따라서 애플리케이션 도메인에 가까운 책임을 Edge Header 계약에서 분리할 수 있다.
러면 엣지는 애플리케이션의 역할과 테넌트, 권한 정책 알 필요가 없다. BFF가 사용자와 권한을 조회해 인가를 판단하고, 화면에 필요한 여러 Resource Server의 API를 불러 결과를 합칠 수 있다.
이 구조에서는 BFF를 도입하면서 서버가 다시 인증 상태를 관리해야 한다.
브라우저와 BFF 사이의 Application Session을 보호해야 하고, Cookie 기반 Session을 사용한다면 상태 변경 요청에 대한 CSRF 검증도 필요하다.
여러 BFF Replica에서 인증 상태를 유지해야 한다면 Session과 Authorized Client를 어떻게 공유할지 결정하고 Shared Store의 장애와 만료 처리도 운영해야 한다.
대신 BFF를 넣으면 서버가 다시 인증 상태를 들고 있어야 한다. 브라우저와 BFF 사이의 애플리케이션 세션을 보호해야 하고, 쿠키 기반 세션을 쓴다면 쿠키가 요청마다 자동으로 붙어서 값을 바꾸는 요청에는 사용자가 의도한 것인지 확인하는 CSRF(Cross-Site Request Forgery) 검증도 있어야 한다. BFF를 여러 대로 늘려 인증 상태를 유지하려면 세션과 authorized client를 어떻게 공유할지 정하고, 공유 저장소의 장애와 만료 처리까지 운영해야 한다.
이 구조를 골랐다가 다시 엣지 쪽으로 되돌린다면 BFF가 맡던 사용자별 인가를 업스트림이나 별도 정책 서비스로 다시 옮겨야 한다.
## 다음 검증
upstream이 실제로 요구하는 claim을 먼저 적는다.
업스트림이 실제로 요구하는 사용자 속성(claim)부터 적는다.
1. 전달하려는 claim이 계속 늘어나는가.
2. role이나 tenant 변경이 즉시 반영돼야 하는가.
1. 넘기려는 속성이 계속 늘어나는가.
2. 역할이나 테넌트를 바꾸면 곧바로 반영돼야 하는가.
3. 정책이 애플리케이션 도메인을 알아야 하는가.
4. 헤더 값이 인가 판단의 근거가 되는가.
5. 서비스 정책 차이가 커지는가.
5. 서비스마다 정책 차이가 커지는가.
2번부터 5번 하나라도 그렇다면 헤더를 늘리는 대신 BFF 구조를 검토한다.
2번부터 5번 가운데 하나라도 그렇다면 헤더를 늘리는 대신 BFF 구조를 검토한다.
role을 헤더에 담는 구성을 먼저 만들고, 다중 값과 크기 상한을 넣어 무엇이 먼저 잘못되는지 확인한다.
role을 바꾼 뒤 몇 번째 요청부터 반영되는지도 확인한다.
역할을 헤더에 담는 구성을 먼저 만들고, 값을 여러 개 넣고 크기 상한까지 올려 무엇이 먼저 깨지는지 확인한다. 역할을 바꾼 뒤 몇 번째 요청부터 새 값이 실리는지도 센다.
어느 쪽으로 옮길지는 새로 일을 맡는 쪽이 상태와 검증을 감당할 수 있는지를 보고 정한다.
@@ -11,122 +11,94 @@ version: 39
questionStatus: OPEN
studio: "https://hyeonworks.com/studio/documents/c72656b5-842d-45d9-b5f6-82b66b09d0b9/edit"
public: "https://hyeonworks.com/questions/server-session-pattern-multi-instance"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#문제를-어렵게-만든-제약-학습-환경
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap2
---
# 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가
Mediator와 BFF는 브라우저의 로그인 세션과 OAuth token을 서로 다른 저장소에서 관리한다.
현재 구현에서는 두 저장소 모두 애플리케이션 서버의 메모리에 있기 때문에, 서버 프로세스가 종료되면 저장된 상태도 같이 사라진다.
Mediator와 BFF(Backend for Frontend)는 브라우저의 로그인 세션과 OAuth 토큰을 서로 다른 저장소에 둔다. 지금 구현은 두 저장소를 모두 애플리케이션 서버의 메모리에 두기 때문에, 서버 프로세스가 끝나면 담아 둔 상태도 같이 사라진다.
따라서 운영 환경에서 서버를 여러 인스턴스로 구성하게 될 경우 추가 설계가 필요한데, 사용자의 요청이 로그인할 때와 다른 인스턴스로 전달되어도 세션과 토큰을 찾을 수 있어야 하고, 서버 재시작 뒤에도 로그인 상태를 유지할 것인지 결정해야 한다.
또한 로그아웃할 때 여러 인스턴스에 걸쳐 저장된 세션과 토큰을 어떻게 같이 제거할지도 정해야 한다.
운영에서 서버를 여러 인스턴스로 띄우려면 여기에 설계를 더해야 한다. 사용자의 요청이 로그인할 때와 다른 인스턴스로 도 세션과 토큰을 찾을 수 있어야 하고, 서버 재시작 뒤에도 로그인 상태를 이어 갈지 정해야 한다. 로그아웃할 때 여러 인스턴스에 흩어져 있는 세션과 토큰을 어떻게 함께 지울지도 정해야 한다.
## 관계
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
두 상태가 모두 process-local memory에 있다는 사실의 출처다.
두 상태가 모두 인스턴스 하나의 메모리에 있다는 사실을 이 기록에서 가져왔다.
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
같은 저장소 구성을 쓰는 다른 패턴이다.
이 패턴도 세션과 토큰을 같은 방식으로 저장한다.
- **BFF 인증 구조 설계 기준**
이 질문이 이 기준의 빈 항목다.
이 질문하면 그 기준에서 비어 있는 항목워진다.
- **BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가**
저장소 후보 비교로 독립시킨 질문이다.
저장소 후보를 견주는 일만 따로 떼어 낸 질문이다.
## 사실
- Mediator와 BFF는 로그인 상태와 OAuth Token을 서로 다른 저장소에서 관리한다.
로그인 상태는 HttpSession에 저장하고 session ID로 조회한다.
반면 OAuth Token은 OAuth2AuthorizedClientService에 저장하며, client registration 이름과 principal name을 기준으로 조회한다.
- 현재 세션과 OAuth Token을 저장할 별도의 저장소를 직접 설정하지 않았다.
따라서 Spring Boot의 자동 구성이 고르는 메모리 기반 기본 구현이 사용된다.
다만 코드에 저장소를 직접 생성하는 Bean이 없기 때문에, 어떤 구현체가 실제로 사용되는지는 Spring Boot의 자동 구성 결과까지 확인해야 정확하게 알 수 있다.
- 현재는 Spring Session이나 Redis, JDBC 기반 Token Store와 같은 외부 저장소를 사용하지 않는다.
따라서 로그인 세션과 OAuth Token 정보는 모두 해당 애플리케이션 인스턴스의 메모리에 저장된다.
이 때문에 인스턴스가 종료되거나 재시작되면 해당 인스턴스가 가지고 있던 로그인 세션과 OAuth Token 정보도 같이 없어진다.
- Authorized Client는 세션별로 구분되지 않는다.
조회 기준에 session ID가 없기 때문에 같은 사용자가 여러 브라우저에서 로그인하면 동일한 Authorized Client 정보를 사용하게 된다.
- OAuth2-Proxy 구조에서는 로그인 상태를 별도의 서버 저장소에 보관하지 않고,
필요한 최소한의 정보를 브라우저의 세션 쿠키에 담아 관리한다.
현재 설정에서는 이 쿠키의 유효 시간을 1시간으로 두고 있다.
- 현재 테스트에는 서버가 재시작되거나 사용자의 요청이 다른 인스턴스로 전달된 뒤에도 로그인 상태와 OAuth Token을 정상적으로 사용할 수 있는지 확인하는 항목이 없다.
따라서 세션이나 Token 저장 방식을 변경하더라도 기존 동작이 그대로 유지되는지는 별도로 확인 및 검증이 필요하다.
- Mediator와 BFF는 로그인 상태와 OAuth 토큰을 서로 다른 저장소에 둔다. 로그인 상태는 HttpSession에 담고 세션 ID로 찾는다. OAuth 토큰은 OAuth2AuthorizedClientService에 담고, client registration 이름과 principal name으로 찾는다.
- 세션과 OAuth 토큰을 담을 저장소를 따로 설정하지 않아서, Spring Boot 자동 구성이 고르는 메모리 기반 기본 구현이 쓰인다. 다만 저장소를 직접 만드는 Bean이 코드에 없으므로, 어떤 구현체가 실제로 올라오는지는 자동 구성 결과까지 열어 봐야 정확히 알 수 있다.
- Spring Session이나 Redis, JDBC 기반 Token Store 같은 외부 저장소를 쓰지 않기 때문에, 로그인 세션과 OAuth 토큰은 모두 그 애플리케이션 인스턴스의 메모리에 있다. 인스턴스가 종료되거나 재시작되면 그 인스턴스가 들고 있던 로그인 세션과 OAuth 토큰도 같이 사라진다.
- Authorized Client는 세션별로 나뉘지 않는다. 조회 기준에 세션 ID가 없다 보니 같은 사용자가 여러 브라우저에서 로그인하면 모두 같은 Authorized Client 하나를 쓴다.
- OAuth2-Proxy 구조는 로그인 상태를 서버 저장소에 두지 않고, 필요한 최소한의 정보만 브라우저의 세션 쿠키에 담는다. 지금 설정에서 이 쿠키의 유효 시간은 1시간이다.
- 지금 테스트에는 서버를 재시작하거나 요청이 다른 인스턴스로 갔을 때 로그인 상태와 OAuth 토큰을 그대로 쓸 수 있는지 확인하는 항목이 없다. 그래서 세션이나 토큰의 저장 방식을 바꿔도 기존 동작이 깨지지 않는지는 따로 확인해야 한다.
## 가정
- 운영에서는 인스턴스가 둘 이상이다.
- 재시작과 배포가 로그인 상태를 끊어서는 안 되는데, 지금 구조에서는 끊기게 된다.
- 같은 사용자 여러 브라우저 session이 서로의 token 항목을 덮어써서는 안 된다.
- 재시작과 배포가 로그인 상태를 끊어서는 안 된다. 지금 구조에서는 끊다.
- 같은 사용자 여러 브라우저에서 로그인해도, 한쪽 세션이 다른 쪽의 토큰 항목을 덮어써서는 안 된다.
## 미지수
- 재시작 뒤 로그인이 유지되는가. 지금은 안 된다는 것까지 알지만 무엇을 바꿔야 되는지는 정하지 않았다.
- 인스턴스가 바뀌어도 같은 session을 찾게 되는가.
- 같은 사용자의 여러 sessionauthorized client 항목을 공유하거나 덮어쓰게 되는가.
한쪽에서 로그아웃하면 다른 쪽도 끊기게 되는가.
- 저장된 refresh token이 암호화되는가.
저장소를 여는 사람이 그 값을 그대로 읽게 되는가.
- logout에서 HttpSession과 authorized client를 모두 정리하는가.
한쪽만 삭제했을 때 다음 요청이나 재로그인에서 어떤 상태가 복원되는가.
- session 만료와 token 만료가 어긋나면 무엇이 먼저 실패하고 사용자 화면에는 어떻게 보이게 되는가.
- OAuth2-Proxy 구조의 replica들이 같은 cookie secret을 어떻게 공유하고 교체하게 되는가.
교체하는 동안 로그인해 있던 사람은 어떻게 되는가.
- 재시작 로그인이 유지되는가. 지금은 유지되지 않는다는 것까지만 확인했고, 무엇을 바꿀지는 아직 정하지 않았다.
- 요청이 다른 인스턴스로 가도 같은 세션을 찾는가.
- 같은 사용자의 여러 세션Authorized Client 항목 하나를 함께 쓰는가, 아니면 서로 덮어쓰는가. 한쪽에서 로그아웃하면 다른 쪽 로그인도 끊기는가.
- 저장한 Refresh Token이 암호화되는가. 저장소를 여는 사람이 그 값을 그대로 읽는가.
- 로그아웃할 때 HttpSession과 Authorized Client를 모두 지우는가. 한쪽만 지웠을 때 다음 요청이나 재로그인에서 어떤 상태가 살아나는가.
- 세션 만료와 토큰 만료가 어긋나면 무엇이 먼저 실패하고 사용자 화면에는 무엇이 보이는가.
- OAuth2-Proxy 구조의 레플리카들은 같은 Cookie Secret을 어떻게 나눠 갖고 어떻게 바꾸는가. 바꾸는 동안 이미 로그인해 있던 사람은 어떻게 되는가.
## 제약
- 현재 구조는 단일 인스턴스로 실행하고 있어 replica 간 session 조회와 failover 동작은 아직 구현되지 않았다.
- Authorized Client는 session ID를 기준으로 저장하거나 조회하지 않는다.
따라서 여러 인스턴스가 같은 세션을 사용할 수 있도록 Session Store를 공유 저장소로 변경하는 것만으로는 충분하지 않다.
로그인 세션을 여러 인스턴스에서 공유하는 방법과 OAuth Token이 저장된 Authorized Client를 어떻게 저장하고 공유할지는 각각 별도로 설계해야 한다.
- Resource Server의 8081이 host에도 열려 있어서 모든 client가 BFF만 거치도록 network에서 강제된 상태가 아니다.
- 지금은 인스턴스 하나로만 실행하고 있어서 레플리카 사이의 세션 조회나 장애 조치(failover) 동작은 아직 만들지 않았다.
- Authorized Client는 세션 ID로 저장하지도 조회하지 않는다. 그래서 여러 인스턴스가 같은 세션을 쓰도록 세션 저장소만 공유 저장소로 바꿔서는 부족하다. 로그인 세션을 여러 인스턴스가 나눠 쓰는 방법과, OAuth 토큰이 들어 있는 Authorized Client를 어디에 어떻게 저장할지는 각각 따로 설계해야 한다.
- Resource Server의 8081이 host에도 열려 있어서, 모든 클라이언트가 BFF만 거치도록 네트워크가 강제하고 있지는 않다.
## 선택지
### 1. 공유 저장소를 사용한다
HttpSession과 Authorized Client를 모두 외부의 공유 저장소에 보관하면 여러 애플리케이션 인스턴스가 동일한 로그인 세션과 OAuth Token 정보를 조회할 수 있다.
따라서 사용자의 요청이 다른 인스턴스로 전달되거나 특정 인스턴스가 재시작되더라도 기존 로그인 상태를 계속 사용할 수 있다.
HttpSession과 Authorized Client를 모두 바깥의 공유 저장소에 면 여러 애플리케이션 인스턴스가 같은 로그인 세션과 OAuth 토큰을 조회할 수 있다. 요청이 다른 인스턴스로 가거나 인스턴스 하나가 재시작해도 기존 로그인 상태를 그대로 쓸 수 있다.
다만 인증 과정이 외부 저장소에 의존하게 되므로 추가로 고려해야 할 사항이 생긴다.
저장소에 장애가 발생했을 때 인증 요청을 어떻게 처리할지 정해야 하고, 세션과 Token을 어떤 형식으로 저장할지와 저장된 Token을 어떻게 보호할지도 결정해야 한다.
또한 세션은 남아 있는데 Token은 이미 만료되는 것과 같은 불일치가 발생하지 않도록 두 상태의 만료 시간과 제거 시점도 함께 설계해야 한다.
저장소에 장애가 나면 인증 요청을 어떻게 처리할지 정해야 하고, 세션과 토큰을 어떤 형식으로 저장할지와 저장한 토큰을 어떻게 보호할지도 정해야 한다. 세션은 아직 유효한데 토큰은 이미 만료된 것 같은 어긋남이 생기지 않도록, 두 상태의 만료 시간과 지우는 시점도 함께 설계해야 한다.
### 2. session affinity로 같은 인스턴스에 붙인다
Sticky Session을 사용하면 같은 세션 요청을 가능한 한 동일한 애플리케이션 인스턴스로 전달할 수 있다.
기존의 메모리 기반 세션과 Token 저장 방식을 그대로 사용할 수 있기 때문에 애플리케이션 코드의 변경이 적고 별도의 공유 저장소도 필요하지 않다.
Sticky Session 같은 세션에서 온 요청을 되도록 같은 애플리케이션 인스턴스로 보내는 방식이다. 지금의 메모리 기반 세션·토큰 저장을 그대로 두어도 되므로 애플리케이션 코드는 거의 손대지 않고, 공유 저장소도 따로 두지 않는다.
하지만 해당 인스턴스가 종료되면 그 인스턴스의 메모리에 저장되어 있던 로그인 세션과 OAuth Token 정보도 함께 사라진다. 따라서 배포나 오토스케일링으로 인스턴스가 자주 교체되는 환경에서는 Sticky Session만으로 로그인 상태를 안정적으로 유지하기 어렵고, 인스턴스가 사라졌을 때 상태를 어떻게 복구할지 별도로 설계해야 한다.
다만 그 인스턴스가 종료되면 그 메모리에 있던 로그인 세션과 OAuth 토큰도 함께 사라진다. 배포나 오토스케일링으로 인스턴스가 자주 바뀌는 환경이라면 Sticky Session만으로 로그인 상태를 지키기 어렵고, 인스턴스가 사라졌을 때 상태를 어떻게 되살릴지 따로 설계해야 한다.
### 3. 브라우저가 token을 들고 API를 직접 부른다
서버에 로그인 세션이나 OAuth Token 상태를 저장하지 않는 구조로 바꾸면, 여러 인스턴스가 공유해야 할 상태 자체가 없어지므로 별도의 공유 저장소나 Session Affinity 필요하지 않다.
Resource Server는 각 요청에 포함된 Access Token을 검증하여 요청을 처리한다.
서버에 로그인 세션이나 OAuth 토큰을 두지 않는 구조로 바꾸면 여러 인스턴스가 나눠 가질 상태 자체가 없어서, 공유 저장소도 session affinity 필요하지 않다. Resource Server는 요청마다 실려 온 Access Token을 검증해서 처리한다.
SPA처럼 브라우저가 OAuth Token을 직접 보관하고 API 요청에 사용하는 구조가 여기에 해당한다.
다만 브라우저에 OAuth Token을 노출하지 않아야 한다면 이 선택지는 제외한다.
SPA(Single Page Application)처럼 브라우저가 OAuth 토큰을 직접 고 API를 부르는 구조가 여기에 해당한다. 다만 브라우저에 OAuth 토큰을 노출하지 않는 것이 조건이라면 이 선택지는 뺀다.
### 4. 층이 다른 선택지 — 최소 정보만 담은 client-side cookie
이 방식은 기존 세션이나 Token 저장소를 다른 저장소로 교체하는 방법이 아니다.
서버에 인증 상태를 저장하는 구조 자체를 없애고, 필요한 인증 상태를 쿠키에 담아 전달하는 방식으로 변경하는 것이다.
따라서 공유 저장소나 Sticky Session처럼 기존 서버 상태를 어떻게 유지할지 결정하는 방법과 같이 비교하긴 어렵다.
이 방식은 세션이나 토큰 저장소를 다른 저장소로 바꾸는 것이 아니다. 서버에 인증 상태를 두는 구조 자체를 없애고, 필요한 인증 상태만 쿠키에 담아 보낸다. 그래서 공유 저장소나 Sticky Session처럼 서버 상태를 어떻게 지킬지 정하는 방법과 나란히 놓고 견주기 어렵다.
Forward-Auth 구조로 전환하면 애플리케이션이 OAuth Token을 서버에 직접 저장하고 관리할 필요가 없어진다.
대신 여러 인스턴스가 동일한 인증 쿠키를 처리할 수 있도록 Cookie Secret을 공유해야 한다.
또한 인증 프록시가 전달하는 사용자 정보를 애플리케이션이 신뢰하게 되므로, 외부 요청이 해당 헤더를 위조할 수 없도록 네트워크 접근 경로와 전달 헤더를 함께 관리해야 한다.
Forward-Auth는 실제 요청을 넘기기 전에 별도의 인증 엔드포인트에 허용 여부를 먼저 묻는 방식이다. 이 구조로 옮기면 애플리케이션이 OAuth 토큰을 서버에 직접 저장하고 관리하지 않아도 된다. 대신 여러 인스턴스가 같은 인증 쿠키를 풀 수 있도록 Cookie Secret을 나눠 가져야 한다. 그리고 인증 프록시가 넘겨 주는 사용자 정보를 애플리케이션이 믿으므로, 바깥 요청이 그 헤더를 위조하지 못하게 네트워크 접근 경로와 전달 헤더를 함께 관리해야 한다.
## 다음 검증
인스턴스를 둘로 띄우고 순서대로 확인한다.
1. 한쪽에서 로그인한 뒤 다른 인스턴스로 요청을 보내 200이 유지되는지 본다.
2. 인스턴스를 재시작하고 같은 session cookie로 로그인 상태가 는지 본다.
3. 같은 사용자로 두 브라우저에서 로그인해 authorized client 항목이 서로를 덮어쓰는지 본다.
4. 한쪽에서 logout한 뒤 다른 쪽 요청이 어떻게 되는지 본다.
5. session 만료를 token 만료보다 짧게, 다시 길게 두고 각 경우의 응답과 화면을 기록한다.
1. 한쪽에서 로그인한 뒤 다른 인스턴스로 요청을 보내 200이 그대로 나오는지 본다.
2. 인스턴스 하나를 재시작하고 같은 세션 쿠키로 로그인 상태가 유지되는지 본다.
3. 같은 사용자로 두 브라우저에서 로그인해 Authorized Client 항목이 서로를 덮어쓰는지 본다.
4. 한쪽에서 로그아웃한 뒤 다른 쪽 요청이 어떤 응답을 받는지 본다.
5. 세션 만료를 토큰 만료보다 짧게 두고, 다시 길게 두고, 각 경우의 응답과 화면을 기록한다.
여기서 확인한 결과로 선택지를 좁힌다.
각 항목의 결과는 어느 엔드포인트와 핸들러가 요청을 받았는지, 다음 요청에 무엇이 입력으로 들어갔는지, 최종 응답이 무엇이었는지까지 적는다.
@@ -11,54 +11,62 @@ version: 33
questionStatus: OPEN
studio: "https://hyeonworks.com/studio/documents/9ae4ec71-a32e-49a7-88c2-f7368541c28d/edit"
public: "https://hyeonworks.com/questions/refresh-rotation-replica-contention"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#선택의-이유와-지킨-경계-ap1
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap2
---
# Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가
realm refresh token rotation과 재사용 허용 0회를 쓰고 있다.
두 replica가 같은 refresh token으로 동시에 갱신할 수 있고, 그때 두 번째 사용이 거부될 가능성이 있다.
실제 Keycloak 응답과 session 영향은 아직 재현해 보지 않았다.
realm refresh token rotation과 재사용 허용 0회를 쓴다. 한 번 갱신하면 이전 refresh token이 바로
무효가 되기 때문에, 두 replica가 같은 refresh token으로 동시에 갱신하면 두 번째 사용이 거부될 있다.
실제 Keycloak 응답과 session에 미치는 영향은 아직 재현지 않았다.
## 관계
- **BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가**
저장소 결정이 이 질문보다 앞선다.
이 질문에 답하기 전에 저장소부터 정해야 한다.
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
rotation과 재사용 0회를 쓰는 구성의 출처다.
rotation과 재사용 0회를 쓰는 구성이 여기서 나왔다.
- **서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가**
다중 인스턴스 운영이 이 경쟁의 전제다.
인스턴스를 여러 대 띄워야 이 경쟁이 생긴다.
- **BFF 인증 구조 설계 기준**
갱신 실패를 화면 오류로 바꾸는 규칙이 이 기준의 항목이다.
갱신 실패를 화면 오류로 바꾸는 규칙이 이 기준에 들어 있다.
## 사실
- realm refresh token rotation과 재사용 허용 0회를 쓴다. 한 번 갱신하면 이전 refresh token은 바로 무효가 된다.
- 커밋된 테스트는 새 refresh token 발급 이전 token 거부, revocation 뒤 refresh 실패를 확인한다.
- authorized client manager에는 refresh-token provider가 구성되어 있어 access token 만료 시 refresh를 시도할 수 있다.
- 만료를 기다려 실제 갱신이 성공하고 새 token이 저장되는지까지는 확인하지 않다.
- 현재 authorized client 저장소는 process-local이라 replica가 같은 refresh token 상태를 공유하지 않는다.
따라서 이번 단일 인스턴스 검증에서는 동시 refresh 경쟁을 재현하지 않았다.
- 이미 발급된 access token 만료 전까지 API에서 계속 통하기 때문에, 갱신이 실패해도 그동안은 화면이 정상으로 보이게 된다.
- realm에는 refresh token rotation과 재사용 허용 0회가 설정되어 있다.
- 커밋된 테스트는 새 refresh token 발급되는지, 이전 token 거부되는지, revocation 뒤 refresh
실패하는지를 확인한다. 다만 이 셋은 refresh token을 직접 써서 받은 결과라, 애플리케이션이 스스로
갱신할 때도 같은 결과가 나온다고 보지 않다.
- authorized client manager에는 refresh-token provider가 구성되어 있어, access token이 만료되면
refresh를 시도할 수 있다.
- access token 만료될 때까지 기다려 실제로 갱신이 성공하고 새 token이 저장되는지까지는 확인하지 않았다.
- 현재 authorized client 저장소는 프로세스 안에만 있어서(process-local) replica끼리 같은 refresh token
상태를 공유하지 않는다. 그래서 이번 단일 인스턴스 검증에서는 동시 refresh 경쟁을 재현하지 않았다.
- 이미 발급된 access token은 만료 전까지 API에서 계속 통하기 때문에, 갱신이 실패해도 그동안 화면은
정상으로 보인다.
## 가정
- 운영에서는 replica 둘 이상고 저장소를 공유 같은 authorized client 항목을 보게 된다.
- 두 replica가 비슷한 시각에 만료를 만나면 각각 갱신을 시도하게 된다.
- 운영에서는 replica 둘 이상 띄우고 저장소를 공유하므로, 두 replica가 같은 authorized client 항목을 읽는다.
- 두 replica의 access token이 비슷한 시각에 만료되면 각 replica가 따로 갱신을 시도다.
## 미지수
- 같은 refresh token으로 두 replica가 동시에 갱신하면 각 replica가 어떻게 동작하게 되는지.
- 재사용 허용 0회에서 요청이 사용자 화면에 어떻게 보이게 되는지.
로그인 만료로 보이는가 일시적 오류로 보이는.
- 저장소에서 새 token을 다시 읽어 재시도하면 성공하게 되는지, 아니면 재인증이 필요해지는지.
- 갱신을 한 곳에서만 할 것인, 각자 하게 두고 실패는 재시도로 처리할 것인.
- lock을 쓴다면 어디에 두고 얼마나 잡을지. 잡은 채로 프로세스가 내려가면 어떻게 푸는.
- 갱신 실패를 로그인 만료와 구분해 표시할 수 있는.
- 같은 refresh token으로 두 replica가 동시에 갱신하면 각 replica가 어떻게 동작하는가.
- 재사용 허용 0회에서 두 번째 사용이 거부되면 사용자 화면에 무엇이 보이는가.
로그인 만료로 보이는가, 일시적 오류로 보이는.
- 저장소에서 새 token을 다시 읽어 재시도하면 성공하는가, 아니면 재인증까지 해야 하는가.
- 갱신을 한 곳에서만 할 것인, 각자 하게 두고 실패는 재시도로 처리할 것인.
- lock을 쓴다면 어디에 두고 얼마나 잡는가. 잡은 채로 프로세스가 내려가면 어떻게 푸는.
- 갱신 실패를 로그인 만료와 구분해 표시할 수 있는.
## 제약
- rotation과 재사용 0회는 이미 realm에 설정한 상태다. 이 전제는 바꾸지 않고 답한다.
- 이미 발급된 access token은 만료 전까지 사용할 수 있으므로 refresh 실패는 즉시 보이지 않을 수 있다.
- rotation과 재사용 0회는 이미 realm에 설정다. 이 전제는 바꾸지 않고 답한다.
- 이미 발급된 access token은 만료 전까지 수 있으므로 refresh 실패가 곧바로 드러나지 않을 수 있다.
재현 테스트는 access token 만료 직후에 맞춰 실행한다.
- 이 경쟁은 저장소를 공유한 뒤에야 재현되므로 저장소를 정한 다음에 이어서 푼다.
@@ -66,31 +74,33 @@ realm이 refresh token rotation과 재사용 허용 0회를 쓰고 있다.
### 1. 분산 lock으로 갱신을 직렬화한다
한 replica만 갱신하고 나머지는 끝나기를 기다렸다가 결과를 읽는 구성이다. 같은 refresh token을 두 replica가 동시에 쓰는 상황 자체를 만들지 않는 것이 목표다.
분산 lock은 여러 프로세스가 같은 자원을 동시에 건드리지 못하도록 프로세스 밖에 두는 잠금이다.
한 replica만 갱신하고 나머지는 그 갱신이 끝나기를 기다렸다가 결과를 읽는다.
분산 lock을 사용하면 refresh 구간을 직렬화할 수 있다.
lock 저장소의 가용성, lock 만료, 재진입, lock 보유 process 종료 상황까지 함께 처리해야 한다.
대신 lock 저장소의 가용성, lock 만료, 재진입, lock을 쥔 프로세스가 내려간 상황까지 함께 처리해야 한다.
### 2. 각자 갱신하고 실패는 재시도로 처리한다
구현이 가장 단순하다. 지는 쪽이 거부를 받으면 저장소에서 최신 token을 다시 읽어 재시도한다는 전제인데,
이 재시도가 성립하는지 확인이 필요하다.
구현이 가장 단순하다. 지는 쪽이 거부를 받으면 저장소에서 최신 token을 다시 읽어 재시도한다는
전제인데, 이 재시도가 성립하는지부터 확인해야 한다.
reuse detection 정책에 따라 같은 refresh token 두 번째 사용이 token family 전체에 영향을 줄 수 있다.
이 경우 단순 retry로 끝나지 않고 재인증이 필요할 수 있다.
갱신에 성공한 replica가 새 token을 저장하기 전에 다른 replica가 다시 조회하는 순서도 별도로 확인해야 한다.
같은 refresh token 두 번 들어오는 것을 잡아내는 reuse detection 정책에 따라, 두 번째 사용이
token family 전체에 영향을 줄 수 있다. 그러면 재시도 한 번으로 끝나지 않고 재인증이 필요할 수 있다.
갱신에 성공한 replica가 새 token을 저장하기 전에 다른 replica가 저장소를 다시 읽는 순서도 따로
확인해야 한다.
### 3. 갱신 전용 경로를 하나 둔다
refresh를 전담하는 구성요소 하나만 refresh token을 사용하고 다른 replica는 갱신 결과를 조회하도록 구성할 수 있다.
refresh를 전담하는 구성요소 하나만 refresh token을 쓰고, 다른 replica는 결과를 조회하게 둘 수 있다.
refresh를 전담하는 구성요소가 중단되면 access token 만료 이후 갱신을 수행할 주체가 없어지므로 해당 구성요소의 가용성과 복구 방식이 중요해진다.
구성요소가 멈추면 access token 만료된 뒤에 갱신할 주체가 없어진다. 그래서 이 구성요소를 어떻게
살려 두고 어떻게 복구할지를 먼저 정해야 한다.
### 4. 제약상 제외 — 재사용 허용을 늘린다
재사용을 짧게 허용하면 두 번째 사용이 거부되지 않고 코드도 고치지 않는다.
다만 rotation과 재사용 0회는 이미 realm에 설정한 상태이고,
훔친 refresh token을 그 시간 안에 쓸 수 있다는 문제도 남는다.
재사용을 짧게 허용하면 두 번째 사용이 거부되지 않고 코드도 고칠 필요가 없다.
다만 rotation과 재사용 0회는 이미 realm에 설정했고, 허용한 시간 안에는 훔친 refresh token이 들어와도
막지 못한다.
## 다음 검증