diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 9f85c5a0..f194ecb5 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -167,6 +167,16 @@ import { LoginButton } from '@/features/auth/ui/LoginButton'; - 기본 헤더: `Authorization`, `X-Trace-Id`(클라이언트 생성) - 401 응답 시 refresh → 원 요청 재시도 (interceptor) - 에러는 표준 에러 코드 ([`/docs/api-conventions.md §5`](../docs/api-conventions.md)) 기반 분기 +- **refresh 는 탭 사이에서도 직렬화한다** (`withRefreshLock`, Web Locks). + 서버의 `RefreshTokenService.rotate` 는 기존 토큰을 **유예 없이 즉시 revoke** 하고 새 토큰을 + 발급한다. 모듈 변수인 `refreshing` 단일 비행은 **한 탭 안에서만** 듣는데 리프레시 쿠키는 + 탭이 공유하므로, 탭 두 개가 동시에 부팅하면(창 복원·새 탭으로 열기 — 하드 로드마다 + 부트스트랩이 `ensureAccessToken`→refresh 를 한 번 쏜다) 진 쪽이 `AUTH_REVOKED_TOKEN`(401)을 + 받아 그 탭만 로그아웃된다. 진행 중인 면접 탭이 지면 사용자는 이유 없이 튕긴 것으로 본다. + - 기다린 탭은 이긴 탭이 갱신해 둔 쿠키로 다시 회전하므로(T2→T3) 정상 동작한다. + - Web Locks 가 없는 환경(구형 Safari·비보안 컨텍스트)에서는 기존과 같이 그냥 진행한다. + - 운영에서 **아직 발생한 적은 없다** — 리프레시 토큰 발급 51건 중 1초 이내 간격 0건 + (최단 3.86초). 예방 목적의 수정이다. ### 7.3 features의 API 호출 - 각 feature는 자체 `api/` 폴더에서 query/mutation 정의 diff --git a/frontend/src/shared/api/client.ts b/frontend/src/shared/api/client.ts index 092b1bc6..16314e42 100644 --- a/frontend/src/shared/api/client.ts +++ b/frontend/src/shared/api/client.ts @@ -60,6 +60,28 @@ function isRefreshPayload(data: unknown): data is { accessToken: string } { return typeof token === 'string' && token.length > 0 } +// 탭 사이 직렬화. +// +// refresh 는 서버에서 **회전**한다 — 기존 토큰을 즉시 revoke 하고 새 토큰을 발급한다 +// (RefreshTokenService.rotate, 유예 없음). 아래 `refreshing` 단일 비행은 모듈 변수라 +// **한 탭 안에서만** 중복을 막는다. 리프레시 쿠키는 탭이 공유하므로, 탭 두 개가 동시에 +// 부팅하면(창 복원·새 탭으로 열기) 둘 다 같은 토큰으로 refresh 를 쏘고 진 쪽이 +// AUTH_REVOKED_TOKEN(401)을 받아 **그 탭만 로그아웃된다.** 진행 중인 면접 탭이 지면 +// 사용자는 이유 없이 튕긴 것으로 본다. +// +// Web Locks 로 origin 전체에서 한 번에 하나만 돌게 한다. 기다린 탭은 A 가 갱신해 둔 +// 쿠키로 다시 회전하므로(T2→T3) 정상 동작한다 — 회전이 한 번 더 일어날 뿐이다. +// 락을 못 쓰는 환경(구형 Safari·비보안 컨텍스트)에서는 지금과 같이 그냥 진행한다. +const REFRESH_LOCK = 'stackup-auth-refresh' + +export function withRefreshLock(fn: () => Promise): Promise { + const locks = typeof navigator === 'undefined' ? undefined : navigator.locks + if (!locks?.request) return fn() + // lib.dom 은 콜백의 반환값을 그대로 결과 타입으로 잡는다. 프라미스를 돌려주면 + // Promise> 로 추론되는데, 플랫폼은 이를 평탄화해 T 로 resolve 한다. + return locks.request(REFRESH_LOCK, fn) as Promise +} + async function performRefresh(): Promise { const response = await refreshClient.post(REFRESH_PATH, {}) if (!isRefreshPayload(response.data)) { @@ -100,7 +122,7 @@ function refreshOnce(): Promise { return Promise.reject(makeTransientAuthError()) } if (!refreshing) { - refreshing = performRefresh() + refreshing = withRefreshLock(performRefresh) .catch((err: unknown) => { if (isAuthRefreshFailure(err)) { tokenStore.clear() diff --git a/frontend/src/shared/api/withRefreshLock.test.ts b/frontend/src/shared/api/withRefreshLock.test.ts new file mode 100644 index 00000000..61d44ff3 --- /dev/null +++ b/frontend/src/shared/api/withRefreshLock.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { withRefreshLock } from './client' + +/** + * refresh 는 서버에서 회전한다 — 기존 토큰을 즉시 revoke 하고 새 토큰을 발급한다. + * client.ts 의 단일 비행(`refreshing`)은 모듈 변수라 **한 탭 안에서만** 듣는다. + * 리프레시 쿠키는 탭이 공유하므로 탭 두 개가 동시에 부팅하면 진 쪽이 401 로 로그아웃된다. + */ +describe('withRefreshLock', () => { + const original = Object.getOwnPropertyDescriptor(navigator, 'locks') + + afterEach(() => { + if (original) Object.defineProperty(navigator, 'locks', original) + else delete (navigator as { locks?: unknown }).locks + vi.restoreAllMocks() + }) + + function stubLocks() { + // 이름별로 직렬화하는 최소 구현. 실제 Web Locks 의 계약과 같다. + const chains = new Map>() + const request = vi.fn(async (name: string, fn: () => Promise) => { + const prev = chains.get(name) ?? Promise.resolve() + const next = prev.then(fn, fn) + chains.set( + name, + next.then( + () => undefined, + () => undefined, + ), + ) + return next + }) + Object.defineProperty(navigator, 'locks', { + value: { request }, + configurable: true, + }) + return request + } + + it('락이 있으면 같은 이름으로 감싸 실행한다', async () => { + const request = stubLocks() + + await expect(withRefreshLock(async () => 'token')).resolves.toBe('token') + + expect(request).toHaveBeenCalledTimes(1) + expect(request.mock.calls[0][0]).toBe('stackup-auth-refresh') + }) + + it('동시에 들어온 갱신이 겹치지 않는다', async () => { + stubLocks() + let running = 0 + let maxConcurrent = 0 + const job = async () => { + running += 1 + maxConcurrent = Math.max(maxConcurrent, running) + await new Promise((r) => setTimeout(r, 5)) + running -= 1 + return 'ok' + } + + await Promise.all([withRefreshLock(job), withRefreshLock(job), withRefreshLock(job)]) + + // 이게 1이 아니면 탭들이 같은 리프레시 토큰으로 동시에 회전을 시도한다는 뜻이다. + expect(maxConcurrent).toBe(1) + }) + + it('앞선 갱신이 실패해도 뒤 갱신이 막히지 않는다', async () => { + stubLocks() + const boom = withRefreshLock(async () => { + throw new Error('refresh failed') + }) + + await expect(boom).rejects.toThrow('refresh failed') + await expect(withRefreshLock(async () => 'next')).resolves.toBe('next') + }) + + // 구형 Safari·비보안 컨텍스트에는 Web Locks 가 없다. 그때도 갱신은 되어야 한다. + it('락이 없으면 그대로 실행한다', async () => { + delete (navigator as { locks?: unknown }).locks + + await expect(withRefreshLock(async () => 'token')).resolves.toBe('token') + }) +})