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

@@ -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` — 실시간 컴포넌트 스캔 + 관계도 (목록/카드형 플로우 뷰) |
---

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

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

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