Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FE] react-query에 맞는 error handling 적용 #415

Merged
merged 38 commits into from
Aug 22, 2024
Merged

[FE] react-query에 맞는 error handling 적용 #415

merged 38 commits into from
Aug 22, 2024

Conversation

Todari
Copy link
Contributor

@Todari Todari commented Aug 20, 2024

issue

image

(여기부턴 웨디가 씀)

구현 목적

  • 핸들링되지 않는 에러인 경우 에러 바운더리가 뜨지 않는 문제를 해결합니다.
  • 테스트코드를 바뀐 코드에 맞게 수정합니다.

구현 내용

✅ 테스트 커버리지

시간이 많이 없으므로 테스트 커버리지는 많이 신경쓰지 못했습니다.
image

image image image

✅ 핸들링 불가능한 에러가 발생한 경우 에러 바운더리를 뜨도록 하기

기존의 상태는 핸들링 불가능한 에러가 발생해도 에러 바운더리가 뜨지 않고 있었습니다.

리액트 쿼리를 도입하기 전에 이 에러 핸들링 방식을 개선하려고 생각했었는데요. 그 방식은 useError역할을 하는 전역 상태가 중간에 없어도 정상적으로 에러 바운더리가 뜨도록 하는 방식입니다.

다만 다시 생각해보니 컴포넌트의 렌더링 과정이거나 상태를 업데이트해 강제로 리렌더링을 발생시키지 않는다면 에러 바운더리를 트리거할 수 없었습니다.

따라서 useError역할을 하는 에러 스토어를 그대로 유지하는 결정을 내리게 되었습니다. 그리고 강제로 리렌더링을 유발하는 것은 ErrorCatcher라는 Router를 감싸는 컴포넌트가 담당하게 됐습니다. 이 ErrorCatcher에서 에러 스토어의 에러를 useEffect로 구독한 채로 리렌더링을 유발해 에러 바운더리와 토스트를 뜨도록 했습니다. (사실 정확하게는 리렌더링이 아니지만 최대한 빨리 이해할 수 있도록 설명하려고 했습니다. 양해바랍니다.)

다만 기존의 방식처럼 useToast에서 error 상태를 구독하고 있는 결합도 높은 코드가 아닌, 에러를 잡은 쪽에서 토스트를 부르는 방식입니다. 따라서useToast에는 이제 에러에 관련된 코드는 없고, 토스트 기능 자체 로직만 남게 되었습니다.

그리고 던져진 에러를 잡기 위해 UnhandledErrorBoundary를 다시 사용하게 되었습니다.

시간 상 설계에 많은 공을 들이지 못해 일단 최소한의 에러 핸들링 정도만 구현해놓은 상태입니다.

(여기까지 웨디 끝)

image

구현 목적

  1. 기존 useFetch 코드에서 에러 처리를 담당했지만, react-query를 적용함으로 인해 이에 맞는 에러 핸들링을 적용해 줘야합니다.
  2. 기존 방식에서 useFetch, useToast, useError 등 다양한 hook에서 에러 처리 로직이 있어 이를 분리하려고 했습니다.
    1. 발생한 error를 errorInfo Type으로 변환해 주는 역할 - useFetch
    2. errorInfo Type이 예상했던 error인지 판별하는 역할 - useError
    3. 예상했던 error의 경우 토스트를 띄우는 역할 - useToast
    4. 예상하지 못한 에러를 errorBoundary로 넘기는 역할 - useError
  3. 현재 여러 역할을 하는 각 hook의 역할을 분리하면, 다양한 변화에 쉽고 빠르게 대응할 수 있을 것이라고 생각했습니다.

고려 사항

as-is

// App.tsx
const App: React.FC = () => {
  return (
    <HDesignProvider>
      <QueryClientProvider client={queryClient}>
        <UnhandledErrorBoundary>
          <Global styles={GlobalStyle} />
          <ErrorProvider>
            {/* <ErrorProvider callback={toast}> */}
            <ToastProvider>
              <Outlet />
            </ToastProvider>
          </ErrorProvider>
        </UnhandledErrorBoundary>
      </QueryClientProvider>
    </HDesignProvider>
  );
};
  • ErrorProvider 에서 errorInfo가 변경되면 예상했던 에러인지 판별하여 예상하지 못한 에러라면 UnhandledErrorBoundary가 이를 잡도록 error를 throw합니다.
  • 예상했던 error가 발생하면 ErrorProvider를 구독하던 ToastProvider가 toast를 띄웁니다.

to-be

