본문 바로가기

웹 프론트엔드

ErrorBoundary 가 포착할 수 없는 에러와 그 이론적 원리 분석

서언

ErrorBoundary 는 하위 컴포넌트에서 발생하는 자바스크립트 에러를 잡아서 fallback UI를 보여주는 React 컴포넌트 입니다. 하지만 하위에 존재하는 컴포넌트에서 에러가 발생한다고 하여, 모든 에러를 잡아주는 것은 아닙니다. React 공식문서에는 다음과 같은 에러는 ErrorBoundary가 포착할 수 없다고 합니다. 과연 저것은 암기해야하는 것들일까요? 그렇지 않습니다. JavaScript 와 React를 잘 이해하고 있다면 당연한 현상입니다. 이 글에서는 에러바운더리가 특정 에러를 포착하지 못 하는 원리를 설명해보려고 합니다.

목차

  1. [사전지식] 실행 컨텍스트와 예외처리
  2. Case1. 이벤트 핸들러
  3. Case2. 비동기적 코드
  4. Case3. 서버 사이드 렌더링
  5. Case4. 자식에서가 아닌 에러 경계 자체에서 발생하는 에러

[사전지식] 실행 컨텍스트와 예외처리

JavaScript를 학습하다 보면, 유명한 예제를 배우게 됩니다.

try {
  function throwErrorFn() {
    throw new Error("will it be catched?");
  }
  setTimeout(throwErrorFn, 1000);
} catch (e) {
  console.log(e);
}

setTimeout 에서 Error를 throw 하고, 저렇게 try/catch 를 감싸면 과연 catch 문에 잡힐까요?

 

“잡히지 않는다” 가 정답입니다.

 

이는 실행 컨텍스트의 동작으로 설명할 수 있습니다.

 

실행 컨텍스트란, 소스코드 평가 및 실행을 통해 발생하는 환경을 관리하는 객체 를 말합니다. 쉽게 말하자면, 함수 실행에 필요한 변수(Context)를 관리하는 역할을 합니다. 코드 예시를 들어보겠습니다.

function foo(a) {
  const x = 10;
  const y = 20;

  function bar() {
    return x + y + a;
  }

  return bar();
}

foo(100);

foo 코드가 실행되면, foo 실행 컨텍스트가 만들어지고, 실행 컨텍스트에는 내부 변수인 x, y, a, bar 라는 변수를 관리합니다.

 

추가로 실행 컨텍스트는 call stack 이라고 하는 곳에 순차적으로 쌓이고 pop 됩니다. foo 위에 bar가 쌓이고, bar가 실행되고 나면, bar가 pop 되고 foo만 남게 됩니다. foo 까지 실행되고 나면 foo 도 pop 되고, 콜스택은 비게 됩니다.

 

이때 하나의 컨텍스트에서 에러가 발생하면 어떻게 될까요? 에러는 상위 컨텍스트로 전파됩니다. bar 에서 에러가 발생한다고 해볼게요. bar 에서 에러가 throw 되는데, bar 에서 처리되지 않으면 foo 까지로 전파됩니다. foo 에서도 처리되지 않으면, 최상위 실행 컨텍스트로 전파되고 crash 가 일어납니다.

 

이 배경지식을 기반으로 setTimeout 예제를 다시 보도록 할게요.

setTimeout 내의 callback 은 1초 후에 실행 컨텍스트에서 실행됩니다. 그때는 이미 try/catch 문이 있던 컨텍스트가 pop 된 이후의 상황이기 때문에 catch 가 잡을 수 없고, 최상단에 에러가 던져지게 됩니다. (main 함수는 최상위 컨텍스트 함수를 의미합니다.)

 

즉, try/catch 문의 컨텍스트 내에서 throwErrorFn이 실행되지 않기 때문에, 에러를 포착할 수 없는 것입니다.

 

에러바운더리도 try/catch 와 동일한 원리로 동작합니다. 리액트 공식문서에서도 다음과 같은 문장을 볼 수 있습니다.

에러 경계는 자바스크립트의 catch {} 구문과 유사하게 동작하지만 컴포넌트에 적용됩니다.

