diff --git a/src/App.tsx b/src/App.tsx index 200ab5ad..6dc7e6d1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -38,7 +38,9 @@ import ManagedMemberList from './pages/Manager/ManagedMemberList'; import ManagedRecruitment from './pages/Manager/ManagedRecruitment'; import ManagedRecruitmentForm from './pages/Manager/ManagedRecruitmentForm'; import ManagedRecruitmentWrite from './pages/Manager/ManagedRecruitmentWrite'; +import NotFoundPage from './pages/NotFound'; import Schedule from './pages/Schedule'; +import ServerErrorPage from './pages/ServerError'; import Timer from './pages/Timer'; import MyPage from './pages/User/MyPage'; import Profile from './pages/User/Profile'; @@ -119,6 +121,9 @@ function App() { + + } /> + } /> diff --git a/src/apis/auth/index.ts b/src/apis/auth/index.ts index 89d50f24..0787a90f 100644 --- a/src/apis/auth/index.ts +++ b/src/apis/auth/index.ts @@ -1,3 +1,5 @@ +import type { ApiError } from '@/interface/error'; +import { isServerErrorStatus, redirectToServerErrorPage } from '@/utils/ts/errorRedirect'; import { apiClient } from '../client'; import type { ModifyMyInfoRequest, MyInfoResponse, RefreshTokenResponse, SignupRequest } from './entity'; @@ -5,13 +7,37 @@ const BASE_URL = import.meta.env.VITE_API_PATH; export const refreshAccessToken = async (): Promise => { const url = `${BASE_URL.replace(/\/+$/, '')}/users/refresh`; - const response = await fetch(url, { - method: 'POST', - credentials: 'include', - }); + + let response: Response; + try { + response = await fetch(url, { + method: 'POST', + credentials: 'include', + }); + } catch (err) { + if (err instanceof TypeError) { + const networkError = new Error('네트워크 연결에 실패했습니다. 잠시 후 다시 시도해 주세요.') as ApiError; + networkError.name = 'NetworkError'; + networkError.status = 0; + networkError.statusText = 'NETWORK_ERROR'; + networkError.url = url; + throw networkError; + } + throw err as Error; + } if (!response.ok) { - throw new Error('토큰 갱신 실패'); + if (isServerErrorStatus(response.status)) { + redirectToServerErrorPage(); + throw new Error('서버 오류가 발생했습니다.'); + } + + const error = new Error('토큰 갱신 실패') as ApiError; + error.name = 'TokenRefreshError'; + error.status = response.status; + error.statusText = response.statusText; + error.url = url; + throw error; } const data: RefreshTokenResponse = await response.json(); diff --git a/src/apis/client.ts b/src/apis/client.ts index e43babc2..fcb27348 100644 --- a/src/apis/client.ts +++ b/src/apis/client.ts @@ -1,6 +1,7 @@ import { refreshAccessToken } from '@/apis/auth'; import type { ApiError, ApiErrorResponse } from '@/interface/error'; import { useAuthStore } from '@/stores/authStore'; +import { isServerErrorStatus, redirectToServerErrorPage } from '@/utils/ts/errorRedirect'; const BASE_URL = import.meta.env.VITE_API_PATH; @@ -43,6 +44,67 @@ export const apiClient = { ) => sendRequest(endPoint, { ...options, method: 'PATCH' }), }; +function isFetchNetworkError(error: unknown): error is TypeError { + if (!(error instanceof TypeError)) return false; + + const message = error.message.toLowerCase(); + return ( + message.includes('failed to fetch') || + message.includes('load failed') || + message.includes('networkerror') || + message.includes('network request failed') + ); +} + +async function throwApiError(response: Response): Promise { + if (isServerErrorStatus(response.status)) { + redirectToServerErrorPage(); + throw new Error('서버 오류가 발생했습니다.'); + } + + const errorData = await parseErrorResponse(response); + + const error = new Error(errorData?.message ?? 'API 요청 실패') as ApiError; + error.status = response.status; + error.statusText = response.statusText; + error.url = response.url; + error.apiError = errorData ?? undefined; + + throw error; +} + +function rethrowFetchError(error: unknown, url: string, isTimeout = false): never { + if (error instanceof Error && error.name === 'AbortError') { + if (isTimeout) { + const timeoutError = new Error('요청 시간이 초과되었습니다.') as ApiError; + timeoutError.name = 'TimeoutError'; + timeoutError.status = 0; + timeoutError.statusText = 'TIMEOUT'; + timeoutError.url = url; + throw timeoutError; + } + const cancelError = new Error('요청이 취소되었습니다.') as ApiError; + cancelError.name = 'Canceled'; + cancelError.status = 0; + cancelError.statusText = 'CANCELED'; + cancelError.url = url; + throw cancelError; + } + if (isFetchNetworkError(error)) { + throw createNetworkApiError(url); + } + throw error as Error; +} + +function createNetworkApiError(requestUrl: string): ApiError { + const error = new Error('네트워크 연결에 실패했습니다. 잠시 후 다시 시도해 주세요.') as ApiError; + error.name = 'NetworkError'; + error.status = 0; + error.statusText = 'NETWORK_ERROR'; + error.url = requestUrl; + return error; +} + function joinUrl(baseUrl: string, path: string) { const base = baseUrl.replace(/\/+$/, ''); const p = path.replace(/^\/+/, ''); @@ -84,7 +146,11 @@ async function sendRequest abortController.abort(), timeout); + let didTimeout = false; + const timeoutId = setTimeout(() => { + didTimeout = true; + abortController.abort(); + }, timeout); const isJsonBody = body !== undefined && body !== null && !(body instanceof FormData); @@ -128,23 +194,12 @@ async function sendRequest(response); } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - throw new Error('요청 시간이 초과되었습니다.'); - } - throw error; + rethrowFetchError(error, url, didTimeout); } finally { clearTimeout(timeoutId); } @@ -202,7 +257,11 @@ async function sendRequestWithoutRetry abortController.abort(), timeout); + let didTimeout = false; + const timeoutId = setTimeout(() => { + didTimeout = true; + abortController.abort(); + }, timeout); const isJsonBody = body !== undefined && body !== null && !(body instanceof FormData); @@ -237,23 +296,12 @@ async function sendRequestWithoutRetry(response); } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - throw new Error('요청 시간이 초과되었습니다.'); - } - throw error; + rethrowFetchError(error, url, didTimeout); } finally { clearTimeout(timeoutId); } diff --git a/src/assets/image/not-found-cat.webp b/src/assets/image/not-found-cat.webp new file mode 100644 index 00000000..48ae82d1 Binary files /dev/null and b/src/assets/image/not-found-cat.webp differ diff --git a/src/components/auth/AuthGuard.tsx b/src/components/auth/AuthGuard.tsx index 727f927d..2456b8c3 100644 --- a/src/components/auth/AuthGuard.tsx +++ b/src/components/auth/AuthGuard.tsx @@ -1,20 +1,27 @@ import { useEffect, type ReactNode } from 'react'; +import { useLocation } from 'react-router-dom'; import { useAuthStore } from '@/stores/authStore'; +import { SERVER_ERROR_PATH } from '@/utils/ts/errorRedirect'; interface AuthGuardProps { children: ReactNode; } function AuthGuard({ children }: AuthGuardProps) { + const { pathname } = useLocation(); const { isLoading, initialize } = useAuthStore(); + const shouldSkipInitialize = pathname === SERVER_ERROR_PATH; useEffect(() => { + if (shouldSkipInitialize) return; + initialize(); - }, [initialize]); + }, [initialize, shouldSkipInitialize]); - if (isLoading) { + if (isLoading && !shouldSkipInitialize) { return ( -
+
+ 로딩 중…
); diff --git a/src/components/common/ErrorPageLayout.tsx b/src/components/common/ErrorPageLayout.tsx new file mode 100644 index 00000000..c50a8b9d --- /dev/null +++ b/src/components/common/ErrorPageLayout.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from 'react'; + +interface ErrorPageLayoutProps { + imageSrc: string; + imageAlt: string; + title: string; + message: ReactNode; + primaryLabel: string; + onPrimaryClick: () => void; +} + +function ErrorPageLayout({ imageSrc, imageAlt, title, message, primaryLabel, onPrimaryClick }: ErrorPageLayoutProps) { + return ( +
+
+ {imageAlt} + +
+
+

{title}

+

{message}

+
+ + +
+
+
+ ); +} + +export default ErrorPageLayout; diff --git a/src/main.tsx b/src/main.tsx index 884e6b70..e6dcd96b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,6 +5,7 @@ import { createRoot } from 'react-dom/client'; import './index.css'; import { initSentry } from './config/sentry.ts'; import ToastProvider from './contexts/ToastContext'; +import { isApiError } from './interface/error.ts'; import { installViewportVars } from './utils/ts/viewport.ts'; installViewportVars(); @@ -14,7 +15,10 @@ const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnReconnect: true, - retry: false, + retry: (failureCount, error) => { + const maxRetries = 2; + return failureCount <= maxRetries && isApiError(error) && error.status === 0; + }, }, }, }); diff --git a/src/pages/NotFound/index.tsx b/src/pages/NotFound/index.tsx new file mode 100644 index 00000000..b7b65adf --- /dev/null +++ b/src/pages/NotFound/index.tsx @@ -0,0 +1,26 @@ +import NotFoundCatImage from '@/assets/image/not-found-cat.webp'; +import ErrorPageLayout from '@/components/common/ErrorPageLayout'; +import { useErrorPageHomeNavigation } from '@/utils/hooks/useErrorPageHomeNavigation'; + +function NotFoundPage() { + const handleGoHome = useErrorPageHomeNavigation(); + + return ( + + 주소가 잘못 입력되었거나 +
+ 삭제되어 페이지를 찾을 수 없어요 + + } + primaryLabel="홈으로 가기" + onPrimaryClick={handleGoHome} + /> + ); +} + +export default NotFoundPage; diff --git a/src/pages/ServerError/index.tsx b/src/pages/ServerError/index.tsx new file mode 100644 index 00000000..68f6a7f1 --- /dev/null +++ b/src/pages/ServerError/index.tsx @@ -0,0 +1,26 @@ +import NotFoundCatImage from '@/assets/image/not-found-cat.webp'; +import ErrorPageLayout from '@/components/common/ErrorPageLayout'; +import { useErrorPageHomeNavigation } from '@/utils/hooks/useErrorPageHomeNavigation'; + +function ServerErrorPage() { + const handleGoHome = useErrorPageHomeNavigation(); + + return ( + + 서버에 일시적인 문제가 생겼어요 +
+ 잠시 후 다시 시도해 주세요 + + } + primaryLabel="홈으로 가기" + onPrimaryClick={handleGoHome} + /> + ); +} + +export default ServerErrorPage; diff --git a/src/utils/hooks/useErrorPageHomeNavigation.ts b/src/utils/hooks/useErrorPageHomeNavigation.ts new file mode 100644 index 00000000..5a392acf --- /dev/null +++ b/src/utils/hooks/useErrorPageHomeNavigation.ts @@ -0,0 +1,11 @@ +import { useNavigate } from 'react-router-dom'; +import { useAuthStore } from '@/stores/authStore'; + +export function useErrorPageHomeNavigation() { + const navigate = useNavigate(); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + + return () => { + navigate(isAuthenticated ? '/home' : '/', { replace: true }); + }; +} diff --git a/src/utils/ts/errorRedirect.ts b/src/utils/ts/errorRedirect.ts new file mode 100644 index 00000000..ec3acb98 --- /dev/null +++ b/src/utils/ts/errorRedirect.ts @@ -0,0 +1,12 @@ +export const SERVER_ERROR_PATH = '/server-error'; + +export function isServerErrorStatus(status: number): boolean { + return status >= 500 && status < 600; +} + +export function redirectToServerErrorPage(): void { + if (typeof window === 'undefined') return; + if (window.location.pathname === SERVER_ERROR_PATH) return; + + window.location.replace(SERVER_ERROR_PATH); +}