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' 이벤트: