Info
이 글에서 다루는 프로젝트 환경은 아래와 같습니다.
- React + Vite
react-router-domv7- TanStack Query +
useSuspenseQuery
프로젝트에서 네트워크 에러가 발생했을 때, 사용자에게 보여줄 에러 화면을 만들고 있었어요.
ErrorBoundary로 감싸면 되니까 간단할 거라고 생각했습니다. main.tsx에도 하나, App.tsx에도 하나, 두 겹으로 감쌌어요.
그런데 실제로 에러가 터졌을 때, 제가 만든 fallback UI는 어디에도 보이지 않았습니다. 대신 화면에 떠 있던 건 이거였어요.
Unexpected Application Error!
React Router가 보여주는 기본 에러 UI였습니다. 분명 ErrorBoundary를 두 겹이나 감쌌는데, 왜 내 fallback이 아니라 React Router의 기본 UI가 뜨는 걸까요?
이 글에서는 ErrorBoundary가 에러를 잡지 못한 원인을 추적하고, React Router의 errorElement로 해결한 과정을 공유합니다.
ErrorBoundary를 감쌌는데 왜 안 잡힐까
당시 에러 처리 구조는 이랬습니다.
// main.tsx
<ErrorBoundary fallback={<ErrorState title="Error" message="문제가 발생했습니다." />}>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</ErrorBoundary>
// App.tsx
<ErrorBoundary fallback={<ErrorState title="Error" message="문제가 발생했습니다." />}>
<RouterProvider router={router} />
</ErrorBoundary>
@suspensive/react의 ErrorBoundary를 사용하고 있었고, RouterProvider 바깥과 QueryClientProvider 바깥 양쪽에 배치해 뒀어요. 어디서 에러가 터지든 둘 중 하나가 잡아줄 거라고 생각했습니다.
그런데 실제 에러가 발생한 지점은 이랬어요.
Axios 인터셉터에서 네트워크 에러를 throw하고, 그 에러가 useSuspenseQuery를 쓰는 컴포넌트까지 올라가는 구조였습니다. 에러는 라우트 컴포넌트 안에서 발생했고, 당연히 위쪽 ErrorBoundary로 bubble up될 거라고 기대했어요.
그런데 그 에러는 제 ErrorBoundary까지 도달하지 않았습니다. 누군가가 중간에서 먼저 잡고 있었거든요.
React Router가 먼저 잡고 있었다
“Unexpected Application Error!”라는 텍스트가 계속 보여서, 이 문자열을 그대로 검색해 봤습니다. React Router 소스 코드에서 찾았어요.
createBrowserRouter는 React Router v6.4에서 도입되어 v7까지 이어지는 API인데, 각 라우트에 내부 error boundary를 내장하고 있었습니다. errorElement를 명시하지 않으면, React Router가 기본 에러 UI를 보여주는 구조예요.
그래서 제가 이해한 에러 전파 흐름은 이랬어요.
라우트 컴포넌트에서 에러 발생
→ React Router 내부 error boundary가 catch
→ 기본 에러 UI 렌더 ("Unexpected Application Error!")
→ 외부 ErrorBoundary까지 도달하지 않음
이걸 보고 나서야 이해가 됐습니다. React의 에러 전파 규칙상, 트리에서 가장 가까운 error boundary가 먼저 catch하거든요. 제 ErrorBoundary는 구조적으로 에러가 도달할 수 없는 위치에 있었던 거예요.
당시 라우트 설정을 보면 문제가 명확해집니다.
// Router.tsx
export const router = createBrowserRouter([
{
element: <RouteChangeHandler />,
children: [...모든 라우트],
// errorElement가 없음 → React Router 기본 에러 UI 사용
},
]);
errorElement를 지정하지 않았으니, React Router가 알아서 기본 에러 UI를 보여준 거예요. 제가 만든 ErrorBoundary는 이 구조 바깥에 있어서 아무 역할도 하지 못했습니다.
그렇다면 React Router 안에서 발생한 에러는 React Router의 방식으로 잡아야 합니다.
errorElement로 해결하기
그게 바로 errorElement였어요.
useRouteError() 훅으로 에러 객체를 받아서, 기존에 만들어 둔 ErrorState 컴포넌트에 넘기는 구조로 만들었습니다.
// Router.tsx
function RouterErrorBoundary() {
const error = useRouteError();
const message =
error instanceof Error
? error.message
: '페이지 로드 중 오류가 발생했습니다.';
return <ErrorState title="Error" message={message} />;
}
export const router = createBrowserRouter([
{
element: <RouteChangeHandler />,
errorElement: <RouterErrorBoundary />,
children: [...모든 라우트],
},
]);
변경은 딱 두 가지예요.
RouterErrorBoundary컴포넌트를 만들고- 루트 라우트에
errorElement로 등록
이렇게 하면 라우트 내부에서 발생한 에러가 React Router의 내부 error boundary를 거쳐 제가 만든 ErrorState UI로 렌더됩니다. “Unexpected Application Error!” 대신 일관된 에러 화면을 보여줄 수 있게 됐어요.
해결하고 나니 한 가지가 더 눈에 들어왔습니다. App.tsx에 감싸 둔 ErrorBoundary가 의미 없는 레이어가 된 거예요. 라우트 레벨 에러는 이제 errorElement가 처리하니까요. 그래서 중복 레이어를 정리했습니다.
| 위치 | 변경 | 이유 |
|---|---|---|
App.tsx의 ErrorBoundary | 제거 | errorElement가 라우트 에러를 처리하므로 중복 |
main.tsx의 ErrorBoundary | 유지 | QueryClientProvider 등 provider 에러를 잡는 최후 안전망 |
정리하기
처음에는 “ErrorBoundary를 충분히 감쌌으니까 에러는 잡히겠지”라고 생각했습니다. 그런데 createBrowserRouter는 자체 error boundary를 내장하고 있어서, 외부에서 아무리 감싸도 라우트 레벨 에러는 React Router가 먼저 잡아요.
- 원인: React Router 내부 error boundary가 트리에서 더 가까이 있어서, 외부
ErrorBoundary보다 먼저 에러를 catch한다 - 해결: 루트 라우트에
errorElement를 등록하고,useRouteError()로 에러를 받아 처리한다 - 구분: React
ErrorBoundary는 렌더링 에러, React RoutererrorElement는 라우트 레벨 에러. 둘은 다른 레이어다
“Unexpected Application Error!” 화면이 보인다면, ErrorBoundary를 더 감싸는 게 아니라 errorElement를 확인해 보세요. 이 글에서는 루트 라우트 하나에 errorElement를 둔 경우만 다뤘는데, 중첩 라우트별로 다른 에러 UI를 보여줘야 하거나 loader/action에서 발생하는 에러를 분리 처리하는 패턴은 또 다른 맥락이에요.