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:
유병철
2026-02-12 17:04:42 +09:00
parent 3aeaaa76a8
commit 31be9d4a25
7 changed files with 331 additions and 3 deletions

View File

@@ -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 (

View File

@@ -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 (

View File

@@ -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>

View 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 }
);
}
}