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
113 changes: 48 additions & 65 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { lazy, Suspense } from 'react';
import { lazy } from 'react';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from 'styled-components';
Expand All @@ -15,7 +15,9 @@ import ApplicationFormPage from './pages/ApplicationFormPage/ApplicationFormPage
import ClubUnionPage from './pages/ClubUnionPage/ClubUnionPage';
import IntroducePage from './pages/IntroducePage/IntroducePage';
import 'swiper/css';
import { GlobalBoundary } from './components/common/ErrorBoundary';
import LegacyClubDetailPage from './pages/ClubDetailPage/LegacyClubDetailPage';
import ErrorTestPage from './pages/ErrorTestPage/ErrorTestPage';

const queryClient = new QueryClient({
defaultOptions: {
Expand All @@ -33,70 +35,51 @@ const AdminRoutes = lazy(() => import('@/pages/AdminPage/AdminRoutes'));

const App = () => {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
<BrowserRouter>
<GlobalStyles />
<ScrollToTop />
<ScrollToTopButton />
<Routes>
<Route
path='/'
element={
<Suspense fallback={null}>
<MainPage />
</Suspense>
}
/>
{/*기존 웹 & 안드로이드 url (android: v1.1.0)*/}
<Route
path='/club/:clubId'
element={
<Suspense fallback={null}>
<LegacyClubDetailPage />
</Suspense>
}
/>
{/*웹 유저에게 신규 상세페이지 보유주기 위한 임시 url*/}
<Route
path='/clubDetail/:clubId'
element={
<Suspense fallback={null}>
<ClubDetailPage />
</Suspense>
}
/>
{/*새로 빌드해서 배포할 앱 주소 url*/}
<Route
path='/webview/club/:clubId'
element={
<Suspense fallback={null}>
<ClubDetailPage />
</Suspense>
}
/>
<Route path='/introduce' element={<IntroducePage />} />
<Route path='/admin/login' element={<LoginTab />} />
<Route
path='/admin/*'
element={
<AdminClubProvider>
<PrivateRoute>
<AdminRoutes />
</PrivateRoute>
</AdminClubProvider>
}
/>
<Route
path='/application/:clubId/:applicationFormId'
element={<ApplicationFormPage />}
/>
<Route path='/club-union' element={<ClubUnionPage />} />
<Route path='*' element={<Navigate to='/' replace />} />
</Routes>
</BrowserRouter>
</ThemeProvider>
</QueryClientProvider>
<GlobalBoundary>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
<BrowserRouter>
<GlobalStyles />
<ScrollToTop />
<ScrollToTopButton />
<Routes>
<Route path='/' element={<MainPage />} />
{/*기존 웹 & 안드로이드 url (android: v1.1.0)*/}
<Route path='/club/:clubId' element={<LegacyClubDetailPage />} />
{/*웹 유저에게 신규 상세페이지 보유주기 위한 임시 url*/}
<Route path='/clubDetail/:clubId' element={<ClubDetailPage />} />
{/*새로 빌드해서 배포할 앱 주소 url*/}
<Route
path='/webview/club/:clubId'
element={<ClubDetailPage />}
/>
<Route path='/introduce' element={<IntroducePage />} />
<Route path='/admin/login' element={<LoginTab />} />
<Route
path='/admin/*'
element={
<AdminClubProvider>
<PrivateRoute>
<AdminRoutes />
</PrivateRoute>
</AdminClubProvider>
}
/>
<Route
path='/application/:clubId/:applicationFormId'
element={<ApplicationFormPage />}
/>
<Route path='/club-union' element={<ClubUnionPage />} />
{/* 개발 환경에서만 사용 가능한 에러 테스트 페이지 */}
{import.meta.env.DEV && (
<Route path='/error-test' element={<ErrorTestPage />} />
)}
<Route path='*' element={<Navigate to='/' replace />} />
</Routes>
</BrowserRouter>
</ThemeProvider>
</QueryClientProvider>
</GlobalBoundary>
);
};

Expand Down
25 changes: 25 additions & 0 deletions frontend/src/components/common/ErrorBoundary/GlobalBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { ReactNode, Suspense } from 'react';
import * as Sentry from '@sentry/react';
import Spinner from '../Spinner/Spinner';
import GlobalErrorFallback from './GlobalErrorFallback';

interface GlobalBoundaryProps {
children: ReactNode;
}

const GlobalBoundary = ({ children }: GlobalBoundaryProps) => {
return (
<Sentry.ErrorBoundary
fallback={(errorData) => (
<GlobalErrorFallback
error={errorData.error as Error}
resetError={errorData.resetError}
/>
)}
>
<Suspense fallback={<Spinner />}>{children}</Suspense>
</Sentry.ErrorBoundary>
Comment on lines +20 to +21
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suspense가 GlobalBoundary로 이동하면서 로딩과 에러 처리가 한 곳에서 관리되도록 정리된 것 같네요
App.tsx에서 덕지덕지 관리하던 것보다 책임이 분리되어 좋아 보입니다 ㅎㅎ
(물론 Suspense 트리거가 없긴하지만... 안전빵으로 있는 것도 좋은 듯해요)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useQuery를 쓰고 있는 페이지에선 내부에서 로딩,에러 상태를 관리하니 suspense 대상이 아니지만
UX를 고려해서 useSuspenseQuery가 필요한 곳이라면 Suspense를 쓸 수는 있을 것 같아요.

안전빵이라도 Suspense보단 에러 추적 목적으로 상위 에러바운더리로 전파하는 방식이 좋을 것 같아요.

);
};

export default GlobalBoundary;
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import styled from 'styled-components';

export const Container = styled.div`
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: linear-gradient(135deg, #fff5f0 0%, #ffffff 100%);
`;

export const Content = styled.div`
max-width: 600px;
width: 100%;
text-align: center;
background: white;
border-radius: 16px;
padding: 48px 32px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
`;

export const IconWrapper = styled.div`
margin-bottom: 24px;
color: #ff5414;
display: flex;
justify-content: center;

svg {
width: 64px;
height: 64px;
}
`;

export const Title = styled.h1`
font-size: 24px;
font-weight: 700;
color: #111111;
margin-bottom: 16px;
line-height: 1.4;
`;

export const Message = styled.p`
font-size: 16px;
font-weight: 500;
color: #787878;
line-height: 1.6;
margin-bottom: 32px;
`;

export const ErrorDetails = styled.div`
background: #f5f5f5;
border: 1px solid #ebebeb;
border-radius: 8px;
padding: 16px;
margin-bottom: 32px;
text-align: left;
max-height: 300px;
overflow-y: auto;
`;

export const ErrorDetailsTitle = styled.div`
font-size: 12px;
font-weight: 600;
color: #989898;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
`;

export const ErrorMessage = styled.div`
font-size: 14px;
font-weight: 600;
color: #ff5414;
margin-bottom: 12px;
word-break: break-word;
`;

export const StackTrace = styled.pre`
font-size: 12px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
color: #4b4b4b;
white-space: pre-wrap;
word-break: break-all;
line-height: 1.5;
`;

export const ButtonGroup = styled.div`
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
`;

const BaseButton = styled.button`
padding: 14px 32px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
border: none;
cursor: pointer;
transition: all 0.2s ease;
min-width: 140px;
font-family: 'Pretendard', sans-serif;

&:active {
transform: scale(0.98);
}
`;

export const PrimaryButton = styled(BaseButton)`
background: #ff5414;
color: white;

&:hover {
background: #ff7543;
box-shadow: 0 4px 12px rgba(255, 84, 20, 0.3);
}
`;

export const SecondaryButton = styled(BaseButton)`
background: white;
color: #4b4b4b;
border: 1px solid #dcdcdc;

&:hover {
background: #f5f5f5;
border-color: #c5c5c5;
}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import * as Styled from './GlobalErrorFallback.styles';

interface ErrorFallbackProps {
error: Error;
resetError: () => void;
}

const WarningIcon = () => (
<svg
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
stroke='currentColor'
>
<path
d='M12 9V14M12 17.5V18M12 22C6.477 22 2 17.523 2 12C2 6.477 6.477 2 12 2C17.523 2 22 6.477 22 12C22 17.523 17.523 22 12 22Z'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
);

const GlobalErrorFallback = ({ error, resetError }: ErrorFallbackProps) => {
const isDev = import.meta.env.DEV;

const handleReload = () => {
window.location.href = '/';
};

const handleReset = () => {
resetError();
};

return (
<Styled.Container>
<Styled.Content>
<Styled.IconWrapper>
<WarningIcon />
</Styled.IconWrapper>

<Styled.Title>서비스 이용에 불편을 드려 죄송합니다</Styled.Title>
<Styled.Message>
예상치 못한 오류가 발생하여 페이지를 표시할 수 없습니다.
<br />
잠시 후 다시 시도해 주세요.
</Styled.Message>

{isDev && error && (
<Styled.ErrorDetails>
<Styled.ErrorDetailsTitle>
개발자 정보 (프로덕션에서는 표시되지 않습니다)
</Styled.ErrorDetailsTitle>
<Styled.ErrorMessage>{error.message}</Styled.ErrorMessage>
{error.stack && (
<Styled.StackTrace>{error.stack}</Styled.StackTrace>
)}
</Styled.ErrorDetails>
)}

<Styled.ButtonGroup>
<Styled.PrimaryButton onClick={handleReset}>
다시 시도
</Styled.PrimaryButton>
Comment on lines +62 to +64
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleReset이 Sentry에서 전달해주는 resetError이라는 함수군요
ErrorBoundary 아래 subtree만 다시 렌더링해야 해서 다시 시도 버튼이 필요한 구조군요.
별도 새로고침이 아니라 이렇게 처리할수 있네요
배워갑니다

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네 Sentry 에러바운더리 내에서 에러 상태를 초기화하는 방식입니다 !

<Styled.SecondaryButton onClick={handleReload}>
홈으로 이동
</Styled.SecondaryButton>
</Styled.ButtonGroup>
</Styled.Content>
</Styled.Container>
);
};

export default GlobalErrorFallback;
2 changes: 2 additions & 0 deletions frontend/src/components/common/ErrorBoundary/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './GlobalErrorFallback';
export { default as GlobalBoundary } from './GlobalBoundary';
Loading
Loading