From 31be9d4a25990e0d4d47d57f3f0dd9375d40aae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Thu, 12 Feb 2026 17:04:42 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EC=97=90=EB=9F=AC=20=EB=A6=AC?= =?UTF-8?q?=ED=8F=AC=ED=8C=85=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=B0=8F=20?= =?UTF-8?q?ErrorBoundary=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 에러 리포팅 유틸리티 및 API route 추가 - ErrorBoundary(error.tsx) 에러 리포팅 연동 - providers 컴포넌트 구조 추가 - layout에 provider 적용 Co-Authored-By: Claude Opus 4.6 --- claudedocs/_index.md | 16 +- src/app/[locale]/(protected)/error.tsx | 11 +- src/app/[locale]/error.tsx | 11 +- src/app/[locale]/layout.tsx | 2 + src/app/api/error-report/route.ts | 178 ++++++++++++++++++ .../providers/ChunkErrorHandler.tsx | 68 +++++++ src/lib/error-reporting.ts | 48 +++++ 7 files changed, 331 insertions(+), 3 deletions(-) create mode 100644 src/app/api/error-report/route.ts create mode 100644 src/components/providers/ChunkErrorHandler.tsx create mode 100644 src/lib/error-reporting.ts diff --git a/claudedocs/_index.md b/claudedocs/_index.md index c0467c86..ea24616c 100644 --- a/claudedocs/_index.md +++ b/claudedocs/_index.md @@ -1,6 +1,6 @@ # claudedocs 문서 맵 -> 프로젝트 기술 문서 인덱스 (Last Updated: 2026-02-10) +> 프로젝트 기술 문서 인덱스 (Last Updated: 2026-02-12) ## 빠른 참조 @@ -116,6 +116,19 @@ export async function downloadExcel(...) { **도입 시점**: 동일 데이터를 여러 컴포넌트에서 동시 요구하거나, 목록 ↔ 상세 이동 시 재로딩이 체감될 때 +### 컴포넌트 레지스트리 관계도 (2026-02-12) + +**구현**: `/dev/component-registry` 페이지에 관계도(카드형 플로우) 뷰 추가 + +**구성**: +- `actions.ts` — `extractComponentImports()` + `buildRelationships()`로 import 관계 양방향 파싱 (imports/usedBy) +- `ComponentRelationshipView.tsx` — 3칼럼 카드형 플로우 (사용처 → 선택 컴포넌트 → 구성요소) +- `ComponentRegistryClient.tsx` — 목록/관계도 뷰 토글 + +**활용 규칙** (CLAUDE.md에 추가됨): +- 새 컴포넌트 생성 전 → 목록에서 중복 검색 + 관계도에서 조합 패턴 확인 +- 기존 컴포넌트 수정 시 → usedBy로 영향 범위 파악 + ### Action 팩토리 패턴 — 신규 CRUD 적용 규칙 (2026-02-10) **결정**: 기존 84개 actions.ts 전면 전환은 하지 않음. **신규 CRUD 도메인에만 팩토리 사용** @@ -404,6 +417,7 @@ claudedocs/ | `[IMPL-2026-01-23] full-page-inspection.md` | 전체 페이지 검사 | | `[FIX-2026-01-29] typecheck-errors-checklist.md` | 타입체크 에러 체크리스트 | | `[HOTFIX-2026-01-27] E2E-테스트-수정계획서.md` | E2E 테스트 수정 계획서 | +| **Component Registry** | `/dev/component-registry` — 실시간 컴포넌트 스캔 + 관계도 (목록/카드형 플로우 뷰) | --- diff --git a/src/app/[locale]/(protected)/error.tsx b/src/app/[locale]/(protected)/error.tsx index 8534082c..d18f612f 100644 --- a/src/app/[locale]/(protected)/error.tsx +++ b/src/app/[locale]/(protected)/error.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import Link from 'next/link'; import { AlertCircle, Home, RotateCcw, Shield } from 'lucide-react'; +import { reportErrorToSlack } from '@/lib/error-reporting'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -22,8 +23,16 @@ export default function ProtectedError({ reset: () => void; }) { useEffect(() => { - // 에러 로깅 console.error('🔴 Protected Route Error:', error); + reportErrorToSlack({ + errorType: 'ERROR_BOUNDARY', + message: error.message, + pageUrl: typeof window !== 'undefined' ? window.location.href : '', + timestamp: new Date().toISOString(), + digest: error.digest, + stack: error.stack?.slice(0, 500), + userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined, + }); }, [error]); return ( diff --git a/src/app/[locale]/error.tsx b/src/app/[locale]/error.tsx index dd9d36c8..840aaaa3 100644 --- a/src/app/[locale]/error.tsx +++ b/src/app/[locale]/error.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import Link from 'next/link'; import { AlertTriangle, Home, RotateCcw, Bug } from 'lucide-react'; +import { reportErrorToSlack } from '@/lib/error-reporting'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -26,8 +27,16 @@ export default function GlobalError({ reset: () => void; }) { useEffect(() => { - // 에러 로깅 (Sentry, LogRocket 등) console.error('🔴 Global Error:', error); + reportErrorToSlack({ + errorType: 'ERROR_BOUNDARY', + message: error.message, + pageUrl: typeof window !== 'undefined' ? window.location.href : '', + timestamp: new Date().toISOString(), + digest: error.digest, + stack: error.stack?.slice(0, 500), + userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined, + }); }, [error]); return ( diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 7d8430f3..353d9ab7 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -6,6 +6,7 @@ import { notFound } from 'next/navigation'; import { locales, type Locale } from '@/i18n/config'; import { ThemeProvider } from '@/contexts/ThemeContext'; import { Toaster } from 'sonner'; +import { ChunkErrorHandler } from '@/components/providers/ChunkErrorHandler'; import "../globals.css"; // 🔧 Pretendard Variable 폰트 - FOUT 완전 방지 @@ -99,6 +100,7 @@ export default async function RootLayout({ + {children} diff --git a/src/app/api/error-report/route.ts b/src/app/api/error-report/route.ts new file mode 100644 index 00000000..fb5bf3a9 --- /dev/null +++ b/src/app/api/error-report/route.ts @@ -0,0 +1,178 @@ +import { NextRequest, NextResponse } from 'next/server'; + +/** + * POST /api/error-report + * + * 클라이언트 에러를 수신하여 Slack Webhook으로 전송 + * - 서버 측 스로틀링: 동일 에러 30초 쿨다운 + * - Webhook URL 미설정 시 조용히 실패 + */ + +interface ErrorReport { + errorType: 'ERROR_BOUNDARY' | 'CHUNK_LOAD_ERROR'; + message: string; + pageUrl: string; + timestamp: string; + digest?: string; + stack?: string; + userAgent?: string; +} + +const VALID_TYPES = new Set(['ERROR_BOUNDARY', 'CHUNK_LOAD_ERROR']); +const SERVER_COOLDOWN_MS = 30_000; +const MAX_THROTTLE_ENTRIES = 500; +const recentErrors = new Map(); + +function cleanupThrottleMap() { + if (recentErrors.size <= MAX_THROTTLE_ENTRIES) return; + const now = Date.now(); + for (const [key, ts] of recentErrors) { + if (now - ts > SERVER_COOLDOWN_MS) { + recentErrors.delete(key); + } + } + // 정리 후에도 초과하면 전체 초기화 + if (recentErrors.size > MAX_THROTTLE_ENTRIES) { + recentErrors.clear(); + } +} + +function isThrottled(report: ErrorReport): boolean { + const key = `${report.errorType}::${report.message}`; + const now = Date.now(); + const last = recentErrors.get(key); + + if (last && now - last < SERVER_COOLDOWN_MS) { + return true; + } + + cleanupThrottleMap(); + recentErrors.set(key, now); + return false; +} + +function getErrorLabel(type: string): string { + return type === 'ERROR_BOUNDARY' + ? 'Error Boundary (컴포넌트 크래시)' + : 'ChunkLoadError (JS 로딩 실패)'; +} + +function buildSlackPayload(report: ErrorReport) { + const blocks: Record[] = [ + { + type: 'header', + text: { type: 'plain_text', text: '🔴 Frontend Error', emoji: true }, + }, + { + type: 'section', + fields: [ + { type: 'mrkdwn', text: `*Type:*\n${getErrorLabel(report.errorType)}` }, + { type: 'mrkdwn', text: `*Time:*\n${report.timestamp}` }, + ], + }, + { + type: 'section', + fields: [ + { type: 'mrkdwn', text: `*Page:*\n${report.pageUrl}` }, + ...(report.digest + ? [{ type: 'mrkdwn', text: `*Digest:*\n${report.digest}` }] + : []), + ], + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Error:*\n\`\`\`${report.message.slice(0, 300)}\`\`\``, + }, + }, + ]; + + if (report.stack) { + blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: `*Stack:*\n\`\`\`${report.stack.slice(0, 300)}\`\`\``, + }, + }); + } + + if (report.userAgent) { + blocks.push({ + type: 'context', + elements: [ + { type: 'mrkdwn', text: `UA: ${report.userAgent.slice(0, 150)}` }, + ], + }); + } + + return { blocks }; +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // 입력 검증 + if ( + !body.errorType || + !VALID_TYPES.has(body.errorType) || + !body.message || + !body.pageUrl || + !body.timestamp + ) { + return NextResponse.json( + { error: 'Invalid error report' }, + { status: 400 } + ); + } + + const report: ErrorReport = { + errorType: body.errorType, + message: String(body.message).slice(0, 1000), + pageUrl: String(body.pageUrl).slice(0, 500), + timestamp: String(body.timestamp), + digest: body.digest ? String(body.digest).slice(0, 100) : undefined, + stack: body.stack ? String(body.stack).slice(0, 500) : undefined, + userAgent: body.userAgent + ? String(body.userAgent).slice(0, 300) + : undefined, + }; + + // 서버 측 스로틀링 + if (isThrottled(report)) { + return NextResponse.json({ status: 'throttled' }); + } + + // Webhook URL 확인 + const webhookUrl = process.env.SLACK_FRONTEND_WEBHOOK_URL; + if (!webhookUrl) { + console.warn('[error-report] SLACK_FRONTEND_WEBHOOK_URL not configured'); + return NextResponse.json({ status: 'skipped' }); + } + + // Slack 전송 + const slackRes = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildSlackPayload(report)), + }); + + if (!slackRes.ok) { + console.error('[error-report] Slack webhook failed:', slackRes.status); + return NextResponse.json( + { status: 'slack_error' }, + { status: 502 } + ); + } + + return NextResponse.json({ status: 'sent' }); + } catch (err) { + console.error('[error-report] Unexpected error:', err); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/components/providers/ChunkErrorHandler.tsx b/src/components/providers/ChunkErrorHandler.tsx new file mode 100644 index 00000000..45ba84b1 --- /dev/null +++ b/src/components/providers/ChunkErrorHandler.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { useEffect } from 'react'; +import { reportErrorToSlack } from '@/lib/error-reporting'; + +/** + * ChunkLoadError 전역 감지 컴포넌트 + * + * 배포 후 캐시된 구버전 JS 청크 로딩 실패를 감지하여 Slack으로 전송 + * - window 'error' 이벤트: