453 lines
15 KiB
TypeScript
453 lines
15 KiB
TypeScript
import React, {
|
||
useEffect,
|
||
useState,
|
||
Component,
|
||
ErrorInfo,
|
||
ReactNode,
|
||
Suspense,
|
||
} from "react";
|
||
import { QueryClientProvider } from "@tanstack/react-query";
|
||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||
import { logger } from "@/utils/logger";
|
||
import { Routes, Route, useLocation } from "react-router-dom";
|
||
import { initializeStores } from "./stores/storeInitializer";
|
||
import { queryClient, isDevMode } from "./lib/query/queryClient";
|
||
import { Toaster } from "./components/ui/toaster";
|
||
import { SentryErrorBoundary, captureError, trackPageView } from "./lib/sentry";
|
||
import { initializePWA } from "./utils/pwa";
|
||
import { EnvTest } from "./components/debug/EnvTest";
|
||
// import { setupChunkErrorHandler, resetRetryCount } from "./utils/chunkErrorHandler"; // 임시 비활성화
|
||
import { createLazyComponent } from "./utils/lazyWithRetry";
|
||
import {
|
||
isChunkLoadError,
|
||
isClerkChunkError,
|
||
handleChunkLoadError,
|
||
} from "./utils/chunkErrorProtection";
|
||
import {
|
||
ClerkProvider,
|
||
ClerkDebugInfo,
|
||
} from "./components/providers/ClerkProvider";
|
||
import { BudgetProvider } from "./contexts/budget/BudgetContext";
|
||
|
||
// 페이지 컴포넌트들을 개선된 레이지 로딩으로 변경 (ChunkLoadError 재시도 포함)
|
||
const Index = createLazyComponent(() => import("./pages/Index"));
|
||
const Login = createLazyComponent(() => import("./pages/Login"));
|
||
const Register = createLazyComponent(() => import("./pages/Register"));
|
||
const Settings = createLazyComponent(() => import("./pages/Settings"));
|
||
const Transactions = createLazyComponent(() => import("./pages/Transactions"));
|
||
const Analytics = createLazyComponent(() => import("./pages/Analytics"));
|
||
const ProfileManagement = createLazyComponent(
|
||
() => import("./pages/ProfileManagement")
|
||
);
|
||
const NotFound = createLazyComponent(() => import("./pages/NotFound"));
|
||
const PaymentMethods = createLazyComponent(
|
||
() => import("./pages/PaymentMethods")
|
||
);
|
||
const HelpSupport = createLazyComponent(() => import("./pages/HelpSupport"));
|
||
const SecurityPrivacySettings = createLazyComponent(
|
||
() => import("./pages/SecurityPrivacySettings")
|
||
);
|
||
const NotificationSettings = createLazyComponent(
|
||
() => import("./pages/NotificationSettings")
|
||
);
|
||
const ForgotPassword = createLazyComponent(
|
||
() => import("./pages/ForgotPassword")
|
||
);
|
||
const PWADebugPage = createLazyComponent(() => import("./pages/PWADebugPage"));
|
||
|
||
// 중요하지 않은 컴포넌트들도 개선된 레이지 로딩 적용
|
||
const BackgroundSync = createLazyComponent(
|
||
() => import("./components/sync/BackgroundSync")
|
||
);
|
||
const QueryCacheManager = createLazyComponent(
|
||
() => import("./components/query/QueryCacheManager")
|
||
);
|
||
const OfflineManager = createLazyComponent(
|
||
() => import("./components/offline/OfflineManager")
|
||
);
|
||
const SentryTestButton = createLazyComponent(
|
||
() => import("./components/SentryTestButton")
|
||
);
|
||
const ClerkDebugControl = createLazyComponent(
|
||
() => import("./components/debug/ClerkDebugControl")
|
||
);
|
||
|
||
// Clerk 인증 컴포넌트 (다시 활성화)
|
||
const SignIn = createLazyComponent(() =>
|
||
import("./components/auth/SignIn").then((module) => ({
|
||
default: module.SignIn,
|
||
}))
|
||
);
|
||
const SignUp = createLazyComponent(() =>
|
||
import("./components/auth/SignUp").then((module) => ({
|
||
default: module.SignUp,
|
||
}))
|
||
);
|
||
|
||
// 간단한 오류 경계 컴포넌트 구현
|
||
interface ErrorBoundaryProps {
|
||
children: ReactNode;
|
||
fallback?: ReactNode;
|
||
}
|
||
|
||
interface ErrorBoundaryState {
|
||
hasError: boolean;
|
||
error: Error | null;
|
||
}
|
||
|
||
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||
constructor(props: ErrorBoundaryProps) {
|
||
super(props);
|
||
this.state = { hasError: false, error: null };
|
||
}
|
||
|
||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||
return { hasError: true, error };
|
||
}
|
||
|
||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||
logger.error("애플리케이션 오류:", {
|
||
error: error.message,
|
||
componentStack: errorInfo.componentStack,
|
||
});
|
||
|
||
// ChunkLoadError 처리
|
||
if (isChunkLoadError(error)) {
|
||
if (isClerkChunkError(error)) {
|
||
logger.warn("Error Boundary에서 Clerk 청크 오류 감지. 자동 복구 시도");
|
||
// Clerk 자동 비활성화
|
||
sessionStorage.setItem("disableClerk", "true");
|
||
// 3초 후 새로고침
|
||
setTimeout(() => {
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.set("noClerk", "true");
|
||
url.searchParams.set("_t", Date.now().toString());
|
||
window.location.href = url.toString();
|
||
}, 3000);
|
||
return;
|
||
} else {
|
||
// 일반 청크 오류 처리
|
||
handleChunkLoadError(error);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Sentry에 에러 리포팅 (청크 오류가 아닌 경우만)
|
||
captureError(error, { errorInfo });
|
||
}
|
||
|
||
render(): ReactNode {
|
||
if (this.state.hasError) {
|
||
// ChunkLoadError인 경우 특별한 UI 표시
|
||
if (this.state.error && isChunkLoadError(this.state.error)) {
|
||
const isClerkError = isClerkChunkError(this.state.error);
|
||
|
||
return (
|
||
<div className="flex flex-col items-center justify-center min-h-screen p-4 text-center">
|
||
<div className={`text-4xl mb-4 ${isClerkError ? "🔧" : "⚠️"}`}>
|
||
{isClerkError ? "🔧" : "⚠️"}
|
||
</div>
|
||
<h2 className="text-xl font-bold mb-4">
|
||
{isClerkError ? "Clerk 로딩 오류" : "앱 로딩 오류"}
|
||
</h2>
|
||
<p className="mb-4 text-gray-600">
|
||
{isClerkError
|
||
? "Supabase 인증으로 자동 전환 중입니다. 잠시만 기다려주세요..."
|
||
: "앱을 복구하고 있습니다. 잠시만 기다려주세요..."}
|
||
</p>
|
||
{!isClerkError && (
|
||
<button
|
||
onClick={() => {
|
||
sessionStorage.clear();
|
||
window.location.reload();
|
||
}}
|
||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||
>
|
||
수동 새로고침
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 일반 오류 처리
|
||
return (
|
||
this.props.fallback || (
|
||
<div className="flex flex-col items-center justify-center min-h-screen p-4 text-center">
|
||
<h2 className="text-xl font-bold mb-4">
|
||
앱 로딩 중 오류가 발생했습니다
|
||
</h2>
|
||
<p className="mb-4">잠시 후 다시 시도해주세요.</p>
|
||
<button
|
||
onClick={() => window.location.reload()}
|
||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||
>
|
||
새로고침
|
||
</button>
|
||
</div>
|
||
)
|
||
);
|
||
}
|
||
|
||
return this.props.children;
|
||
}
|
||
}
|
||
|
||
// 로딩 상태 표시 컴포넌트
|
||
const LoadingScreen: React.FC = () => (
|
||
<div className="flex flex-col items-center justify-center min-h-screen p-4 text-center bg-neuro-background">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500 mb-4"></div>
|
||
<h2 className="text-xl font-bold mb-2">Zellyy Finance</h2>
|
||
<p className="text-gray-600">앱을 로딩하고 있습니다...</p>
|
||
<div className="mt-4 text-xs text-gray-500">
|
||
환경: {import.meta.env.MODE} | Clerk:{" "}
|
||
{import.meta.env.VITE_CLERK_PUBLISHABLE_KEY ? "✓" : "✗"} | Supabase:{" "}
|
||
{import.meta.env.VITE_SUPABASE_URL ? "✓" : "✗"}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
// 페이지 로딩 컴포넌트 (코드 스플리팅용)
|
||
const PageLoadingSpinner: React.FC = () => (
|
||
<div className="flex flex-col items-center justify-center min-h-[50vh] p-4 text-center">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500 mb-2"></div>
|
||
<p className="text-gray-600 text-sm">페이지를 로딩하고 있습니다...</p>
|
||
</div>
|
||
);
|
||
|
||
// 페이지 전환 추적 컴포넌트
|
||
const PageTracker = () => {
|
||
const location = useLocation();
|
||
|
||
useEffect(() => {
|
||
// 페이지 이름 매핑
|
||
const getPageName = (pathname: string) => {
|
||
const pageMap: Record<string, string> = {
|
||
"/": "홈",
|
||
"/login": "로그인",
|
||
"/register": "회원가입",
|
||
"/forgot-password": "비밀번호 찾기",
|
||
"/analytics": "분석",
|
||
"/transactions": "거래내역",
|
||
"/settings": "설정",
|
||
"/profile": "프로필",
|
||
"/help": "도움말",
|
||
};
|
||
return pageMap[pathname] || pathname;
|
||
};
|
||
|
||
const pageName = getPageName(location.pathname);
|
||
trackPageView(pageName, location.pathname + location.search);
|
||
}, [location]);
|
||
|
||
return null;
|
||
};
|
||
|
||
// 오류 화면 컴포넌트
|
||
const ErrorScreen: React.FC<{ error: Error | null; retry: () => void }> = ({
|
||
error,
|
||
retry,
|
||
}) => (
|
||
<div className="flex flex-col items-center justify-center min-h-screen p-4 text-center bg-neuro-background">
|
||
<div className="text-red-500 text-5xl mb-4">⚠️</div>
|
||
<h2 className="text-xl font-bold mb-4">애플리케이션 오류</h2>
|
||
<p className="text-center mb-6">
|
||
{error?.message || "애플리케이션 로딩 중 오류가 발생했습니다."}
|
||
</p>
|
||
<button
|
||
onClick={retry}
|
||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||
>
|
||
재시도
|
||
</button>
|
||
</div>
|
||
);
|
||
|
||
// 기본 레이아웃 컴포넌트 - 인증 없이도 표시 가능
|
||
const BasicLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||
<div className="App">
|
||
{children}
|
||
<Toaster />
|
||
</div>
|
||
);
|
||
|
||
function App() {
|
||
const [appState, setAppState] = useState<"loading" | "error" | "ready">(
|
||
"loading"
|
||
);
|
||
const [error, setError] = useState<Error | null>(null);
|
||
|
||
useEffect(() => {
|
||
document.title = "Zellyy Finance";
|
||
// eslint-disable-next-line no-console
|
||
console.log("🚀 App useEffect 실행됨");
|
||
|
||
// 프로덕션 환경에서 간단한 초기화 테스트
|
||
const simpleInitialize = async () => {
|
||
try {
|
||
// eslint-disable-next-line no-console
|
||
console.log("🔧 간단한 초기화 시작");
|
||
// eslint-disable-next-line no-console
|
||
console.log("환경:", import.meta.env.MODE);
|
||
// eslint-disable-next-line no-console
|
||
console.log("모든 환경변수:", import.meta.env);
|
||
// eslint-disable-next-line no-console
|
||
console.log(
|
||
"VITE_CLERK_PUBLISHABLE_KEY:",
|
||
import.meta.env.VITE_CLERK_PUBLISHABLE_KEY || "없음"
|
||
);
|
||
// eslint-disable-next-line no-console
|
||
console.log(
|
||
"VITE_SUPABASE_URL:",
|
||
import.meta.env.VITE_SUPABASE_URL || "없음"
|
||
);
|
||
|
||
// 매우 간단한 초기화만 수행
|
||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||
|
||
// eslint-disable-next-line no-console
|
||
console.log("✅ 간단한 초기화 완료 - ready 상태로 변경");
|
||
setAppState("ready");
|
||
} catch (error) {
|
||
console.error("❌ 간단한 초기화 실패:", error);
|
||
setError(error instanceof Error ? error : new Error("초기화 실패"));
|
||
setAppState("error");
|
||
}
|
||
};
|
||
|
||
simpleInitialize();
|
||
|
||
// 컴포넌트 언마운트 시 정리
|
||
return () => {
|
||
// eslint-disable-next-line no-console
|
||
console.log("🧹 App 컴포넌트 정리");
|
||
};
|
||
}, []);
|
||
|
||
// 재시도 기능
|
||
const handleRetry = async () => {
|
||
setAppState("loading");
|
||
setError(null);
|
||
|
||
try {
|
||
// 재시도 시 PWA 및 스토어 재초기화
|
||
await initializePWA();
|
||
await initializeStores();
|
||
setAppState("ready");
|
||
} catch (error) {
|
||
logger.error(
|
||
"재시도 실패",
|
||
error instanceof Error
|
||
? { message: error.message, stack: error.stack }
|
||
: String(error)
|
||
);
|
||
setError(error instanceof Error ? error : new Error("재시도 실패"));
|
||
setAppState("error");
|
||
}
|
||
};
|
||
|
||
// 로딩 상태 표시
|
||
if (appState === "loading") {
|
||
return (
|
||
<ErrorBoundary
|
||
fallback={<ErrorScreen error={error} retry={handleRetry} />}
|
||
>
|
||
<LoadingScreen />
|
||
</ErrorBoundary>
|
||
);
|
||
}
|
||
|
||
// 오류 상태 표시
|
||
if (appState === "error") {
|
||
return <ErrorScreen error={error} retry={handleRetry} />;
|
||
}
|
||
|
||
return (
|
||
<ClerkProvider>
|
||
<QueryClientProvider client={queryClient}>
|
||
<SentryErrorBoundary
|
||
fallback={<ErrorScreen error={error} retry={handleRetry} />}
|
||
showDialog={false}
|
||
>
|
||
<BudgetProvider>
|
||
<BasicLayout>
|
||
<PageTracker />
|
||
<Suspense fallback={<PageLoadingSpinner />}>
|
||
<Routes>
|
||
<Route path="/" element={<Index />} />
|
||
{/* Clerk 라우트 다시 활성화 */}
|
||
<Route path="/sign-in/*" element={<SignIn />} />
|
||
<Route path="/sign-up/*" element={<SignUp />} />
|
||
<Route path="/login" element={<Login />} />
|
||
<Route path="/register" element={<Register />} />
|
||
<Route path="/settings" element={<Settings />} />
|
||
<Route path="/transactions" element={<Transactions />} />
|
||
<Route path="/analytics" element={<Analytics />} />
|
||
<Route path="/profile" element={<ProfileManagement />} />
|
||
<Route path="/payment-methods" element={<PaymentMethods />} />
|
||
<Route path="/help-support" element={<HelpSupport />} />
|
||
<Route
|
||
path="/security-privacy"
|
||
element={<SecurityPrivacySettings />}
|
||
/>
|
||
<Route
|
||
path="/notifications"
|
||
element={<NotificationSettings />}
|
||
/>
|
||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||
<Route path="/pwa-debug" element={<PWADebugPage />} />
|
||
<Route path="*" element={<NotFound />} />
|
||
</Routes>
|
||
</Suspense>
|
||
{/* React Query 캐시 관리 */}
|
||
<Suspense fallback={null}>
|
||
<QueryCacheManager
|
||
cleanupIntervalMinutes={30}
|
||
enableOfflineCache={true}
|
||
enableCacheAnalysis={isDevMode}
|
||
/>
|
||
</Suspense>
|
||
|
||
{/* 오프라인 상태 관리 */}
|
||
<Suspense fallback={null}>
|
||
<OfflineManager
|
||
showOfflineToast={true}
|
||
autoSyncOnReconnect={true}
|
||
/>
|
||
</Suspense>
|
||
|
||
{/* 백그라운드 자동 동기화 - 성능 최적화로 30초 간격으로 조정 */}
|
||
<Suspense fallback={null}>
|
||
<BackgroundSync
|
||
intervalMinutes={0.5}
|
||
syncOnFocus={true}
|
||
syncOnOnline={true}
|
||
/>
|
||
</Suspense>
|
||
|
||
{/* 개발환경에서 Sentry 테스트 버튼 */}
|
||
<Suspense fallback={null}>
|
||
<SentryTestButton />
|
||
</Suspense>
|
||
|
||
{/* 개발환경에서 Clerk 상태 디버깅 */}
|
||
<ClerkDebugInfo />
|
||
|
||
{/* 개발환경에서 환경 변수 테스트 */}
|
||
{isDevMode && <EnvTest />}
|
||
|
||
{/* Clerk 디버그 및 제어 */}
|
||
<Suspense fallback={null}>
|
||
<ClerkDebugControl />
|
||
</Suspense>
|
||
</BasicLayout>
|
||
</BudgetProvider>
|
||
{isDevMode && <ReactQueryDevtools initialIsOpen={false} />}
|
||
</SentryErrorBoundary>
|
||
</QueryClientProvider>
|
||
</ClerkProvider>
|
||
);
|
||
}
|
||
|
||
export default App;
|