본문 바로가기
이론/Frontend

리액트의 에러 경계 (React Error Boundaries)

by 유세지 2022. 6. 5.

이전 글에서 클래스의 생명주기 단계 중, 리액트의 훅이 지원하지 않는 메서드가 일부 있다고 하였습니다. 그 중 하나가 이번 글의 주제인 에러 경계(Error boundaries) 를 형성하는 componentDidCatch 단계입니다. 오늘은 에러 경계란 무엇이고, 어떻게 등장하게 되었으며 사용하는 방법은 무엇인지 알아보겠습니다.

 

에러 경계

에러 경계란 컴포넌트의 하위 단계에서 발생하는 에러를 잡아내어 비정상적인 화면 대신 우리가 지정한 폴백 UI를 보여주는 리액트 컴포넌트의 한 종류입니다.

 

리액트 프로젝트를 개발해보신적이 있다면, 한 번쯤 런타임에서 에러가 발생했을때 실행중인 화면에 에러 내용이 오버레이 되어 표시되는 것을 보신적 있을겁니다.

 

어딘지 친숙한 화면입니다.

 

이 화면은 create-react-app에 포함된 패키지 중 하나인 react-error-overlay에서 제공하는 일종의 개발용 툴입니다. 어디에서 어떤 에러가 발생했는지 개발자에게 친절하게 알려주지만, production 모드로 빌드한 실제 서비스에서는 보이지 않습니다.

 

이것은 코드에서 에러가 발생하더라도, 일반적인 사용자 입장에서는 지금 어떤 일이 일어나고 있는지 알 방법이 없다는 의미입니다. 원하는 기능이 제대로 동작하지 않아 버그가 발생했다고 추측할 뿐이지요.

 

따라서 우리는 컴포넌트에서 발생한 에러를 적절하게 핸들링 해주어야 할 필요가 있습니다. 핸들링 되지 않은 에러는 나쁜 사용자 경험을 제공할뿐 아니라, 앞으로의 동작에서 문제를 일으킬 소지가 있습니다.

 

이전의 리액트에서는 삼켜진(swallows) 에러들로 인해 많은 문제가 발생하였습니다.

 

이 스레드를 보면, 리액트 15버젼을 이용하던 사람들이 알 수 없는 문제를 겪었지만 이에 대한 원인을 찾는데 어려움이 많았던 것을 알 수 있습니다. 대부분의 원인은 이전 상태에서 발생했던 에러들로 인해 발생하였지만, 이를 적절히 핸들링하지 못했고 이는 결국 애플리케이션의 다음의 동작에도 영향을 미쳤던 것입니다.

 

이에 대해 리액트의 개발자인 Dan Abramov는 컴포넌트에서 발생한 에러들을 적절히 핸들링 할 수 있는 방법의 필요성을 느꼈고, 그 대안으로 리액트 16버젼부터 사용할 수 있는 에러 경계가 스펙에 도입되었습니다.

 

리액트 16버젼부터 에러 경계가 도입되었습니다.

 

 

 

에러 경계 컴포넌트 생성

에러 경계 컴포넌트를 생성하려면, getDerivedStateFromError() 메서드 또는 componentDidCatch() 메서드를 정의해야 합니다. 둘 모두를 정의해도 되지만 둘 중 하나만 정의해도 그 컴포넌트는 에러 경계가 됩니다.

 

/* react-devtools-shared/src/backend/renderer.js */

function isErrorBoundary(fiber: Fiber): boolean {
    const {tag, type} = fiber;

    switch (tag) {
      case ClassComponent:
      case IncompleteClassComponent:
        const instance = fiber.stateNode;
        return (
          // getDerivedStateFromError가 정의되어 있거나
          typeof type.getDerivedStateFromError === 'function' ||
          (instance !== null &&
            // componentDidCatch가 정의되어 있으면 ErrorBoundary 이다.
            typeof instance.componentDidCatch === 'function')
        );
      default:
        return false;
    }
}

 

위에서 언급했듯, 리액트의 훅은 컴포넌트의 생명주기 중 getDerivedStateFromError와 componentDidCatch를 지원하지 않기 때문에 함수 컴포넌트로는 에러 경계를 생성할 수 없습니다. 따라서 에러 경계는 아래와 같은 모습입니다.

 

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: null, errorInfo: null };
  }
  
  static getDerivedStateFromError(error) {
    console.log('error catch in getDerivedStateFromError');
    return { hasError: true };
  }
  
  componentDidCatch(error, errorInfo) {
    console.log('error catch in DidCatch');
    
    this.setState({
      error: error,
      errorInfo: errorInfo
    })
  }
  
  render() {
    if (this.state.errorInfo) {
      return (
        <div>
          <h1> 에러가 발생했어요 ㅠ.ㅠ </h1>
        </div>
      );
    }
    
    return this.props.children;
  }  
}

 

이렇게 생성한 에러 경계로 에러가 발생할 수 있는 기존의 컴포넌트를 try - catch 문 처럼 감싸주면, 에러가 발생했을때 이런식으로 동작합니다.

 

에러가 발생하면 지정한 폴백 UI가 대신 표시됩니다.

 

 

어떻게 동작하는지 감이 잡히시나요? 이제 각 생명주기 메서드에 대해서 하나씩 살펴보겠습니다.

 

 

static getDerivedStateFromError(error)

