ErrorBoundary with React Query

김윤진·2022년 10월 30일
7

우아한테크코스

목록 보기
7/8

레벨로그 프로젝트

상황에 맞는 에러처리

같은 API를 통해 응답을 받아오더라도 상황에 맞는 에러처리가 필요하다. 예를 들어 A라는 콘텐츠가 있다고 생각해보자.
A라는 콘텐츠가 주가 되는 화면이 있다. 이 화면에서는 A의 API 응답에 에러가 오면 에러 페이지로 리다이렉트 되는 것이 사용성에 더 좋다고 생각한다. 왜냐하면 해당 페이지에서 사용자가 할 수 있는 것은 없기 때문이다.
그리고 A라는 콘텐츠가 부가 되는 화면에서는 에러 페이지로 리다이렉트되는 것보다는 토스트나 스낵바를 띄워주는 것이 좋다고 생각한다. 왜냐하면 사용자가 다른 콘텐츠를 이용할 수도 있기 때문이다.
이렇게 상황에 맞는 에러 처리는 개발자가 결정하는 것이다. 이 결정에는 사용자 입장에서 깊이 생각해보고 결정하는 것이 좋다. 그래서 항상 사용자 입장에서 생각하는 것은 웹서비스 개발자에게는 필수적인 덕목이라고 할 수 있다.


에러 바운더리 적용 이유

팀 상세 정보를 요청하는 API를 예로 들어보자
팀 상세 정보를 응답 받기 위해서는 teamId를 통해서 요청할 수 있다.
/teams/{teamId}

teamId은 number 타입이여야 하고 teamId가 존재해야 팀 상세 정보를 응답 받을 수 있다.
teamId에 예상치 못한 값('qwer', '11ww') 이런 값들을 넣는다면 당연하게 에러가 발생한다.

  • 에러 화면은 다음과 같다.

예상치 못한 값이나 통신과정 예상치 못한 일이 발생해서 에러가 발생한다고 해도 사용자에게 이런 화면을 보여주는 것은 좋지 못하다. 왜냐하면 사용자는 왜 이 에러가 발생했는지도 인지시켜주지 못하고 사후처리도 없기 때문이다.

  • 에러 바운더리를 적용한 이후 에러 화면
import { Component, PropsWithChildren } from 'react';

import { AxiosError } from 'axios';

import Error from 'pages/status/Error';

class ErrorBoundary extends Component<PropsWithChildren, ErrorBoundaryState> {
  constructor(props: PropsWithChildren) {
    super(props);
    this.state = {
      showError: false,
    };
    this.handleErrorReset = this.handleErrorReset.bind(this);
  }

  handleErrorReset() {
    this.setState({ showError: false });
  }

  static getDerivedStateFromError(err: Error) {
    if (err.name === 'TypeError') {
      return {
        showError: true,
      };
    }

    if (err instanceof AxiosError) {
      if (err.response?.status === 500 || err.response?.status === 404) {
        return {
          showError: true,
        };
      }
    }

    return {
      showError: true,
    };
  }

  render() {
    const { children } = this.props;

    if (this.state.showError) {
      return <Error onClick={this.handleErrorReset} />;
    }

    return children;
  }
}

interface ErrorBoundaryState {
  showError: boolean;
}

export default ErrorBoundary;

전체적인 flow를 설명하면
1. 에러가 발생해면 상위로 에러가 전파된다.
2. getDerivedStateFromError메서드에서 전파되는 에러를 캐치한다.
3. 에러가 캐치되면 showError를 true로 바꿔준다.
(여기서 분기 처리를 하지만 showError를 true로 변경하는 것은 같다. 분기처리를 한 이유는 에러 발생하면 개발자가 어떤 에러를 발생했는지 디버깅하기 쉽게 하기 위함이 크다. 404, 500 에러가 아닌 400대 에러는 useQuery, API를 요청하는 곳에서 하는 것이 더 옳다고 생각한다. 왜냐하면 일단 API 요청과 에러처리는 최대한 가까운 부분에서 해야 추후에 변경이 쉽다고 생각한다.)
4. showError가 true로 변경되면 Error페이지로 리다이렉트된다.
5. Error 컴포넌트에서 홈으로 버튼을 클릭하면 showError가 false로 변경되고 홈화면으로 리다이렉트 된다.

