361 lines
9.8 KiB
TypeScript
361 lines
9.8 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* V2 ErrorBoundary - 컴포넌트별 에러 격리
|
|
*
|
|
* 특징:
|
|
* - 각 컴포넌트의 에러가 다른 컴포넌트에 영향을 주지 않음
|
|
* - 폴백 UI 제공
|
|
* - 재시도 기능
|
|
* - 에러 로깅 및 이벤트 발행
|
|
*/
|
|
|
|
import React, { Component, ErrorInfo, ReactNode } from "react";
|
|
import { v2EventBus, V2_EVENTS } from "../events";
|
|
import { AlertCircle, RefreshCw } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
|
|
interface V2ErrorBoundaryProps {
|
|
/** 자식 컴포넌트 */
|
|
children: ReactNode;
|
|
/** 컴포넌트 ID (에러 추적용) */
|
|
componentId: string;
|
|
/** 컴포넌트 타입 (에러 추적용) */
|
|
componentType: string;
|
|
/** 사용자 정의 폴백 UI */
|
|
fallback?: ReactNode | ((error: Error, retry: () => void) => ReactNode);
|
|
/** 폴백 UI 표시 방식 */
|
|
fallbackStyle?: "minimal" | "compact" | "full";
|
|
/** 에러 발생 시 콜백 */
|
|
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
|
/** 복구 가능 여부 */
|
|
recoverable?: boolean;
|
|
/** 자동 재시도 횟수 (0이면 자동 재시도 안 함) */
|
|
autoRetryCount?: number;
|
|
/** 자동 재시도 간격 (ms) */
|
|
autoRetryDelay?: number;
|
|
}
|
|
|
|
interface V2ErrorBoundaryState {
|
|
hasError: boolean;
|
|
error: Error | null;
|
|
errorInfo: ErrorInfo | null;
|
|
retryCount: number;
|
|
}
|
|
|
|
export class V2ErrorBoundary extends Component<
|
|
V2ErrorBoundaryProps,
|
|
V2ErrorBoundaryState
|
|
> {
|
|
private retryTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
static defaultProps = {
|
|
fallbackStyle: "compact" as const,
|
|
recoverable: true,
|
|
autoRetryCount: 0,
|
|
autoRetryDelay: 3000,
|
|
};
|
|
|
|
constructor(props: V2ErrorBoundaryProps) {
|
|
super(props);
|
|
this.state = {
|
|
hasError: false,
|
|
error: null,
|
|
errorInfo: null,
|
|
retryCount: 0,
|
|
};
|
|
}
|
|
|
|
static getDerivedStateFromError(error: Error): Partial<V2ErrorBoundaryState> {
|
|
return { hasError: true, error };
|
|
}
|
|
|
|
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
|
const { componentId, componentType, onError, autoRetryCount = 0 } = this.props;
|
|
const { retryCount } = this.state;
|
|
|
|
// 상태 업데이트
|
|
this.setState({ errorInfo });
|
|
|
|
// 에러 로깅
|
|
console.error(
|
|
`[V2ErrorBoundary] 컴포넌트 에러 - ${componentType}(${componentId}):`,
|
|
error
|
|
);
|
|
console.error("Component Stack:", errorInfo.componentStack);
|
|
|
|
// 에러 이벤트 발행
|
|
v2EventBus.emitSync(V2_EVENTS.COMPONENT_ERROR, {
|
|
componentId,
|
|
componentType,
|
|
error,
|
|
recoverable: this.props.recoverable ?? true,
|
|
});
|
|
|
|
// 사용자 정의 에러 핸들러 호출
|
|
onError?.(error, errorInfo);
|
|
|
|
// 자동 재시도
|
|
if (autoRetryCount > 0 && retryCount < autoRetryCount) {
|
|
this.scheduleAutoRetry();
|
|
}
|
|
}
|
|
|
|
componentWillUnmount(): void {
|
|
if (this.retryTimeoutId) {
|
|
clearTimeout(this.retryTimeoutId);
|
|
}
|
|
}
|
|
|
|
private scheduleAutoRetry = (): void => {
|
|
const { autoRetryDelay = 3000 } = this.props;
|
|
|
|
this.retryTimeoutId = setTimeout(() => {
|
|
this.handleRetry();
|
|
}, autoRetryDelay);
|
|
};
|
|
|
|
private handleRetry = (): void => {
|
|
const { componentId, componentType } = this.props;
|
|
|
|
// 복구 이벤트 발행
|
|
v2EventBus.emitSync(V2_EVENTS.COMPONENT_RECOVER, {
|
|
componentId,
|
|
componentType,
|
|
});
|
|
|
|
this.setState((prev) => ({
|
|
hasError: false,
|
|
error: null,
|
|
errorInfo: null,
|
|
retryCount: prev.retryCount + 1,
|
|
}));
|
|
};
|
|
|
|
private renderMinimalFallback(): ReactNode {
|
|
const { recoverable = true } = this.props;
|
|
|
|
return (
|
|
<div className="flex items-center justify-center p-2 text-xs text-destructive">
|
|
<AlertCircle className="mr-1 h-3 w-3" />
|
|
<span>오류 발생</span>
|
|
{recoverable && (
|
|
<button
|
|
onClick={this.handleRetry}
|
|
className="ml-2 underline hover:no-underline"
|
|
>
|
|
재시도
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
private renderCompactFallback(): ReactNode {
|
|
const { componentType, recoverable = true } = this.props;
|
|
const { error } = this.state;
|
|
|
|
return (
|
|
<div className="rounded-md border border-destructive/50 bg-destructive/5 p-3">
|
|
<div className="flex items-center gap-2">
|
|
<AlertCircle className="h-4 w-4 text-destructive" />
|
|
<span className="text-sm font-medium text-destructive">
|
|
{componentType} 로드 실패
|
|
</span>
|
|
</div>
|
|
{error && (
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{error.message.substring(0, 100)}
|
|
{error.message.length > 100 ? "..." : ""}
|
|
</p>
|
|
)}
|
|
{recoverable && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={this.handleRetry}
|
|
className="mt-2"
|
|
>
|
|
<RefreshCw className="mr-1 h-3 w-3" />
|
|
재시도
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
private renderFullFallback(): ReactNode {
|
|
const { componentId, componentType, recoverable = true } = this.props;
|
|
const { error, errorInfo, retryCount } = this.state;
|
|
|
|
return (
|
|
<Alert variant="destructive" className="my-2">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertTitle>
|
|
{componentType} 컴포넌트 오류
|
|
</AlertTitle>
|
|
<AlertDescription>
|
|
<div className="mt-2 space-y-2">
|
|
<p className="text-sm">
|
|
<strong>컴포넌트 ID:</strong> {componentId}
|
|
</p>
|
|
{error && (
|
|
<p className="text-sm">
|
|
<strong>에러 메시지:</strong> {error.message}
|
|
</p>
|
|
)}
|
|
{retryCount > 0 && (
|
|
<p className="text-sm text-muted-foreground">
|
|
재시도 횟수: {retryCount}회
|
|
</p>
|
|
)}
|
|
{process.env.NODE_ENV === "development" && errorInfo && (
|
|
<details className="mt-2">
|
|
<summary className="cursor-pointer text-xs">
|
|
스택 트레이스 보기
|
|
</summary>
|
|
<pre className="mt-1 max-h-32 overflow-auto whitespace-pre-wrap rounded bg-muted p-2 text-xs">
|
|
{errorInfo.componentStack}
|
|
</pre>
|
|
</details>
|
|
)}
|
|
{recoverable && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={this.handleRetry}
|
|
className="mt-2"
|
|
>
|
|
<RefreshCw className="mr-1 h-3 w-3" />
|
|
다시 시도
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
render(): ReactNode {
|
|
const { children, fallback, fallbackStyle = "compact" } = this.props;
|
|
const { hasError, error } = this.state;
|
|
|
|
if (!hasError) {
|
|
return children;
|
|
}
|
|
|
|
// 사용자 정의 폴백
|
|
if (fallback) {
|
|
if (typeof fallback === "function") {
|
|
return fallback(error!, this.handleRetry);
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
// 기본 폴백 스타일별 렌더링
|
|
switch (fallbackStyle) {
|
|
case "minimal":
|
|
return this.renderMinimalFallback();
|
|
case "full":
|
|
return this.renderFullFallback();
|
|
case "compact":
|
|
default:
|
|
return this.renderCompactFallback();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// 함수형 래퍼 (HOC)
|
|
// ============================================================================
|
|
|
|
interface WithV2ErrorBoundaryOptions {
|
|
componentType: string;
|
|
fallbackStyle?: "minimal" | "compact" | "full";
|
|
recoverable?: boolean;
|
|
}
|
|
|
|
/**
|
|
* V2 에러 바운더리 HOC
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const SafeComponent = withV2ErrorBoundary(MyComponent, {
|
|
* componentType: "MyComponent",
|
|
* fallbackStyle: "compact",
|
|
* });
|
|
* ```
|
|
*/
|
|
export function withV2ErrorBoundary<P extends object>(
|
|
WrappedComponent: React.ComponentType<P>,
|
|
options: WithV2ErrorBoundaryOptions
|
|
): React.FC<P & { componentId?: string }> {
|
|
const { componentType, fallbackStyle, recoverable } = options;
|
|
|
|
const WithErrorBoundary: React.FC<P & { componentId?: string }> = (props) => {
|
|
const componentId =
|
|
props.componentId ?? `${componentType}_${Date.now()}`;
|
|
|
|
return (
|
|
<V2ErrorBoundary
|
|
componentId={componentId}
|
|
componentType={componentType}
|
|
fallbackStyle={fallbackStyle}
|
|
recoverable={recoverable}
|
|
>
|
|
<WrappedComponent {...props} />
|
|
</V2ErrorBoundary>
|
|
);
|
|
};
|
|
|
|
WithErrorBoundary.displayName = `WithV2ErrorBoundary(${componentType})`;
|
|
|
|
return WithErrorBoundary;
|
|
}
|
|
|
|
// ============================================================================
|
|
// 훅 기반 에러 리포팅 (ErrorBoundary 외부에서 에러 보고용)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* V2 에러 리포팅 훅
|
|
*
|
|
* ErrorBoundary가 잡지 못하는 비동기 에러 등을 보고할 때 사용
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const reportError = useV2ErrorReporter("my-component", "MyComponent");
|
|
*
|
|
* const handleClick = async () => {
|
|
* try {
|
|
* await someAsyncOperation();
|
|
* } catch (error) {
|
|
* reportError(error as Error);
|
|
* }
|
|
* };
|
|
* ```
|
|
*/
|
|
export function useV2ErrorReporter(
|
|
componentId: string,
|
|
componentType: string
|
|
): (error: Error, recoverable?: boolean) => void {
|
|
return React.useCallback(
|
|
(error: Error, recoverable = true) => {
|
|
console.error(
|
|
`[V2ErrorReporter] ${componentType}(${componentId}):`,
|
|
error
|
|
);
|
|
|
|
v2EventBus.emitSync(V2_EVENTS.COMPONENT_ERROR, {
|
|
componentId,
|
|
componentType,
|
|
error,
|
|
recoverable,
|
|
});
|
|
},
|
|
[componentId, componentType]
|
|
);
|
|
}
|
|
|