// App.tsx
const App: React.FC = () => {
  return (
    <HDesignProvider>
      <Global styles={GlobalStyle} />
      <ToastProvider>
        <AppErrorBoundary>
          <QueryClientBoundary>
            <Outlet />
          </QueryClientBoundary>
        </AppErrorBoundary>
      </ToastProvider>
    </HDesignProvider>
  );
};
  • QueryClientBoundary에서 queryFunction의 error를 일괄적으로 처리합니다(상태관리, throw 등).
  • AppErrorBoundary에서 예상했던 오류라면, showToast를 이용해 toast를 띄웁니다.
  • 예상하지 못한 오류라면, FallbackComponent를 출력합니다.

TroubleShooting

QueryClientBoundary에서 error를 throw하는 방식

// QueryClientBoundary.tsx
const QueryClientBoundary = ({children}: React.PropsWithChildren) => {
  const queryClient = new QueryClient({
    queryCache: new QueryCache({
      onError: (error: Error) => {
        throw error;
      },
    }),
    mutationCache: new MutationCache({
      onError: (error: Error) => {
        throw error;
      },
    }),
  });
  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};

export default QueryClientBoundary;
// AppErrorBoundary.tsx
const AppErrorBoundary = ({children}: React.PropsWithChildren) => {
  const {showToast} = useToast();

  const fallbackComponent = ({error}: FallbackComponentProps) => {
    captureError(error instanceof FetchError ? error : new Error(UNKNOWN_ERROR));
    const errorInfo = convertAppErrorToErrorInfo(error);

    if (isUnhandledError(errorInfo)) {
      
      return <ErrorPage />;
    } else {
      showToast({
        showingTime: 3000,
        message: SERVER_ERROR_MESSAGES[errorInfo.errorCode],
        type: 'error',
        position: 'bottom',
        bottom: '8rem',
      });

      return null;
    }
  };

  return <ErrorBoundary FallbackComponent={fallbackComponent}>{children}</ErrorBoundary>;
};

export default AppErrorBoundary;
  • QueryClientBoundary에서 error를 throw 하면 AppErrorBoundary에서 <ErrorPage>를 출력하는 방향을 고려했습니다.
  • 하지만 이는 정상적으로 작동하지 않았는데, 그 이유는 아래와 같습니다.
  • ErrorBoundary는 렌더링 중 발생한 에러, 생명주기 메서드에서 발생한 에러, 자식 컴포넌트의 생성자에서 발생한 에러를 잡는데, 비동기 코드나 이벤트 핸들러의 에러를 잡을 수 없습니다.
  • QueryClientBoundaryQueryClient를 생성하고, QueryClientProvider를 통해 React 컴포넌트 트리 내에서 사용할 수 있도록 합니다.
  • QueryClient 내부의 queryCachemutationCache에서 발생하는 에러가 React의 렌더링 트리에서 직접 발생하지 않기 때문에 ErrorBoundary가 이를 감지할 수 없습니다.

구현 내용

// QueryClientBoundary.tsx
const QueryClientBoundary = ({children}: React.PropsWithChildren) => {
  const {updateAppError} = useAppErrorStore();
  const queryClient = new QueryClient({
    queryCache: new QueryCache({
      onError: (error: Error) => {
        updateAppError(error);
        throw error;
      },
    }),
    mutationCache: new MutationCache({
      onError: (error: Error) => {
        updateAppError(error);
      },
    }),
  });
  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};

export default QueryClientBoundary;
//AppErrorBoundary.tsx
const AppErrorBoundary = ({children}: React.PropsWithChildren) => {
  const {appError} = useAppErrorStore();
  const {showToast} = useToast();

  const fallbackComponent = ({error}: FallbackComponentProps) => {
    if (error) {
      captureError(error instanceof FetchError ? error : new Error(UNKNOWN_ERROR));
      const errorInfo = convertAppErrorToErrorInfo(error);
      if (!isUnhandledError(errorInfo)) {
        showToast({
          showingTime: 3000,
          message: SERVER_ERROR_MESSAGES[errorInfo.errorCode],
          type: 'error',
          position: 'bottom',
          bottom: '8rem',
        });

        return null;
      } else {
        return <ErrorPage />;
      }
    }

    useEffect(() => {
      if (appError) {
        captureError(appError instanceof FetchError ? appError : new Error(UNKNOWN_ERROR));
        const errorInfo = convertAppErrorToErrorInfo(appError);

        if (!isUnhandledError(errorInfo)) {
          showToast({
            showingTime: 3000,
            message: SERVER_ERROR_MESSAGES[errorInfo.errorCode],
            type: 'error',
            position: 'bottom',
            bottom: '8rem',
          });
        } else {
          showToast({
            showingTime: 3000,
            message: SERVER_ERROR_MESSAGES.UNHANDLED,
            type: 'error',
            position: 'bottom',
            bottom: '8rem',
          });
        }
      }
    }, [appError]);

    return <ErrorBoundary FallbackComponent={fallbackComponent}>{children}</ErrorBoundary>;
  };
};

