본문 바로가기

에러 바운더리

4. 에러 바운더리

에러 바운더리(Error Boundary)는 React 애플리케이션에서 JavaScript 오류를 잡아내고, 오류 정보를 기록하며, 오류가 발생한 컴포넌트 대신 대체 UI를 표시하는 React 컴포넌트입니다. 애플리케이션 전체가 크래시되는 것을 방지할 수 있습니다.

에러 바운더리가 필요한 이유

React 16 이전과 이후의 차이점

React 16 이전에는 컴포넌트 내부의 JavaScript 오류가 React의 내부 상태를 손상시켜 다음 렌더링에서 알 수 없는 오류를 발생시켰습니다. React 16부터는 오류가 발생하면 전체 컴포넌트 트리를 언마운트합니다.

// 에러가 발생할 수 있는 컴포넌트
function ProblematicComponent({ user }) {
  // user가 null이면 오류 발생
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

function App() {
  const [user, setUser] = useState(null);
  
  return (
    <div>
      <h1>내 애플리케이션</h1>
      <ProblematicComponent user={user} />
      <button onClick={() => setUser({ name: '김개발', email: 'kim@example.com' })}>
        사용자 설정
      </button>
    </div>
  );
}

위 코드에서 user가 null인 상태에서 ProblematicComponent가 렌더링되면 오류가 발생하고 전체 앱이 크래시됩니다.

클래스 컴포넌트로 에러 바운더리 구현

기본 에러 바운더리

import React from 'react';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }
  
  // 에러가 발생했을 때 state를 업데이트
  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }
  
  // 에러 정보를 기록
  componentDidCatch(error, errorInfo) {
    console.error('에러 바운더리가 에러를 포착했습니다:', error);
    console.error('에러 정보:', errorInfo);
  }
  
  render() {
    if (this.state.hasError) {
      return (
        <div>
          <h2>문제가 발생했습니다</h2>
          <p>죄송합니다. 예기치 못한 오류가 발생했습니다.</p>
          <button onClick={() => this.setState({ hasError: false, error: null })}>
            다시 시도
          </button>
        </div>
      );
    }
    
    return this.props.children;
  }
}

에러 바운더리 사용하기

function SafeApp() {
  const [user, setUser] = useState(null);
  
  return (
    <div>
      <h1>안전한 애플리케이션</h1>
      
      <ErrorBoundary>
        <ProblematicComponent user={user} />
      </ErrorBoundary>
      
      <button onClick={() => setUser({ name: '김개발', email: 'kim@example.com' })}>
        사용자 설정
      </button>
      <button onClick={() => setUser(null)}>
        사용자 제거 (오류 발생)
      </button>
    </div>
  );
}

고급 에러 바운더리

상세한 에러 정보를 포함한 에러 바운더리

import React from 'react';

class DetailedErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      hasError: false,
      error: null,
      errorInfo: null
    };
  }
  
  static getDerivedStateFromError(error) {
    return { hasError: true };
  }
  
  componentDidCatch(error, errorInfo) {
    this.setState({
      error: error,
      errorInfo: errorInfo
    });
    
    // 에러 로깅 서비스에 전송 (예: Sentry, LogRocket)
    console.error('에러 발생:', error);
    console.error('컴포넌트 스택:', errorInfo.componentStack);
  }
  
  render() {
    if (this.state.hasError) {
      const { fallback: Fallback } = this.props;
      
      // 커스텀 fallback 컴포넌트가 있으면 사용
      if (Fallback) {
        return (
          <Fallback
            error={this.state.error}
            errorInfo={this.state.errorInfo}
            resetError={() => this.setState({ hasError: false, error: null, errorInfo: null })}
          />
        );
      }
      
      // 기본 에러 UI
      return (
        <div>
          <h2>오류가 발생했습니다</h2>
          <details>
            <summary>에러 상세 정보 (개발 모드)</summary>
            <pre>{this.state.error && this.state.error.toString()}</pre>
            <pre>{this.state.errorInfo.componentStack}</pre>
          </details>
          <button onClick={() => this.setState({ hasError: false, error: null, errorInfo: null })}>
            다시 시도
          </button>
        </div>
      );
    }
    
    return this.props.children;
  }
}

