Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions frontend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 정의
Expand Down
24 changes: 23 additions & 1 deletion frontend/src/shared/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(fn: () => Promise<T>): Promise<T> {
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks
if (!locks?.request) return fn()
// lib.dom 은 콜백의 반환값을 그대로 결과 타입으로 잡는다. 프라미스를 돌려주면
// Promise<Promise<T>> 로 추론되는데, 플랫폼은 이를 평탄화해 T 로 resolve 한다.
return locks.request(REFRESH_LOCK, fn) as Promise<T>
}

async function performRefresh(): Promise<string> {
const response = await refreshClient.post(REFRESH_PATH, {})
if (!isRefreshPayload(response.data)) {
Expand Down Expand Up @@ -100,7 +122,7 @@ function refreshOnce(): Promise<string> {
return Promise.reject(makeTransientAuthError())
}
if (!refreshing) {
refreshing = performRefresh()
refreshing = withRefreshLock(performRefresh)
.catch((err: unknown) => {
if (isAuthRefreshFailure(err)) {
tokenStore.clear()
Expand Down
84 changes: 84 additions & 0 deletions frontend/src/shared/api/withRefreshLock.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Promise<unknown>>()
const request = vi.fn(async (name: string, fn: () => Promise<unknown>) => {
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')
})
})
Loading