From f48c83ee60ee4a728e9155db3207aa680c678a25 Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 2 Sep 2026 10:14:27 +0900 Subject: [PATCH] =?UTF-8?q?fix(web):=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EC=B6=94=EA=B0=80=20+=20=EB=B6=80=ED=8A=B8?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=9E=A9=20401=20=EB=A0=88=EC=9D=B4=EC=8A=A4?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) 모바일 네비 갭 md 미만에서 마케팅 네비(기능·동작 방식·FAQ)와 (로그인 사용자의) 워크스페이스 링크가 헤더에서 사라졌다. 햄버거 토글 + 드롭다운 메뉴를 추가해 해당 항목을 담았다. 시작하기 CTA 는 그대로 노출 유지. aria-expanded/controls, Esc 닫기, 링크 클릭 시 닫기까지 접근성 반영. 2) 부트스트랩 401 레이스 앱 로드 시 AuthProvider 가 토큰 없이 /api/users/me 를 먼저 쏴 401 → refresh → 재시도하던 낭비가 있었다(매 로드 401 1건, 백엔드 WARN 로그 노이즈). client 에 ensureAccessToken() 을 추가해 refresh 로 토큰을 먼저 확보한 뒤 사용자 정보를 부르도록 부트스트랩을 변경. 세션이 없으면 refresh 401 로 비로그인 처리. typecheck·lint·build·vitest(182) 전부 통과. 모바일 메뉴 렌더/토글은 로컬 빌드로 확인. --- .../src/features/auth/model/AuthProvider.tsx | 14 ++- frontend/src/shared/api/client.ts | 16 +++ frontend/src/shared/api/index.ts | 7 +- frontend/src/widgets/site-nav/ui/SiteNav.tsx | 99 ++++++++++++++++++- 4 files changed, 132 insertions(+), 4 deletions(-) diff --git a/frontend/src/features/auth/model/AuthProvider.tsx b/frontend/src/features/auth/model/AuthProvider.tsx index a2308400..8e396676 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 d3f39d2a..092b1bc6 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 2edb6e5c..cc9f6016 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 0f91b438..059f70e1 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}
) }