이렇게 예상치 못한 사용자의 동작이나 통신에 의해 발생한 에러는 에러 바운더리를 통해 사용성을 개선하였다.


에러 바운더리는 모든 에러를 잡는가?

뭔가 에러 바운더리가 모든 에러를 캐치할 수 있다고 생각할 수 있다. 그러나 에러 바운더리는 API에 대한 에러는 잡지 못한다. 왜냐하면 API 요청과 응답은 비동기로 동작한다. class component도 함수다. 요청을 보낼 때는 에러 바운더리의 실행 컨텍스트가 존재했지만 API 응답이 에러로 오는 순간에는 에러 바운더리의 실행 컨텍스트는 존재하지 않는다.
그렇다면 현재 에러 바운더리는 어떻게 동작하는거지라는 의문이 들 수 있다. 에러 바운더리가 동작하는 이유는 react query와 함께 쓰기 때문이다.
전역에 존재하는 queryClient에 useErrorBoundary를 true로 설정해서 동작하는 것이다.

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      suspense: true,
      useErrorBoundary: true,
      retry: 0,
    },
    mutations: {
      useErrorBoundary: false,
      retry: 0,
    },
  },
});

useQuery를 통해 API를 통신하는 로직에서 생긴 API 에러는 모두 errorboundary로 가는 것이다.

그렇다면 react query는 어떻게 처리할까?
react query에는 isError라는 옵션이 존재한다. isError는 쿼리의 실패를 boolen값으로 나온다.

그리고 useQuery에는 이에 대한 처리를 위해 분기처리를 통해 에러를 throw한다.

  if (
    getHasError({
      result,
      errorResetBoundary,
      useErrorBoundary: defaultedOptions.useErrorBoundary,
      query: observer.getCurrentQuery(),
    })
  ) {
    throw result.error
  }
export const getHasError = <
  TData,
  TError,
  TQueryFnData,
  TQueryData,
  TQueryKey extends QueryKey,
>({
  result,
  errorResetBoundary,
  useErrorBoundary,
  query,
}: {
  result: QueryObserverResult<TData, TError>
  errorResetBoundary: QueryErrorResetBoundaryValue
  useErrorBoundary: UseErrorBoundary<
    TQueryFnData,
    TError,
    TQueryData,
    TQueryKey
  >
  query: Query<TQueryFnData, TError, TQueryData, TQueryKey>
}) => {
  return (
    result.isError &&
    !errorResetBoundary.isReset() &&
    !result.isFetching &&
    shouldThrowError(useErrorBoundary, [result.error, query])
  )
}

쿼리 결과의 isError와 에러바운더리의 reset 상태, 쿼리 fetching 상태를 기준으로 boolean 값을 반환한다.
그리고 queryClient의 defaultOptions 옵션의 useErrorboundary 값도 가져오는 것을 확인할 수 있다.
이를 통해 API 통신 같은 비동기에 대한 에러를 Errorboundary에서 잡을 수 있는 것이다.

현재 defaultOptions의 queries.useErrorBoundary true로 설정되어있다. 그렇다면 모든 쿼리에 대해 에러가 발생시 Errorboundary를 통해 Error컴포넌트를 띄워주는 것이다.
이렇게 되면 앞서 말했던 상황에 따른 유연한 에러처리가 되지 않는다.
실제 예시를 보자.
해당 API는 작성했던 사전질문을 응답으로 내려준다.

  • 밑에 페이지는 사전질문 수정 페이지이다. 사전질문 수정 페이지에서는 당연하게도 사전질문이 "주"가 되는 콘텐츠이다. 이 때 응답에 에러가 내려온다면 Error 페이지로 리다이렉트 하는 것이 좋다. 왜냐하면 사용자는 아무 행동도 할 수 없기 때문이다.
  • 밑에 페이지는 피드백 작성 페이지이다. 피드백 작성 페이지에서도 사전질문API를 통해 사전질문을 화면에서 보여준다. 하지만 이 페이지에서는 사전질문은 "부"가 되는 콘텐츠이다. 피드백 작성시 사전질문은 반드시 있어야 하는 콘텐츠가 아니기 때문이다. 그래서 이 페이지에서 사전질문API 응답이 에러가 내려온다면 Error 페이지로 리다이렉트하는 것이 아닌 토스트바 or 텍스트를 통해서 보여주는 것이 사용성에 더 좋다고 생각한다.

