이 글은 Tanstack-query를 사용한 프로젝트에서 커스텀한 ErrorBoundary를 개선한 내용을 작성한 글입니다.
👉 Custom ErrorBoundary 적용기 바로가기
Custom ErrorBoundary 개선 목록
1. ErrorBoundary 상태를 초기화했음에도 에러가 초기화 되지 않는다.
2. Tanstack-query의QueryErrorResetBoundary
적용하기
3. API 오류가 연속 두 번 발생하는 경우
ErrorBoundary 상태를 초기화했음에도 에러가 초기화되지 않고 여전히 에러 컴포넌트가 렌더링된다.
InternalServerError 컴포넌트의 버튼 클릭시 ErrorBoundary의 resetState가 실행되는데, 이때 ErrorBoundary내의 에러 상태가 초기화된다.
하지만 react-query의 쿼리값에는 여전히 에러가 존재하므로 쿼리 값에 저장된 에러 상태도 초기화해야 한다.
ErrorBoundary 상태뿐만 아니라 queryClient의 쿼리에 저장되는 에러도 초기화해주어야 완전한 에러 초기화가 이루어진다.
queryClient.clear()
메서드를 통해 쿼리를 초기화하니 정상적으로 에러가 초기화 되었다.
QueryErrorResetBoundary
적용하기위에서는 ErrorBoundary 내에서 queryClient.clear()로 에러를 초기화했다.
하지만 queryClient.clear()는 기존에 캐시되었던 모든 데이터 역시 초기화되므로 효율적인 방식이 아니다.
또한 더 범용적인 ErrorBoundary를 만들기 위해서 tanstack-query에서 제공하는 QueryErrorResetBoundary
메서드로 에러를 초기화 하는 방식으로 리팩토링하기로 했다.
QueryErrorResetBoundary
는 하위 컴포넌트에서 사용하는 쿼리에 대한 에러를 reset한다.
ErrorBoundary의 onRest props로 react-query의 QueryErrorResetBoundary
의 reset 메서드를 전달한다.
ErrorBoundary를 사용할 때 resetErrorBoundary를
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary>
onReset={reset}
...
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
const fallbackRedirectAuth = () => (
<CustomErrorBoundary fallbackRender={({ resetErrorBoundary }) => <NotValidRedirectCode resetError={resetErrorBoundary} />}>
<RedirectAuth />
</CustomErrorBoundary>
);
하지만 reset 메서드를 실행해도 에러가 정상적으로 reset되지 않았다.
tanstack-query의 QueryErrorResetBoundary가 어떻게 동작하는지 조금 더 뜯어보기로 했다.
QueryErrorResetBoundary 컴포넌트는 하위 컴포넌트를 QueryErrorResetBoundaryContext가 감싸고 있고 만약 함수로 전달된다면 인자로 value 값을 전달한다. 이로 인해 래핑된 하위 컴포넌트에서 reset 메서드를 가져다 쓸 수 있게 된다.
하위 컴포넌트에서 reset 메서드를 onReset prop으로 전달하고 실행하면 value state는 isReset 메서드의 반환값이 true가 된다.
따라서 getHasError의 조건문에 isReset 값이 충족되지 않아 ErrorBoundary로 에러를 던질 수 없게 된다.
// react-query/src/QueryErrorResetBoundary.tsx
import * as React from 'react'
// CONTEXT
// QueryErrorResetBoundaryContext를 생성한다.
export interface QueryErrorResetBoundaryValue {
clearReset: () => void
isReset: () => boolean
reset: () => void
}
function createValue(): QueryErrorResetBoundaryValue {
let isReset = false
return {
clearReset: () => {
isReset = false
},
reset: () => {
isReset = true
},
isReset: () => {
return isReset
},
}
}
const QueryErrorResetBoundaryContext = React.createContext(createValue())
// HOOK
// useQueryErrorResetBoundary 훅은 QueryErrorResetBoundaryContext를 반환한다.
export const useQueryErrorResetBoundary = () =>
React.useContext(QueryErrorResetBoundaryContext)
// COMPONENT
// QueryErrorResetBoundary 컴포넌트는 prop으로 리액트 노드를 반환하는 함수 또는 리액트 노드를 받는다.
export interface QueryErrorResetBoundaryProps {
children:
| ((value: QueryErrorResetBoundaryValue) => React.ReactNode)
| React.ReactNode
}
export const QueryErrorResetBoundary = ({
children,
}: QueryErrorResetBoundaryProps) => {
// value state의 초기값은 { clearReset: func, reset: func, isRest: func }
const [value] = React.useState(() => createValue())
// value 상태를 하위 컴포넌트에게 뿌려준다. 하위컴포넌트 어디에서나 value에 접근할 수 있다.
return (
<QueryErrorResetBoundaryContext.Provider value={value}>
{typeof children === 'function'
// children이 함수로 전달되면 인자로 value 상태를 전달한다.
// 그래서 {({reset})=> <ErrorBoundary onReset={reset} />} 으로 전달가능했던 것
? (children as Function)(value)
: children}
</QueryErrorResetBoundaryContext.Provider>
)
}
// react-query/src/errorBoundaryUtils.ts
export const getHasError = ({
result,
errorResetBoundary,
useErrorBoundary,
query,
} => {
return (
// QueryObserverResult에 isError가 true이고
result.isError &&
// errorResetBoundary의 isReset 메서드의 반환값이 false 이고
!errorResetBoundary.isReset() &&
// QueryObserverResult가 feching 중이 아니면
!result.isFetching &&
// ErrorBoundary에 에러를 던진다.
shouldThrowError(useErrorBoundary, [result.error, query])
)
}
💡 QueryErrorResetBoundary가 하는 역할은 reset 메서드를 통해 isReset 반환값을 true로 만들고, 그로 인해 getHasError 조건문을 충족하지 않아서 ErrorBoundary에 에러를 던질 수 없게 하는 것이다. 즉, query 에러를 초기화 해주는 역할이 아니다!!
같은 페이지 내에서 ErrorBoundary를 reset하면 에러를 무시하므로(초기화X) 계속해서 다른 작업들이 가능하다.
하지만 라우터로 다른 페이지 컴포넌트가 렌더링된다면, 그리고 해당 컴포넌트에서 에러가 발생했던 데이터를 사용한다면 백그라운드에 캐시된 에러를 가져오므로 이미 발생했던 에러를 다시 throw하고 ErrorBoundary는 이를 캐치하고 터진다.
따라서 QueryResetErrorBoundary는 같은 페이지 내에서의 에러를 무시해줄 수는 있지만, 라우터가 발생할 시 캐시된 에러가 다시 throw 될 수 있다.
결론적으로 에러가 발생한 쿼리 데이터, 캐시된 에러를 삭제해주어야 초기화가 정상적으로 이루어진다.
tanstack-query에서 쿼리를 초기화하는 방법들은 다음과 같다.
queryCache.clear
QueryCache
는 탄스택 쿼리의 저장소다. 모든 데이터, 메타 정보 그리고 쿼리 상태를 저장한다. 일반적으로 QueryCache와 직접적으로 상호작용하기보다는 특정 캐시에 대한 QueryClient
를 사용한다.queryClient.clear
QueryClient
는 캐시와 상호작용하기 위해 사용된다. clear
메서드는 모든 연결된 캐시를 삭제한다.⇒ 에러가 발생한 쿼리만 캐시에서 삭제하고 싶은 것인데 모든 캐시를 초기화하는 것은 비효율적이다. 에러가 발생할 때마다 기존에 저장했던 데이터들을 모두 삭제하고 네트워크 요청을 다시 해야하기 때문이다.
queryClient.invalidateQueries
invalidateQueries
메서드는 fresh 쿼리를 stale 상태로 만들어서 암묵적 refetch를 실행시킨다.queryClient.removeQueries({ queryKey, exact: true })
⇒ 보통 데이터를 추가, 삭제, 변경하는 useMutation의 onSuccess 콜백 함수에 사용되며, 변경된 데이터 값을 캐시 데이터에도 적용할 수 있다. (background refetching)
queryClient.removeQueries
queryClient.resetQueries
clear
와는 달리 subscribers에게 알림을 준다.invalidateQueries
와는 달리 미리 로드된 상태로 쿼리를 재설정한다. 만약 쿼리가 initialData
값이 있다면, 초기값으로 재설정된다. 만약 쿼리가 active 상태라면 refetch 된다.clear
는 캐시를 전체 초기화하므로 에러 한번으로 캐시 데이터를 모두 날리는 것은 비효율적이다invalidate
는 쿼리를 stale 하면서 background refetching 이 발생하므로 에러 쿼리를 처리하는 방식으로는 적합하지 않다.remove
는 캐시에서 쿼리를 삭제하는 방식인데 API 재요청이 있을 경우 삭제된 쿼리를 다시 캐시에 추가해야한다. 사용되지 않을 쿼리면 삭제해도 괜찮지만 어차피 Cache Time이 지난 inactive 쿼리는 GC 되기때문에 이 처리는 react query에게 넘기기로 했다.reset
은 캐시 데이터를 초기화하는 방식으로 초기값이 있다면 해당 값으로 설정되고 active 쿼리인 경우에 refetch 된다. 에러 쿼리는 inactive 상태로 refetch 없이 초기화되므로 이 방법을 채택했다.resetQueries
메서드를 이용해서 에러 쿼리를 초기화해보자.status가 error인 쿼리를 찾아 queryKey를 얻어 에러 쿼리를 reset하는 함수를 만들었다.
ErrorBoundary의 onReset prop으로 이 함수를 전달해서 resetErrorBoundary 메서드에서 실행하도록 했다.
const resetErrorQuery = (queryClient: QueryClient) => {
const queryCache = queryClient.getQueryCache();
const queryKeys = queryCache.getAll().filter((q) => q.state.status === 'error');
if (queryKeys) {
queryKeys.forEach(({ queryKey }) => {
queryClient.resetQueries({ queryKey });
});
}
};
현재까지 위의 두가지 개선점을 종합해보면 resetErrorBoundary에서는
class ErrorBounday {
resetErrorBoundary(...args: any[]) {
const { onReset } = this.props;
const { error } = this.state;
if (error !== null) {
onReset?.();
this.setState(initErrorState);
}
}
}
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary>
onReset={() => {
reset();
resetErrorQuery(queryClient);
}}
...
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
이슈트래커 프로젝트에서는 Access Token이 만료되었을 시 ErrorBoundary에서 에러를 캐치하고 자동 로그인 API를 요청한다.
이때 Refresh Token의 유효기간도 만료되었다면 자동 로그인 API 요청도 에러가 발생한다.
발생한 두가지 에러 모두 공통 에러에 해당한다. 공통에러란 토큰 만료, 서버 에러, 네트워크 에러와 같이 모든 API 요청에서 공통적으로 발생할 수 있는 에러를 의미한다.
예를 들어, 이슈 조회 API 요청이 실패했을 때 일반적인 에러가 발생했다면 첫번째 화면이 렌더링되어 API 재요청을 시도하도록 할 것이다. 하지만 토큰 만료로 인한 에러가 발생했다면 두번째 화면이 렌더링되어 로그인 재시도를 하도록 유도할 것이다.
토큰 만료시 에러 UI | 조회 API 요청 실패시 에러 UI |
---|---|
하나의 ErrorBoundary 에서 다른 유형의 에러를 처리하다보니 코드 양이 길어지고 가독성이 떨어졌다.
또한 공통에러가 일반 API 요청 에러보다 우선순위가 더 높다.(AccessToken 만료시 로그인 재시도 UI를 렌더링하는 것이 refetch UI를 렌더링하는 것보다 우선순위가 높아보인다.)
이러한 우선순위에 따른 처리를 ErrorBoundary 내에 조건문으로 작성하다보니 공통 에러 처리 로직을 전부 작성하게 되었고 비즈니스 로직이 분리되지 않았다.
공통 에러는 토큰 만료, 로그인 갱신 또는 서버 통신 오류 등으로 페이지 전체에 에러를 띄운다. 공통 에러 핸들링을 통해 각 에러 바운더리에서 미처 핸들링하지 못한 에러를 처리하거나 우선 순위에 따라 에러를 처리할 수 있다.
모든 API 요청은 공통적으로 서버 에러가 발생할 수 있다. 그렇다고 모든 ErrorBoundary에 서버 에러 코드에 따른 fallback UI를 분기하여 작성하는 것은 중복코드가 발생할 뿐만 아니라 유지보수도 비효율적이다. 이런 공통 에러는 같은 에러 UI를 렌더하도록 한 곳에서 작성하는 것이 효율적일 것이다.
에러 종류는 크게 API 요청 에러와 런타임 에러가 있다. 물론 모든 에러에 미리 대응하여 코드 작성을 하면 좋겠지만 어플리케이션의 규모가 커질수록 이는 불가능에 가까워진다. 에러 핸들링을 하지 않으면 어플리케이션 자체가 중단되어 사용자 경험이 크게 떨어질 수 있다. 따라서 ErrorBoundary가 감싸져 있지 않는 컴포넌트에서 발생한 에러를 놓치지 않고 처리할 수 있도록 가장 상위에 ErrorBoundary를 감싼다.
루트에 있는 GlobalErrorBoundary는 모든 에러를 캐치할 수 있고, GeneralErrorBoundary는 서버오류, 토큰 오류를 제외한 오류만을 캐치한다.
오류 캐치는 getDerivedStateFromProps
**메서드에서 실행되는데 static 메서드이므로 this 메서드로 prop에 접근이 불가능하기 때문에 해당 ErrorBoundary 가 루트에 있는지, 하위에 있는지 구분할 방법이 없었다.
결론적으로 상속을 이용해서 getDerivedStateFromProps
메서드에 공통 오류를 상위로 던져주는 로직을 추가해 확장한 GeneralErrorBoundary를 만들었다.
모든 컴포넌트에 ErrorBoundary 를 감싼다면 더 세밀한 에러 핸들링이 가능하겠지만, 오히려 단일 ErrorBoundary보다 나쁠 수 있다.
<form>
<ErrorBoundary>
<CartDescription items={props.items} />
</ErrorBoundary>
<ErrorBoundary>
{/* Uh oh! Something broke in here 😢 */}
<CreditCardInput />
</ErrorBoundary>
<ErrorBoundary>
<CheckoutButton cartId={props.id} />
</ErrorBoundary>
</form>
위의 예제에서 CreditCardInput에서 에러가 발생한 경우 checkoutForm의 나머지 컴포넌트들에게 에러가 전파되지는 않을 것이다. 따라서 CartDescription과 CheckoutButton 컴포넌트는 여전히 mount 된 상태로 사용자들이 장바구니를 보고 결제를 시도할 수 있지만 신용카드 정보는 입력에서 에러가 발생했으므로 정상적으로 동작하지 않는다. 이처럼 오히려 세분화된 ErrorBoundary는 사용자에게 혼란을 줄 수 있으며 또한 과도한 ErrorBoundary 사용은 성능에 부정적인 영향을 줄 수 있다.
ErrorBoundary의 적절한 양은 애플리케이션에서 기능 경계를 식별하고 그곳에 ErrorBoundary를 놓는 것이다. 대개 시각적으로 독립적인 섹션은 독립적인 기능을 하고 그곳에 ErrorBoundary를 놓는다. 독립적인 기능이란 에러가 발생해도 다른 기능에는 영향이 없는 것을 의미한다. 즉, 구성 요소 트리를 참고해서 하나의 컴포넌트에서 에러가 발생했을 때 어떤 다른 형제 컴포넌트에 영향을 줄 수 있을지 생각하면 된다.
⇒ 기능 단위로 ErrorBoundary를 사용하고, 다른 컴포넌트에 어떤 영향을 줄 지 생각해야 한다.