diff --git a/src/App.tsx b/src/App.tsx index ed6474b..b346190 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,7 +27,12 @@ import { createLazyComponent, resetChunkRetryFlags, } from "./utils/lazyWithRetry"; -import { setupChunkErrorProtection } from "./utils/chunkErrorProtection"; +import { + setupChunkErrorProtection, + isChunkLoadError, + isClerkChunkError, + handleChunkLoadError, +} from "./utils/chunkErrorProtection"; import { ClerkProvider, ClerkDebugInfo, @@ -111,13 +116,67 @@ class ErrorBoundary extends Component { componentDidCatch(error: Error, errorInfo: ErrorInfo): void { logger.error("애플리케이션 오류:", error, errorInfo); - // Sentry에 에러 리포팅 + + // 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) { - // 오류 발생 시 대체 UI 표시 + // ChunkLoadError인 경우 특별한 UI 표시 + if (this.state.error && isChunkLoadError(this.state.error)) { + const isClerkError = isClerkChunkError(this.state.error); + + return ( +
+
+ {isClerkError ? "🔧" : "⚠️"} +
+

+ {isClerkError ? "Clerk 로딩 오류" : "앱 로딩 오류"} +

+

+ {isClerkError + ? "Supabase 인증으로 자동 전환 중입니다. 잠시만 기다려주세요..." + : "앱을 복구하고 있습니다. 잠시만 기다려주세요..."} +

+ {!isClerkError && ( + + )} +
+ ); + } + + // 일반 오류 처리 return ( this.props.fallback || (
diff --git a/src/components/providers/ClerkProvider.tsx b/src/components/providers/ClerkProvider.tsx index d7e8499..8bb3287 100644 --- a/src/components/providers/ClerkProvider.tsx +++ b/src/components/providers/ClerkProvider.tsx @@ -14,8 +14,8 @@ import { isClerkEnabled } from "@/lib/clerk/utils"; import { setUser, clearUser } from "@/lib/sentry"; import { isChunkLoadError, - handleChunkLoadError, -} from "@/utils/chunkErrorHandler"; + isClerkChunkError, +} from "@/utils/chunkErrorProtection"; // Mock Clerk Context for when Clerk is disabled const MockClerkContext = createContext({ @@ -126,16 +126,14 @@ export const ClerkProvider: React.FC = ({ children }) => { // ChunkLoadError인 경우 처리 if (isChunkLoadError(clerkLoadError)) { - // 에러 핸들러 호출 (자동 새로고침은 하지 않음) - handleChunkLoadError(clerkLoadError); + // Clerk 관련 청크 오류인 경우 즉시 비활성화 + if (isClerkChunkError(clerkLoadError)) { + logger.warn( + "Clerk 청크 로딩 오류 감지. 자동으로 Supabase 인증으로 전환" + ); + sessionStorage.setItem("disableClerk", "true"); - // 최대 재시도 초과 확인 - const maxRetriesReached = - sessionStorage.getItem("chunkLoadErrorMaxRetries") === "true"; - - if (maxRetriesReached) { - // 재시도 초과 시 Mock Context와 함께 진행 - logger.warn("Clerk 로딩 최대 재시도 초과. Mock Context와 함께 앱 실행"); + // Mock Context와 함께 진행 return ( = ({ children }) => { ); } - // 재시도 중 표시 + // 일반 청크 오류인 경우 사용자에게 선택 제공 return (
⚠️

인증 모듈 로딩 실패

-

네트워크 연결을 확인해주세요.

- - +

+ 네트워크 연결을 확인하거나 인증 없이 계속 사용할 수 있습니다. +