에러바운더리가 잡을 수 있는 에러인지 판단할 때 가장 중요한 것은 “ErrorBoundary 실행 컨텍스트” 입니다. 그 안에 있다면 잡을 수 있고, 그 안에 없다면 잡을 수 없습니다. 유일한 원리입니다.

공식문서에 나오는 ErrorBoundary 가 포착할 수 없는 에러의 사례를 이 개념을 통해서 설명해보도록 하겠습니다.

 

Case 1. 이벤트 핸들러

이벤트 핸들러 내에서 발생한 에러는 ErrorBoundary에 잡히지 않습니다.

export default function App() {
  return (
    <ErrorBoundary>
      <Button />
    </ErrorBoundary>
  );
}

function Button() {
  return (
    <button
      onClick={() => {
        throw new Error("will it be catched?");
      }}
    >
      click
    </button>
  );
}

이를 이해하기 위해서는 React의 이벤트 핸들러가 바인딩되는 과정을 이해할 필요가 있습니다.

 

React v17의 Relase note를 읽어보면, React의 이벤트 위임 방식 변화를 알 수 있습니다.

React v17.0 Release Candidate: No New Features – React Blog

 

JavaScript에서는 DOM node에 직접 이벤트를 등록합니다.

document.getElementById("home_button").addEventListener("click", (e) => {
  // callback
});

 

하지만 React에서는 모든 이벤트가 root element 에서 핸들링 됩니다.

 

실제로 아래처럼 console을 찍어보면 root 에서 이벤트가 다뤄지고 있음을 알 수 있습니다.

<button
  onClick={(e) => {
    console.log(e.nativeEvent.currentTarget);
  }}
>
  click
</button>

 

또한 root 에 등록된 eventListeners 들을 한번에 조회해볼 수도 있는데요.

getEventListeners(document.getElementById('root'))

 

 

이처럼 모든 이벤트가 사전에 root 에 등록되어 있는 것을 볼 수 있습니다.

 

React가 이벤트를 핸들링하는 방식을 알아보았습니다. 이를 기반으로 보면 왜 ErrorBoundary가 이벤트핸들러에서 발생한 에러를 잡을 수 없는지 이해할 수 있습니다.

root는 최상위 tag 이고, 이곳에서 이벤트 핸들링이 이루어집니다. 즉, Button 컴포넌트에서 onClick 이벤트가 다루어지는 것처럼 보이지만, 이는 root tag 에서 다루어지고 있고, 그곳에서 에러가 발생한다면 그것의 실행 컨텍스트는, ErrorBoundary 내부에 있지 않습니다.

그렇기 때문에 ErrorBoundary가 이벤트 핸들러에서 발생한 에러를 잡을 수 없습니다. 이벤트 핸들러에서 에러를 핸들링하고 싶다면 try/catch 구문을 사용해야 합니다.

Case 2. 비동기적 코드

다음으로 ErrorBoundary 가 컴포넌트 내 비동기적 동작에서 발생하는 에러를 포착할 수 없는 이유를 알아보겠습니다.

export default function App() {
  return (
    <ErrorBoundary>
      <Children />
    </ErrorBoundary>
  );
}

function Children() {
  function throwErrorFn() {
    throw new Error("will it be catched?");
  }
  setTimeout(throwErrorFn, 1000);
  return <div></div>;
}

이것은 위에서 예시로 보여드린 setTimeout - try/catch 와 완전히 동일한 케이스 입니다.

 

setTimeout 의 callback은 1초 후에, 실행 컨텍스트에 들어와서 실행되며, 이는 ErrorBoundary의 컨텍스트가 끝난 시점입니다.

 

좀 더 실무적인 예시를 들어보겠습니다. axios를 통한 비동기 통신입니다.

export default function App() {
  return (
    <ErrorBoundary>
      <Children />
    </ErrorBoundary>
  );
}

function Children() {
  const [todos, setTodos] = useState([]);
  useEffect(() => {
    (async () => {
      try {
        const res = await axios.get(
          "https://jsonplaceholder.typicode.com/todos21231"
        );
        setTodos(res);
      } catch (e) {
        throw new Error("is it ?");
      }
    })();
  });

  return <button>click</button>;
}

