Error boundary란?
React를 개발하다 보면, 갑자기화면 전체가 사라지고 콘솔에는 Error만 찍혀 있는 상황을 겪게 됩니다. 이러한 경험은 사용자 경험과 개발자 경험을 모두 좋지 않게 만드는데요, 아래 두 가지의 관점에서 이를 볼 수 있습니다.
사용자 관점(UX)
React 렌더링 중 오류가 발생하면 트리 전체가 unmount됩니다. 이는 작은 컴포넌트 에러도 브라우저 전체를 멈추게 합니다. 사용자 입장에서는 하얀 빈 화면만 보게 되고 "아무것도 안 보여요"라는 반응을 보이게 되어요.
개발자 관점(DX)
다음은 개발자 관점에서 보았을 때입니다. 아래와 같은 코드를 다들 한 번씩은 짜보셨을 거예요.
import { useQuery } from '@tanstack/react-query';
const MyComponent = () => {
const { data, error, isLoading } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
if (isLoading) {
return <p>불러오는 중...</p>;
}
if (error) {
return <p>오류가 발생했어요</p>;
}
return (
<ul>
{data?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
};
이런 식으로 컴포넌트 내에서 로딩 상태와 에러 상태를 정의하는 것은 익숙한 패턴입니다. 위와 같은 코드를 명령형 코드라고 하는데, 명령형(절차적) 프로그래밍은 일을 ‘어떻게’ 할 것인가에 관한 것이라면, 선언적 프로그래밍은 ‘무엇을’ 할 것인가에 관한 것입니다.
명령형 프로그래밍이 항상 나쁜 것만은 아니지만, 컴포넌트마다 에러를 직접 처리해야 하고, 에러 처리가 여러 곳에 흩어지고 중복되는 문제가 있으며, 로딩 상태, 에러 상태에 의존하게 됩니다. 선언형으로 작성하면, 에러 처리 로직을 한 곳에 모아 코드의 응집도를 높일 수 있습니다.
Errorboundary란?
Errorboundary란 하위 컴포넌트 트리의 에러를 기록하며 에러가 발생하면, 해당 컴포넌트 대신 Fallback UI를 보여줍니다. 다음과 같은 클래스형 컴포넌트로 구현할 수 있습니다.
Error Boundary의 기본 코드는 다음과 같아요.
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
logErrorToMyService(error, errorInfo);
}
render() {
if (this.state.hasError) {
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
이를 봤을때 유추해볼 수 있는것은 렌더링 과정 중에 에러가 발생하면 이를 try catch 의 catch 블록에서 컴포넌트의 componentDidCatch와 getDerivedStateFromError 메서드가 호출됩니다.
static getDerivedStateFromError 는 렌더링 단계에서 호출이되며, 렌더링 중 에러가 발생하면, state 바꿔서 fallbackComponent 가 렌더링 됩니다.
componentDidCatch()는 부수효과에 사용합니다. 에러 로그를 전송하고, Sentry에 연동하는데 사용합니다.
Error boundary를 사용하면 좋은점!
그렇다면 Error boundary를 사용하면 좋은점은 무엇일까요?
-
선언형 프로그래밍
<ErrorBoundary fallback={<ErrorFallback />}>
<MyComponet />
</ErrorBoundary>- 가독성: 선언형 코드는 직관적이고 코드의 의도를 명확히 전달합니다. 또한 하위 컴포넌트에게 책임을 전가하여 불필요한 코드를 상위 컴포넌트가 알 필요가 없게 되죠.
-
에러를 전파하지 않는다.
- JS에서 에러는 기본적으로 콜스택을 따라 전파되지만 → 애플리케이션의 특정 컴포넌트에서 발생한 오류가 전체 애플리케이션에 영향을 미치지 않도록 렌더링 중 에러의 전파를 중단할 수 있어요.
-
로그 및 디버깅:
componentDidCatch를 통해 오류를 기록하고, Sentry, Datadog, Discord Webhook 등 외부 서비스로 전송할 수 있습니다.
ErrorBoundary는 만능일까?
그렇다면, ErrorBoundary로 감싸면, 모든 에러를 감지할 수 있을까요?
Error Boundary가 못 잡는 경우
1. 브라우저의 Web API
import { useEffect } from 'react';
function Component() {
useEffect(() => {
fetch('https://잘못된URL/api/data')
.then((res) => res.json())
.then(console.log)
.catch((err) => {
console.error('fetch 내부에서 잡힘:', err);
throw err;
});
}, []);
return <div>데이터를 불러오는 중...</div>;
}
export default function App() {
return (
<ErrorBoundary>
<BadFetchComponent />
</ErrorBoundary>
);
}
이 코드는 에러가 발생해도 Error Boundary가 감지하지 못합니다.
왜 fetch 안에서 error를 던지면 에러 경계가 잡지 못할까요?
그 이유는 실행 컨텍스트의 주체 즉, 실행 순서가 다르기 때문입니다.
Errorboundary의 목적
Errorboundary는 React Fiber 트리 내부에서 실행되는 함수(컴포넌트 ,렌더링, 라이프사이클 등)에서 발생한 에러만 감시합니다.
더 자세히 말씀드리자면, js는 싱글 스레드로, 실행 과정이 파싱, 컴파일, 실행 과정으로 나누어지는데요.
- V8엔진이 자바스크립트 코드를 파싱합니다.
- 바이트 코드로 컴파일합니다.
- 실행합니다. (이 때 실행 컨텍스트가 생성)
React Fiber 트리 안에서 렌더링 컨텍스트에서 실행하는 것까지 감지할 수 있습니다.
반면 fetch는 브라우저의 Web API (V8 외부 런타임) 가 주체가 되어 실행됩니다. 자바스크립트는 싱글 스레드이기 때문에 실행 시점에서, fetch와 같은 비동기 로직을 만나면 이를 Web API에 위임하게 되고, 다른 실행 시점이 되게 되는 것이죠.
즉, 아래 코드는 React Fibe 트리 밖, 완전히 다른 실행 컨텍스트에서 수행됩니다.
실행 시점이 완전히 다릅니다. 가 kick 포인트입니다.
useEffect(() => {
fetch('/api/data')
.then((res) => res.json())
.catch((err) => {
throw err;
});
}, []);
따라서 Errorboundary가 catch할 수 없게 됩니다.
2. 서버 사이드 렌더링
서버 사이드에서 발생한 Error 또한 ErrorBoundary가 잡을 수 없습니다. 이유도 위의 상황과 연결됩니다. 렌더링하면서 발생하는 에러를 감지하는데, 그 이전에 서버에서 발생한 에러는 추적하지 못하게 되는 것입니다.
그럼 어떻게 하면 좋을까요?
import { useEffect, useState } from 'react';
function Component() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
fetch('https://잘못된URL/api/data')
.then((res) => res.json())
.then(setData)
.catch(setError);
}, []);
if (error) throw error;
return <div>{data ? JSON.stringify(data) : '불러오는 중...'}</div>;
}
이 예시를 보면 catch 블록에서 먼저 에러가 발생하면 위의 예시와는 다르게, setError()로 React state에 저장하고, Update 즉, 상태 변화에 따른 리렌더링이 일어나게되면 V8엔진이 if (error) throw error; 에서 에러를 던지고 에러 경계가 이를 감지하여 fallback UI를 띄우게 됩니다.
에러 자체는 fetch에서 발생했지만, React Fiber가 제어하는 ‘렌더링 컨텍스트’ 안으로 다시 던졌기 때문에 Error Boundary가 잡을 수 있게됩니다.