Files
document-haness/.playwright-mcp/page-2026-09-07T10-14-56-301Z.yml
T

886 lines
74 KiB
YAML

- generic [ref=f6e3]:
- link "본문으로 건너뛰기" [ref=f6e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f6e5]:
- generic [ref=f6e6]:
- link "TechLog Studio" [ref=f6e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f6e8]: Studio
- navigation "Studio 주 탐색" [ref=f6e10]:
- link "작업본" [ref=f6e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f6e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f6e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f6e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f6e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f6e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f6e17]
- main [ref=f6e18]:
- generic [ref=f6e19]:
- generic [ref=f6e20]:
- region [ref=f6e21]:
- generic [ref=f6e22]:
- paragraph [ref=f6e23]: CASE · VERSION 25
- heading "문서 편집" [level=1] [ref=f6e24]
- paragraph [ref=f6e25]: Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출
- region [ref=f6e26]:
- generic [ref=f6e27]:
- paragraph [ref=f6e28]: DOCUMENT
- heading "기본 정보" [level=2] [ref=f6e29]
- generic [ref=f6e30]:
- generic [ref=f6e31]:
- generic [ref=f6e32]: 제목
- textbox "제목" [ref=f6e33]: Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출
- generic [ref=f6e34]:
- generic [ref=f6e35]: slug
- textbox "slug" [ref=f6e36]:
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
- text: split-custody-access-token
- generic [ref=f6e37]:
- generic [ref=f6e38]: 요약
- textbox "요약" [ref=f6e39]: confidential client인 mediator가 code를 교환하고 refresh token을 server-side authorized client에 보관한다. 그런데 브라우저가 Resource Server를 직접 부르려면 access token이 필요해서, mediator가 그것을 JSON으로 반환한다. refresh만 서버로 이동하고, access는 여전히 브라우저에 노출된다.
- generic [aria-hidden] [ref=f6e40]: 목록 카드에는 약 90자까지 보입니다 · 218 / 2000
- generic [ref=f6e41]:
- generic [ref=f6e42]: Topic
- combobox "Topic" [ref=f6e43]:
- option "선택하지 않음"
- option "JPA 피드 조회 성능"
- option "OAuth/OIDC 인증 경계" [selected]
- generic [ref=f6e44]:
- generic [ref=f6e45]: Project
- combobox "Project" [ref=f6e46]:
- option "미지정"
- option "Backend Clean Architecture"
- option "KeyCloak Patterns" [selected]
- option "Liner N + 1문제"
- status [ref=f6e47]
- group "축 — 고르지 않으면 이 주제의 공통 기록이 됩니다" [ref=f6e48]:
- generic [ref=f6e50] [cursor=pointer]:
- checkbox "SPA" [ref=f6e51]
- generic [ref=f6e52]: SPA
- generic [ref=f6e53] [cursor=pointer]:
- checkbox "Mediator" [checked] [ref=f6e54]
- generic [ref=f6e55]: Mediator
- generic [ref=f6e56] [cursor=pointer]:
- checkbox "BFF" [ref=f6e57]
- generic [ref=f6e58]: BFF
- generic [ref=f6e59] [cursor=pointer]:
- checkbox "Forward-Auth" [ref=f6e60]
- generic [ref=f6e61]: Forward-Auth
- group "관계" [ref=f6e62]:
- generic [ref=f6e64]:
- generic [ref=f6e65]:
- generic [ref=f6e66]: 관계 1 대상
- combobox "관계 1 대상" [ref=f6e67]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우" [selected]
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준"
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준" [disabled]
- option "OAuth Token과 Application Session을 구분하는 기준" [disabled]
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준" [disabled]
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" [disabled]
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f6e68]:
- generic [ref=f6e69]: 관계 1 이유
- textbox "관계 1 이유" [ref=f6e70]: SPA은 브라우저가 code 교환과 token 보관을 모두 맡는다. 이 기록은 거기서 refresh token 관리만 서버로 옮긴 다음 단계다.
- generic [aria-hidden] [ref=f6e71]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f6e72]:
- button "위로" [disabled] [ref=f6e73]
- button "아래로" [ref=f6e74]
- button "삭제" [ref=f6e75]
- generic [ref=f6e76]:
- generic [ref=f6e77]:
- generic [ref=f6e78]: 관계 2 대상
- combobox "관계 2 대상" [ref=f6e79]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우" [disabled]
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준"
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준" [disabled]
- option "OAuth Token과 Application Session을 구분하는 기준" [disabled]
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준" [selected]
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" [disabled]
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f6e80]:
- generic [ref=f6e81]: 관계 2 이유
- textbox "관계 2 이유" [ref=f6e82]: confidential client를 쓰면서도 access token이 브라우저 응답에 실린다. 종류와 token 노출이 별개라는 근거다.
- generic [aria-hidden] [ref=f6e83]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f6e84]:
- button "위로" [ref=f6e85]
- button "아래로" [ref=f6e86]
- button "삭제" [ref=f6e87]
- generic [ref=f6e88]:
- generic [ref=f6e89]:
- generic [ref=f6e90]: 관계 3 대상
- combobox "관계 3 대상" [ref=f6e91]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우" [disabled]
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준"
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준" [disabled]
- option "OAuth Token과 Application Session을 구분하는 기준" [selected]
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준" [disabled]
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" [disabled]
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f6e92]:
- generic [ref=f6e93]: 관계 3 이유
- textbox "관계 3 이유" [ref=f6e94]: access token 원문이 응답 본문과 지역 변수와 헤더를 지난다. 상태별 이름을 나눠야 하는 이유다.
- generic [aria-hidden] [ref=f6e95]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f6e96]:
- button "위로" [ref=f6e97]
- button "아래로" [ref=f6e98]
- button "삭제" [ref=f6e99]
- generic [ref=f6e100]:
- generic [ref=f6e101]:
- generic [ref=f6e102]: 관계 4 대상
- combobox "관계 4 대상" [ref=f6e103]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우" [disabled]
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준"
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준" [selected]
- option "OAuth Token과 Application Session을 구분하는 기준" [disabled]
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준" [disabled]
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" [disabled]
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f6e104]:
- generic [ref=f6e105]: 관계 4 이유
- textbox "관계 4 이유" [ref=f6e106]: refresh만 옮기고 access 노출과 server state를 함께 지는 경우다. 선택 기준의 한 칸이다.
- generic [aria-hidden] [ref=f6e107]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f6e108]:
- button "위로" [ref=f6e109]
- button "아래로" [ref=f6e110]
- button "삭제" [ref=f6e111]
- generic [ref=f6e112]:
- generic [ref=f6e113]:
- generic [ref=f6e114]: 관계 5 대상
- combobox "관계 5 대상" [ref=f6e115]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우" [disabled]
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준"
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준" [disabled]
- option "OAuth Token과 Application Session을 구분하는 기준" [disabled]
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준" [disabled]
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" [selected]
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f6e116]:
- generic [ref=f6e117]: 관계 5 이유
- textbox "관계 5 이유" [ref=f6e118]: refresh token rotation과 재사용 0회를 쓰는 구성이다. replica 경쟁 질문의 전제다.
- generic [aria-hidden] [ref=f6e119]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f6e120]:
- button "위로" [ref=f6e121]
- button "아래로" [disabled] [ref=f6e122]
- button "삭제" [ref=f6e123]
- button "관계 추가" [ref=f6e124]
- region [ref=f6e125]:
- generic [ref=f6e126]:
- paragraph [ref=f6e127]: CASE
- heading "문제와 검증" [level=2] [ref=f6e128]
- generic [ref=f6e129]:
- generic [ref=f6e130]:
- generic [ref=f6e131]: 문제
- textbox "문제" [ref=f6e132]: "Mediator에서는 Spring mediator가 confidential client가 되어 code를 교환하고 access token과 refresh token을 server-side authorized-client service에 저장한다. 브라우저에는 HttpOnly AP2_SESSION만 관리하게 된다. 여기까지만 보면 BFF 구조와 같아 보이지만, Mediator의 브라우저는 여전히 Resource Server를 직접 호출하고 있다. 그러면 access token이 필요하고, mediator가 그것을 응답으로 반환하게 된다. 처음에는 refresh token을 서버로 옮기면 브라우저의 credential 책임도 대부분 사라진다고 봤다. `/token/access` 응답을 따라가면서 무엇이 실제로 옮겨졌고 무엇이 그대로 노출되는지 나눠야 했다."
- generic [ref=f6e133]:
- generic [ref=f6e134]: 결론
- textbox "결론" [ref=f6e135]: "옮겨진 것은 client secret과 refresh token이다. access token 원문은 세 자리를 지난다. access token이 남기는 흔적 /token/access 응답 본문 : o JavaScript 지역 변수 : o /api/me Authorization 헤더 : o server state : mediator의 session과 authorized-client 저장소를 운영해야 한다. browser 노출 : access token은 브라우저 실행 영역 안에 그대로 있다."
- generic [ref=f6e136]:
- generic [ref=f6e137]: 검증 환경
- textbox "검증 환경" [ref=f6e138]: "Keycloak 26.7.0 realms client-confidential : o implicit flow, direct grant : x client_authentication : client_secret_basic grant_type : authorization_code scopes : openid profile email callback : http://localhost:8082/login/oauth2/ code/keycloak principal claim : preferred_username OAuth2AuthorizedClientService : Spring Boot의 in-memory Spring Session, Redis, JDBC token store 의존성 : x Resource Server CORS allowlist origin : http://localhost:8082 method : GET, OPTIONS header : Authorization, Content-Type HTTPS : x HTTP : o"
- generic [ref=f6e139]:
- generic [ref=f6e140]: 재현 조건
- textbox "재현 조건" [ref=f6e141]: "1. Mediator UI에서 로그인한 뒤 /token/boundary를 호출. accessTokenStored : true refreshTokenStored : true browserReceivesRefreshToken : false 2. /token/access 응답의 key가 정확히 세 개인지 확인. access_token, token_type, expires_at 3. 같은 응답의 Cache-Control에 no-store가 있는지 확인. 4. 반환된 access JWT를 decode해 audience에 keycloak-pattern-api가 있는지 확인. 5. 브라우저가 그 token으로 Resource Server를 직접 호출해 200을 받는지 확인. 6. cookie가 AP2_SESSION이며 HttpOnly와 SameSite=Lax인지 확인. 7. Local Storage와 Session Storage에 access token 원문이나 refresh_token 문자열이 없는지 확인."
- generic [ref=f6e142]:
- generic [ref=f6e143]: 마지막 검증일
- textbox "마지막 검증일" [ref=f6e144]: 2026-08-24
- generic [ref=f6e145]:
- generic [ref=f6e146]: 본문 Markdown
- group "Markdown 삽입" [ref=f6e147]:
- button "코드" [ref=f6e148] [cursor=pointer]
- button "표" [ref=f6e149] [cursor=pointer]
- button "목록" [ref=f6e150] [cursor=pointer]
- textbox "본문 Markdown" [ref=f6e151]: "## 토큰 관리 경계가 나뉘는 지점 mediator는 access token과 refresh token을 모두 보관한다. 다만 브라우저가 Resource Server를 직접 호출해야 해서, access token은 `/token/access`를 통해 다시 브라우저로 전달된다. :::evidence key=\"ap2-mediator-architecture-c95ed25f\" alt=\"브라우저가 Spring mediator에서 access token만 받아 Resource Server를 직접 호출하고 refresh token은 authorized-client store에 남기는 AP2 split-custody 아키텍처.\" caption=\"\" zoom=\"true\" ::: ## 서버로 옮겨진 책임 SPA 구조에서는 브라우저가 code를 직접 교환후 token 교환을 통해 브라우저에서 토큰 관리를 했지만, Mediator 구조에서는 Spring mediator가 confidential client가 되어 그 책임을 맡게 된다. 옮겨진 것과 그대로인 것을 나누면 다음과 같다. | 무엇 | 브라우저에 있나 | 서버에 있나 | |---|---|---| | client secret | x | o | | refresh token | x | o | | access token | o | o | | 로그인 상태 | AP2_SESSION | HttpSession | 세 번째 줄이 이 Case의 관측이다. access token은 양쪽에 있다. ## AP2_SESSION이 생성되는 시점 `AP2_SESSION`이 token 교환을 마친 뒤에 발급된다고 읽기 쉽지만 그렇지 않다. Spring Security는 로그인을 시작할 때 authorization request와 `state`를 HttpSession에 저장하고, 그 transaction을 찾기 위한 cookie를 먼저 발급한다. Browser가 KeyCloak으로 이동했다가 다시 Spring으로 다시 돌아왔을 때, Spring이 이 사용자가 아까 시작했던 로그인 요청이 무었이었는지 찾을 수 있어야 하기 때문에 로그인 시작 시점에 session 쿠키를 먼저 만들게 된다. ```text label=\"callback 하나가 두 개의 상태로 나뉜다\" AP2_SESSION → servlet HttpSession의 login SecurityContext → Authentication(principal name = preferred_username) (\"keycloak\", principal name) → OAuth2AuthorizedClientService → access token + refresh token ``` cookie가 token을 직렬화해 담고 있는 것이 아니다. cookie는 HttpSession을 식별하는 세션 ID이고, 그 HttpSession 안에 로그인 SecurityContext가 저장되어 있다. token은 이 cookie session에 담겨져 있는 principal을 가지고 별도 store에서 관리되고 있는 token을 찾는 것이다. :::warning `OAuth2AuthorizedClientService` 은 Spring Boot 자동구성이 고르는 in-memory 구현이고 Spring Session·Redis·JDBC token store 의존성도 없다. 로그인 상태와 token 상태가 **둘 다** process-local memory에 있다. ::: ## /token/access가 반환하는 세 가지 field :::evidence key=\"ap2-mediator-handoff-flow-8c2a6f8f\" alt=\"브라우저, Spring mediator, authorized-client store, Resource Server 사이에서 AP2_SESSION 요청, access-only 응답, 브라우저 Bearer 호출과 JSON 응답이 이어지는 순서도.\" caption=\" \" zoom=\"true\" ::: 브라우저가 API를 호출하려면 access token이 필요하다. mediator는 이 endpoint로 반환하게 된다. ```http label=\"브라우저 입력 — cookie 한 개\" GET http://localhost:8082/token/access Accept: application/json Cookie: AP2_SESSION=<opaque-session-id> ``` controller는 `OAuth2AuthorizeRequest.withClientRegistrationId(\"keycloak\")`을 만들고 현재 `Authentication`을 principal로 넣어 `OAuth2AuthorizedClientManager.authorize()`를 부른다. 돌아온 authorized client에서 access token만 꺼내 세 field로 만든다. ```http label=\"응답 헤더\" HTTP/1.1 200 OK Cache-Control: no-store Pragma: no-cache Content-Type: application/json ``` ```json label=\"응답 본문 — refresh_token은 없음\" { \"access_token\": \"<raw-keycloak-jwt>\", \"token_type\": \"Bearer\", \"expires_at\": \"<ISO-8601-instant>\" } ``` access token만 HTTP 응답 본문에 반환한다. authorized client나 access token이 없으면 401이 된다. ## access token가 남기는 흔적들 브라우저 JavaScript는 이 응답을 지역 변수로 분해한다. ```javascript label=\"Web Storage에도 cookie에도 쓰지 않는다\" const { access_token: accessToken, expires_at: expiresAt } = await tokenResponse.json(); ``` 그리고 바로 다음 요청의 헤더가 된다. ```http label=\"mediator를 지나지 않는 경로\" GET http://localhost:8081/api/me Accept: application/json Authorization: Bearer <raw-keycloak-jwt> Origin: http://localhost:8082 ``` 원문이 지나는 자리를 세면 셋이다. ```text /token/access response body → JavaScript local variable → /api/me Authorization header ``` 세 자리 모두 같은 실행 영역 안이다. memory-only는 영구 저장소에 쓰지 않는다는 뜻이다. 실행 중 script가 응답이나 지역 변수를 읽을 수 없다는 뜻이 아니다. ## /token/access는 일회성 전달이 아니다 이 endpoint가 한 번만 건네고 끝나는 교환인지 확인했다. | one-time handoff 요건 | 있나 | |---|---| | handoff ID | x | | nonce | x | | 사용 표시(consume flag) | x | | 건넨 뒤 삭제 | x | | 재호출 거부 | x | 같은 인증된 session은 현재 access token을 몇 번이든 다시 받을 수 있다. ```text repeatable GET → current authorized client lookup/refresh opportunity → current raw access token response ``` 이 mediator가 허용하는 부분은 브라우저에 **access-only**다. ## 이 구조에서 감수한 것 - server state : mediator의 HttpSession과 authorized-client 저장소를 운영해야 한다 - browser 노출 : access token은 여전히 응답 본문과 헤더에 있다 이 구조를 고를 이유는 브라우저가 Resource Server를 직접 호출해야 한다는 요구가 있을 때다. 브라우저에서 access token까지 없애려는 목적이라면 이 구조는 맞지 않는다. server state 자체를 둘 수 없다면 SPA 구성이 더 단순하다. ## 확인한 것과 확인하지 않은 것 아래는 **커밋된 자동 테스트가 확인하도록 정의한 부분**이다. | 항목 | 확인했나? | |---|---| | server access·refresh boolean이 true | o | | `browserReceivesRefreshToken`이 false | o | | 응답이 세 개 | o | | `Cache-Control`에 `no-store` | o | | audience에 `keycloak-pattern-api` 포함 | o | | Resource Server 직접 호출 200 | o | | cookie HttpOnly · SameSite=Lax | o | | Web Storage에 token 문자열 없음 | o | | 두 번째 `/token/access` 거부 | x | | 만료 뒤 실제 refresh | x | | logout 때 두 상태 삭제 | x | | 재시작·replica 이동 뒤 복구 | x | | 허용 밖 origin의 CORS 거부 | x | 만료 뒤 refresh 같은 경우는 manager에는 authorization-code와 refresh-token provider가 함께 구성돼 있다. 갱신을 시도할 수 있도록 만들 수도 있지만, 실제로 만료를 기다려 갱신이 성공하고 rotate된 token이 저장되는지는 확인하지 않았다."
- group [ref=f6e152]:
- paragraph [ref=f6e153]: EVIDENCE
- heading "본문에 Asset 삽입" [level=3] [ref=f6e154]
- paragraph [ref=f6e155]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
- generic [ref=f6e156]:
- generic [ref=f6e157]:
- generic [ref=f6e158]: 업로드 종류
- combobox "업로드 종류" [ref=f6e159]:
- option "이미지" [selected]
- option "다이어그램"
- option "첨부파일"
- button "Asset 업로드" [ref=f6e160]
- generic [ref=f6e161]:
- search [ref=f6e162]:
- generic [ref=f6e163]: Asset 검색
- generic [ref=f6e164]:
- searchbox "Asset 검색" [ref=f6e165]
- button "검색" [ref=f6e166]
- generic [ref=f6e167]:
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f6e168]
- generic [ref=f6e169]: 삽입할 때 크게 보기 허용
- status [ref=f6e170]: 삽입할 수 있는 Asset 24개
- list [ref=f6e171]:
- listitem [ref=f6e172]:
- button "ap4-edge-trust-architecture-1a916e10" [ref=f6e173]
- button "삭제" [ref=f6e174]
- listitem [ref=f6e175]:
- button "ap3-bff-session-flow-1b005e15" [ref=f6e176]
- button "삭제" [ref=f6e177]
- listitem [ref=f6e178]:
- button "ap3-bff-architecture-a27ea91c" [ref=f6e179]
- button "삭제" [ref=f6e180]
- listitem [ref=f6e181]:
- button "ap2-mediator-handoff-flow-efe7039c" [ref=f6e182]
- button "삭제" [ref=f6e183]
- listitem [ref=f6e184]:
- button "ap2-mediator-architecture-c95ed25f" [ref=f6e185]
- button "삭제" [ref=f6e186]
- listitem [ref=f6e187]:
- button "projection-row-over-fetch-f2b1943b" [ref=f6e188]
- button "삭제" [ref=f6e189]
- listitem [ref=f6e190]:
- button "cartesian-row-multiplication-dce2e166" [ref=f6e191]
- button "삭제" [ref=f6e192]
- listitem [ref=f6e193]:
- button "eager-lazy-query-sequence-47c12bda" [ref=f6e194]
- button "삭제" [ref=f6e195]
- listitem [ref=f6e196]:
- button "ap3-bff-session-flow-a8dfff6f" [ref=f6e197]
- button "삭제" [ref=f6e198]
- listitem [ref=f6e199]:
- button "ap2-mediator-handoff-flow-8c2a6f8f" [ref=f6e200]
- button "삭제" [ref=f6e201]
- listitem [ref=f6e202]:
- button "ap4-edge-forward-auth-flow-a6ec423a" [ref=f6e203]
- button "삭제" [ref=f6e204]
- listitem [ref=f6e205]:
- button "ap3-csrf-boundary-971df81c" [ref=f6e206]
- button "삭제" [ref=f6e207]
- listitem [ref=f6e208]:
- button "login-api-phase-split-3e354274" [ref=f6e209]
- button "삭제" [ref=f6e210]
- listitem [ref=f6e211]:
- button "ap1-browser-bearer-flow-a7f8aa9e" [ref=f6e212]
- button "삭제" [ref=f6e213]
- listitem [ref=f6e214]:
- button "ap1-direct-architecture-0adf4199" [ref=f6e215]
- button "삭제" [ref=f6e216]
- listitem [ref=f6e217]:
- button "nplus1-query-fanout-644febe6" [ref=f6e218]
- button "삭제" [ref=f6e219]
- listitem [ref=f6e220]:
- button "ap4-edge-trust-1cff2399" [ref=f6e221]
- button "삭제" [ref=f6e222]
- listitem [ref=f6e223]:
- button "ap3-csrf-split-501dd1f7" [ref=f6e224]
- button "삭제" [ref=f6e225]
- listitem [ref=f6e226]:
- button "ap3-bff-custody-82fa18bd" [ref=f6e227]
- button "삭제" [ref=f6e228]
- listitem [ref=f6e229]:
- button "ap2-split-custody-779cb791" [ref=f6e230]
- button "삭제" [ref=f6e231]
- listitem [ref=f6e232]:
- button "ap1-custody-v3-6e0376d2" [ref=f6e233]
- button "삭제" [ref=f6e234]
- listitem [ref=f6e235]:
- button "ap1-custody-v2-e110bd98" [ref=f6e236]
- button "삭제" [ref=f6e237]
- listitem [ref=f6e238]:
- button "ap1-credential-custody-f5e0c027" [ref=f6e239]
- button "삭제" [ref=f6e240]
- listitem [ref=f6e241]:
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f6e242]
- button "삭제" [ref=f6e243]
- region [ref=f6e244]:
- generic [ref=f6e245]:
- paragraph [ref=f6e246]: LIVE
- heading "즉시 미리보기" [level=2] [ref=f6e247]
- generic [ref=f6e250]:
- generic [ref=f6e251]:
- navigation "문서 경로" [ref=f6e252]:
- link "검증 기록" [ref=f6e253] [cursor=pointer]:
- /url: /explore/cases
- generic [aria-hidden] [ref=f6e254]: /
- generic [ref=f6e255]: OAuth/OIDC 인증 경계
- generic [aria-hidden] [ref=f6e256]: /
- link "KeyCloak Patterns" [ref=f6e257] [cursor=pointer]:
- /url: /projects/keycloak-patterns
- heading "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출" [level=1] [ref=f6e258]
- paragraph [ref=f6e259]: confidential client인 mediator가 code를 교환하고 refresh token을 server-side authorized client에 보관한다. 그런데 브라우저가 Resource Server를 직접 부르려면 access token이 필요해서, mediator가 그것을 JSON으로 반환한다. refresh만 서버로 이동하고, access는 여전히 브라우저에 노출된다.
- region "문제와 결론" [ref=f6e260]:
- generic [ref=f6e261]:
- paragraph [ref=f6e262]: 문제
- paragraph [ref=f6e263]: Mediator에서는 Spring mediator가 confidential client가 되어 code를 교환하고access token과 refresh token을 server-side authorized-client service에 저장한다.브라우저에는 HttpOnly AP2_SESSION만 관리하게 된다.
- paragraph [ref=f6e264]: 여기까지만 보면 BFF 구조와 같아 보이지만,Mediator의 브라우저는 여전히 Resource Server를 직접 호출하고 있다.그러면 access token이 필요하고, mediator가 그것을 응답으로 반환하게 된다.
- paragraph [ref=f6e265]:
- text: 처음에는 refresh token을 서버로 옮기면 브라우저의 credential 책임도 대부분 사라진다고 봤다.
- code [ref=f6e266]: /token/access
- text: 응답을 따라가면서 무엇이 실제로 옮겨졌고 무엇이 그대로 노출되는지 나눠야 했다.
- generic [ref=f6e267]:
- paragraph [ref=f6e268]: 결론
- paragraph [ref=f6e269]: 옮겨진 것은 client secret과 refresh token이다. access token 원문은 세 자리를 지난다.
- paragraph [ref=f6e270]: "access token이 남기는 흔적/token/access 응답 본문 : oJavaScript 지역 변수 : o/api/me Authorization 헤더 : o"
- paragraph [ref=f6e271]: "server state : mediator의 session과 authorized-client 저장소를 운영해야 한다.browser 노출 : access token은 브라우저 실행 영역 안에 그대로 있다."
- generic [ref=f6e272]:
- generic [ref=f6e273]:
- term [ref=f6e274]: 검증 환경
- definition [ref=f6e275]:
- paragraph [ref=f6e276]: Keycloak 26.7.0
- paragraph [ref=f6e277]: "realmsclient-confidential : oimplicit flow, direct grant : xclient_authentication : client_secret_basicgrant_type : authorization_codescopes : openid profile emailcallback : http://localhost:8082/login/oauth2/code/keycloakprincipal claim : preferred_username"
- paragraph [ref=f6e278]: "OAuth2AuthorizedClientService : Spring Boot의 in-memorySpring Session, Redis, JDBC token store 의존성 : x"
- paragraph [ref=f6e279]: "Resource Server CORS allowlistorigin : http://localhost:8082method : GET, OPTIONSheader : Authorization, Content-Type"
- paragraph [ref=f6e280]: "HTTPS : xHTTP : o"
- generic [ref=f6e281]:
- term [ref=f6e282]: 검증 데이터
- definition [ref=f6e283]:
- paragraph [ref=f6e284]: "1. Mediator UI에서 로그인한 뒤 /token/boundary를 호출.accessTokenStored : truerefreshTokenStored : truebrowserReceivesRefreshToken : false"
- paragraph [ref=f6e285]: 2. /token/access 응답의 key가 정확히 세 개인지 확인.access_token, token_type, expires_at
- paragraph [ref=f6e286]: 3. 같은 응답의 Cache-Control에 no-store가 있는지 확인.
- paragraph [ref=f6e287]: 4. 반환된 access JWT를 decode해 audience에 keycloak-pattern-api가 있는지 확인.
- paragraph [ref=f6e288]: 5. 브라우저가 그 token으로 Resource Server를 직접 호출해 200을 받는지 확인.
- paragraph [ref=f6e289]: 6. cookie가 AP2_SESSION이며 HttpOnly와 SameSite=Lax인지 확인.
- paragraph [ref=f6e290]: 7. Local Storage와 Session Storage에 access token 원문이나 refresh_token 문자열이 없는지 확인.
- generic [ref=f6e291]:
- term [ref=f6e292]: 기록
- definition [ref=f6e293]: 게시 2026.08.24 · 마지막 검증 2026.08.24
- group [ref=f6e295]:
- generic "목차 · 토큰 관리 경계가 나뉘는 지점" [ref=f6e296] [cursor=pointer]
- article [ref=f6e298]:
- region [ref=f6e299]:
- heading [level=2] [ref=f6e300]:
- link "토큰 관리 경계가 나뉘는 지점 바로가기" [ref=f6e301] [cursor=pointer]:
- /url: "#토큰-관리-경계가-나뉘는-지점"
- text: 토큰 관리 경계가 나뉘는 지점
- generic [aria-hidden] [ref=f6e302]: "#"
- paragraph [ref=f6e303]:
- text: mediator는 access token과 refresh token을 모두 보관한다. 다만 브라우저가 Resource Server를 직접 호출해야 해서, access token은
- code [ref=f6e304]: /token/access
- text: 를 통해 다시 브라우저로 전달된다.
- figure [ref=f6e305]:
- button "ap2-mediator-architecture-c95ed25f 이미지 크게 보기" [ref=f6e306]:
- img "브라우저가 Spring mediator에서 access token만 받아 Resource Server를 직접 호출하고 refresh token은 authorized-client store에 남기는 AP2 split-custody 아키텍처." [ref=f6e307]
- generic [ref=f6e308]: 크게 보기
- generic [ref=f6e309]: 브라우저가 Spring mediator에서 access token만 받아 Resource Server를 직접 호출하고 refresh token은 authorized-client store에 남기는 AP2 split-custody 아키텍처.
- region [ref=f6e310]:
- heading [level=2] [ref=f6e311]:
- link "서버로 옮겨진 책임 바로가기" [ref=f6e312] [cursor=pointer]:
- /url: "#서버로-옮겨진-책임"
- text: 서버로 옮겨진 책임
- generic [aria-hidden] [ref=f6e313]: "#"
- paragraph [ref=f6e314]: SPA 구조에서는 브라우저가 code를 직접 교환후 token 교환을 통해 브라우저에서 토큰 관리를 했지만, Mediator 구조에서는 Spring mediator가 confidential client가 되어 그 책임을 맡게 된다.
- paragraph [ref=f6e315]: 옮겨진 것과 그대로인 것을 나누면 다음과 같다.
- region "표" [ref=f6e316]:
- table [ref=f6e317]:
- caption [ref=f6e318]
- rowgroup [ref=f6e319]:
- row [ref=f6e320]:
- columnheader "무엇" [ref=f6e321]
- columnheader "브라우저에 있나" [ref=f6e322]
- columnheader "서버에 있나" [ref=f6e323]
- rowgroup [ref=f6e324]:
- row [ref=f6e325]:
- cell "client secret" [ref=f6e326]
- cell "x" [ref=f6e327]
- cell "o" [ref=f6e328]
- row [ref=f6e329]:
- cell "refresh token" [ref=f6e330]
- cell "x" [ref=f6e331]
- cell "o" [ref=f6e332]
- row [ref=f6e333]:
- cell "access token" [ref=f6e334]
- cell "o" [ref=f6e335]
- cell "o" [ref=f6e336]
- row [ref=f6e337]:
- cell "로그인 상태" [ref=f6e338]
- cell "AP2_SESSION" [ref=f6e339]
- cell "HttpSession" [ref=f6e340]
- paragraph [ref=f6e341]: 세 번째 줄이 이 Case의 관측이다. access token은 양쪽에 있다.
- region [ref=f6e342]:
- heading [level=2] [ref=f6e343]:
- link "AP2_SESSION이 생성되는 시점 바로가기" [ref=f6e344] [cursor=pointer]:
- /url: "#ap2-session이-생성되는-시점"
- text: AP2_SESSION이 생성되는 시점
- generic [aria-hidden] [ref=f6e345]: "#"
- paragraph [ref=f6e346]:
- code [ref=f6e347]: AP2_SESSION
- text: 이 token 교환을 마친 뒤에 발급된다고 읽기 쉽지만 그렇지 않다.
- paragraph [ref=f6e348]:
- text: Spring Security는 로그인을 시작할 때 authorization request와
- code [ref=f6e349]: state
- text: 를 HttpSession에 저장하고, 그 transaction을 찾기 위한 cookie를 먼저 발급한다. Browser가 KeyCloak으로 이동했다가 다시 Spring으로 다시 돌아왔을 때, Spring이 이 사용자가 아까 시작했던 로그인 요청이 무었이었는지 찾을 수 있어야 하기 때문에 로그인 시작 시점에 session 쿠키를 먼저 만들게 된다.
- figure "TEXT ·callback 하나가 두 개의 상태로 나뉜다 코드 복사" [ref=f6e350]:
- generic [ref=f6e351]:
- generic [ref=f6e352]: TEXT
- generic [ref=f6e353]: ·callback 하나가 두 개의 상태로 나뉜다
- button "코드 복사" [ref=f6e354] [cursor=pointer]: 복사
- region "callback 하나가 두 개의 상태로 나뉜다 코드" [ref=f6e355]:
- code [ref=f6e356]: AP2_SESSION → servlet HttpSession의 login SecurityContext → Authentication(principal name = preferred_username) ("keycloak", principal name) → OAuth2AuthorizedClientService → access token + refresh token
- paragraph [ref=f6e358]: cookie가 token을 직렬화해 담고 있는 것이 아니다. cookie는 HttpSession을 식별하는 세션 ID이고, 그 HttpSession 안에 로그인 SecurityContext가 저장되어 있다. token은 이 cookie session에 담겨져 있는 principal을 가지고 별도 store에서 관리되고 있는 token을 찾는 것이다.
- complementary "주의" [ref=f6e359]:
- paragraph [ref=f6e360]: 주의
- paragraph [ref=f6e361]:
- code [ref=f6e362]: OAuth2AuthorizedClientService
- text: 은 Spring Boot 자동구성이 고르는 in-memory 구현이고 Spring Session·Redis·JDBC token store 의존성도 없다. 로그인 상태와 token 상태가
- strong [ref=f6e363]: 둘 다
- text: process-local memory에 있다.
- region [ref=f6e364]:
- heading [level=2] [ref=f6e365]:
- link "/token/access가 반환하는 세 가지 field 바로가기" [ref=f6e366] [cursor=pointer]:
- /url: "#token-access가-반환하는-세-가지-field"
- text: /token/access가 반환하는 세 가지 field
- generic [aria-hidden] [ref=f6e367]: "#"
- figure [ref=f6e368]:
- button "ap2-mediator-handoff-flow-8c2a6f8f 이미지 크게 보기" [ref=f6e369]:
- img "브라우저, Spring mediator, authorized-client store, Resource Server 사이에서 AP2_SESSION 요청, access-only 응답, 브라우저 Bearer 호출과 JSON 응답이 이어지는 순서도." [ref=f6e370]
- generic [ref=f6e371]: 크게 보기
- generic [ref=f6e372]: 브라우저, Spring mediator, authorized-client store, Resource Server 사이에서 AP2_SESSION 요청, access-only 응답, 브라우저 Bearer 호출과 JSON 응답이 이어지는 순서도.
- paragraph [ref=f6e373]: 브라우저가 API를 호출하려면 access token이 필요하다. mediator는 이 endpoint로 반환하게 된다.
- figure "HTTP ·브라우저 입력 — cookie 한 개 코드 복사" [ref=f6e374]:
- generic [ref=f6e375]:
- generic [ref=f6e376]: HTTP
- generic [ref=f6e377]: ·브라우저 입력 — cookie 한 개
- button "코드 복사" [ref=f6e378] [cursor=pointer]: 복사
- region "브라우저 입력 — cookie 한 개 코드" [ref=f6e379]:
- code [ref=f6e380]: "GET http://localhost:8082/token/access Accept: application/json Cookie: AP2_SESSION=<opaque-session-id>"
- paragraph [ref=f6e382]:
- text: controller는
- code [ref=f6e383]: OAuth2AuthorizeRequest.withClientRegistrationId("keycloak")
- text: 을 만들고 현재
- code [ref=f6e384]: Authentication
- text: 을 principal로 넣어
- code [ref=f6e385]: OAuth2AuthorizedClientManager.authorize()
- text: 를 부른다. 돌아온 authorized client에서 access token만 꺼내 세 field로 만든다.
- figure "HTTP ·응답 헤더 코드 복사" [ref=f6e386]:
- generic [ref=f6e387]:
- generic [ref=f6e388]: HTTP
- generic [ref=f6e389]: ·응답 헤더
- button "코드 복사" [ref=f6e390] [cursor=pointer]: 복사
- region "응답 헤더 코드" [ref=f6e391]:
- code [ref=f6e392]: "HTTP/1.1 200 OK Cache-Control: no-store Pragma: no-cache Content-Type: application/json"
- figure "JSON ·응답 본문 — refresh_token은 없음 코드 복사" [ref=f6e394]:
- generic [ref=f6e395]:
- generic [ref=f6e396]: JSON
- generic [ref=f6e397]: ·응답 본문 — refresh_token은 없음
- button "코드 복사" [ref=f6e398] [cursor=pointer]: 복사
- region "응답 본문 — refresh_token은 없음 코드" [ref=f6e399]:
- code [ref=f6e400]: "{ \"access_token\": \"<raw-keycloak-jwt>\", \"token_type\": \"Bearer\", \"expires_at\": \"<ISO-8601-instant>\" }"
- paragraph [ref=f6e402]: access token만 HTTP 응답 본문에 반환한다.
- paragraph [ref=f6e403]: authorized client나 access token이 없으면 401이 된다.
- region [ref=f6e404]:
- heading [level=2] [ref=f6e405]:
- link "access token가 남기는 흔적들 바로가기" [ref=f6e406] [cursor=pointer]:
- /url: "#access-token가-남기는-흔적들"
- text: access token가 남기는 흔적들
- generic [aria-hidden] [ref=f6e407]: "#"
- paragraph [ref=f6e408]: 브라우저 JavaScript는 이 응답을 지역 변수로 분해한다.
- figure "JAVASCRIPT ·Web Storage에도 cookie에도 쓰지 않는다 코드 복사" [ref=f6e409]:
- generic [ref=f6e410]:
- generic [ref=f6e411]: JAVASCRIPT
- generic [ref=f6e412]: ·Web Storage에도 cookie에도 쓰지 않는다
- button "코드 복사" [ref=f6e413] [cursor=pointer]: 복사
- region "Web Storage에도 cookie에도 쓰지 않는다 코드" [ref=f6e414]:
- code [ref=f6e415]: "const { access_token: accessToken, expires_at: expiresAt } = await tokenResponse.json();"
- paragraph [ref=f6e417]: 그리고 바로 다음 요청의 헤더가 된다.
- figure "HTTP ·mediator를 지나지 않는 경로 코드 복사" [ref=f6e418]:
- generic [ref=f6e419]:
- generic [ref=f6e420]: HTTP
- generic [ref=f6e421]: ·mediator를 지나지 않는 경로
- button "코드 복사" [ref=f6e422] [cursor=pointer]: 복사
- region "mediator를 지나지 않는 경로 코드" [ref=f6e423]:
- code [ref=f6e424]: "GET http://localhost:8081/api/me Accept: application/json Authorization: Bearer <raw-keycloak-jwt> Origin: http://localhost:8082"
- paragraph [ref=f6e426]: 원문이 지나는 자리를 세면 셋이다.
- figure "TEXT ·코드 코드 복사" [ref=f6e427]:
- generic [ref=f6e428]:
- generic [ref=f6e429]: TEXT
- generic [ref=f6e430]: ·코드
- button "코드 복사" [ref=f6e431] [cursor=pointer]: 복사
- region "코드 코드" [ref=f6e432]:
- code [ref=f6e433]: /token/access response body → JavaScript local variable → /api/me Authorization header
- paragraph [ref=f6e435]: 세 자리 모두 같은 실행 영역 안이다.
- paragraph [ref=f6e436]: memory-only는 영구 저장소에 쓰지 않는다는 뜻이다. 실행 중 script가 응답이나 지역 변수를 읽을 수 없다는 뜻이 아니다.
- region [ref=f6e437]:
- heading [level=2] [ref=f6e438]:
- link "/token/access는 일회성 전달이 아니다 바로가기" [ref=f6e439] [cursor=pointer]:
- /url: "#token-access는-일회성-전달이-아니다"
- text: /token/access는 일회성 전달이 아니다
- generic [aria-hidden] [ref=f6e440]: "#"
- paragraph [ref=f6e441]: 이 endpoint가 한 번만 건네고 끝나는 교환인지 확인했다.
- region "표" [ref=f6e442]:
- table [ref=f6e443]:
- caption [ref=f6e444]
- rowgroup [ref=f6e445]:
- row [ref=f6e446]:
- columnheader "one-time handoff 요건" [ref=f6e447]
- columnheader "있나" [ref=f6e448]
- rowgroup [ref=f6e449]:
- row [ref=f6e450]:
- cell "handoff ID" [ref=f6e451]
- cell "x" [ref=f6e452]
- row [ref=f6e453]:
- cell "nonce" [ref=f6e454]
- cell "x" [ref=f6e455]
- row [ref=f6e456]:
- cell "사용 표시(consume flag)" [ref=f6e457]
- cell "x" [ref=f6e458]
- row [ref=f6e459]:
- cell "건넨 뒤 삭제" [ref=f6e460]
- cell "x" [ref=f6e461]
- row [ref=f6e462]:
- cell "재호출 거부" [ref=f6e463]
- cell "x" [ref=f6e464]
- paragraph [ref=f6e465]: 같은 인증된 session은 현재 access token을 몇 번이든 다시 받을 수 있다.
- figure "TEXT ·코드 코드 복사" [ref=f6e466]:
- generic [ref=f6e467]:
- generic [ref=f6e468]: TEXT
- generic [ref=f6e469]: ·코드
- button "코드 복사" [ref=f6e470] [cursor=pointer]: 복사
- region "코드 코드" [ref=f6e471]:
- code [ref=f6e472]: repeatable GET → current authorized client lookup/refresh opportunity → current raw access token response
- paragraph [ref=f6e474]:
- text: 이 mediator가 허용하는 부분은 브라우저에
- strong [ref=f6e475]: access-only
- text: 다.
- region [ref=f6e476]:
- heading [level=2] [ref=f6e477]:
- link "이 구조에서 감수한 것 바로가기" [ref=f6e478] [cursor=pointer]:
- /url: "#이-구조에서-감수한-것"
- text: 이 구조에서 감수한 것
- generic [aria-hidden] [ref=f6e479]: "#"
- list [ref=f6e480]:
- listitem [ref=f6e481]: "server state : mediator의 HttpSession과 authorized-client 저장소를 운영해야 한다"
- listitem [ref=f6e482]: "browser 노출 : access token은 여전히 응답 본문과 헤더에 있다"
- paragraph [ref=f6e483]: 이 구조를 고를 이유는 브라우저가 Resource Server를 직접 호출해야 한다는 요구가 있을 때다. 브라우저에서 access token까지 없애려는 목적이라면 이 구조는 맞지 않는다. server state 자체를 둘 수 없다면 SPA 구성이 더 단순하다.
- region [ref=f6e484]:
- heading [level=2] [ref=f6e485]:
- link "확인한 것과 확인하지 않은 것 바로가기" [ref=f6e486] [cursor=pointer]:
- /url: "#확인한-것과-확인하지-않은-것"
- text: 확인한 것과 확인하지 않은 것
- generic [aria-hidden] [ref=f6e487]: "#"
- paragraph [ref=f6e488]:
- text: 아래는
- strong [ref=f6e489]: 커밋된 자동 테스트가 확인하도록 정의한 부분
- text: 이다.
- region "표" [ref=f6e490]:
- table [ref=f6e491]:
- caption [ref=f6e492]
- rowgroup [ref=f6e493]:
- row [ref=f6e494]:
- columnheader "항목" [ref=f6e495]
- columnheader "확인했나?" [ref=f6e496]
- rowgroup [ref=f6e497]:
- row [ref=f6e498]:
- cell "server access·refresh boolean이 true" [ref=f6e499]
- cell "o" [ref=f6e500]
- row [ref=f6e501]:
- cell [ref=f6e502]:
- code [ref=f6e503]: browserReceivesRefreshToken
- text: 이 false
- cell "o" [ref=f6e504]
- row [ref=f6e505]:
- cell "응답이 세 개" [ref=f6e506]
- cell "o" [ref=f6e507]
- row [ref=f6e508]:
- cell [ref=f6e509]:
- code [ref=f6e510]: Cache-Control
- text:
- code [ref=f6e511]: no-store
- cell "o" [ref=f6e512]
- row [ref=f6e513]:
- cell [ref=f6e514]:
- text: audience에
- code [ref=f6e515]: keycloak-pattern-api
- text: 포함
- cell "o" [ref=f6e516]
- row [ref=f6e517]:
- cell "Resource Server 직접 호출 200" [ref=f6e518]
- cell "o" [ref=f6e519]
- row [ref=f6e520]:
- cell "cookie HttpOnly · SameSite=Lax" [ref=f6e521]
- cell "o" [ref=f6e522]
- row [ref=f6e523]:
- cell "Web Storage에 token 문자열 없음" [ref=f6e524]
- cell "o" [ref=f6e525]
- row [ref=f6e526]:
- cell [ref=f6e527]:
- text: 두 번째
- code [ref=f6e528]: /token/access
- text: 거부
- cell "x" [ref=f6e529]
- row [ref=f6e530]:
- cell "만료 뒤 실제 refresh" [ref=f6e531]
- cell "x" [ref=f6e532]
- row [ref=f6e533]:
- cell "logout 때 두 상태 삭제" [ref=f6e534]
- cell "x" [ref=f6e535]
- row [ref=f6e536]:
- cell "재시작·replica 이동 뒤 복구" [ref=f6e537]
- cell "x" [ref=f6e538]
- row [ref=f6e539]:
- cell "허용 밖 origin의 CORS 거부" [ref=f6e540]
- cell "x" [ref=f6e541]
- paragraph [ref=f6e542]: 만료 뒤 refresh 같은 경우는 manager에는 authorization-code와 refresh-token provider가 함께 구성돼 있다. 갱신을 시도할 수 있도록 만들 수도 있지만, 실제로 만료를 기다려 갱신이 성공하고 rotate된 token이 저장되는지는 확인하지 않았다.
- region [ref=f6e543]:
- paragraph [ref=f6e544]: Next
- heading "다음에 읽을 것" [level=2] [ref=f6e545]
- list [ref=f6e546]:
- listitem [ref=f6e547]:
- link "검증 기록 SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우" [ref=f6e548] [cursor=pointer]:
- /url: /cases/spa-browser-credential-boundary
- generic [ref=f6e549]: 검증 기록
- generic [ref=f6e550]:
- strong [ref=f6e551]: SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우
- paragraph [aria-hidden] [ref=f6e552]: SPA은 브라우저가 code 교환과 token 보관을 모두 맡는다. 이 기록은 거기서 refresh token 관리만 서버로 옮긴 다음 단계다.
- generic [aria-hidden] [ref=f6e553]:
- listitem [ref=f6e554]:
- link "적용 기준 Public Client와 Confidential Client 구분 기준" [ref=f6e555] [cursor=pointer]:
- /url: /references/public-confidential-client-boundary
- generic [ref=f6e556]: 적용 기준
- generic [ref=f6e557]:
- strong [ref=f6e558]: Public Client와 Confidential Client 구분 기준
- paragraph [aria-hidden] [ref=f6e559]: confidential client를 쓰면서도 access token이 브라우저 응답에 실린다. 종류와 token 노출이 별개라는 근거다.
- generic [aria-hidden] [ref=f6e560]:
- listitem [ref=f6e561]:
- link "적용 기준 OAuth Token과 Application Session을 구분하는 기준" [ref=f6e562] [cursor=pointer]:
- /url: /references/oauth-token-application-session-boundary
- generic [ref=f6e563]: 적용 기준
- generic [ref=f6e564]:
- strong [ref=f6e565]: OAuth Token과 Application Session을 구분하는 기준
- paragraph [aria-hidden] [ref=f6e566]: access token 원문이 응답 본문과 지역 변수와 헤더를 지난다. 상태별 이름을 나눠야 하는 이유다.
- generic [aria-hidden] [ref=f6e567]:
- listitem [ref=f6e568]:
- link "적용 기준 OAuth/OIDC 인증 패턴 선택 기준" [ref=f6e569] [cursor=pointer]:
- /url: /references/oauth-oidc-pattern-selection-criteria
- generic [ref=f6e570]: 적용 기준
- generic [ref=f6e571]:
- strong [ref=f6e572]: OAuth/OIDC 인증 패턴 선택 기준
- paragraph [aria-hidden] [ref=f6e573]: refresh만 옮기고 access 노출과 server state를 함께 지는 경우다. 선택 기준의 한 칸이다.
- generic [aria-hidden] [ref=f6e574]:
- listitem [ref=f6e575]:
- link "열린 질문 Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" [ref=f6e576] [cursor=pointer]:
- /url: /questions/refresh-rotation-replica-contention
- generic [ref=f6e577]: 열린 질문
- generic [ref=f6e578]:
- strong [ref=f6e579]: Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가
- paragraph [aria-hidden] [ref=f6e580]: refresh token rotation과 재사용 0회를 쓰는 구성이다. replica 경쟁 질문의 전제다.
- generic [aria-hidden] [ref=f6e581]:
- complementary [ref=f6e582]:
- heading "작업 상태" [level=2] [ref=f6e583]
- status "편집 상태" [ref=f6e584]: 저장됨
- generic [ref=f6e585]:
- generic [ref=f6e586]:
- term [ref=f6e587]: 저장 버전
- definition [ref=f6e588]: "25"
- generic [ref=f6e589]:
- term [ref=f6e590]: 종류
- definition [ref=f6e591]: 검증 기록
- paragraph [ref=f6e592]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.
- generic [ref=f6e593]:
- button "저장" [disabled] [ref=f6e594]
- button "게시" [ref=f6e595]
- paragraph [ref=f6e596]: 버전 25으로 저장했습니다.