Files
document-haness/.playwright-mcp/_p2.mjs
T

54 lines
30 KiB
JavaScript

async (page) => {
const DOCS = [{"id": "8c1ebea7-204e-445c-9812-0421d9eb0e9c", "name": "decision-federation-not-a-pattern", "jobs": [["요약", "Google은 upstream IdP, Keycloak은 애플리케이션이 신뢰하는 issuer이자 broker, 네 구조는 애플리케이션 credential 경계다. 세 층을 분리해서 적고 소셜 로그인 추가를 인증 구조 변경으로 세지 않는다."], ["결정문", "외부 IdP federation을 다섯 번째 인증 구조로 세지 않는다.\n\nGoogle은 upstream IdP, Keycloak은 애플리케이션이 신뢰하는 issuer이자 broker, 네 구조는 애플리케이션 credential 경계로 각각 분리해 적는다."], ["판단 이유", "Google을 구조 하나로 세게 되면 upstream IdP 경계와 애플리케이션 OAuth 경계를 같은 기준으로 묶게 되는데, 두 경계는 검증 방법이 서로 다르다.\n\n사용자가 Keycloak 로그인 화면에서 Google을 고르면 브라우저가 upstream authorization을 수행하게 되고, 브로커가 그 응답을 검증해 local identity와 연결한 뒤 다시 자기가 만든 authorization code를 애플리케이션으로 보내게 된다. 그래서 code 교환부터 뒤의 흐름은 외부 IdP가 없을 때와 똑같아진다.\n\nResource Server가 검증하는 issuer도 브로커이고 애플리케이션은 Google token을 받지 않기 때문에, 소셜 로그인을 붙여도 브라우저가 token을 받는지와 어느 계층이 API를 부르는지는 하나도 바뀌지 않는다.\n\n두 경계를 섞어 두게 되면 비교표에 성격이 다른 항목이 끼어들고, 계정 연결 규칙도 인증 구조 이야기에 섞여서 따로 설계하지 않고 넘어가게 된다."], ["영향 1", "Google을 추가해도 애플리케이션이 검증하는 issuer는 Keycloak으로 유지한다. 네 구조의 credential 배치 기준은 바뀌지 않는다."], ["영향 2", "계정 연결을 별도 문제로 다뤄야 하고, provider와 upstream subject의 조합을 열쇠로 쓰면서 email이 같다고 자동 병합하지 않는다."], ["영향 3", "검증 범위를 두 겹으로 적어야 해서 mock provider로 확인한 broker·claim mapping 계약과 실제 계정·공개 HTTPS callback·consent를 구분하게 된다."], ["영향 4", "upstream IdP가 늘면 브로커 설정이 늘어나게 되어서 그 설정의 소유자를 애플리케이션 팀과 따로 정해야 한다."], ["근거 1 이유", "이 결정을 규칙으로 편 기준이다."], ["근거 2 이유", "브로커가 발급한 code를 받는 애플리케이션 경계다."], ["근거 3 이유", "upstream IdP 상태와 애플리케이션 상태를 같은 이름으로 부르지 않는다."]], "groups": {"영향": 4}}, {"id": "5f4b6000-cb78-400c-bf6e-a25632a4bb40", "name": "decision-not-maturity-ladder", "jobs": [["요약", "브라우저에 token이 덜 보이는 순서는 있다. 그렇다고 뒤로 갈수록 더 안전해지지는 않는다. 네 구조를 credential과 상태의 책임을 서로 다른 계층에 배치하는 독립적인 아키텍처 패턴으로 취급한다."], ["결정문", "SPA에서 Mediator, BFF, OAuth2-Proxy로 가는 순서를 낮은 보안에서 높은 보안으로 가는 단계로 모델링하지 않는다.\n\n네 구조를 credential과 상태의 책임을 서로 다른 계층에 배치하는 독립적인 아키텍처 패턴으로 취급한다."], ["판단 이유", "BFF는 브라우저 token을 없애지만 server session과 CSRF, 공유 저장소를 만든다. Forward-Auth는 애플리케이션의 token custody를 줄이지만 edge 헤더 신뢰와 network 경계를 만든다. 뒤 구조가 앞 구조의 문제를 없애는 것이 아니라 다른 곳에 다른 요구를 만든다.\n\n그래서 네 구조를 실행해 봐도 보안 수준으로는 갈리지 않았다. 갈린 것은 배치다. code를 token으로 바꾸는 곳이 브라우저인지 Mediator인지 BFF인지 oauth2-proxy인지, token을 들고 있는 곳이 JavaScript memory인지 authorized client인지 최소 client-side cookie인지, API를 부르는 곳이 브라우저인지 BFF인지 Nginx인지에 따라 구조가 달라졌다.\n\n성숙도 모델로 두면 「일단 제일 뒤 구조로 가자」는 판단이 나온다. backend 직접 경로를 닫을 수 없는 환경에서 edge에 인증을 맡기면 upstream이 헤더 하나로 사용자를 판단하는데 그 헤더를 누구나 만들어 보낼 수 있다. 그런 환경에서는 브라우저가 token을 직접 들고 서명을 검증받는 구조가 낫다."], ["영향 1", "비교할 때 없앤 것과 새로 맡은 것, 잘 맞는 조건과 피해야 할 조건을 같이 적는다. 한쪽만 적으면 다시 성숙도 모델이 된다."], ["영향 2", "구조를 고를 때 번호가 아니라 code 교환·token 보관·API 호출의 배치를 먼저 답한다. 뒤 구조에서 앞 구조로 되돌아가는 선택도 후퇴가 아니라 credential 계약의 변경으로 적는다."], ["영향 3", "구조 이름만으로 운영 속성을 추정하지 않는다. 공유 저장소와 장애 복구, secret 교체는 매번 따로 확인한다."], ["근거 1 이유", "브라우저가 세 가지를 모두 맡는 배치다."], ["근거 2 이유", "refresh만 옮기고 두 비용을 함께 지는 배치다."], ["근거 3 이유", "token 비노출과 server state를 맞바꾼 배치다."], ["근거 4 이유", "인증을 edge로 옮긴 배치다."], ["근거 5 이유", "이 결정을 적용하는 선택 기준이다."]], "groups": {"영향": 3}}, {"id": "18a5cde2-dd1e-4bff-9f1c-997577ae438f", "name": "question-bff-state-store", "jobs": [["요약", "session과 authorized client는 찾는 열쇠가 달라서 같은 저장소에 두는 것이 당연하지 않다. 저장소 후보는 Redis 쪽으로 기울어 있지만 token 암호화와 만료 정합, logout 정리를 확인하지 않았다."], ["다음 검증", "후보마다 같은 입력으로 재서 비교한다.\n\n1. 인스턴스 두 대에서 로그인 유지와 재시작 복구가 되는지 본다.\n2. 저장소를 직접 열어 refresh token이 평문으로 남는지 확인한다.\n3. session TTL과 token 만료를 어긋나게 두고 그 순간의 응답과 화면을 기록한다.\n4. logout 뒤 두 store에 잔여 항목이 없는지 확인한다.\n5. 저장소를 끊은 상태에서 로그인과 API 호출이 어떤 오류를 내는지 본다.\n\n암호화 key 교체 절차는 후보를 고른 뒤에 따로 설계한다."], ["선택지 1 제목", "유력 후보 — session과 authorized client를 모두 Redis에 둔다"], ["선택지 1 설명", "Spring Session Redis와 Redis authorized-client repository를 쓰게 되면 만료를 store가 관리해 주고 인스턴스를 늘리기도 쉬워진다.\n\n대신 Redis가 인증 경로의 단일 장애 지점이 된다. access token과 refresh token을 여기 저장하면 저장소가 유출됐을 때 credential이 그대로 노출되므로, 암호화 여부와 key 관리 방식을 따로 정해야 한다."], ["선택지 2 제목", "session과 authorized client를 모두 JDBC에 둔다"], ["선택지 2 설명", "이미 운영 중인 DB를 쓴다. 백업과 감사 절차가 그 DB에 이미 있다면 그만큼 새로 만들 것이 줄어든다.\n\n대신 요청마다 DB를 조회하게 되고 만료된 행을 지우는 작업도 직접 돌려야 해서, session 조회가 화면 응답 시간에 그대로 실리게 된다."], ["선택지 3 제목", "변경이 가장 작은 안 — session만 공유하고 sticky session을 쓴다"], ["선택지 3 설명", "Spring Session만 붙이면 되어서 변경이 가장 적다.\n\n대신 authorized client가 여전히 process 안에 있게 되어서, 인스턴스가 바뀌면 session은 찾는데 token이 없는 상태가 된다. 로그인은 돼 있는데 API만 실패하게 된다."], ["선택지 4 제목", "session은 Redis, token은 암호화한 JDBC에 둔다"], ["선택지 4 설명", "요청마다 읽는 session은 빠른 저장소에 두고 오래 보관하면서 암호화가 필요한 token은 DB에 두게 되어서 접근 패턴에 맞다.\n\n대신 두 저장소의 만료를 서로 맞춰야 하고 logout이 두 곳을 함께 지워야 하며, 운영해야 할 저장소가 하나 늘어나게 된다."], ["사실 1", "현재 구성에 Spring Session과 Redis, JDBC repository, 암호화 token store가 없다."], ["사실 2", "HttpSession은 servlet container의 in-memory 구현이라서 컨테이너가 내려가면 같이 사라지게 된다."], ["사실 3", "OAuth2AuthorizedClientService도 자동구성이 고르는 in-memory 구현이고 코드가 직접 선언하지 않는다."], ["사실 4", "두 저장소의 열쇠가 달라서 session은 session ID로 찾고 authorized client는 registration 이름과 principal name으로 찾게 되고, 그래서 하나를 옮긴다고 다른 하나가 따라오지 않는다."], ["사실 5", "authorized client manager에 authorization-code와 refresh-token provider가 함께 구성돼 있어서, 저장소를 공유하게 되면 여러 인스턴스가 같은 항목을 동시에 갱신할 수 있게 된다."], ["가정 1", "두 상태를 같은 저장소에 둘 필요는 없다."], ["가정 2", "저장된 refresh token을 평문으로 두면 안 된다."], ["가정 3", "session 만료와 token 만료 중 하나가 먼저 오게 되면 그 순간의 동작이 정의돼 있어야 한다."], ["미지수 1", "Redis와 JDBC 중 무엇이 이 상태의 접근 패턴에 맞는가. 요청마다 읽는 값과 가끔 읽는 값이 섞여 있다."], ["미지수 2", "session과 authorized client를 같은 store에 둘지 나눌지."], ["미지수 3", "암호화 key를 어디에 두고 어떻게 교체하게 되는가. 교체하는 동안 이전 key로 저장된 값은 어떻게 읽는가."], ["미지수 4", "session TTL과 refresh token 수명 중 어느 것을 기준으로 만료를 맞추게 되는가."], ["미지수 5", "열쇠가 다른 두 store를 logout에서 어떻게 한 번에 지우게 되는가."], ["미지수 6", "sticky session이 durable store의 대안이 되는가 보완이 되는가."], ["제약 1", "authorized client의 열쇠에 session ID가 없어서 session만 공유해도 같은 사용자의 여러 session이 같은 token 항목을 보게 된다."], ["제약 2", "커밋된 테스트에 저장소 관련 계약이 없어서 어느 후보를 골라도 지금은 회귀를 잡아 줄 검사가 없다."], ["제약 3", "모든 UI 요청이 BFF를 지나기 때문에 저장소 지연이 화면 지연으로 바로 드러나게 된다."], ["관계 1 이유", "이 질문에서 저장소 부분만 떼어 낸 것이다."], ["관계 2 이유", "session과 authorized client의 열쇠가 다르다는 사실의 출처다."], ["관계 3 이유", "이 기준의 저장소 항목이 이 질문의 답을 기다린다."], ["관계 4 이유", "저장소를 공유한 뒤에야 replica 경쟁이 재현된다."]], "groups": {"선택지": 4, "사실": 5, "가정": 3, "미지수": 6, "제약": 3}}, {"id": "7ff40767-a00b-4db2-98f6-0cdfce8c8936", "name": "question-edge-authorization-scope", "jobs": [["요약", "지금 edge는 user와 email만 전달하고 upstream은 role 판단을 하지 않는다. 다음 요구가 들어왔을 때 role까지 헤더로 보낼지, 아니면 인가를 애플리케이션으로 되돌릴지 정하지 않았다."], ["다음 검증", "upstream이 실제로 요구하는 claim을 먼저 적는다. 그 목록을 놓고 아래를 본다.\n\n1. 전달하려는 claim이 계속 늘어나는가.\n2. role이나 tenant 변경이 즉시 반영돼야 하는가.\n3. 정책이 애플리케이션 도메인을 알아야 하는가.\n4. 헤더 값이 인가 판단의 근거가 되는가.\n5. 서비스별 정책 차이가 커지는가.\n\n2번부터 5번 중 하나라도 그렇다면 헤더를 늘리는 방향이 아니라 되돌리는 방향을 본다.\n\nrole을 헤더로 실은 구성을 먼저 만들어 다중 값과 크기 상한을 넣고 무엇이 먼저 깨지는지 확인한다. role을 바꾼 뒤 몇 번째 요청부터 반영되는지도 잰다."], ["선택지 1 제목", "현재 — 인증만 edge에 둔다"], ["선택지 1 설명", "헤더가 user와 email 둘로 고정돼 있어서 계약이 가장 작고 크기 상한 문제도 생기지 않는다. 인가는 upstream이 자기 저장소로 해결한다. 서비스마다 권한 조회를 따로 붙여야 한다."], ["선택지 2 제목", "다음 후보 — role 전달까지 edge에 둔다"], ["선택지 2 설명", "공통 role을 한 곳에서 주면 서비스마다 권한을 조회하지 않아도 된다. 이 선택을 하면 다중 값 직렬화와 크기 상한, 갱신 시점 계약을 먼저 정해야 한다. upstream은 그 값을 검증할 수단이 없어서 edge가 틀리면 그대로 틀린다."], ["선택지 3 제목", "보류 — tenant와 인가 판단까지 edge에 둔다"], ["선택지 3 설명", "tenant는 잘못 들어간 값 하나가 다른 조직의 데이터를 그대로 열어 준다. 이 값만은 upstream이 다시 확인할 수단을 함께 설계해야 해서 지금 구성으로는 감당할 수 없다. 인가 판단까지 옮기면 edge가 애플리케이션 도메인을 알아야 하고 정책이 바뀔 때마다 edge를 배포하게 된다."], ["선택지 4 제목", "경계가 커지면 — BFF로 되돌린다"], ["선택지 4 설명", "헤더를 늘리는 대신 API 조합과 인가를 애플리케이션에 돌려준다. 애플리케이션이 필요한 값을 스스로 조회하므로 헤더 계약이 사라진다. session과 CSRF, 공유 저장소는 다시 필요해진다."], ["사실 1", "지금 edge 응답은 user와 email만 전달한다. role과 groups, tenant, 인증 방식, token 만료는 전달하지 않는다."], ["사실 2", "upstream의 identity endpoint는 role 판단을 하지 않고 누가 왔는지만 응답에 담는다."], ["사실 3", "internal token 검사가 controller 한 곳에 있고 security 설정은 그 경로 전체를 permitAll로 둔다. 새 endpoint에는 보호가 따라오지 않는다."], ["사실 4", "Nginx는 client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다. 늘리는 헤더도 같은 처리를 받아야 한다."], ["사실 5", "upstream은 JWT를 입력으로 받지 않아서 헤더로 온 값을 스스로 검증할 수단이 없다."], ["가정 1", "헤더 종류가 늘어나면 정해야 할 계약도 함께 늘어난다."], ["가정 2", "role이 바뀌는 시점과 요청이 오는 시점이 달라서 그 사이에 들어온 요청은 옛 값을 본다."], ["미지수 1", "다중 값 role을 어떤 구분자와 escaping으로 보낼지. 값 안에 그 구분자가 들어오면 어떻게 되는지."], ["미지수 2", "헤더 크기 상한을 넘으면 무엇이 먼저 깨지는지. proxy가 자르는지 요청 자체가 거부되는지."], ["미지수 3", "role이 바뀌었을 때 proxy session과 downstream 인가가 언제 따라가는지. 권한 회수가 몇 분 뒤에 반영되는지."], ["미지수 4", "upstream이 헤더 존재만 볼지 값과 service identity까지 볼지."], ["제약 1", "전달할 헤더는 allowlist로 고정해야 하고 client가 보낸 동명 헤더는 언제나 덮어써야 한다."], ["제약 2", "internal token 검사가 controller 한 곳에만 있다. 헤더를 늘리기 전에 이 검사를 공통 경계로 옮기는 것이 먼저다."], ["제약 3", "upstream을 고칠 수 없어서 이 구조를 골랐다면 BFF로 되돌리는 선택지는 없다."], ["관계 1 이유", "edge가 user와 email만 전달한다는 사실의 출처다."], ["관계 2 이유", "헤더 allowlist와 검증 조건이 이 기준에 있다."], ["관계 3 이유", "되돌리는 선택지의 기준이 이 문서다."]], "groups": {"선택지": 4, "사실": 5, "가정": 2, "미지수": 4, "제약": 3}}, {"id": "c72656b5-842d-45d9-b5f6-82b66b09d0b9", "name": "question-multi-instance-session", "jobs": [["요약", "Mediator와 BFF는 로그인 상태와 token 상태를 열쇠가 다른 두 저장소에 나눠 두게 되는데 지금은 두 상태가 다 process 안에 있다. 인스턴스가 둘 이상인 운영에서 재시작과 이동, logout이 어떻게 동작해야 하는지 아직 정하지 않았다."], ["다음 검증", "인스턴스를 둘로 띄우고 순서대로 확인한다.\n\n1. 한쪽에서 로그인한 뒤 다른 인스턴스로 요청을 보내 200이 유지되는지 본다.\n2. 한 인스턴스를 재시작하고 같은 session cookie로 로그인 상태가 남는지 본다.\n3. 같은 사용자로 두 브라우저에서 로그인해 authorized client 항목이 서로를 덮어쓰는지 본다.\n4. 한쪽에서 logout한 뒤 다른 쪽 요청이 어떻게 되는지 본다.\n5. session 만료를 token 만료보다 짧게, 다시 길게 두고 각 경우의 응답과 화면을 기록한다.\n\n여기서 무엇이 깨지는지가 갈리게 되면 저장소 후보 비교로 넘어간다."], ["선택지 1 제목", "공유 durable store로 옮긴다"], ["선택지 1 설명", "HttpSession과 authorized client를 모두 외부 store에 두게 되면 인스턴스가 늘어도 같은 상태를 찾고 재시작도 견디게 된다.\n\n대신 그 store가 인증 경로의 단일 장애 지점이 되어서 store가 끊기면 로그인도 API 호출도 함께 멈추게 되고, 직렬화 형식과 암호화, 만료 정합을 새로 설계하면서 그 만료를 IdP가 준 token 수명과도 맞춰야 한다."], ["선택지 2 제목", "session affinity로 묶는다"], ["선택지 2 설명", "같은 사용자를 같은 인스턴스로 보내게 되어서 코드를 거의 안 고쳐도 되고 저장소도 늘지 않는다.\n\n대신 그 인스턴스가 내려가면 그 사용자만 로그인이 끊기게 되고 배포할 때마다 전원이 끊기는 것도 그대로라서, 오토스케일링으로 인스턴스가 자주 바뀌는 환경에서는 사실상 매번 끊기게 된다."], ["선택지 3 제목", "브라우저가 token을 들고 API를 직접 부르게 되돌린다"], ["선택지 3 설명", "server에 상태를 두지 않게 되어서 공유 저장소도 affinity도 필요 없어지고 Resource Server는 요청마다 서명만 검증한다.\n\n대신 브라우저에 token이 노출되는 것을 받아들여야 하기 때문에, 정책상 브라우저 token이 금지라면 이 선택지는 처음부터 없다."], ["선택지 4 제목", "저장소 선택이 아니라 구조 변경 — 최소 정보만 담은 client-side cookie"], ["선택지 4 설명", "이것은 저장소를 바꾸는 선택이 아니다. server-side store를 없애고 인증 상태를 cookie 자체에 담는 구조 변경이라서 앞의 세 후보와 같은 층에 놓고 비교할 수 없다.\n\n확장은 가장 단순해진다. 대신 cookie secret을 replica에 어떻게 배포하고 교체할지가 새 문제가 되고, upstream이 JWT를 받지 못하고 edge가 붙인 값을 믿게 되므로 그 신뢰 조건을 따로 갖춰야 한다."], ["사실 1", "Mediator와 BFF는 로그인 상태를 HttpSession에 두고 token은 OAuth2AuthorizedClientService에 두게 되는데, 두 저장소는 열쇠가 다르다. session은 session ID로 찾고 authorized client는 registration 이름과 principal name으로 찾는다."], ["사실 2", "두 저장소 모두 Spring Boot 자동구성이 고르는 in-memory 구현이라서 코드가 직접 선언하지 않고, 그래서 설정 파일만 봐서는 드러나지 않는다."], ["사실 3", "Spring Session과 Redis, JDBC token store 의존성이 없어서 두 상태가 모두 process 안에 있다. 그 instance가 종료되면 그 instance가 들고 있던 session과 authorized client는 사라진다."], ["사실 4", "authorized client의 열쇠에 session ID가 없기 때문에 같은 사용자가 두 브라우저에서 로그인하면 같은 항목을 보게 된다."], ["사실 5", "OAuth2-Proxy 구조는 server-side session store를 두지 않고 최소 정보만 담은 client-side cookie를 쓰게 되며, cookie 만료는 proxy 설정의 1 hour다."], ["사실 6", "커밋된 테스트에 재시작이나 replica 이동 뒤 복구 계약이 없어서 지금 무엇을 바꿔도 회귀를 잡아 줄 검사가 없다."], ["가정 1", "운영에서는 인스턴스가 둘 이상이다."], ["가정 2", "재시작과 배포가 로그인 상태를 끊어서는 안 되는데, 지금 구조에서는 끊기게 된다."], ["가정 3", "같은 사용자의 여러 브라우저 session이 서로의 token 항목을 덮어써서는 안 된다."], ["미지수 1", "재시작 뒤 로그인이 유지되는가. 지금은 안 된다는 것까지 알지만 무엇을 바꿔야 되는지는 정하지 않았다."], ["미지수 2", "인스턴스가 바뀌어도 같은 session을 찾게 되는가."], ["미지수 3", "같은 사용자의 여러 session이 authorized client 항목을 공유하거나 덮어쓰게 되는가. 한쪽에서 로그아웃하면 다른 쪽도 끊기게 되는가."], ["미지수 4", "저장된 refresh token이 암호화되는가. 저장소를 여는 사람이 그 값을 그대로 읽게 되는가."], ["미지수 5", "logout이 열쇠가 다른 두 상태를 함께 지우게 되는가. 한쪽만 지우면 다음 로그인에서 남은 쪽으로 복구되는가."], ["미지수 6", "session 만료와 token 만료가 어긋나면 무엇이 먼저 실패하고 사용자 화면에는 어떻게 보이게 되는가."], ["미지수 7", "OAuth2-Proxy 구조의 replica들이 같은 cookie secret을 어떻게 공유하고 교체하게 되는가. 교체하는 동안 로그인해 있던 사람은 어떻게 되는가."], ["제약 1", "현재 예제는 한 대에서 실행하는 학습 환경이라서 인스턴스가 하나이고, 그래서 이 문제가 아직 드러나지 않는다."], ["제약 2", "authorized client의 열쇠에 session ID가 없기 때문에 session만 공유 저장소로 옮겨도 token 항목은 사용자 단위로 남게 되고, 결국 두 저장소를 따로 정해야 한다."], ["제약 3", "Resource Server의 8081이 host에도 열려 있어서 모든 client가 BFF만 거치도록 network에서 강제된 상태가 아니다."], ["관계 1 이유", "두 상태가 모두 process-local memory에 있다는 사실의 출처다."], ["관계 2 이유", "같은 저장소 구성을 쓰는 다른 패턴이다."], ["관계 3 이유", "이 질문의 답이 이 기준의 빈 항목을 채운다."], ["관계 4 이유", "저장소 후보 비교로 독립시킨 질문이다."]], "groups": {"선택지": 4, "사실": 6, "가정": 3, "미지수": 7, "제약": 3}}, {"id": "9ae4ec71-a32e-49a7-88c2-f7368541c28d", "name": "question-refresh-rotation-replica", "jobs": [["요약", "realm이 refresh token rotation과 재사용 허용 0회를 쓴다. 두 replica가 같은 refresh token으로 동시에 갱신할 수 있고, 그때 두 번째 사용이 거부될 가능성이 있다. 실제 Keycloak 응답과 session 영향은 아직 재현하지 않았다."], ["다음 검증", "저장소를 공유한 뒤에 재현한다.\n\n1. replica 두 대에서 같은 사용자로 access token 만료 직후 동시에 요청을 보낸다.\n2. 이긴 쪽과 지는 쪽의 응답을 각각 기록한다.\n3. 지는 쪽이 저장된 새 token으로 재시도해 성공하는지 본다.\n4. 지는 쪽 사용자 화면에 무엇이 보이는지 기록한다.\n5. lock을 넣은 구성과 안 넣은 구성을 같은 입력으로 비교해 실패율과 지연을 잰다.\n\n실패가 사용자에게 노출되면 lock을 고르고, 노출되지 않으면 재시도로 둔다."], ["선택지 1 제목", "분산 lock으로 갱신을 직렬화한다"], ["선택지 1 설명", "한 replica만 갱신하고 나머지는 끝나기를 기다렸다가 결과를 읽게 되어서 재사용 거부가 아예 생기지 않는다.\n\n대신 lock 저장소가 필요하고 만료와 재진입을 처리해야 하며, lock을 잡은 채로 프로세스가 내려가는 경우까지 다뤄야 해서 lock 자체가 새 장애 지점이 된다."], ["선택지 2 제목", "각자 갱신하고 실패는 재시도로 처리한다"], ["선택지 2 설명", "구현이 가장 단순하다. 지는 쪽이 거부를 받으면 저장소에서 최신 token을 다시 읽어 재시도한다는 전제인데, 이 재시도가 성립하는지는 아직 확인하지 않았다.\n\nreuse detection 정책에 따라 두 번째 사용이 그 token family 전체를 무효로 만들 수도 있다. 그러면 재시도가 아니라 재인증이 된다. 이긴 쪽이 저장을 마치기 전에 지는 쪽이 다시 읽으면 또 실패하는 구간도 남는다."], ["선택지 3 제목", "갱신 전용 경로를 하나 둔다"], ["선택지 3 설명", "갱신을 맡는 구성요소를 따로 두고 나머지는 조회만 하게 되어서 경쟁이 구조적으로 사라진다.\n\n대신 그 구성요소가 멈추면 아무도 갱신하지 못하게 되고, 모든 사용자의 로그인이 access token 수명만큼 뒤에 한꺼번에 끊기게 된다."], ["선택지 4 제목", "제약상 제외 — 재사용 허용을 늘린다"], ["선택지 4 설명", "짧은 유예를 주면 경쟁이 저절로 해소되고 코드도 고칠 필요가 없다. 다만 rotation과 재사용 0회는 이 질문이 바꾸지 않기로 한 realm 설정이다. 훔친 refresh token을 쓸 수 있는 창도 같이 늘어난다.\n\n비교 대상으로만 남긴다."], ["사실 1", "realm은 refresh token rotation과 재사용 허용 0회를 쓰게 되어서, 한 번 갱신하면 이전 refresh token은 바로 무효가 된다."], ["사실 2", "커밋된 테스트는 새 refresh token 발급과 이전 token 거부, revocation 뒤 refresh 실패를 확인하는데 모두 한 주체가 순서대로 부르는 경우다."], ["사실 3", "authorized client manager에 refresh-token provider가 구성돼 있어서 만료를 만나면 갱신을 시도할 자리는 있다."], ["사실 4", "다만 만료를 기다려 실제 갱신이 성공하고 새 token이 저장되는지까지는 확인하지 않았다."], ["사실 5", "현재 저장소가 process 안에 있어서 replica마다 자기 token을 따로 들고 있게 되고, 그래서 경쟁이 아직 재현되지 않는다."], ["사실 6", "이미 발급된 access token은 만료 전까지 API에서 계속 통하기 때문에, 갱신이 실패해도 그동안은 화면이 정상으로 보이게 된다."], ["가정 1", "운영에서는 replica가 둘 이상이고 저장소를 공유해 같은 authorized client 항목을 보게 된다."], ["가정 2", "두 replica가 비슷한 시각에 만료를 만나면 각각 갱신을 시도하게 된다."], ["미지수 1", "같은 refresh token으로 두 replica가 동시에 갱신하면 어느 쪽이 이기고 지는 쪽은 무엇을 받게 되는가."], ["미지수 2", "재사용 허용 0회에서 지는 쪽의 요청이 사용자 화면에 어떻게 보이게 되는가. 로그인 만료로 보이는가 일시적 오류로 보이는가."], ["미지수 3", "지는 쪽이 저장소에서 새 token을 다시 읽어 재시도하면 성공하게 되는가, 아니면 재인증이 필요해지는가."], ["미지수 4", "갱신을 한 곳에서만 할 것인가, 각자 하게 두고 실패는 재시도로 처리할 것인가."], ["미지수 5", "lock을 쓴다면 어디에 두고 얼마나 잡게 되는가. 잡은 채로 프로세스가 내려가면 어떻게 푸는가."], ["미지수 6", "갱신 실패를 로그인 만료와 구분해서 표시할 수 있게 되는가."], ["제약 1", "rotation과 재사용 0회는 이미 realm 설정이라서 이 전제를 바꾸지 않고 답해야 한다."], ["제약 2", "이미 발급된 access token이 만료 전까지 유효해서 갱신 실패가 즉시 드러나지 않게 되고, 그래서 관측 시점을 access token 만료 직후로 따로 잡아야 한다."], ["제약 3", "이 경쟁은 저장소를 공유한 뒤에야 재현되기 때문에 저장소 결정이 이 질문보다 앞서게 된다."], ["관계 1 이유", "저장소 결정이 이 질문보다 앞선다."], ["관계 2 이유", "rotation과 재사용 0회를 쓰는 구성의 출처다."], ["관계 3 이유", "다중 인스턴스 운영이 이 경쟁의 전제다."], ["관계 4 이유", "갱신 실패를 화면 오류로 바꾸는 규칙이 이 기준의 항목이다."]], "groups": {"선택지": 4, "사실": 6, "가정": 2, "미지수": 6, "제약": 3}}];
const RAIL = 'aside[class*="studio-document-status"]';
const out = [];
for (const d of DOCS) {
const rec = { name: d.name, filled: [], skipped: 0, miss: [], rows: [] };
await page.goto('https://hyeonworks.com/studio/documents/' + d.id + '/edit');
try { await page.waitForSelector(RAIL, { timeout: 25000 }); }
catch { rec.error = 'AUTH? ' + page.url(); out.push(rec); return out; }
await page.waitForTimeout(400);
rec.version = await page.locator(RAIL + ' dd').first().innerText();
for (const [legend, want] of Object.entries(d.groups)) {
const fs = page.locator('xpath=//fieldset[./legend[normalize-space(.)="' + legend + '"]]').first();
let cur = await fs.locator('.studio-ordered-item').count();
const from = cur;
while (cur > want) {
await fs.locator('.studio-ordered-item').last().getByRole('button', { name: '삭제' }).click();
await page.waitForTimeout(60); cur--;
}
while (cur < want) {
await fs.getByRole('button', { name: legend + ' 추가' }).click();
await page.waitForTimeout(60); cur++;
}
if (from !== want) rec.rows.push(legend + ' ' + from + '→' + want);
}
for (const [label, wantv] of d.jobs) {
const loc = page.locator('xpath=//label[./span[normalize-space(.)="' + label + '"]]')
.locator('textarea, input').first();
const n = await loc.count().catch(() => 0);
if (n !== 1) { rec.miss.push(label); continue; }
if ((await loc.inputValue()) === wantv) { rec.skipped++; continue; }
await loc.fill(wantv);
rec.filled.push(label);
}
if (rec.filled.length === 0 && rec.rows.length === 0) { rec.saved = 'CLEAN'; out.push(rec); continue; }
await page.locator(RAIL).getByRole('button', { name: '저장' }).first().click();
try {
await page.waitForFunction(() => {
const el = document.querySelector('aside[class*="studio-document-status"]');
return el && /저장됨/.test(el.innerText);
}, null, { timeout: 30000 });
rec.saved = 'OK v' + (await page.locator(RAIL + ' dd').first().innerText());
} catch {
rec.saved = 'FAIL ' + (await page.locator(RAIL).innerText()).replace(/\s+/g, ' ').slice(0, 140);
}
out.push({ name: rec.name, version: rec.version, saved: rec.saved,
filled: rec.filled.length, rows: rec.rows, miss: rec.miss });
}
return out;
}