// 커스텀 에러 UI 컴포넌트
function CustomErrorFallback({ error, resetError }) {
  return (
    <div role="alert">
      <h2>앗! 무언가 잘못되었습니다</h2>
      <pre>{error.message}</pre>
      <button onClick={resetError}>다시 시도</button>
    </div>
  );
}

// 사용 예제
function AppWithCustomError() {
  return (
    <DetailedErrorBoundary fallback={CustomErrorFallback}>
      <ProblematicComponent user={null} />
    </DetailedErrorBoundary>
  );
}

함수형 컴포넌트와 함께 사용하기

react-error-boundary 라이브러리 스타일

import React from 'react';

// 에러 바운더리 Hook (실제 Hook은 아니지만 Hook 스타일의 API)
function withErrorBoundary(Component, ErrorComponent) {
  return function WithErrorBoundaryComponent(props) {
    return (
      <ErrorBoundary fallback={ErrorComponent}>
        <Component {...props} />
      </ErrorBoundary>
    );
  };
}

// 함수형 컴포넌트에서 에러 바운더리 사용
const SafeProblematicComponent = withErrorBoundary(
  ProblematicComponent,
  function ErrorFallback({ error, resetError }) {
    return (
      <div>
        <h3>컴포넌트에서 오류가 발생했습니다</h3>
        <p>{error?.message}</p>
        <button onClick={resetError}>다시 시도</button>
      </div>
    );
  }
);

에러 바운더리가 포착하지 못하는 오류들

에러 바운더리의 한계

function ErrorExamples() {
  const [asyncError, setAsyncError] = useState(false);
  
  // ❌ 에러 바운더리가 포착하지 못하는 오류들
  
  // 1. 이벤트 핸들러 내의 오류
  const handleClick = () => {
    throw new Error('이벤트 핸들러 오류'); // 포착되지 않음
  };
  
  // 2. 비동기 코드의 오류
  const handleAsyncError = () => {
    setTimeout(() => {
      throw new Error('비동기 오류'); // 포착되지 않음
    }, 1000);
  };
  
  // 3. Server-side rendering 오류
  // 4. 에러 바운더리 자체의 오류
  
  return (
    <div>
      <button onClick={handleClick}>
        이벤트 핸들러 오류 (포착되지 않음)
      </button>
      <button onClick={handleAsyncError}>
        비동기 오류 (포착되지 않음)
      </button>
    </div>
  );
}

// 이벤트 핸들러 오류 처리 방법
function SafeEventHandler() {
  const [error, setError] = useState(null);
  
  const handleClick = () => {
    try {
      // 오류가 발생할 수 있는 코드
      throw new Error('의도적인 오류');
    } catch (err) {
      setError(err.message);
    }
  };
  
  if (error) {
    return (
      <div>
        <p>오류: {error}</p>
        <button onClick={() => setError(null)}>다시 시도</button>
      </div>
    );
  }
  
  return (
    <button onClick={handleClick}>
      안전한 이벤트 핸들러
    </button>
  );
}

실습: 컴포넌트별 에러 처리

여러 레벨의 에러 바운더리

import React from 'react';
import { useState } from 'react';

// 페이지 레벨 에러 바운더리
class PageErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }
  
  static getDerivedStateFromError(error) {
    return { hasError: true };
  }
  
  componentDidCatch(error, errorInfo) {
    console.error('페이지 레벨 오류:', error, errorInfo);
  }
  
  render() {
    if (this.state.hasError) {
      return (
        <div>
          <h1>페이지를 불러올 수 없습니다</h1>
          <p>전체 페이지에 문제가 발생했습니다.</p>
          <button onClick={() => window.location.reload()}>
            페이지 새로고침
          </button>
        </div>
      );
    }
    
    return this.props.children;
  }
}