+
+ + +
); } diff --git a/src/main.tsx b/src/main.tsx index b0bf313..6953020 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,11 +1,15 @@ import { createRoot } from "react-dom/client"; import { logger } from "@/utils/logger"; import { BrowserRouter } from "react-router-dom"; +import { setupChunkErrorProtection } from "@/utils/chunkErrorProtection"; import App from "./App.tsx"; import "./index.css"; logger.info("main.tsx loaded"); +// 청크 로딩 오류 보호 시스템 즉시 활성화 +setupChunkErrorProtection(); + // iOS 안전 영역 메타 태그 추가 const setViewportMetaTag = () => { // 기존 viewport 메타 태그 찾기 diff --git a/src/utils/chunkErrorProtection.ts b/src/utils/chunkErrorProtection.ts index fb3a768..0715eb7 100644 --- a/src/utils/chunkErrorProtection.ts +++ b/src/utils/chunkErrorProtection.ts @@ -38,10 +38,44 @@ export const isClerkChunkError = (error: unknown): boolean => { return ( errorMessage.includes("clerk") || errorMessage.includes("@clerk") || - errorMessage.includes("clerk.accounts.dev") + errorMessage.includes("clerk.accounts.dev") || + errorMessage.includes("framework_clerk") || + errorMessage.includes("clerk-js") || + errorMessage.includes("joint-cheetah-86.clerk.accounts.dev") ); }; +/** + * 사용자에게 오류 상황을 알리는 임시 메시지 표시 + */ +const showTempErrorMessage = (message: string, isClerkError = false) => { + // 기존 메시지가 있으면 제거 + const existingMessage = document.getElementById("temp-error-message"); + if (existingMessage) { + existingMessage.remove(); + } + + const messageDiv = document.createElement("div"); + messageDiv.id = "temp-error-message"; + messageDiv.style.cssText = ` + position: fixed; top: 20px; left: 50%; transform: translateX(-50%); z-index: 10000; + background: ${isClerkError ? "#f59e0b" : "#ef4444"}; color: white; padding: 12px 24px; + border-radius: 8px; font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 14px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); + max-width: 90vw; text-align: center; + `; + messageDiv.textContent = message; + + document.body.appendChild(messageDiv); + + // 3초 후 자동 제거 + setTimeout(() => { + if (messageDiv.parentNode) { + messageDiv.remove(); + } + }, 3000); +}; + /** * ChunkLoadError 발생 시 즉시 Clerk 비활성화 */ @@ -51,11 +85,17 @@ export const handleChunkLoadError = (error: unknown): void => { if (isClerkChunkError(error)) { logger.warn("Clerk 관련 ChunkLoadError 감지. Clerk을 비활성화합니다."); + // 사용자에게 알림 + showTempErrorMessage( + "🔧 로그인 서비스 연결 오류가 발생했습니다. Supabase 인증으로 전환하여 복구 중...", + true + ); + // Clerk 비활성화 플래그 설정 sessionStorage.setItem("disableClerk", "true"); sessionStorage.setItem("chunkLoadErrorMaxRetries", "true"); - // 2초 후 페이지 새로고침 (Clerk 없이 로드) + // 3초 후 페이지 새로고침 (Clerk 없이 로드) setTimeout(() => { const url = new URL(window.location.href); url.searchParams.set("noClerk", "true"); @@ -63,14 +103,20 @@ export const handleChunkLoadError = (error: unknown): void => { logger.info("Clerk 비활성화 후 페이지 새로고침"); window.location.href = url.toString(); - }, 2000); + }, 3000); } else { // 일반적인 청크 오류는 단순 새로고침 logger.warn("일반 ChunkLoadError 감지. 페이지를 새로고침합니다."); + showTempErrorMessage( + "⚠️ 앱 로딩 중 오류가 발생했습니다. 곧 자동으로 복구됩니다..." + ); + setTimeout(() => { - window.location.reload(); - }, 1000); + const url = new URL(window.location.href); + url.searchParams.set("_t", Date.now().toString()); + window.location.href = url.toString(); + }, 2000); } }; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index d7ca7bd..eb4486c 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -45,11 +45,9 @@ const createLogger = (): Logger => { console.info(formatMessage("info", message, meta)); }, warn: (message: string, meta?: LogMeta) => { - console.warn(formatMessage("warn", message, meta)); }, error: (message: string, error?: LogMeta) => { - console.error(formatMessage("error", message, error)); }, }; @@ -61,7 +59,7 @@ const createLogger = (): Logger => { warn: () => {}, // 프로덕션에서는 무시 error: (message: string, error?: LogMeta) => { // 프로덕션에서도 에러는 기록 (향후 Sentry 연동) - + console.error(formatMessage("error", message, error)); }, }; diff --git a/vite.config.ts b/vite.config.ts index 20c76aa..12cde3e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -110,12 +110,35 @@ export default defineConfig(({ mode }) => ({ // 청크 로딩 실패에 대한 재시도 설정 target: "esnext", rollupOptions: { + // 외부 종속성 명시적 처리 (CDN 오류 방지) + external: (id) => { + // Clerk CDN 관련 오류 방지를 위해 조건부 외부화 + if ( + id.includes("@clerk") && + process.env.VITE_DISABLE_CLERK === "true" + ) { + return true; + } + return false; + }, output: { // 청크 파일명 일관성 보장 (ChunkLoadError 방지) chunkFileNames: "assets/[name]-[hash].js", entryFileNames: "assets/[name]-[hash].js", assetFileNames: "assets/[name]-[hash].[ext]", + // 청크 로딩 실패 시 재시도 로직 추가 + intro: ` + window.__vitePreloadOriginal = window.__vitePreload; + window.__vitePreload = function(baseModule, deps) { + return window.__vitePreloadOriginal(baseModule, deps).catch(err => { + console.warn('Chunk loading failed, retrying...', err); + // 청크 오류 처리 시스템이 이미 활성화되어 있으므로 에러를 다시 던짐 + throw err; + }); + }; + `, + manualChunks: (id) => { // 노드 모듈들을 카테고리별로 분할 if (id.includes("node_modules")) {