feat(WEB): 에러 리포팅 시스템 및 ErrorBoundary 개선
- 에러 리포팅 유틸리티 및 API route 추가 - ErrorBoundary(error.tsx) 에러 리포팅 연동 - providers 컴포넌트 구조 추가 - layout에 provider 적용 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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` — 실시간 컴포넌트 스캔 + 관계도 (목록/카드형 플로우 뷰) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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({
|
||||
<body className={`${pretendard.className} antialiased`} suppressHydrationWarning>
|
||||
<ThemeProvider>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<ChunkErrorHandler />
|
||||
{children}
|
||||
<Toaster richColors position="top-right" />
|
||||
</NextIntlClientProvider>
|
||||
|
||||
178
src/app/api/error-report/route.ts
Normal file
178
src/app/api/error-report/route.ts
Normal file
@@ -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<string, number>();
|
||||
|
||||
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<string, unknown>[] = [
|
||||
{
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
68
src/components/providers/ChunkErrorHandler.tsx
Normal file
68
src/components/providers/ChunkErrorHandler.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { reportErrorToSlack } from '@/lib/error-reporting';
|
||||
|
||||
/**
|
||||
* ChunkLoadError 전역 감지 컴포넌트
|
||||
*
|
||||
* 배포 후 캐시된 구버전 JS 청크 로딩 실패를 감지하여 Slack으로 전송
|
||||
* - window 'error' 이벤트: <script> 태그 로딩 실패
|
||||
* - window 'unhandledrejection': dynamic import() 실패
|
||||
*/
|
||||
|
||||
const CHUNK_ERROR_PATTERNS = [
|
||||
'Loading chunk',
|
||||
'ChunkLoadError',
|
||||
'Failed to fetch dynamically imported module',
|
||||
'error loading dynamically imported module',
|
||||
];
|
||||
|
||||
function isChunkError(message: string): boolean {
|
||||
return CHUNK_ERROR_PATTERNS.some((pattern) =>
|
||||
message.toLowerCase().includes(pattern.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
function sendReport(message: string, stack?: string) {
|
||||
reportErrorToSlack({
|
||||
errorType: 'CHUNK_LOAD_ERROR',
|
||||
message,
|
||||
pageUrl: window.location.href,
|
||||
timestamp: new Date().toISOString(),
|
||||
stack: stack?.slice(0, 500),
|
||||
userAgent: navigator.userAgent,
|
||||
});
|
||||
}
|
||||
|
||||
export function ChunkErrorHandler() {
|
||||
useEffect(() => {
|
||||
function handleError(event: ErrorEvent) {
|
||||
if (event.message && isChunkError(event.message)) {
|
||||
sendReport(event.message, event.error?.stack);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRejection(event: PromiseRejectionEvent) {
|
||||
const reason = event.reason;
|
||||
const message =
|
||||
reason instanceof Error ? reason.message : String(reason ?? '');
|
||||
if (isChunkError(message)) {
|
||||
sendReport(
|
||||
message,
|
||||
reason instanceof Error ? reason.stack?.slice(0, 500) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('error', handleError);
|
||||
window.addEventListener('unhandledrejection', handleRejection);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('error', handleError);
|
||||
window.removeEventListener('unhandledrejection', handleRejection);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
48
src/lib/error-reporting.ts
Normal file
48
src/lib/error-reporting.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Frontend Error → Slack 알림 유틸리티
|
||||
*
|
||||
* 프로덕션에서만 /api/error-report로 POST 전송
|
||||
* 동일 에러(type+message) 60초 쿨다운 → 스팸 방지
|
||||
*/
|
||||
|
||||
export interface ErrorReport {
|
||||
errorType: 'ERROR_BOUNDARY' | 'CHUNK_LOAD_ERROR';
|
||||
message: string;
|
||||
pageUrl: string;
|
||||
timestamp: string;
|
||||
digest?: string;
|
||||
stack?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
const COOLDOWN_MS = 60_000;
|
||||
const recentErrors = new Map<string, number>();
|
||||
|
||||
function makeKey(report: ErrorReport): string {
|
||||
return `${report.errorType}::${report.message}`;
|
||||
}
|
||||
|
||||
export function reportErrorToSlack(report: ErrorReport): void {
|
||||
const key = makeKey(report);
|
||||
const now = Date.now();
|
||||
const last = recentErrors.get(key);
|
||||
|
||||
if (last && now - last < COOLDOWN_MS) {
|
||||
return; // 쿨다운 중 → 스킵
|
||||
}
|
||||
recentErrors.set(key, now);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log('[ErrorReport] Dev mode — would send:', report);
|
||||
return;
|
||||
}
|
||||
|
||||
// fire-and-forget
|
||||
fetch('/api/error-report', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(report),
|
||||
}).catch(() => {
|
||||
// 절대 throw 안 함
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user