에러 상태 디자인 UX 패턴: 404부터 네트워크 오류까지

디자인

에러 상태 디자인UX 패턴에러 메시지404 페이지사용자 경험

이 글은 누구를 위한 것인가

  • 404, 500, 네트워크 오류 등 에러 상태 UX를 체계화하려는 팀
  • 에러 메시지를 사용자 친화적으로 작성하려는 디자이너/개발자
  • 에러 바운더리와 재시도 패턴을 구현하려는 팀

들어가며

"An error occurred" — 사용자는 무엇을 해야 할지 모른다. 좋은 에러 UX는 무슨 일이 일어났는지, 왜 그런지, 어떻게 해결할 수 있는지를 명확히 알려준다. 에러는 숨길 수 없다. 잘 설계해야 한다.

이 글은 bluefoxdev.kr의 에러 상태 UX 디자인 가이드 를 참고하여 작성했습니다.


1. 에러 유형별 설계 원칙

[에러 메시지 구조]
  1. 무슨 일이 일어났나: "페이지를 찾을 수 없습니다"
  2. 왜 그런가 (선택): "링크가 변경되었거나 삭제되었을 수 있습니다"
  3. 어떻게 해결하나: "홈으로 돌아가기" 버튼

[에러 유형별 처리]
  404: 유머러스하게, 대안 경로 제시
  500: 사과 + 재시도 버튼 + 지원팀 링크
  네트워크 오류: 오프라인 감지 + 자동 재시도
  권한 없음 403: 로그인 유도 또는 권한 요청
  폼 검증: 인라인, 즉시 표시

[에러 문구 원칙]
  Bad: "Error 404 - Not Found"
  Good: "페이지를 찾을 수 없어요"

  Bad: "처리 중 오류가 발생했습니다"
  Good: "잠시 문제가 생겼어요. 다시 시도해주세요"

  Bad: "Invalid credentials"
  Good: "이메일 또는 비밀번호를 확인해주세요"

[재시도 패턴]
  지수 백오프: 1초 → 2초 → 4초 → 8초
  최대 재시도: 3회
  수동 재시도 버튼 항상 제공

2. 에러 상태 컴포넌트 구현

import React, { Component } from 'react';

// 에러 바운더리
interface ErrorBoundaryState { hasError: boolean; error?: Error; }

class ErrorBoundary extends Component<
  { fallback: React.ComponentType<{ error: Error; reset: () => void }>; children: React.ReactNode },
  ErrorBoundaryState
> {
  constructor(props: any) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    console.error('ErrorBoundary caught:', error, info);
    // 에러 추적 서비스 (Sentry 등) 보고
  }

  render() {
    if (this.state.hasError && this.state.error) {
      const Fallback = this.props.fallback;
      return <Fallback error={this.state.error} reset={() => this.setState({ hasError: false })} />;
    }
    return this.props.children;
  }
}

// 에러 상태 컴포넌트
function ErrorState({ title, description, action }: {
  title: string;
  description?: string;
  action?: { label: string; onClick: () => void };
}) {
  return (
    <div role="alert" style={{ textAlign: 'center', padding: '48px 24px' }}>
      <div style={{ fontSize: 48, marginBottom: 16 }} aria-hidden>⚠️</div>
      <h2 style={{ fontSize: 20, fontWeight: 600, color: '#111827', marginBottom: 8 }}>{title}</h2>
      {description && <p style={{ color: '#6b7280', marginBottom: 24 }}>{description}</p>}
      {action && (
        <button onClick={action.onClick} style={{ padding: '10px 24px', background: '#3b82f6', color: 'white', border: 'none', borderRadius: 8, cursor: 'pointer' }}>
          {action.label}
        </button>
      )}
    </div>
  );
}

// 네트워크 오류 + 자동 재시도
function useRetryFetch<T>(fetcher: () => Promise<T>, maxRetries = 3) {
  const [data, setData] = React.useState<T | null>(null);
  const [error, setError] = React.useState<Error | null>(null);
  const [retryCount, setRetryCount] = React.useState(0);
  const [isLoading, setIsLoading] = React.useState(true);

  const fetchWithRetry = React.useCallback(async () => {
    setIsLoading(true);
    setError(null);

    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      try {
        const result = await fetcher();
        setData(result);
        setRetryCount(0);
        return;
      } catch (err) {
        if (attempt < maxRetries) {
          const delay = Math.pow(2, attempt) * 1000; // 지수 백오프
          setRetryCount(attempt + 1);
          await new Promise(r => setTimeout(r, delay));
        } else {
          setError(err instanceof Error ? err : new Error('Unknown error'));
        }
      }
    }
    setIsLoading(false);
  }, [fetcher, maxRetries]);

  React.useEffect(() => { fetchWithRetry(); }, [fetchWithRetry]);

  return { data, error, isLoading, retryCount, retry: fetchWithRetry };
}

// 오프라인 상태 배너
function OfflineBanner() {
  const [isOffline, setIsOffline] = React.useState(!navigator.onLine);

  React.useEffect(() => {
    const handleOnline = () => setIsOffline(false);
    const handleOffline = () => setIsOffline(true);
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); };
  }, []);

  if (!isOffline) return null;
  return (
    <div role="status" aria-live="polite" style={{ background: '#fef3c7', borderBottom: '1px solid #fcd34d', padding: '8px 16px', textAlign: 'center', fontSize: 14 }}>
      인터넷 연결이 끊어졌습니다. 연결을 확인해주세요.
    </div>
  );
}

마무리

에러 UX의 핵심은 "사용자를 막힌 곳에 두지 않는 것"이다. 모든 에러 상태에 대안 행동(재시도, 홈으로, 지원팀 연락)을 제공하고, 문구는 기술적 용어 없이 명확하게 작성한다. 에러 바운더리로 컴포넌트 레벨 에러를 격리하고, 지수 백오프로 네트워크 오류를 자동 복구한다.