이 생명주기 메서드는 하위의 컴포넌트에서 오류가 발생하면 호출되고, 해당 오류를 인자로 받습니다. 또한 반드시 갱신된 state를 반환하여 폴백 UI를 표시할 수 있도록 해야 합니다. 

 

getDerivedStateFromError 메서드는 컴포넌트의 render 단계에서 호출됩니다. 즉, render() 메소드가 실행되기 전에 호출되기 때문에 VDOM에 영향을 끼칠 수 있는 사이드 이펙트가 발생하는 작업을 해서는 안됩니다. 이때는 대신 componentDidCatch 메서드를 이용해야 합니다.

 

이러시면 곤란합니다.

 

 

componentDidCatch(error, info)

이 생명주기 메서드는 하위의 컴포넌트에서 오류가 발생하면 호출되고, 해당 오류와 어떤 컴포넌트가 오류를 발생시켰는지에 대한 정보를 가진 componentStack 키를 포함한 객체를 인자로 받습니다.

 

componentDidCatch 메서드는 getDerivedStateFromError와 달리 render 이후의 commit 단계에서 호출되기 때문에, 사이드 이펙트가 발생하는 작업을 수행할 수 있습니다.

 

render 이후에 componentDidCatch가 실행되는 모습

 

 

에러 경계의 한계

에러 경계는 컴포넌트 단에서 발생하는 오류를 잡아낼 수 있지만, 모든 오류를 잡아내지는 못합니다. 아래의 네 가지 경우가 대표적인 예시입니다.

 

  • 이벤트 핸들러
  • 비동기적 코드
  • 서버 사이드 렌더링
  • 자식에서가 아닌 에러 경계 자체에서 발생하는 에러

 

 

이벤트 핸들러

이벤트 핸들러는 컴포넌트의 렌더링 단계와는 연관이 없습니다. 따라서 생명주기 메서드로 잡아낼 수 없고, try catch 구문을 이용하여 처리해야 합니다.

 

// 에러 경계가 아니기 때문에 함수형 컴포넌트로 작성할 수 있습니다!
const MyComponent = () => {
  const [errorState, setErrorState] = setState(false);

  const EvilHandler = () => {
    try {
      // 오류가 발생하는 이벤트
      throw new Error('쾅!');
    } catch(error) {
      setErrorState(true);
    }
  }

  return (
    <div>
      {errorState ?
        <h1>아직까지는 멀쩡해요!</h1> :
        <h1>아뿔싸</h1>
      }
      <button onClick={EvilHandler}>누르면 큰일나는 버튼</button>
    </div>
  )
}

 

 

비동기적 코드

비동기 콜백으로부터 발생하는 에러 또한 에러 경계에서 처리하지 않습니다.

 

비동기 코드에서 에러가 발생했지만 에러 경계의 폴백 UI가 보이지 않습니다.

 

이런 경우엔 응답을 기다렸다가 오류가 발생했을때 컴포넌트 단계에서 에러를 throw 시켜주면 에러 경계가 잡아낼 수 있습니다.

 

return에서 오류를 throw해주니 에러 경계의 폴백 UI가 잘 표시됩니다.

 

개인적인 생각으로는 return 단계에서 에러를 던지는 방법보다는, 에러 상태를 전역 컨텍스트로 관리하여 해당 상태를 변경해주는게 사용성 측면에서 더 좋아 보입니다.

 

 

서버 사이드 렌더링

서버 사이드 렌더링 또한 에러 경계가 잡아낼 수 없습니다. 애초에 컴포넌트가 컨트롤 할 수 있는 영역을 벗어났기 때문입니다.

 

이 링크에서 html 파서를 만들어 서버 측의 오류도 끌고 오려는 시도를 하는 것처럼 보이긴 하는데, 일반적으로 클라이언트에서 감당해야 할 영역으로는 보이지 않는다는 느낌이네요.

 

혹시나 이 외에 서버 사이드 렌더링도 컨트롤 할 수 있는 방법을 알고 계신다면... 알려주시면 감사하겠습니다 😂

 

 

자식에서가 아닌 에러 경계 자체에서 발생하는 에러

위의 세 가지 경우는 모두 컴포넌트의 범위를 벗어난 곳에서 생긴 에러라는 공통된 원인을 갖고 있습니다. 이번 경우는 컴포넌트의 범위에서 발생하는 에러는 맞지만, 에러 경계 자체에서 발생하기 때문에 잡아내지 못하는 케이스입니다.

 

에러 경계를 인식하지 못하는 모습입니다.

 

에러 경계는 언제나 하위 단계의 에러만 잡아낼 수 있다는 점을 다시 한 번 확인하였습니다.

 

 

마무리

이번 포스트에서는 에러 경계에 대해 알아보았습니다. 에러 경계를 잘 이용하면 애플리케이션의 사용성을 끌어 올릴 수 있을 것 같네요.

 

추가로, 리액트의 에러 경계를 직접 제공하는 react-error-boundary라는 라이브러리도 있습니다. 직접 만들어서 사용하셔도 좋지만, 라이브러리를 이용하시는 것도 좋은 방법이 될 것 같습니다.

 

 

 

참고 문서

에러 경계(Error Boundaries) - React docs

React.Component (#Error Boundary) -  React docs

Error Boundary Live Demo - React docs

React git repository - facebook

what's the difference between getDerivedStateFromError and componentDidCatch - Stackoverflow

반응형

댓글