diff --git a/frontend/src/features/auth/model/AuthProvider.tsx b/frontend/src/features/auth/model/AuthProvider.tsx index a230840..8e39667 100644 --- a/frontend/src/features/auth/model/AuthProvider.tsx +++ b/frontend/src/features/auth/model/AuthProvider.tsx @@ -7,7 +7,12 @@ import { type ReactNode, } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { isApiError, setAuthSideEffects, tokenStore } from '@/shared/api' +import { + ensureAccessToken, + isApiError, + setAuthSideEffects, + tokenStore, +} from '@/shared/api' import { fetchCurrentUser, logout as logoutApi } from '../api/auth' import { AuthContext, @@ -83,6 +88,13 @@ export function AuthProvider({ children }: AuthProviderProps) { void (async () => { try { + // 토큰을 refresh 로 먼저 확보한 뒤 사용자 정보를 부른다. + // (토큰 없이 /users/me 를 쏴 401 → refresh → 재시도하던 낭비 제거) + const token = await ensureAccessToken() + if (!token) { + clearAuth() + return + } await refreshUser() } catch { clearAuth() diff --git a/frontend/src/shared/api/client.ts b/frontend/src/shared/api/client.ts index d3f39d2..092b1bc 100644 --- a/frontend/src/shared/api/client.ts +++ b/frontend/src/shared/api/client.ts @@ -120,6 +120,22 @@ function refreshOnce(): Promise { return refreshing } +// 앱 부트스트랩용: 토큰이 없으면 refresh 로 먼저 확보한다. +// 이렇게 해야 첫 인증 요청(/api/users/me)이 토큰 없이 나가 401 을 유발하고 +// 재시도되는 낭비가 사라진다. 세션이 없으면(refresh 401) null 을 돌려준다. +export async function ensureAccessToken(): Promise { + const existing = tokenStore.get() + if (existing) return existing + try { + return await refreshOnce() + } catch (err) { + // 일시적 장애(SYS_DEPENDENCY_DOWN)는 그대로 던져 상위에서 구분 처리. + if (err instanceof ApiError && err.code === 'SYS_DEPENDENCY_DOWN') throw err + // 그 외(세션 없음 등)는 비로그인으로 취급. + return null + } +} + apiClient.interceptors.response.use( (response) => response, async (error: AxiosError) => { diff --git a/frontend/src/shared/api/index.ts b/frontend/src/shared/api/index.ts index 2edb6e5..cc9f601 100644 --- a/frontend/src/shared/api/index.ts +++ b/frontend/src/shared/api/index.ts @@ -1,4 +1,9 @@ -export { apiClient, setAuthSideEffects, type ApiResponse } from './client' +export { + apiClient, + setAuthSideEffects, + ensureAccessToken, + type ApiResponse, +} from './client' export { tokenStore } from './token-store' export { ApiError, diff --git a/frontend/src/widgets/site-nav/ui/SiteNav.tsx b/frontend/src/widgets/site-nav/ui/SiteNav.tsx index 0f91b43..059f70e 100644 --- a/frontend/src/widgets/site-nav/ui/SiteNav.tsx +++ b/frontend/src/widgets/site-nav/ui/SiteNav.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useId, useState } from 'react' import { Link } from 'react-router-dom' import { useAuth, useLogout } from '@/features/auth' import { ColorModeToggle } from '@/shared/ui' @@ -9,8 +9,38 @@ const items = [ { to: '/#faq', label: 'FAQ' }, ] +function MenuIcon({ open }: { open: boolean }) { + return ( + + {open ? ( + <> + + + + ) : ( + <> + + + + + )} + + ) +} + export function SiteNav() { const [scrolled, setScrolled] = useState(false) + const [open, setOpen] = useState(false) + const menuId = useId() const { status, user } = useAuth() const { logout, loggingOut } = useLogout() @@ -21,11 +51,21 @@ export function SiteNav() { return () => window.removeEventListener('scroll', onScroll) }, []) + // Esc 로 닫기. + useEffect(() => { + if (!open) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false) + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [open]) + return (
)} + + {/* 모바일 메뉴 토글 — 데스크톱 네비/보조 링크가 숨겨지는 구간을 보완 */} + + + {/* 모바일 드롭다운 메뉴 */} + {open ? ( + + ) : null}
) }