diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index eb0b615f2..bf29ada12 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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'; @@ -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: { @@ -33,70 +35,51 @@ const AdminRoutes = lazy(() => import('@/pages/AdminPage/AdminRoutes')); const App = () => { return ( - - - - - - - - - - - } - /> - {/*기존 웹 & 안드로이드 url (android: v1.1.0)*/} - - - - } - /> - {/*웹 유저에게 신규 상세페이지 보유주기 위한 임시 url*/} - - - - } - /> - {/*새로 빌드해서 배포할 앱 주소 url*/} - - - - } - /> - } /> - } /> - - - - - - } - /> - } - /> - } /> - } /> - - - - + + + + + + + + + } /> + {/*기존 웹 & 안드로이드 url (android: v1.1.0)*/} + } /> + {/*웹 유저에게 신규 상세페이지 보유주기 위한 임시 url*/} + } /> + {/*새로 빌드해서 배포할 앱 주소 url*/} + } + /> + } /> + } /> + + + + + + } + /> + } + /> + } /> + {/* 개발 환경에서만 사용 가능한 에러 테스트 페이지 */} + {import.meta.env.DEV && ( + } /> + )} + } /> + + + + + ); }; diff --git a/frontend/src/components/common/ErrorBoundary/GlobalBoundary.tsx b/frontend/src/components/common/ErrorBoundary/GlobalBoundary.tsx new file mode 100644 index 000000000..6bf44c5b6 --- /dev/null +++ b/frontend/src/components/common/ErrorBoundary/GlobalBoundary.tsx @@ -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 ( + ( + + )} + > + }>{children} + + ); +}; + +export default GlobalBoundary; diff --git a/frontend/src/components/common/ErrorBoundary/GlobalErrorFallback.styles.ts b/frontend/src/components/common/ErrorBoundary/GlobalErrorFallback.styles.ts new file mode 100644 index 000000000..2845c4618 --- /dev/null +++ b/frontend/src/components/common/ErrorBoundary/GlobalErrorFallback.styles.ts @@ -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; + } +`; diff --git a/frontend/src/components/common/ErrorBoundary/GlobalErrorFallback.tsx b/frontend/src/components/common/ErrorBoundary/GlobalErrorFallback.tsx new file mode 100644 index 000000000..0fb33b525 --- /dev/null +++ b/frontend/src/components/common/ErrorBoundary/GlobalErrorFallback.tsx @@ -0,0 +1,74 @@ +import * as Styled from './GlobalErrorFallback.styles'; + +interface ErrorFallbackProps { + error: Error; + resetError: () => void; +} + +const WarningIcon = () => ( + + + +); + +const GlobalErrorFallback = ({ error, resetError }: ErrorFallbackProps) => { + const isDev = import.meta.env.DEV; + + const handleReload = () => { + window.location.href = '/'; + }; + + const handleReset = () => { + resetError(); + }; + + return ( + + + + + + + 서비스 이용에 불편을 드려 죄송합니다 + + 예상치 못한 오류가 발생하여 페이지를 표시할 수 없습니다. +
+ 잠시 후 다시 시도해 주세요. +
+ + {isDev && error && ( + + + 개발자 정보 (프로덕션에서는 표시되지 않습니다) + + {error.message} + {error.stack && ( + {error.stack} + )} + + )} + + + + 다시 시도 + + + 홈으로 이동 + + +
+
+ ); +}; + +export default GlobalErrorFallback; diff --git a/frontend/src/components/common/ErrorBoundary/index.ts b/frontend/src/components/common/ErrorBoundary/index.ts new file mode 100644 index 000000000..f7da0a3ed --- /dev/null +++ b/frontend/src/components/common/ErrorBoundary/index.ts @@ -0,0 +1,2 @@ +export * from './GlobalErrorFallback'; +export { default as GlobalBoundary } from './GlobalBoundary'; diff --git a/frontend/src/pages/ErrorTestPage/ErrorTestPage.styles.ts b/frontend/src/pages/ErrorTestPage/ErrorTestPage.styles.ts new file mode 100644 index 000000000..8f89d8839 --- /dev/null +++ b/frontend/src/pages/ErrorTestPage/ErrorTestPage.styles.ts @@ -0,0 +1,173 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + max-width: 900px; + margin: 0 auto; + padding: 40px 20px; + min-height: 100vh; +`; + +export const Header = styled.div` + text-align: center; + margin-bottom: 48px; + padding-bottom: 24px; + border-bottom: 2px solid ${({ theme }) => theme.colors.gray[300]}; +`; + +export const Title = styled.h1` + font-size: ${({ theme }) => theme.typography.title.title1.size}; + font-weight: ${({ theme }) => theme.typography.title.title1.weight}; + color: ${({ theme }) => theme.colors.base.black}; + margin-bottom: 12px; +`; + +export const Subtitle = styled.p` + font-size: ${({ theme }) => theme.typography.paragraph.p3.size}; + color: ${({ theme }) => theme.colors.gray[700]}; + line-height: 1.6; +`; + +export const Section = styled.div` + background: white; + border: 1px solid ${({ theme }) => theme.colors.gray[300]}; + border-radius: 12px; + padding: 24px; + margin-bottom: 24px; + transition: all 0.2s ease; + + &:hover { + border-color: ${({ theme }) => theme.colors.gray[400]}; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + } +`; + +export const SectionTitle = styled.h3` + font-size: ${({ theme }) => theme.typography.title.title5.size}; + font-weight: ${({ theme }) => theme.typography.title.title5.weight}; + color: ${({ theme }) => theme.colors.base.black}; + margin-bottom: 8px; +`; + +export const Description = styled.p` + font-size: ${({ theme }) => theme.typography.paragraph.p5.size}; + color: ${({ theme }) => theme.colors.gray[600]}; + line-height: 1.5; + margin-bottom: 16px; +`; + +interface TestButtonProps { + $variant: 'danger' | 'warning' | 'info'; +} + +export const TestButton = styled.button` + width: 100%; + padding: 16px 24px; + font-size: ${({ theme }) => theme.typography.paragraph.p3.size}; + font-weight: 600; + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + + ${({ $variant, theme }) => { + switch ($variant) { + case 'danger': + return ` + background: ${theme.colors.primary[900]}; + color: white; + &:hover { + background: ${theme.colors.primary[800]}; + box-shadow: 0 4px 12px rgba(255, 84, 20, 0.3); + } + `; + case 'warning': + return ` + background: ${theme.colors.secondary[2].main}; + color: ${theme.colors.gray[900]}; + &:hover { + background: ${theme.colors.secondary[5].main}; + box-shadow: 0 4px 12px rgba(255, 160, 77, 0.3); + } + `; + case 'info': + return ` + background: ${theme.colors.secondary[4].main}; + color: white; + &:hover { + background: ${theme.colors.accent[1][900]}; + box-shadow: 0 4px 12px rgba(61, 187, 255, 0.3); + } + `; + } + }} + + &:active { + transform: scale(0.98); + } +`; + +export const InfoBox = styled.div` + background: ${({ theme }) => theme.colors.accent[1][600]}; + border: 1px solid ${({ theme }) => theme.colors.accent[1][700]}; + border-radius: 12px; + padding: 24px; + margin-top: 32px; +`; + +export const InfoTitle = styled.h4` + font-size: ${({ theme }) => theme.typography.paragraph.p1.size}; + font-weight: 600; + color: ${({ theme }) => theme.colors.base.black}; + margin-bottom: 12px; +`; + +export const InfoList = styled.ul` + list-style: none; + padding: 0; + margin: 0; + + li { + font-size: ${({ theme }) => theme.typography.paragraph.p5.size}; + color: ${({ theme }) => theme.colors.gray[800]}; + line-height: 1.6; + margin-bottom: 8px; + padding-left: 20px; + position: relative; + + &:before { + content: '•'; + position: absolute; + left: 8px; + color: ${({ theme }) => theme.colors.primary[900]}; + font-weight: bold; + } + + strong { + color: ${({ theme }) => theme.colors.base.black}; + font-weight: 600; + } + } +`; + +export const BackButton = styled.button` + display: block; + margin: 32px auto 0; + padding: 12px 24px; + font-size: ${({ theme }) => theme.typography.paragraph.p3.size}; + font-weight: 500; + color: ${({ theme }) => theme.colors.gray[700]}; + background: transparent; + border: 1px solid ${({ theme }) => theme.colors.gray[400]}; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: ${({ theme }) => theme.colors.gray[100]}; + border-color: ${({ theme }) => theme.colors.gray[500]}; + } + + &:active { + transform: scale(0.98); + } +`; diff --git a/frontend/src/pages/ErrorTestPage/ErrorTestPage.tsx b/frontend/src/pages/ErrorTestPage/ErrorTestPage.tsx new file mode 100644 index 000000000..9c1616349 --- /dev/null +++ b/frontend/src/pages/ErrorTestPage/ErrorTestPage.tsx @@ -0,0 +1,148 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import * as Styled from './ErrorTestPage.styles'; + +/** + * 에러바운더리 테스트용 페이지 + * 개발 환경에서만 사용 + */ +const ErrorTestPage = () => { + const [shouldThrow, setShouldThrow] = useState(false); + + // 1. 동기 런타임 에러 테스트 + const throwSyncError = () => { + setShouldThrow(true); + }; + + // 2. 비동기 에러 테스트 + const throwAsyncError = async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + throw new Error('비동기 에러 테스트: Promise 내부에서 에러 발생'); + }; + + // 3. API 에러 테스트 (React Query) + const { refetch: triggerQueryError } = useQuery({ + queryKey: ['error-test'], + queryFn: async () => { + throw new Error('React Query 에러 테스트: API 호출 실패'); + }, + enabled: false, + throwOnError: true, + }); + + // 4. 타입 에러 시뮬레이션 + const throwTypeError = () => { + // @ts-ignore + const obj = null; + // @ts-ignore + console.log(obj.property.nested); + }; + + // 5. 이벤트 핸들러 에러 + const throwEventError = () => { + throw new Error('이벤트 핸들러 에러 테스트'); + }; + + if (shouldThrow) { + throw new Error('동기 런타임 에러 테스트: 렌더링 중 에러 발생'); + } + + return ( + + + 🧪 에러바운더리 테스트 페이지 + + 개발 환경에서만 사용 가능합니다. 각 버튼을 클릭하여 에러바운더리 + 동작을 테스트하세요. + + + + + + 🔥 동기 에러 (ErrorBoundary 캐치) + + + 컴포넌트 렌더링 중 발생하는 에러입니다. ErrorBoundary가 캐치합니다. + + + 동기 런타임 에러 발생 + + + + + + ⚡ 이벤트 핸들러 에러 (콘솔 에러) + + + 이벤트 핸들러 내부 에러는 ErrorBoundary가 캐치하지 않습니다. 콘솔에 + 에러가 기록됩니다. + + + 이벤트 핸들러 에러 발생 + + + + + + 🌐 React Query 에러 (ErrorBoundary 캐치) + + + throwOnError: true 설정 시 ErrorBoundary가 캐치합니다. + + triggerQueryError()} + $variant='danger' + > + React Query 에러 발생 + + + + + ⏱️ 비동기 에러 (콘솔 에러) + + Promise 내부 에러는 ErrorBoundary가 캐치하지 않습니다. try-catch나 + .catch()로 처리해야 합니다. + + + 비동기 에러 발생 + + + + + + 💥 타입 에러 (ErrorBoundary 캐치) + + + null/undefined 접근 에러입니다. 렌더링 중 발생하면 캐치됩니다. + + + 타입 에러 발생 + + + + + ℹ️ 테스트 가이드 + +
  • + ErrorBoundary 캐치: 빨간색 버튼 - 에러 폴백 UI가 + 표시됩니다 +
  • +
  • + 콘솔 에러: 노란색 버튼 - 콘솔에 에러가 기록되지만 + 앱은 정상 동작합니다 +
  • +
  • + Sentry 전송: 모든 에러는 Sentry 대시보드에 + 기록됩니다 +
  • +
    +
    + + (window.location.href = '/')}> + ← 메인 페이지로 돌아가기 + +
    + ); +}; + +export default ErrorTestPage; diff --git a/frontend/src/pages/MainPage/MainPage.styles.ts b/frontend/src/pages/MainPage/MainPage.styles.ts index b8a1a9c1f..641cdddfd 100644 --- a/frontend/src/pages/MainPage/MainPage.styles.ts +++ b/frontend/src/pages/MainPage/MainPage.styles.ts @@ -119,3 +119,30 @@ export const EmptyResult = styled.div` font-size: 0.95rem; } `; + +export const RetryButton = styled.button` + margin-top: 24px; + padding: 12px 32px; + font-size: 16px; + font-weight: 600; + color: white; + background: ${({ theme }) => theme.colors.primary[900]}; + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: ${({ theme }) => theme.colors.primary[800]}; + box-shadow: 0 4px 12px rgba(255, 84, 20, 0.3); + } + + &:active { + transform: scale(0.98); + } + + ${media.mobile} { + padding: 10px 24px; + font-size: 14px; + } +`; diff --git a/frontend/src/pages/MainPage/MainPage.tsx b/frontend/src/pages/MainPage/MainPage.tsx index 7b88f6f7a..f09224ce6 100644 --- a/frontend/src/pages/MainPage/MainPage.tsx +++ b/frontend/src/pages/MainPage/MainPage.tsx @@ -28,7 +28,7 @@ const MainPage = () => { const [active, setActive] = useState<(typeof tabs)[number]>('부경대학교 중앙동아리'); - const { data, error, isLoading } = useGetCardList({ + const { data, error, isLoading, refetch } = useGetCardList({ keyword, recruitmentStatus, category: searchCategory, @@ -46,10 +46,6 @@ const MainPage = () => { return clubs.map((club: Club) => ); }, [clubs, hasData]); - if (error) { - return
    에러가 발생했습니다.
    ; - } - return ( <> @@ -75,10 +71,17 @@ const MainPage = () => { {`전체 ${isLoading ? 0 : totalCount}개의 동아리`} - {isLoading ? ( + ) : error ? ( + + 동아리 목록을 불러오는 중 문제가 발생했습니다. +
    + refetch()}> + 다시 시도 + +
    ) : isEmpty ? ( 앗, 조건에 맞는 동아리가 없어요. diff --git a/frontend/src/utils/initSDK.ts b/frontend/src/utils/initSDK.ts index 4478e1c6c..2f0d113e2 100644 --- a/frontend/src/utils/initSDK.ts +++ b/frontend/src/utils/initSDK.ts @@ -51,6 +51,7 @@ export function initializeSentry() { sendDefaultPii: false, release: import.meta.env.VITE_SENTRY_RELEASE, tracesSampleRate: 0.1, + integrations: [Sentry.browserTracingIntegration()], }); }