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

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