export default AppErrorBoundary;
  • onErrorappErrorStore에 error 상태를 업데이트합니다.
  • ErrorBoundary는 error가 변경될 경우, showToast를 이용하여 toast를 띄워줍니다.
  • onError에서 발생한 error가 아닐 땐, fallbackComponent에서 이를 처리합니다.
  • fallbackComponent는�예상한 error일 때, toast를 띄워주고, 아닌 경우 fallbackComponent를 보여줍니다.

참고사항

사용되지 않는 test코드들에 대해서 test를 옮기려고 했는데 생각보다 쉽지 않았습니다...
해당 케이스들이 대부분 queryFunction들에 대한 함수가 될 것 같아서, 런칭데이를 앞두고
바쁜 일들 먼저 마무리하고 queryFunction들에 대한 테스트를 작성해보도록 하겠습니다...

시켜주는 일들 위주로 하면서, landingPage, 낙관적업데이트 부분들을 해볼까 생각중이에요

@Todari Todari requested review from pakxe, soi-ha and jinhokim98 August 20, 2024 17:32
@Todari Todari self-assigned this Aug 20, 2024
@Todari Todari added 🖥️ FE Frontend ⚙️ feat feature 🚧 refactor refactoring labels Aug 20, 2024
@Todari Todari added this to the lev3 milestone Aug 20, 2024
@Todari Todari linked an issue Aug 20, 2024 that may be closed by this pull request
1 task
@Todari
Copy link
Contributor Author

Todari commented Aug 20, 2024

주석처리한다고 테스트가 넘어가지는게 아니었군요...

Copy link
Contributor

@soi-ha soi-ha left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR 내용만 읽어도 복잡하다는게 느껴지네요..
어떻게 이걸 해낸거지 ㄷㄷ
고생 너무 많았어요 토다리!

터지는 test로 인해 merge가 불가능하니, 해당 PR은 후에 merge해도 괜찮을 것 같네요!

@pakxe pakxe self-assigned this Aug 22, 2024
@pakxe pakxe requested a review from soi-ha August 22, 2024 13:18
Copy link
Contributor

@jinhokim98 jinhokim98 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

정말 어려운 작업이었을텐데 너무 고생했어요 웨디 토다리~

</UnhandledErrorBoundary>
</QueryClientProvider>
</QueryClientBoundary>
</ErrorCatcher>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

컴포넌트 명 너무 좋다! 에러를 캐치해주는 녀석

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

캣쮜

Comment on lines +9 to +18
queryCache: new QueryCache({
onError: (error: Error) => {
updateAppError(error);
},
}),
mutationCache: new MutationCache({
onError: (error: Error) => {
updateAppError(error);
},
}),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

너무 좋아요! 기존의 에러 처리방식보다 확실히 더 좋아진 것 같아요!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useFetch가 useQuery, useMutation으로 대체되며 더 신경쓸 필요가 적어진 것 같아요 ㅎ.ㅎㅎㅎ

@@ -2,7 +2,7 @@ import {useState} from 'react';

import validateEventName from '@utils/validate/validateEventName';

const useSetEventName = () => {
const useSetEventNamePage = () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요거는 페이지에서 사용하는 훅이라서 Page를 붙인걸까요?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

어... 이건 뭔지 모르겠습니다........... 토다리에게 패스...

Comment on lines 41 to 51
// 토스트 메시지가 나타나는지 확인
expect(screen.getByText(TOAST_CONFIG.message)).toBeInTheDocument();

// 1초 후에 토스트 메시지가 사라지는지 확인
await waitFor(
() => {
expect(screen.queryByText(TOAST_CONFIG.message)).not.toBeInTheDocument();
},
{timeout: 3100},
); // 타임아웃을 3100ms로 설정하여 정확히 3초 후 확인
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

주석을 잘 적어줘서 테스트 이해하기 너무 좋은 것 같아요.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GPT는 신인가,..

<Title title="알 수 없는 오류입니다." description="오류가 난 상황에 대해 {메일}로 연락주시면 소정의 상품을..." />
<Title
title="알 수 없는 오류입니다."
description="오류가 난 상황에 대해 [email protected] 로 연락주시면 소정의 상품을 드립니다."
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아 우리 메일로 바꿔놨군요! 디테일 대박

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵넵 내일이 데모라..!

Copy link
Contributor

@soi-ha soi-ha left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

웨디 PR 내용으로 후대댁 이해를 박았습니댜!
코드..뭐.. 잘 해줬겠죠! 화이팅!

@pakxe pakxe merged commit f76dbdf into fe-dev Aug 22, 2024
1 check passed
@pakxe pakxe deleted the feature/#402 branch August 22, 2024 15:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
🖥️ FE Frontend ⚙️ feat feature 🚧 refactor refactoring
Projects
Status: ✅ Done
Development

Successfully merging this pull request may close these issues.

[FE] react-query에 맞는 error handling 적용
4 participants