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
5 changes: 5 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -119,6 +121,9 @@ function App() {
</Route>
</Route>
</Route>

<Route path="server-error" element={<ServerErrorPage />} />
<Route path="*" element={<NotFoundPage />} />
</SentryRoutes>
</AuthGuard>
</BrowserRouter>
Expand Down
36 changes: 31 additions & 5 deletions src/apis/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,43 @@
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';

const BASE_URL = import.meta.env.VITE_API_PATH;

export const refreshAccessToken = async (): Promise<string> => {
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();
Expand Down
104 changes: 76 additions & 28 deletions src/apis/client.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -43,6 +44,67 @@ export const apiClient = {
) => sendRequest<T, P>(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<never> {
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(/^\/+/, '');
Expand Down Expand Up @@ -84,7 +146,11 @@ async function sendRequest<T = unknown, P extends object = Record<string, QueryP
}

const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), timeout);
let didTimeout = false;
const timeoutId = setTimeout(() => {
didTimeout = true;
abortController.abort();
}, timeout);

const isJsonBody = body !== undefined && body !== null && !(body instanceof FormData);

Expand Down Expand Up @@ -128,23 +194,12 @@ async function sendRequest<T = unknown, P extends object = Record<string, QueryP
}

if (!response.ok) {
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;
return await throwApiError(response);
}

return parseResponse<T>(response);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('요청 시간이 초과되었습니다.');
}
throw error;
rethrowFetchError(error, url, didTimeout);
} finally {
clearTimeout(timeoutId);
}
Expand Down Expand Up @@ -202,7 +257,11 @@ async function sendRequestWithoutRetry<T = unknown, P extends object = Record<st
}

const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), timeout);
let didTimeout = false;
const timeoutId = setTimeout(() => {
didTimeout = true;
abortController.abort();
}, timeout);

const isJsonBody = body !== undefined && body !== null && !(body instanceof FormData);

Expand Down Expand Up @@ -237,23 +296,12 @@ async function sendRequestWithoutRetry<T = unknown, P extends object = Record<st
const response = await fetch(url, fetchOptions);

if (!response.ok) {
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;
return await throwApiError(response);
}

return parseResponse<T>(response);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('요청 시간이 초과되었습니다.');
}
throw error;
rethrowFetchError(error, url, didTimeout);
} finally {
clearTimeout(timeoutId);
}
Expand Down
Binary file added src/assets/image/not-found-cat.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 10 additions & 3 deletions src/components/auth/AuthGuard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex h-screen items-center justify-center">
<div role="status" className="flex h-screen items-center justify-center">
<span className="sr-only">로딩 중…</span>
<div className="h-8 w-8 animate-spin rounded-full border-4 border-indigo-200 border-t-indigo-600" />
</div>
);
Expand Down
37 changes: 37 additions & 0 deletions src/components/common/ErrorPageLayout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="bg-indigo-5 flex min-h-(--viewport-height) w-full flex-col items-center px-8 pt-[22vh]">
<div className="flex w-full max-w-[323px] flex-col items-center gap-3">
<img src={imageSrc} alt={imageAlt} className="h-auto w-[243px]" />

<div className="flex w-full flex-col items-center gap-[26px] text-center">
<div className="flex flex-col items-center gap-3">
<h1 className="text-[24px] leading-[22px] font-bold tracking-[-0.408px] text-black">{title}</h1>
<p className="text-[16px] leading-[22px] tracking-[-0.408px] text-indigo-200">{message}</p>
</div>

<button
type="button"
onClick={onPrimaryClick}
className="text-indigo-5 w-full rounded-[10px] bg-[#69BFDF] py-[15px] text-[16px] leading-[22px] font-bold tracking-[-0.408px]"
>
{primaryLabel}
</button>
</div>
</div>
</section>
);
}

export default ErrorPageLayout;
6 changes: 5 additions & 1 deletion src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
},
},
},
});
Expand Down
26 changes: 26 additions & 0 deletions src/pages/NotFound/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ErrorPageLayout
imageSrc={NotFoundCatImage}
imageAlt="오류 캐릭터"
title="오류가 발생했어요"
message={
<>
주소가 잘못 입력되었거나
<br />
삭제되어 페이지를 찾을 수 없어요
</>
}
primaryLabel="홈으로 가기"
onPrimaryClick={handleGoHome}
/>
);
}

export default NotFoundPage;
26 changes: 26 additions & 0 deletions src/pages/ServerError/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ErrorPageLayout
imageSrc={NotFoundCatImage}
imageAlt="서버 오류 캐릭터"
title="오류가 발생했어요"
message={
<>
서버에 일시적인 문제가 생겼어요
<br />
잠시 후 다시 시도해 주세요
</>
}
primaryLabel="홈으로 가기"
onPrimaryClick={handleGoHome}
/>
);
}

export default ServerErrorPage;
11 changes: 11 additions & 0 deletions src/utils/hooks/useErrorPageHomeNavigation.ts
Original file line number Diff line number Diff line change
@@ -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 });
};
}
12 changes: 12 additions & 0 deletions src/utils/ts/errorRedirect.ts
Original file line number Diff line number Diff line change
@@ -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);
}