// 섹션 레벨 에러 바운더리
class SectionErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }
  
  static getDerivedStateFromError(error) {
    return { hasError: true };
  }
  
  componentDidCatch(error, errorInfo) {
    console.error('섹션 레벨 오류:', error, errorInfo);
  }
  
  render() {
    if (this.state.hasError) {
      return (
        <div>
          <h3>이 섹션을 불러올 수 없습니다</h3>
          <p>다른 기능은 정상적으로 작동합니다.</p>
          <button onClick={() => this.setState({ hasError: false })}>
            다시 시도
          </button>
        </div>
      );
    }
    
    return this.props.children;
  }
}

// 오류가 발생할 수 있는 컴포넌트들
function UserProfile({ userId }) {
  if (!userId) {
    throw new Error('사용자 ID가 필요합니다');
  }
  
  return (
    <div>
      <h3>사용자 프로필</h3>
      <p>사용자 ID: {userId}</p>
    </div>
  );
}

function ProductList({ products }) {
  if (!Array.isArray(products)) {
    throw new Error('상품 목록이 배열이 아닙니다');
  }
  
  return (
    <div>
      <h3>상품 목록</h3>
      {products.map(product => (
        <div key={product.id}>
          <p>{product.name}: {product.price}원</p>
        </div>
      ))}
    </div>
  );
}

function RecommendedProducts() {
  // 50% 확률로 오류 발생
  if (Math.random() > 0.5) {
    throw new Error('추천 상품을 불러올 수 없습니다');
  }
  
  return (
    <div>
      <h3>추천 상품</h3>
      <p>오늘의 추천 상품입니다.</p>
    </div>
  );
}

// 메인 앱 컴포넌트
function ErrorHandlingApp() {
  const [userId, setUserId] = useState(null);
  const [products, setProducts] = useState([]);
  
  return (
    <PageErrorBoundary>
      <div>
        <h1>쇼핑 앱</h1>
        
        <div>
          <button onClick={() => setUserId('user123')}>
            유효한 사용자 설정
          </button>
          <button onClick={() => setUserId(null)}>
            사용자 제거 (오류 발생)
          </button>
        </div>
        
        <div>
          <button onClick={() => setProducts([
            { id: 1, name: '노트북', price: 1500000 },
            { id: 2, name: '마우스', price: 50000 }
          ])}>
            상품 목록 설정
          </button>
          <button onClick={() => setProducts('invalid')}>
            잘못된 상품 데이터 (오류 발생)
          </button>
        </div>
        
        <div>
          <SectionErrorBoundary>
            <UserProfile userId={userId} />
          </SectionErrorBoundary>
          
          <SectionErrorBoundary>
            <ProductList products={products} />
          </SectionErrorBoundary>
          
          <SectionErrorBoundary>
            <RecommendedProducts />
          </SectionErrorBoundary>
        </div>
      </div>
    </PageErrorBoundary>
  );
}

에러 로깅과 모니터링

에러 정보 수집하기

class ProductionErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, eventId: null };
  }
  
  static getDerivedStateFromError(error) {
    return { hasError: true };
  }
  
  componentDidCatch(error, errorInfo) {
    // 에러 정보 객체 생성
    const errorReport = {
      message: error.message,
      stack: error.stack,
      componentStack: errorInfo.componentStack,
      timestamp: new Date().toISOString(),
      userAgent: navigator.userAgent,
      url: window.location.href
    };
    
    // 에러 로깅 (실제 서비스에서는 외부 서비스로 전송)
    this.logError(errorReport);
  }
  
  logError = (errorReport) => {
    // 로컬 스토리지에 저장 (임시)
    const errors = JSON.parse(localStorage.getItem('errorLogs') || '[]');
    errors.push(errorReport);
    localStorage.setItem('errorLogs', JSON.stringify(errors));
    
    // 실제로는 다음과 같은 서비스로 전송:
    // - Sentry.captureException(error)
    // - LogRocket.captureException(error)
    // - 커스텀 API 엔드포인트로 전송
    
    console.error('에러가 기록되었습니다:', errorReport);
  };
  
  render() {
    if (this.state.hasError) {
      return (
        <div>
          <h2>문제가 발생했습니다</h2>
          <p>오류가 자동으로 기록되었습니다.</p>
          <p>문제가 지속되면 고객 지원팀에 문의해 주세요.</p>
          <button onClick={() => this.setState({ hasError: false })}>
            다시 시도
          </button>
        </div>
      );
    }
    
    return this.props.children;
  }
}

