Repository navigation
fix(frontend): refresh 토큰 회전을 탭 사이에서도 직렬화 - #259
Merged
Merged
Conversation
프론트엔드 전 페이지를 배포본에서 훑다가 찾았다. 하드 로드마다
`POST /api/auth/refresh` 가 정확히 한 번씩 나간다(액세스 토큰을 메모리에만 두는
설계라 정상이다). 문제는 그 다음이다.
서버의 RefreshTokenService.rotate 는 **유예 없이** 기존 토큰을 revoke 하고 새
토큰을 발급한다. client.ts 의 단일 비행(`refreshing`)은 모듈 변수라 **한 탭
안에서만** 듣는데, 리프레시 쿠키는 탭이 공유한다.
탭A: rotate(T1) → T1 revoke, T2 발급, 쿠키 갱신
탭B: rotate(T1) → isRevoked → AUTH_REVOKED_TOKEN(401)
→ isAuthRefreshFailure → tokenStore.clear() + onUnauthorized()
→ 탭B만 로그아웃
창 복원이나 새 탭으로 열기로 두 탭이 동시에 부팅하면 일어난다. 진행 중인 면접
탭이 지면 사용자는 이유 없이 튕긴 것으로 본다.
Web Locks 로 origin 전체에서 한 번에 하나만 돌게 한다. 기다린 탭은 이긴 탭이
갱신해 둔 쿠키로 다시 회전하므로(T2→T3) 정상 동작한다 — 회전이 한 번 더
일어날 뿐이다. 락이 없는 환경(구형 Safari·비보안 컨텍스트)에서는 기존과 같다.
**운영에서 아직 발생한 적은 없다.** 리프레시 토큰 발급 51건의 연속 간격을 보니
1초 이내가 0건이고 최단이 3.86초였다. 예방 목적이다.
서버에 짧은 유예(회전된 토큰을 몇 초간 재사용 허용)를 두는 방법도 있지만,
토큰 재사용 탐지를 약화시키는 변경이라 실제 관측 근거가 생기기 전에는 두지
않는다. 지금 문제는 브라우저 탭 동시성이고 그건 브라우저에서 막는 게 정확하다.
lib.dom 의 LockManager.request 는 콜백의 반환값을 그대로 결과 타입으로 잡는다. 프라미스를 돌려주면 Promise<Promise<T>> 로 추론되는데, 플랫폼은 이를 평탄화해 T 로 resolve 한다. 런타임 동작은 맞고 타입만 어긋난 것이라 캐스트로 좁힌다. error TS2322: Type 'Promise<Promise<T>>' is not assignable to type 'Promise<T>' 로컬에서 `npx tsc --noEmit` 로 확인해 놓쳤다. 이 레포의 타입 체크는 `npm run type-check` = `tsc -b` 다(프로젝트 레퍼런스). 이후로는 CI 와 같은 명령으로 확인한다.
i3months
added a commit
to i3months/stackup
that referenced
this pull request
Oct 6, 2026
Team-StackUp#260 을 배포하고 cron 환경(env -i)으로 실행해 보니 판정은 UP 인데 종료 코드가 1 이었다. [ -n "$previous" ] && ops_notify ok ... 첫 실행에는 previous 가 비어 있어 && 가 단락되고, 그 마지막 상태가 그대로 스크립트 종료 코드가 된다. 감시 도구가 정상 상태에서 실패 코드를 내면 "이 cron 은 원래 그래" 가 되고, 진짜 실패도 같이 무시된다. if 문으로 바꾸고 끝에 명시적 exit 0 을 둔다. ## 자가 테스트를 레포로 이 스크립트들을 만들면서 실제로 두 번 깨졌고 **둘 다 조용한 실패**였다: 1. 임베디드 파이썬 따옴표 이스케이프가 깨져 판정이 빈 값 → 감시가 그냥 통과 2. 위의 exit 1 → cron 이 정상을 실패로 보고 셸은 컴파일러도 타입체커도 없고, 이 둘은 돌려봐도 눈에 안 띈다. 그래서 검증을 스크래치패드에 두지 않고 infra/ops/selftest.sh 로 옮겼다. 14개 케이스를 로컬 수신기로만 확인한다 — **팀 채널로 아무것도 보내지 않는다.** notify.sh 따옴표·백슬래시·줄바꿈 → JSON 무손상 / 웹훅 없으면 무발송 / 죽은 엔드포인트가 호출자를 죽이지 않음 health-check.sh 첫 실행 정상(exit 0, 무발송) / 정상 반복(무발송) / 집계 UP 인데 backup DOWN 포착 / 같은 이상 반복 무발송 / 복구 알림 / 도달 불가 → UNREACHABLE ※ 모든 케이스에서 exit 0 을 함께 확인한다 ## CI `Infra (ops 스크립트)` 잡 추가: bash -n · shellcheck -S warning · selftest. 로컬에서 도커로 shellcheck 를 돌려 clean 을 확인했고, YAML 도 파싱 검증했다 (Team-StackUp#259 에서 CI 와 다른 명령으로 확인해 타입 에러를 놓친 뒤의 습관).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
관측
배포본에서 워크스페이스 전 페이지를 하드 로드하며 네트워크를 봤더니, 로드마다
POST /api/auth/refresh가 정확히 한 번씩 나간다:액세스 토큰을 메모리에만 두는 설계라 이것 자체는 정상이다. 문제는 그 다음이다.
문제
서버
RefreshTokenService.rotate는 유예 없이 기존 토큰을 revoke 하고 새 토큰을 발급한다.client.ts의 단일 비행(refreshing)은 모듈 변수라 한 탭 안에서만 듣는데, 리프레시 쿠키는탭이 공유한다.
창 복원이나 새 탭으로 열기로 두 탭이 동시에 부팅하면 일어난다.
진행 중인 면접 탭이 지면 사용자는 이유 없이 튕긴 것으로 본다.
이긴 탭은 살아남는다(401 응답 경로에
Set-Cookie가 없고,onUnauthorized가/api/auth/logout을 부르지 않는다). 진 탭은 새로고침하면 복구된다. 그래도 사용자에게는설명되지 않는 로그아웃이다.
얼마나 급한가 — 아직 일어난 적 없다
리프레시 토큰 발급 51건의 연속 간격을 운영 DB 에서 재 봤다:
최단 간격이 3.86초다. 레이스가 실제로 걸린 적은 없다. 그래서 예방 수정으로 다룬다.
고친 방법
Web Locks 로 origin 전체에서 refresh 가 한 번에 하나만 돌게 한다. 기다린 탭은 이긴 탭이
갱신해 둔 쿠키로 다시 회전하므로(T2→T3) 정상 동작한다 — 회전이 한 번 더 일어날 뿐이다.
락이 없는 환경(구형 Safari·비보안 컨텍스트)에서는 기존과 똑같이 동작한다.
서버 유예를 두지 않은 이유
회전된 토큰을 몇 초간 재사용 허용하는 방법도 있다. 하지만 토큰 재사용 탐지를 약화시키는
변경이고, 지금 문제는 브라우저 탭 동시성이라 브라우저에서 막는 쪽이 정확하다. 비브라우저
클라이언트에서 같은 레이스가 관측되면 그때 다시 본다.
검증
withRefreshLock.test.ts): 락 이름, 동시 3건에서 최대 동시 실행 1,앞 갱신 실패가 뒤를 막지 않음, 락 없는 환경 폴백
tsc --noEmitclean,eslintclean같이 훑은 결과 (문제 없음)
워크스페이스 8개 페이지(홈·히스토리·오답노트·이력서·레포·자소서·계정·피드백·404)를 배포본에서
로드: 앱 콘솔 에러 0건(경고 6건은 전부 MetaMask 확장), API 요청 23건 전부 200.
피드백 리포트는 점수·하이라이트·패널 평가·질문별 복기·음성 재생까지 정상 렌더.