useAsync 훅의 종말 : Suspense & ErrorBoundary
리액트를 처음 배우던 시절 나는 비동기 데이터를 다루는 방식의 하나로 useAsync라는 이름의 커스텀 훅을 접하게 되었다. 이는 컴포넌트 내부에서 비동기 작업을 안전하게 처리하기 위한 수단이었는데, 그 코드를 기억나는대로 작성해보자면 아래와 같았다.
export const useAsync = (fn) => {
const [state, setState] = useState({ data: null, loading: true, error: null });
useEffect(() => {
const fetchData = async () => {
try {
setState((prev) => ({ ...prev, loading: true }));
const response = await fn();
if (!response.ok) {
throw new Error("Network response was not ok");
}
const result = await response.json();
setState((prev) => ({ ...prev, data: result }));
} catch (error) {
setState((prev) => ({ ...prev, error }));
} finally {
setState((prev) => ({ ...prev, loading: false }));
}
};
fetchData();
}, []);
return { state }
}
그리고 이 커스텀훅을 사용해서 컴포넌트 내에서 아래와 같은 방식으로 비동기 작업을 처리했다. 하지만 이 방법은 컴포넌트가 비동기 작업에 대한 예외 처리 로직을 직접 가지고 있게 되며, 따라서 본래의 목적과 상관 없는 코드를 가지게 된다는 단점을 내포하고 있다.
export default function Comp() {
const { state } = useAsync(() => fetch("..."))
if (state.loading) return <div>...is Loading</div>
if (state.error) return <div>we found error :(</div>
return <div>{state.data.title}<div>
}
리액트 18 이후로 나는 useAsync 훅을 거의 사용하지 않고 있다. Suspense와 ErrorBoundary를 사용하면 컴포넌트 내부의 예외 처리를 외부에서 처리해줄 수 있기 때문이다.
ErrorBoundary
리액트에서 자체적으로 제공하고 있는 ErrorBoundary 컴포넌트는 클래스 컴포넌트에 사용하도록 되어있기에, react-error-boundary 라이브러리에서 제공하는 ErrorBoundary 컴포넌트를 사용하고 있다. 이 컴포넌트에는 fallback을 제공하는 방식이 세 가지나 되므로 상황에 맞게 fallback을 제공할 수 있어 좋다.
fallback prop은 가장 기본적인 방식으로 children 컴포넌트에서 에러가 발생했을 때 보여줄 JSX 요소를 인자로 받는다.
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<ExampleApplication />
</ErrorBoundary>
fallbackRender는 JSX 요소를 리턴하는 콜백 함수를 인자로 받는다. 이 콜백 함수는 error 객체와 resetErrorBoundary 함수를 포함하는 객체를 인자로 받는다. 이 resetErrorBoundary 함수를 호출하면 onReset에 제공된 콜백 함수가 실행되며, ErrorBoundary 컴포넌트가 감싸고 있는 children 컴포넌트를 리랜더링한다.
onReset이 resetErrorBoundary 함수를 호출할 때 실행되는 콜백 함수라면, onError는 children 컴포넌트에서 에러를 던지면 실행되는 콜백 함수이다. error와 info를 인자로 받으며 특히 info 객체의 경우 에러가 발생한 시점에 대한 추가적인 정보를 담고 있어 여러모로 도움이 된다.
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<div role="alert">
<p>Something went wrong:</p>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)}
onReset={() => {}}
onError={(error, info) => {}}
>
<ExampleApplication />
</ErrorBoundary>
FallbackComponent는 fallbackRender와 비슷하지만 컴포넌트를 인자로 받는다는 점이 다르다. 그런데 컴포넌트란 결국 JSX 요소를 리턴하는 함수라는 점을 생각해보면 정말로 다른 점이 있는 걸까 싶기도 하다. 물론 컴포넌트의 경우 이름을 파스칼 케이스로 적어야 한다는 규칙 + 그 강제된 규칙으로 인해 훅을 사용할 수 있다는 점을 생각하면 다르기야 다르겠다만...
재사용할 필요가 없으며 간단하게 처리하고자 할 때는 fallbackRender를 사용하고 있지만, 그 외의 모든 경우에는 FallbackComponent를 사용하고 있다. 체감상 fallback은 거의 사용하지 않는 듯한데, resetErrorBoundary를 호출할 방법이 없기 때문이다.
with React-Query
리액트 쿼리는 에러가 발생할 때도 이를 바깥으로 던지지 않고 내부적으로 처리하여 error 프로퍼티로 제공한다. 이를 외부로 던지게 하기 위해 쿼리 옵션 객체에 throwOnError라는 값을 설정해줄 수 있다.
const {data} = useQuery({
queryKey: ["report"],
queryFn: this.infiniteQueryFn<GetReportList>("..."),
throwOnError: true, // throwOnError는 boolean을 받으며 기본값은 false
};
또한 QueryErrorResetBoundary 컴포넌트가 제공하는 reset을 사용해 onReset 동작을 지정해줄 수 있다.
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={reset}>
<ExampleApplication />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
in App Router
당연하게도 App Router 수준에서는 error.tsx를 사용해 자동으로 에러 핸들링을 처리한다. 때문에 ErrorBoundary 컴포넌트가 필요 없는데다가 ReactQuery 대신 fetch로 요청을 캐싱할테니 QueryErrorResetBoundary도 필요없다.
역시 앱라우터 형님이야 성능 확실하구만, 이라고 말하고 넘어가고 싶지만 몇 가지 걸리는 부분이 있다. 일단 대부분의 서비스는 아직도 페이지 라우터를 기반으로 운영된다. 게다가 앱라우터가 product ready인지도 의견이 분분하다. 따라서 나는 'ErrorBoundary 안 쓰고 싶으니까 앱라우터 써야지' 같은 생각보다는 페이지라우터와 앱라우터에서 던져진 에러가 각각 어떻게 처리되는지 확실히 알아두는 게 낫지 않나 싶다.
Suspense
Suspense는 react 수준에서 제공되는 컴포넌트이며, children으로 받은 컴포넌트가 지체된 경우 fallback을 화면에 표시해준다. 컴포넌트가 지체되는 경우는 여럿 있지만 대부분은 비동기 통신을 진행할 때 지체된다. react 수준에서 제공되는 컴포넌트긴 하지만 페이지라우터와 앱라우터에서 동작이 사소하게 다르다. 그러나 본질은 비슷하며 사소한 차이 ─ 가령 스트리밍 관련 ─ 는 나중에 다른 포스트에서 다뤄볼까 싶다.
<Suspense fallback={<div>...is Loading</div>}>
<ExampleApplication />
</Suspense>
ReactQuery와 함께 사용하는 경우 suspense 값을 true로 설정하거나, useSuspenseQuery를 사용하여 외부로 컴포넌트가 지체되어있음을 알려야 한다.
const {data} = useQuery({
queryKey: ["report"],
queryFn: this.infiniteQueryFn<GetReportList>("..."),
suspense: true // suspense는 boolean을 받으며 기본값은 false
};
컴포넌트가 지체되는 다른 사례로는 많은 계산량이 요구되는 상태 변경이 있는데, 이 부분은 useTransition과 함께 사용하여 처리하게 된다. 이 부분에 대한 설명을 React 공식 문서에서 확인할 수 있으며 이쪽의 블로그에서도 훌륭한 예제와 함께 설명하고 있으므로 살펴보기를 권한다.
블로그의 정보
Ayden's journal
Beard Weard Ayden