// 에러 로그 확인 컴포넌트 (개발용)
function ErrorLogs() {
  const [logs, setLogs] = useState([]);
  
  const loadLogs = () => {
    const errorLogs = JSON.parse(localStorage.getItem('errorLogs') || '[]');
    setLogs(errorLogs);
  };
  
  const clearLogs = () => {
    localStorage.removeItem('errorLogs');
    setLogs([]);
  };
  
  return (
    <div>
      <h3>에러 로그</h3>
      <button onClick={loadLogs}>로그 불러오기</button>
      <button onClick={clearLogs}>로그 지우기</button>
      
      {logs.map((log, index) => (
        <details key={index}>
          <summary>{log.timestamp} - {log.message}</summary>
          <pre>{JSON.stringify(log, null, 2)}</pre>
        </details>
      ))}
    </div>
  );
}

에러 바운더리 테스트

개발 중 에러 테스트

// 의도적으로 오류를 발생시키는 컴포넌트 (개발/테스트용)
function ErrorTrigger({ errorType }) {
  if (errorType === 'render') {
    throw new Error('렌더링 중 오류');
  }
  
  if (errorType === 'null') {
    const obj = null;
    return <div>{obj.property}</div>; // TypeError 발생
  }
  
  return (
    <div>
      <p>정상 컴포넌트</p>
      <button onClick={() => {
        if (errorType === 'event') {
          throw new Error('이벤트 핸들러 오류');
        }
      }}>
        이벤트 오류 테스트
      </button>
    </div>
  );
}

function ErrorTestApp() {
  const [errorType, setErrorType] = useState(null);
  
  return (
    <div>
      <h2>에러 바운더리 테스트</h2>
      
      <div>
        <button onClick={() => setErrorType('render')}>
          렌더링 오류 발생
        </button>
        <button onClick={() => setErrorType('null')}>
          null 참조 오류 발생
        </button>
        <button onClick={() => setErrorType('event')}>
          이벤트 오류 발생 (포착되지 않음)
        </button>
        <button onClick={() => setErrorType(null)}>
          정상 상태
        </button>
      </div>
      
      <ErrorBoundary>
        <ErrorTrigger errorType={errorType} />
      </ErrorBoundary>
    </div>
  );
}

정리

에러 바운더리의 핵심 개념을 학습했습니다:

  1. 에러 바운더리 필요성: React 애플리케이션 크래시 방지
  2. 클래스 컴포넌트 구현: getDerivedStateFromError와 componentDidCatch
  3. 에러 처리 전략: 페이지 레벨, 섹션 레벨의 다계층 에러 처리
  4. 한계점 이해: 이벤트 핸들러, 비동기 코드 오류는 포착하지 못함
  5. 에러 로깅: 실제 서비스에서의 에러 모니터링 방법
  6. 테스트: 개발 중 에러 바운더리 동작 확인

에러 바운더리는 사용자 경험을 크게 개선할 수 있는 중요한 기능입니다. 적절한 위치에 에러 바운더리를 배치하여 일부 컴포넌트의 오류가 전체 애플리케이션을 다운시키지 않도록 하는 것이 중요합니다.

다음 장에서는 성능 최적화 기법에 대해 더 자세히 알아보겠습니다.

에러 바운더리 - 리엑트 베이스캠프 | 위니버시티