이를 위해 쿼리를 보내는 useQuery에서 useErrorBoundary 옵션을 false로 지정하면 queryClient에서 선언했던 값보다 우선시 되기에 에러처리를 상황에 따라 유연하게 대응할 수 있다.

  const { isError: preQuestionError, data: preQuestion } = useQuery(
    [QUERY_KEY.PRE_QUESTION, accessToken, levellogId],
    () =>
      requestGetPreQuestion({
        accessToken,
        levellogId,
      }),
    {
      cacheTime: 0,
      useErrorBoundary: false,
    },
  );

또한 useErrorBoundary를 동적인 값으로 결정해서 좀 더 에러처리를 유연하게 할 수도 있다.

useQuery(
    [QUERY_KEY.PRE_QUESTION, accessToken, levellogId],
    () =>
      requestGetPreQuestion({
        accessToken,
        levellogId,
      }),
    {
      cacheTime: 0,
      useErrorBoundary: (error) => error?.status >= 500,
    },
  );
})

그리고 에러 바운더리에서 처리하지 않는 에러는 최대한 API를 요청보내는 곳에 가깝게 위치하는 것이 좋다고 생각한다.

  const { mutate: postPreQuestion } = useMutation(
    ({ levellogId, preQuestionContent }: Omit<PreQuestionPostRequestType, 'accessToken'>) => {
      return requestPostPreQuestion({
        accessToken,
        levellogId,
        preQuestionContent,
      });
    },
    {
      onSuccess: () => {
        showSnackbar({ message: MESSAGE.PREQUESTION_ADD });
        navigate(teamGetUriBuilder({ teamId }));
      },
      onError: (err) => {
        errorHandler({ err, showSnackbar });
      },
    },
  );

이렇게 사전질문을 POST하는 요청에서는 에러바운더리에서 관리하는 것이 아닌 onError 옵션에서 스낵바를 보여주는 식으로 에러처리를 하였다. 사전질문을 작성완료하고 작성완료 버튼을 눌렀는데 에러 페이지로 리다이렉트된다면 작성했던 지문들은 모두 사라지게 되는 것이다. 이런 불상사를 방지하기 위해 스낵바를 통해 에러가 발생했다는 것을 사용자에게 인지시키고 사용자가 작성했던 지문을 다른 곳에 복사할 수 있도록 하는 것이 사용성에 더 좋다고 생각한다.


react query없이 ErrorBoundary를 쓴다면?

const 컴포넌트 = () => {
  const [isError, setIsError] = useState(false);

  
  const 버튼클릭 = () => {
     new Promise((resolve, reject) => {
      setTimeout(() => {
        reject();
      }, 1000);
    }).catch((error) => {
      setIsError(true);
    });
  }
  
  useEffect(() => {
    if (isError) {
      throw Error();
    }
  }, [isError]);
 
  return (
  	<button onClick={버튼클릭}>버튼</button>  
  )
}

API 요청이 무조건 실패한다는 가정하에 promise에서 reject가 되고 catch문에서 isError 상태를 true로 변경해서 useEffect에서 에러를 던지는 형식으로 react query없이도 에러 바운더리 쓸 수 있다. 에러는 상단으로 전파되기에 에러 바운더리의 getDerivedStateFromError 메서드에서 에러를 캐치할 수 있다.

0개의 댓글