React 프로젝트에서 API 에러 처리, ApiErrorBoundary로 정리해봤다
react-query 에러 처리를 페이지마다 복사하던 코드를 ApiErrorBoundary 하나로 모았다. useErrorBoundary 옵션이 왜 필요한지, 네트워크 에러는 GlobalErrorBoundary로 넘긴 구조까지 코드로 정리했다.
#react-query #ErrorBoundary #zustand
React 프로젝트에서 react-query와 zustand를 같이 쓰고 있다. zustand는 로그인 정보 같은 전역 상태를 들고 있는 용도고, react-query는 서버에서 데이터를 불러오는 용도다. 이 조합 자체는 아무 문제가 없었는데, 어느 순간부터 API 호출이 실패했을 때 처리하는 코드가 너무 반복된다 는 게 눈에 들어왔다. "데이터를 불러오지 못했습니다", "다시 시도" 같은 화면을 페이지마다 복사해서 붙이다 보니 고칠 일이 생기면 여기저기 다 찾아다녀야 했고, 페이지마다 에러 화면 모양이 조금씩 달라서 쓰는 사람 입장에서도 어수선했다. 그래서 react-error-boundary를 바탕으로 ApiErrorBoundary라는 컴포넌트를 하나 만들었고, 지금은 꽤 만족하면서 쓰고 있다. 이 글에서는 이걸 넣기 전과 후에 코드가 어떻게 달라졌는지, 구조를 어떻게 잡았는지 적어본다. 에러 처리를 페이지마다 하고 있었다 처음에는 react-query가 주는 isError, error, refetch 값을 그대로 써서 페이지 안에서 에러를 처리했다. 그때 코드는 대략 이런 모양이었다. const { data, isLoading, isError, error } = useQuery(['getCode'], getCode); useEffect(() = { if (data) { setCodes(data); } return () = { resetCodes(); }; }, [data]); if (isLoading) return Loading / ; if (isError) { return ( div p 에러가 발생했습니다 /p button onClick={() = refetch()} 다시 시도 /button /div ); } 페이지가 몇 개 없을 때는 괜찮았다. 근데 페이지가 늘어날수록 저 if (isError) 블록이 여기저기 똑같이 생겨났다. 게다가 error.message를 화면에 그대로 찍는 건 사용자한테 보여주기엔 친절하지도 않고, 모든 페이지에서 같은 모양의 에러 화면을 유지하기도 어려웠다. 로딩 처리만 하면 되는 컴포넌트가 에러 처리까지 떠안고 있으니 코드도 길어졌다. ApiErrorBoundary를 만들었다 그래서 react-error-boundary 라이브러리를 써서 ApiErrorBoundary라는 컴포넌트를 만들었다. 생각은 단순하다. 에러 처리를 쿼리를 쓰는 컴포넌트 안에서 하지 말고, 그 컴포넌트를 감싸는 바깥에서 한 번에 받자는 거다. 여기서 알아둬야 하는 게 하나 있다. react-query는 기본적으로 에러를 throw하지 않는다. 요청이 실패하면 isError를 true로 바꾸고 error에 담아줄 뿐이라, 컴포넌트가 직접 그 값을 보고 처리해야 한다. 그런데 ErrorBoundary는 렌더링 중에 던져진 에러만 잡는다. 그래서 둘을 이어주려면 react-query한테 "실패하면 그냥 던져라"라고 알려줘야 하고, 그 옵션이 useErrorBoundary: true 다. useQuery에 useErrorBoundary: true를 주면 쿼리가 실패했을 때 react-query가 렌더링 중에 에러를 던진다 그 에러를 가장 가까운 ErrorBoundary, 그러니까 ApiErrorBoundary가 잡아서 fallback 화면으로 바꿔 그린다 ApiErrorBoundary 자체는 react-error-boundary의 ErrorBoundary를 한 번 감싼 것에 가깝다. 에러가 났을 때 보여줄 컴포넌트를 FallbackComponent로 넘긴다. // ApiErrorBoundary.tsx const ApiErrorBoundary: React.FC { children: React.ReactNode } = ({ children }) = { return ( ErrorBoundary FallbackComponent={ApiErrorFallback} onReset={() = { // 다시 시도할 때 초기화할 게 있으면 여기서 한다 }} {children} /ErrorBoundary ); }; export default ApiErrorBoundary; onReset은 fallback에서 다시 시도를 눌렀을 때 불리는 자리다. 바운더리가 에러 상태를 지우고 자식을 다시 그리기 직전에 호출되니까, 여기서 스토어 값을 되돌리거나 캐시를 비울 수 있다. 내 경우엔 비워뒀다. 자식이 다시 마운트되면 useQuery가 다시 돌면서 실패했던 쿼리를 알아서 한 번 더 요청하기 때문에, 따로 refetch를 부를 필요가 없었다. fallback 화면은 이렇게 생겼다. react-error-boundary가 error와 resetErrorBoundary 두 개를 props로 넣어준다. // ApiErrorFallback.tsx export const ApiErrorFallback = ({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () = void; }) = { const statusCode = (error as any)?.response?.status || null; console.log(error.message); // 네트워크 자체가 끊긴 건 여기서 처리할 일이 아니라 바깥으로 넘긴다 if (error.message === 'Network Error') { throw error; } return ( div role="alert" className="flex flex-col items-center justify-center w-[100%] h-[100%]" span {error.message} /span span {statusCode} /span span className="text-[24px]" 데이터를 불러오는데 에러가 발생하였습니다. /span IoIosRefresh className="text-[40px] text-gray-500 cursor-pointer hover:text-black" onClick={resetErrorBoundary} / /div ); }; resetErrorBoundary를 부르면 바운더리가 에러 상태를 지우고 원래 자식을 다시 그린다. 그러니까 새로고침 아이콘 하나가 예전의 "다시 시도" 버튼 역할을 통째로 대신한다. 상태 코드는 axios 에러의 response.status에서 꺼내는데, 401이나 404처럼 상황이 다른 에러를 나중에 나눠 처리하려고 같이 찍어뒀다. 이렇게 만들어두면 컴포넌트 쪽 에러 처리는 이 fallback 하나에서 끝난다. 쿼리를 쓰는 컴포넌트는 그냥 ApiErrorBoundary로 감싸기만 하면 된다. ApiErrorBoundary CommentFetcher CommentManage / /CommentFetcher /ApiErrorBoundary 그리고 안쪽 쿼리에는 useErrorBoundary: true 한 줄만 붙인다. const { data, isLoading, isError, error } = useQuery('getComments', getComments, { useErrorBoundary: true, }); 이제 쿼리 안에서는 isError나 refetch를 따로 다룰 일이 없다. 로딩 중일 때 뭘 보여줄지만 정하면 끝이다. 위에서 본 옛날 코드랑 비교하면 에러 관련 줄이 통째로 사라진 셈이다. 네트워크가 끊긴 건 따로 받는다 ApiErrorBoundary는 컴포넌트 단위의 API 에러만 맡는다. 그런데 인터넷이 아예 끊겼거나 서버가 내려간 경우는 얘기가 다르다. 이건 댓글 하나 못 불러온 게 아니라 앱 전체가 안 되는 상황이라, 부분 화면에 "다시 시도"를 띄우는 게 의미가 없다. 그래서 그런 건 GlobalErrorBoundary가 앱 전체를 감싸서 받게 했다. 구조는 이렇게 잡았다. 라우터 바깥, 그러니까 제일 바깥에 GlobalErrorBoundary가 있다. GlobalErrorBoundary BrowserRouter QueryClientProvider client={queryClient} App / /QueryClientProvider /BrowserRouter /GlobalErrorBoundary 컴포넌트 자체는 ApiErrorBoundary랑 거의 같고 fallback만 다르다. // GlobalErrorBoundary.tsx const GlobalErrorBoundary: React.FC { children: React.ReactNode } = ({ children }) = { return ( ErrorBoundary FallbackComponent={GlobalErrorFallback} onReset={() = {}} {children} /ErrorBoundary ); }; export default GlobalErrorBoundary; fallback은 화면 전체를 덮는다. 네트워크 에러면 "서버가 다운되어 있다"는 안내를, 그 외에는 홈으로 돌아가는 버튼을 보여준다. // GlobalErrorFallback.tsx export const GlobalErrorFallback = ({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () = void; }) = { const statusCode = (error as any)?.response?.status || null; console.log(error.message); return ( div role="alert" className="flex flex-col items-center justify-center w-[100vw] h-[100vh] p-2" Lottie options={defaultOptions} width={'100%'} height={'auto'} / {error.message != 'Network Error' ? ( span className="text-white text-center" 데이터를 불러오는데 에러가 발생하였습니다. /span button className="px-4 py-2 bg-blue-500 text-white rounded" onClick={resetErrorBoundary} 홈으로 돌아가기 /button / ) : ( span className="text-white text-center" 현재 서버가 다운되어있습니다. /span span className="text-white text-center mb-[10px]" 잠시후 다시 시도해주세요. /span / )} /div ); }; 앞에서 ApiErrorFallback 안에 throw error가 있었던 이유가 여기 있다. fallback도 결국 컴포넌트라서 렌더링 중에 에러를 던지면 그 바깥의 바운더리가 받는다. 그래서 Network Error는 안쪽 바운더리가 잡았다가 다시 던지고, 그걸 GlobalErrorBoundary가 받아서 전체 화면으로 바꾸는 흐름이 된다. 바운더리를 두 겹으로 두면서도 어느 에러를 어디서 처리할지는 이 throw 한 줄로 갈린다. 바꾸고 나서 달라진 점 이 구조로 바꾼 뒤로 쿼리를 쓰는 컴포넌트는 거의 로딩 처리만 남았다. 에러 화면은 fallback 두 개로 관리하니까 모양도 통일됐고, 고칠 일이 생겨도 한 군데만 보면 된다. 사용자 입장에서도 어느 페이지에서 에러가 나든 같은 화면에 같은 새로고침 버튼이 있어서 덜 당황스럽다. 정리하면 이렇다. API 요청 실패는 ApiErrorBoundary가 받는다 네트워크 끊김이나 앱 전체 에러는 GlobalErrorBoundary가 받는다 react-query에 useErrorBoundary: true를 줘야 에러가 바운더리까지 올라온다 인증이 만료돼서 401이 오면 zustand의 사용자 상태를 비워서 로그아웃 처리한다 에러 처리가 반복된다 싶으면 한 번쯤 바운더리로 끌어올려 보길 추천한다. 라이브러리 하나에 컴포넌트 두 개면 되는 일이었다. 그리고 1년 뒤에 이 옵션이 없어졌다 이 구조를 짜놓고 잘 쓰고 있었는데, 1년쯤 지나 react-query를 TanStack Query v5로 올려볼까 하고 문서를 열어보니 useErrorBoundary 옵션이 사라져 있었다. 이름이 throwOnError로 바뀌어 있었다. 아직 올리지는 않았고 이 블로그를 포함해 내 프로젝트들은 전부 v3에 남아 있는데, 올리는 날엔 이 글의 코드가 그대로 안 돈다는 뜻이다. // v3에서 짰던 것 useQuery(key, fn, { useErrorBoundary: true }) // v5 useQuery({ queryKey, queryFn, throwOnError: true }) 옵션 이름만 바뀐 거라 고치는 것 자체는 금방일 거다. 다만 ApiErrorBoundary를 여러 페이지에 뿌려놨던 터라 쿼리 옵션은 한 군데씩 다 찾아다녀야 한다. 추상화를 만들어두면 바뀔 때도 한 번에 바뀐다고 생각했는데, 바운더리는 한 곳에 있어도 그걸 켜는 옵션은 쿼리마다 흩어져 있으니 그렇지도 않다. v5에서 뭐가 더 바뀌었는지는 따로 정리해뒀다.