렌더링 과정에서 에러가 발생할 경우, 리액트의 렌더링 엔진은 에러를 상위 컴포넌트로 전파(버블링)합니다.
버블링 과정에서 getDerivedStateFromError를 만나면 호출합니다.
componentDidCatch는 getDerivedStateFromError 뒤에 호출됩니다.
getDerivedStateFromError와 componentDidCatch를 분리한 이유는 다음과 같습니다.
getDerivedStateFromError는 UI가 그려지기 전에 호출되고, componentDidCatch는 UI가 그려진 이후에 호출됩니다.
에러 발생시 getDerivedStateFromError를 사용해서 에러 상태(hasError)를 변경하여 즉시 fallback을 보여주고, 에러 발생 후 이를 로깅하거나 후속 작업을 위해서는 componentDidCatch를 사용함으로써 두 메서드의 역할을 구분해놓았다고 할 수 있습니다.
만나지 못한다면 전역으로 전파됩니다.
전역으로 전파된 에러는 전체 컴포넌트 트리의 마운트를 해제합니다.
crash되는 이유는 react 팀에서 에러 발생시 손상된 UI를 보여주는 것이 아무 것도 보여주지 않는 것보다 더욱 나쁘다고 판단했기 때문입니다.
따라서 ErrorBoundary는 getDerivedStateFromError를 사용하여 하위 컴포넌트의 렌더링 과정에서 발생하는 에러를 포착, 에러가 전역으로 전파되지 않게 하는 컴포넌트라고 요약할 수 있겠습니다.
ErrorBoundary는 다음과 같은 에러는 포착하지 못합니다.
Event handlers
Asynchronous code (e.g. setTimeout or requestAnimationFrame callbacks)
Server side rendering
Errors thrown in the error boundary itself (rather than its children)
(공식문서 참고)
이벤트 핸들러에서 발생한 에러, 비동기 작업에서 발생한 에러의 경우
이 경우는 리액트의 렌더링 과정에서 발생하는 에러가 아니므로 포착할 수 없습니다.
예시 코드는 아래와 같습니다.
두 경우 모두 에러를 포착하지 못합니다.
하지만 이렇게 변경하면 어떨까요?
이벤트 핸들러와 비동기 작업에서 에러를 throw하지 않고, 상태를 변경하여 리렌더링을 트리거하여 렌더링 과정에서 에러를 throw하도록 수정했습니다.
이 경우, ErrorBoundary는 에러를 포착할 수 있습니다. 이유는 렌더링 과정에서 동기적으로 발생한 에러이기 때문입니다.
그렇다면 react query와 함께 써보면 어떨까요?
위의 코드에서, getExample이 실패한다면 ErrorBoundary는 에러를 포착할 수 없습니다.
이유는 useQuery 내부에서 에러를 catch하는 로직이 포함되어 있기 때문입니다. 따라서 에러는 상위로 전파되지 않습니다.
ErrorBoundary에서 이 에러를 포착하기 위해서는 다음과 같이 수정하면 됩니다.
useQuery는 요청이 실패할 경우 컴포넌트를 리렌더링합니다.
useQuery는 useBaseQuery라는 훅으로 구현되어 있습니다. useBaseQuery는 useState와 useSyncExternalStore를 사용하고 있어 리액트의 리렌더링 사이클과 맞물려 동작할 수 있습니다.
실패시 발생하는 리렌더링 과정에 queryResult.error는 존재하므로, queryResult.error를 그대로 throw하고 이를 ErrorBoundary가 포착할 수 있습니다.
이 과정을 대신해주는 옵션이 바로 v5기준 throwOnError, v4기준 useErrorBoundary 입니다. useQuery의 throwOnError(useErrorBoundary)를 켜두면 저렇게 코드를 작성하지 않아도 ErrorBoundary가 useQuery에서 발생한 비동기 작업의 에러를 포착할 수 있게 됩니다.
서버사이드에서 발생한 에러
ErrorBoundary는 클라이언트 사이드에서 발생한 에러만을 포착합니다.
getDerivedStateFromError는 리액트 클래스 컴포넌트의 라이프사이클 메서드로, 동적으로 UI를 업데이트하는 과정에서 발생하는 에러를 포착하기 위한 메서드입니다.
반면 서버사이드 렌더링은 정적인 HTML을 생성하게 됩니다. 이 과정에서는 사용자 인터랙션에 의한 동적인 변화가 없기 때문에 클라이언트 사이드에서만 동작하도록 설계되어 있습니다.
ErrorBoundary 자체에서 발생한 에러
ErrorBoundary가 자신의 렌더링 도중에 발생한 에러를 포착하려고 시도하면, 무한 루프에 빠질 수 있기 때문입니다.
에러를 포착하여 상태를 업데이트하고 fallback UI를 렌더링하려다 다시 에러가 발생, 이를 다시 포착하여 상태를 업데이트하고 fallback UI를 렌더링하려다 다시 에러가 발생…
ErrorBoundary를 사용하는 이유
그렇다면 ErrorBoundary는 어떤 장점이 있어서 사용하는걸까요? try catch구문이나, react-query의 onError를 사용해도 되는 것 아닌가요?
가장 큰 장점은 선언적인 에러 처리 수단이라는 것입니다.
에러 처리 로직을 위임하여, 하위 컴포넌트는 에러와 관련된 로직을 생각하지 않아도 된다.
명령형 코드로 작성할 경우 코드 길이로 인한 가독성 문제, 코드 작성에 따른 리소스 소모, 유사한 중복 코드 생산 문제 등이 발생할 수 있습니다.
유지보수 포인트가 명확해진다.
에러 발생시 보여줄 컴포넌트나 실행할 함수가 변경되어야 한다면, 즉시 가장 가까운 ErrorBoundary를 찾으면 됩니다.
통일된 에러 처리 로직을 팀원이 공유할 수 있다.
명령형 코드로 작성할 경우, 개발자의 코딩 스타일에 따라 다르게 구현될 수 있으므로 코드 리뷰의 피로도를 증가시키거나 유지보수시 코드 파악에 시간이 걸릴 수 있습니다.
ErrorBoundary를 조금 수정해봅시다.
단순히 에러가 발생하면 fallback을 보여주는 기능만으로는 조금 부족합니다. 필요할 것 같은 기능을 간단히 추가해보았습니다.
에러가 발생하면 실행될 onError 구현
fallback 컴포넌트에서 에러를 reset할 수 있도록 하는 resetError 구현
fallback 컴포넌트에서 에러를 reset하며 react-query의 재요청까지 할 수 있도록 하는 resetErrorWithQuery 구현
ErrorBoundary와 Suspense를 함께 사용하는 ErrorBoundaryWithSuspense 구현