diff --git a/client/src/App.tsx b/client/src/App.tsx index 15334d5b..edff20a7 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -13,6 +13,7 @@ const queryClient = new QueryClient({ defaultOptions: { queries: { suspense: true, + retry: false, }, }, }); diff --git a/client/src/client.ts b/client/src/client.ts index f0823e4a..83c1b6c0 100644 --- a/client/src/client.ts +++ b/client/src/client.ts @@ -1,10 +1,17 @@ -import type { AuthProvider, AuthUrl, Cafe, CafeMapLocation, CafeMenu, LikedCafe, MapBounds, Rank, SearchedCafe, User } from './types'; - -export class ClientNetworkError extends Error { - constructor() { - super('인터넷에 연결할 수 없습니다'); - } -} +import APIError from './errors/APIError'; +import NetworkError from './errors/NetworkError'; +import type { + AuthProvider, + AuthUrl, + Cafe, + CafeMapLocation, + CafeMenu, + LikedCafe, + MapBounds, + Rank, + SearchedCafe, + User, +} from './types'; class Client { isAccessTokenRefreshing = false; @@ -20,40 +27,48 @@ class Client { } async fetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const fetchFn: () => Promise = () => + fetch(`/api${input}`, { + ...init, + headers: { + ...init?.headers, + ...(this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : {}), + }, + }); + let response: Response; + try { - const fetchFn = () => - fetch(`/api${input}`, { - ...init, - headers: { - ...init?.headers, - ...(this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : {}), - }, - }); - - let response = await fetchFn(); - if (!response.ok) { - if (response.status === 401 && !this.isAccessTokenRefreshing) { - // this.refreshAccessToken() 을 호출하기 전, - // this.isAccessTokenRefreshing을 true로 설정해줘야 잠재적인 recursion loop를 방지할 수 있습니다. - this.isAccessTokenRefreshing = true; - const accessToken = await this.refreshAccessToken(); - this.accessToken = accessToken; - this.accessTokenRefreshListener?.(accessToken); - this.isAccessTokenRefreshing = false; - - response = await fetchFn(); - } + response = await fetchFn(); + } catch (error) { + throw new NetworkError(); + } + + if (!response.ok) { + if (response.status === 401 && !this.isAccessTokenRefreshing) { + // this.refreshAccessToken() 을 호출하기 전, + // this.isAccessTokenRefreshing을 true로 설정해줘야 잠재적인 recursion loop를 방지할 수 있습니다. + this.isAccessTokenRefreshing = true; + const accessToken = await this.refreshAccessToken(); + this.accessToken = accessToken; + this.accessTokenRefreshListener?.(accessToken); + this.isAccessTokenRefreshing = false; + + response = await fetchFn(); + } - if (!response.ok) { - // access token 재발급이 불가하기 때문에 access token을 삭제한다. - this.accessTokenRefreshListener?.(null); - throw response; + if (!response.ok) { + // access token 재발급이 불가하기 때문에 access token을 삭제한다. + this.accessTokenRefreshListener?.(null); + let body: unknown; + try { + body = await response.json(); + } catch (error) { + body = await response.text(); } + throw new APIError(response, body); } - return response; - } catch (error) { - throw new ClientNetworkError(); } + return response; } async fetchJson(input: RequestInfo | URL, init?: RequestInit): Promise { diff --git a/client/src/components/CafeDetailBottomSheet.tsx b/client/src/components/CafeDetailBottomSheet.tsx index 64b34268..a173754b 100644 --- a/client/src/components/CafeDetailBottomSheet.tsx +++ b/client/src/components/CafeDetailBottomSheet.tsx @@ -7,6 +7,7 @@ import type { Theme } from '../styles/theme'; import type { Cafe } from '../types'; import CafeMenuMiniList from './CafeMenuMiniList'; import OpeningHoursDetail from './OpeningHoursDetail'; +import QueryErrorBoundary from './QueryErrorBoundary'; type CafeDetailBottomSheetProps = { cafe: Cafe; @@ -34,9 +35,11 @@ const CafeDetailBottomSheet = (props: CafeDetailBottomSheetProps) => { {cafe.name} - - - + + + + + diff --git a/client/src/components/CafeMenuBottomSheet.tsx b/client/src/components/CafeMenuBottomSheet.tsx index 54b94a9e..ce5d13a9 100644 --- a/client/src/components/CafeMenuBottomSheet.tsx +++ b/client/src/components/CafeMenuBottomSheet.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { Suspense, useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { BsX } from 'react-icons/bs'; import { styled } from 'styled-components'; @@ -9,6 +9,7 @@ import type { Cafe } from '../types'; import Resource from '../utils/Resource'; import CafeMenuList from './CafeMenuList'; import ImageModal from './ImageModal'; +import QueryErrorBoundary from './QueryErrorBoundary'; type CafeMenuBottomSheetProps = { cafe: Cafe; @@ -17,10 +18,6 @@ type CafeMenuBottomSheetProps = { const CafeMenuBottomSheet = (props: CafeMenuBottomSheetProps) => { const { cafe, onClose } = props; - const { - data: { menus, menuBoards }, - } = useCafeMenus(cafe.id); - const [isImageModalOpen, setIsImageModalOpen] = useState(false); const scrollSnapGuardHandlers = useScrollSnapGuard(); useEffect(() => { @@ -32,10 +29,6 @@ const CafeMenuBottomSheet = (props: CafeMenuBottomSheetProps) => { const handlePreventClickPropagation: React.MouseEventHandler = (event) => { event.stopPropagation(); }; - - const recommendedMenus = menus.filter((menuItem) => menuItem.isRecommended); - const otherMenus = menus.filter((menuItem) => !menuItem.isRecommended); - return ( <> {createPortal( @@ -44,40 +37,67 @@ const CafeMenuBottomSheet = (props: CafeMenuBottomSheetProps) => { - {menuBoards.length > 0 && ( - <> - setIsImageModalOpen(true)}> - 메뉴판 이미지로 보기 ({menuBoards.length}) - - - - )} - - {recommendedMenus.length > 0 && ( - <> - 대표 메뉴 - - - - )} - - {otherMenus.length > 0 && ( - <> - 메뉴 - - - - )} - - {menus.length === 0 && ( - <> - - 등록된 메뉴가 없습니다 - - )} + + + + + , document.bodyRoot, )} + + ); +}; + +export default CafeMenuBottomSheet; + +type CafeMenuBottomSheetContentProps = { + cafe: Cafe; +}; + +const CafeMenuBottomSheetContent = (props: CafeMenuBottomSheetContentProps) => { + const { cafe } = props; + const { + data: { menus, menuBoards }, + } = useCafeMenus(cafe.id); + const [isImageModalOpen, setIsImageModalOpen] = useState(false); + + const recommendedMenus = menus.filter((menuItem) => menuItem.isRecommended); + const otherMenus = menus.filter((menuItem) => !menuItem.isRecommended); + + return ( + <> + {menuBoards.length > 0 && ( + <> + setIsImageModalOpen(true)}> + 메뉴판 이미지로 보기 ({menuBoards.length}) + + + + )} + + {recommendedMenus.length > 0 && ( + <> + 대표 메뉴 + + + + )} + + {otherMenus.length > 0 && ( + <> + 메뉴 + + + + )} + + {menus.length === 0 && ( + <> + + 등록된 메뉴가 없습니다 + + )} {isImageModalOpen && createPortal( @@ -91,8 +111,6 @@ const CafeMenuBottomSheet = (props: CafeMenuBottomSheetProps) => { ); }; -export default CafeMenuBottomSheet; - const Container = styled.div` position: absolute; bottom: 0; diff --git a/client/src/components/ErrorBoundary.tsx b/client/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..15347efb --- /dev/null +++ b/client/src/components/ErrorBoundary.tsx @@ -0,0 +1,86 @@ +import type { ComponentType, ErrorInfo, PropsWithChildren, ReactNode } from 'react'; +import React from 'react'; + +type FallbackProps = { + error: TError; + resetErrorBoundary: () => void; +}; + +export type ErrorBoundaryBaseProps = PropsWithChildren<{ + onReset?: () => void; + caught?: (error: unknown) => error is TError; +}>; + +export type ErrorBoundaryWithRenderProps = ErrorBoundaryBaseProps & { + fallbackRender: ({ error, resetErrorBoundary }: FallbackProps) => ReactNode; + FallbackComponent?: never; +}; + +export type ErrorBoundaryWithComponentProps = ErrorBoundaryBaseProps & { + fallbackRender?: never; + FallbackComponent: ComponentType>; +}; + +export type ErrorBoundaryWithNothingProps = ErrorBoundaryBaseProps & { + fallbackRender?: never; + FallbackComponent?: never; +}; + +export type ErrorBoundaryProps = + | ErrorBoundaryWithRenderProps + | ErrorBoundaryWithComponentProps + | ErrorBoundaryWithNothingProps; + +type ErrorBoundaryState = { + error: TError | null; +}; + +/** + * 자식 컴포넌트를 렌더링하는 중 에러가 발생하였을 때(throw error) + * 대체 컴포넌트(fallback)을 렌더합니다. + */ +class ErrorBoundary extends React.Component, ErrorBoundaryState> { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { + error: null, + }; + } + + static getDerivedStateFromError(error: any): ErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: unknown, errorInfo: ErrorInfo) { + const defaultCaught = (error: unknown): error is Error => error instanceof Error; + const { caught = defaultCaught } = this.props; + if (caught(error)) { + return; + } + throw error; + } + + render() { + const { error } = this.state; + if (error) { + if ('fallbackRender' in this.props && this.props.fallbackRender) { + const { fallbackRender } = this.props; + return fallbackRender({ error, resetErrorBoundary: () => this.resetErrorBoundary() }); + } + if ('FallbackComponent' in this.props && this.props.FallbackComponent) { + const { FallbackComponent } = this.props; + return this.resetErrorBoundary()} />; + } + return null; + } + return this.props.children; + } + + resetErrorBoundary() { + this.setState({ error: null }); + const { onReset } = this.props; + onReset?.(); + } +} + +export default ErrorBoundary; diff --git a/client/src/components/ErrorRetryPrompt.tsx b/client/src/components/ErrorRetryPrompt.tsx new file mode 100644 index 00000000..e846fe19 --- /dev/null +++ b/client/src/components/ErrorRetryPrompt.tsx @@ -0,0 +1,60 @@ +import styled from 'styled-components'; +import Button from './Button'; + +type ErrorRetryPromptProps = { + resetErrorBoundary: () => void; +}; + +const ErrorRetryPrompt = (Props: ErrorRetryPromptProps) => { + const { resetErrorBoundary } = Props; + + return ( + + + 일시적인 장애가 발생했습니다 + 요청사항을 처리하는데 실패했습니다. + + + + + + ); +}; + +export default ErrorRetryPrompt; + +const Container = styled.section` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + width: 100%; + height: 100%; + padding: ${({ theme }) => theme.space['5']}; +`; + +const SentenceContainer = styled.article` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.space[5]}; + align-items: center; +`; + +const ButtonContainer = styled.div` + width: 133px; +`; + +const MainSentence = styled.h1` + font-size: ${({ theme }) => theme.fontSize['2xl']}; + font-weight: bold; + color: ${({ theme }) => theme.color.text.secondary}; +`; + +const Sentence = styled.span` + font-size: ${({ theme }) => theme.fontSize.sm}; + color: ${({ theme }) => theme.color.text.secondary}; + text-align: center; +`; diff --git a/client/src/components/QueryErrorBoundary.tsx b/client/src/components/QueryErrorBoundary.tsx new file mode 100644 index 00000000..36cadc54 --- /dev/null +++ b/client/src/components/QueryErrorBoundary.tsx @@ -0,0 +1,49 @@ +import { QueryErrorResetBoundary, onlineManager } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import type { ErrorBoundaryProps } from './ErrorBoundary'; +import ErrorBoundary from './ErrorBoundary'; +import ErrorRetryPrompt from './ErrorRetryPrompt'; + +type QueryErrorBoundaryProps = ErrorBoundaryProps; + +/** + * react-query에서 `useQuery` 혹은 `useMutation` 등의 쿼리 함수를 + * `suspense: true`로 사용 중 발생하는 에러를 위한 + * ErrorBoundary 컴포넌트입니다. + * + * ErrorBoundary에서 다음 부가 기능이 추가되어있습니다 + * * network 상태가 온라인으로 전환되었을 때 자동으로 재시도(error reset) + * * ErrorBoundary reset 시, query cache도 reset + * * 아무런 fallback이 주어지지 않았을 시 {@link ErrorRetryPrompt} 를 기본으로 사용 + */ +const QueryErrorBoundary = (props: QueryErrorBoundaryProps) => { + const { children, onReset, ...restProps } = props; + const [id, setId] = useState(0); + + useEffect(() => { + return onlineManager.subscribe(() => { + if (onlineManager.isOnline()) setId((id) => id + 1); + }); + }, []); + + return ( + + {({ reset }) => ( + { + reset(); + onReset?.(); + }} + {...('FallbackComponent' in restProps || 'fallbackRender' in restProps + ? restProps + : { FallbackComponent: ErrorRetryPrompt, ...restProps })} + > + {children} + + )} + + ); +}; + +export default QueryErrorBoundary; diff --git a/client/src/context/ToastContext.tsx b/client/src/context/ToastContext.tsx index e86d873a..b2b38f5f 100644 --- a/client/src/context/ToastContext.tsx +++ b/client/src/context/ToastContext.tsx @@ -1,5 +1,5 @@ import type { PropsWithChildren } from 'react'; -import { createContext, useContext, useEffect, useState } from 'react'; +import { createContext, useContext, useState } from 'react'; import styled, { css, keyframes } from 'styled-components'; type ToastVariant = 'default' | 'success' | 'warning' | 'error'; @@ -14,34 +14,24 @@ type ToastProviderPros = PropsWithChildren; export const ToastProvider = (props: ToastProviderPros) => { const { children } = props; + const [toastId, setToastId] = useState(0); const [toastState, setToastState] = useState<{ variant: ToastVariant; message: string | null }>({ variant: 'default', message: null, }); - useEffect(() => { - if (toastState.variant && toastState.message !== null) { - const timer = setTimeout(() => { - setToastState({ variant: 'default', message: null }); - }, 5000); - - return () => clearTimeout(timer); - } - }, [toastState]); - const showToast = (variant: ToastVariant, message: string) => { setToastState({ variant, message }); + setToastId((toastId) => toastId + 1); }; - const contextValue = { - showToast, - }; + const contextValue = { showToast }; return ( {children} {toastState.message !== null && ( - + {toastState.message} )} @@ -59,6 +49,18 @@ export const useToast = () => { return context.showToast; }; +const ToastAnimation = keyframes` + from { + transform: translate(-50%) translateY(100px); + opacity: 0; + } + + to { + transform: translate(-50%) translateY(0px); + opacity: 1; + } +`; + const Container = styled.div` position: absolute; bottom: 50px; @@ -69,18 +71,8 @@ const Container = styled.div` flex-direction: column; white-space: nowrap; -`; -const ToastAnimation = keyframes` - from { - transform: translateY(100px); - opacity: 0; - } - - to { - transform: translateY(0px); - opacity: 1; - } + animation: 0.3s ${ToastAnimation}, 0.3s 3s reverse forwards ${ToastAnimation}; `; const ToastColorVariants = { @@ -100,12 +92,9 @@ const ToastColorVariants = { const ToastMessage = styled.div<{ $variant: ToastVariant }>` padding: ${({ theme }) => theme.space['3.5']}; - color: ${({ theme }) => theme.color.white}; - border-radius: 20px; box-shadow: rgba(60, 64, 67, 0.3) 0px 1px 2px, rgba(60, 64, 67, 0.15) 0px 1px 3px 1px; - animation: 0.3s ${ToastAnimation}, 0.3s 3s reverse forwards ${ToastAnimation}; ${({ $variant }) => ToastColorVariants[$variant]} `; diff --git a/client/src/errors/APIError.ts b/client/src/errors/APIError.ts new file mode 100644 index 00000000..84a87bc5 --- /dev/null +++ b/client/src/errors/APIError.ts @@ -0,0 +1,44 @@ +import type { ErrorResponseBody } from '../types'; +import AppError from './AppError'; + +/** + * API 호출의 응답이 400, 500 등의 error response일 경우 + * 이를 wrapping하는 에러 클래스 + */ +class APIError extends AppError { + readonly response: Response; + + constructor(response: Response, body: unknown) { + super(APIError.getErrorMessageFromBody(body)); + this.response = response; + } + + /** + * 서버 측에서 지정해 준 에러 코드 혹은 메세지가 포함되어 있는지 검사합니다 + */ + static isErrorResponseBody(body: unknown): body is ErrorResponseBody { + return !!body && typeof body === 'object' && 'code' in body && 'message' in body; + } + + /** + * 응답 body에서 적절한 에러 메세지를 추출하여 반환합니다 + * + * @param body 에러가 포함될 수 있는 모든 객체 + * @returns 에러 메세지 + */ + static getErrorMessageFromBody(body: unknown): string { + if (APIError.isErrorResponseBody(body)) { + return body.message; + } + if (!!body && typeof body === 'object') { + if ('message' in body) { + return String(body.message); + } + + return JSON.stringify(body); + } + return String(body); + } +} + +export default APIError; diff --git a/client/src/errors/AppError.ts b/client/src/errors/AppError.ts new file mode 100644 index 00000000..1fc2a59f --- /dev/null +++ b/client/src/errors/AppError.ts @@ -0,0 +1,13 @@ +/** + * 앱에서 의도적으로 발생시킨 에러이며, 복구 가능한 에러이다 + * + * 사용자가 조치할 수 있는 수준의 에러인 경우 `AppError` 혹은 그 자식 클래스여야 한다 + * + * @example + * if (!user.isAdmin) { + * throw new AppError('이 자원에 접근할 수 없습니다. 권한이 부족합니다.'); + * } + */ +class AppError extends Error {} + +export default AppError; diff --git a/client/src/errors/NetworkError.ts b/client/src/errors/NetworkError.ts new file mode 100644 index 00000000..b3d94c1b --- /dev/null +++ b/client/src/errors/NetworkError.ts @@ -0,0 +1,12 @@ +import AppError from './AppError'; + +/** + * 네트워크 오류에 해당되는 에러 클래스이다 + */ +class NetworkError extends AppError { + constructor() { + super('인터넷 연결에 문제가 생겼어요'); + } +} + +export default NetworkError; diff --git a/client/src/hooks/useSilentLink.ts b/client/src/hooks/useSilentLink.ts new file mode 100644 index 00000000..13fc799d --- /dev/null +++ b/client/src/hooks/useSilentLink.ts @@ -0,0 +1,21 @@ +import { onlineManager } from '@tanstack/react-query'; +import { useEffect } from 'react'; +import { useToast } from '../context/ToastContext'; + +/** + * 온라인 혹은 오프라인 상태를 감시하고 변화가 발생할 시 토스트로 알림을 띄워주는 훅입니다. + */ +const useSilentLink = () => { + const setToast = useToast(); + useEffect(() => { + return onlineManager.subscribe(() => { + const isOnline = onlineManager.isOnline(); + setToast( + isOnline ? 'success' : 'warning', + isOnline ? '인터넷이 다시 연결되었습니다' : '인터넷 연결이 끊어졌습니다. 확인해주세요', + ); + }); + }, [setToast]); +}; + +export default useSilentLink; diff --git a/client/src/pages/ErrorPage.tsx b/client/src/pages/ErrorPage.tsx index 77df7ee6..cae250dd 100644 --- a/client/src/pages/ErrorPage.tsx +++ b/client/src/pages/ErrorPage.tsx @@ -1,31 +1,36 @@ import { MdOutlineErrorOutline } from 'react-icons/md'; -import { Link } from 'react-router-dom'; +import { Link, useRouteError } from 'react-router-dom'; import { keyframes, styled } from 'styled-components'; import Button from '../components/Button'; +import AppError from '../errors/AppError'; const ErrorPage = () => { + const error = useRouteError(); + const message = error instanceof Error ? error.message : '알 수 없는 에러'; + const isExpectedError = error instanceof AppError; + return ( - + - 다시 확인해주세요 - 요청하신 내용을 찾을 수 없어요 + {isExpectedError ? '다시 확인해주세요' : '예기치 못한 에러가 발생하였습니다'} + {isExpectedError && {message}} - + ); }; export default ErrorPage; -const Contaienr = styled.section` +const Container = styled.section` display: flex; flex-direction: column; align-items: center; - height: 100vh; + height: 100%; padding-top: 150px; `; @@ -49,6 +54,7 @@ const MainSentence = styled.h1` const Sentence = styled.span` font-size: ${({ theme }) => theme.fontSize.sm}; color: ${({ theme }) => theme.color.text.secondary}; + text-align: center; `; const bounce = keyframes` diff --git a/client/src/pages/NotFoundPage.tsx b/client/src/pages/NotFoundPage.tsx index 08d7c579..f592342a 100644 --- a/client/src/pages/NotFoundPage.tsx +++ b/client/src/pages/NotFoundPage.tsx @@ -1,5 +1,7 @@ +import AppError from '../errors/AppError'; + const NotFoundPage = () => { - return
NotFound
; + throw new AppError('경로를 찾을 수 없어요. 홈 화면으로 이동하거나 경로를 다시 한 번 확인해주시겠어요?'); }; export default NotFoundPage; diff --git a/client/src/pages/Root.tsx b/client/src/pages/Root.tsx index 5d317e17..03e07ce0 100644 --- a/client/src/pages/Root.tsx +++ b/client/src/pages/Root.tsx @@ -2,8 +2,11 @@ import { Suspense } from 'react'; import { Outlet } from 'react-router-dom'; import { styled } from 'styled-components'; import Navbar from '../components/Navbar'; +import useSilentLink from '../hooks/useSilentLink'; const Root = () => { + useSilentLink(); + return ( <> diff --git a/client/src/router.tsx b/client/src/router.tsx index d03ff658..726ddf44 100644 --- a/client/src/router.tsx +++ b/client/src/router.tsx @@ -1,5 +1,6 @@ import React, { Suspense } from 'react'; import { createBrowserRouter } from 'react-router-dom'; +import ErrorPage from './pages/ErrorPage'; import Root from './pages/Root'; const SearchPage = React.lazy(() => import('./pages/SearchPage')); @@ -18,35 +19,44 @@ const router = createBrowserRouter([ { path: '/', element: , - errorElement: , - children: [ - { index: true, element: }, - { path: 'my-profile', element: }, - { path: 'cafes/:cafeId', element: }, - { path: 'rank', element: }, - { path: 'my-profile/cafes/:cafeId', element: }, - { path: 'search', element: }, - { path: 'map', element: }, - ], - }, - { - path: '/auth/:provider', - element: ( - }> - - - ), - }, - { - path: '/test', children: [ { - path: 'auth/:provider', - element: ( - - - - ), + path: '*', + errorElement: , + children: [ + { index: true, element: }, + { path: 'cafes/:cafeId', element: }, + { path: 'rank', element: }, + { path: 'my-profile', element: }, + { path: 'my-profile/cafes/:cafeId', element: }, + { path: 'search', element: }, + { path: 'map', element: }, + { + path: 'auth/:provider', + element: ( + }> + + + ), + }, + { + path: 'test', + children: [ + { + path: 'auth/:provider', + element: ( + + + + ), + }, + ], + }, + { + path: '*', + element: , + }, + ], }, ], }, diff --git a/client/src/types/index.ts b/client/src/types/index.ts index 221122c9..afcedd5a 100644 --- a/client/src/types/index.ts +++ b/client/src/types/index.ts @@ -91,7 +91,7 @@ export type SearchedCafe = { address: Cafe['address']; image: string; likeCount: Cafe['likeCount']; -} +}; export type CafeMapLocation = { id: number; @@ -107,3 +107,8 @@ export type MapBounds = { longitudeDelta: number; latitudeDelta: number; }; + +export type ErrorResponseBody = { + code: string; + message: string; +};