여기서도 에러가 잡히지 않습니다. ErrorBoundary가 이를 잡을 수 있도록 하려면 어떻게 해야할까요?

위에서 도출한 원리를 그대로 활용하자면, ErrorBoundary 내의 컨텍스트 내에서 throw 을 일으켜야 합니다. 그러므로 error 상태를 별도로 분리하고, 에러가 있으면 실행 컨텍스트 내에서 동기적으로 직접 던져버리면 됩니다.

function Children() {
  const [todos, setTodos] = useState([]);
  const [error, setError] = useState(null);

  useEffect(() => {
    (async () => {
      try {
        const res = await axios.get(
          "https://jsonplaceholder.typicode.com/todos21231"
        );
        setTodos(res.data);
      } catch (e) {
        setError(e);
      }
    })();
  }, []);

  if (error) {
    throw error; // 에러 상태가 있으면 에러를 던집니다
  }

  return click;
}

react-query 비동기 통신 라이브러리를 많이들 활용하실 텐데요. useErrorBoundary (v5 에서는 throwOnError) 라는 옵션을 true로 설정하면 위의 기능을 대신해줍니다.

Case 3. 서버 사이드 렌더링

서버 사이드에서 발생한 에러는 ErrorBoundary가 잡을 수 없습니다.

 

ErrorBoundary는 getDerivedStateFromError 를 기반으로, 프론트엔드 상태(hasError)를 변경하여 동작합니다. 이러한 상태를 변화시키는 메서드는 클라이언트 사이드에서만 실행됩니다.

서버 사이드 렌더링은 서버 사이드에서(ex - node) API 를 요청하고 그 응답으로 HTML 을 만들어서 내려주는 기술입니다.

getDerivedStateFromError 은 상태 변화가 존재하는 브라우저 환경에서만 실행되고, node 환경에서 실행되지 않습니다. 그러므로 본질적으로 서버 사이드에서 에러를 포착할 수 없습니다.

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

이것은 try/catch 를 예시로 들어서 설명하겠습니다.

try {
  throw new Error("Error");
} catch (e) {
  throw e;
}

try 에서 에러가 발생하고, catch 에서 잡혔는데 이것을 다시 던진다면 해당 catch 에서 잡을 수 없습니다.

이것을 잡기 위해서는 다른 try/catch 가 필요한 것이지요.

try {
  try {
    throw new Error("Error");
  } catch (e) {
    throw e;
  }
} catch (e) {
  console.log(e);
}

ErrorBoundary도 이와 동일합니다. ErrorBoundary 에서 다시 에러가 throw 된다면, 에러를 다시 상위 컴포넌트로 던지는 것입니다. 상위 ErrorBoundary가 있어야만 포착될 것이고, 그렇지 않다면 최상위에 Error가 도달할 것입니다.

마치며

오늘은 ErrorBoundary가 포착할 수 없는 에러들과, 그 이유를 케이스별로 알아보았습니다. 이 케이스들이 ErrorBoundary의 특수한 성질이라고 생각하여 암기해야 한다고 생각할 수 있습니다. 하지만 이 원리는 실행 컨텍스트, React 이벤트 핸들링 방식, 서버 사이드 렌더링 원리에 기반하고 있습니다. 이것들을 기반으로 바라보면 아주 당연한 성질에 불과합니다.

ErrorBoundary는 리액트로 서비스를 운영함에 있어 매우 중요합니다. 부분별로 에러 전파를 방지할 수 있기 때문에, 하나의 에러가 어플리케이션 전체에 영향을 주는 일을 방지해주죠. 하지만 이에 대한 이해가 부족한 상태로 사용하면 에러가 중간에 잡히지 않고 전체로 전파되는 현상이 발생할 수 있습니다. 이 글을 이해하고, 안정적인 서비스를 운영함에 있어 도움이 되었으면 좋겠습니다.

참고

에러 경계(Error Boundaries) – React

React v17.0 Release Candidate: No New Features – React Blog

React Deep Dive— React Event System (1)