React

React Query, Suspense, Error Boundary를 사용해 선언적으로 비동기 처리하기

sandwe 2023. 2. 23. 16:34

문제 상황

  • 투두 앱 만들기 중에 오류가 발생했다.
  • 특정 투두를 선택해 /todos/:id 페이지로 이동했더니 빈화면이 뜨고 다음의 에러가 발생했다.

 

react-dom.development.js:19055 Uncaught Error: 

A component suspended while responding to synchronous input. 
This will cause the UI to be replaced with a loading indicator. 
To fix, updates that suspend should be wrapped with startTransition.

동기적인 입력에 응답하는 동안 컴포넌트가 중단되었다. 
이는 UI가 로딩중을 나타내는 것으로 대체되게 한다. 
해결하기위해 중단된 컴포넌트를 startTransition으로 감싸라.

페이지 이동 시에 query로 데이터를 요청하고 이로 인해 컴포넌트가 중단된걸까?

리액트 쿼리를 사용하기 전에 리액트만을 사용했을 때는 useEffect로 비동기 요청과 같은 side effect 처리가 가능했다. useEffect를 사용하면 먼저 렌더링을 하고, 비동기 요청이 된다.

리액트 쿼리를 사용하게 되면 함수 컴포넌트가 호출되었을 때 컴포넌트가 렌더링되기 전 비동기 처리가 이루어지면서 렌더링이 제대로 이루어지지 않는 것 같다.

이를 해결하기 위해서 v18에서 나온 Suspense를 사용하나보다.. (드디어 Suspense를 사용해볼 기회다!)

에러를 보니 startTransition 으로도 해결하는 방법이 있는 것 같다.

 

문제 해결

Suspense를 사용해 비동기 요청을 처리중일 때 렌더링이 중단되는 심각한 문제를 해결하기로 했다. 먼저, Suspense가 무엇인지에 대해 알아보았다.

 

Suspense

Suspense는 Suspense로 감싼 하위 컴포넌트 중 일부 컴포넌트가 렌더링할 준비가 되지 않은 경우, 요소들이 다 불러와질 때까지 다른 컴포넌트를 렌더링한다.

아래는 리액트 공식문서의 Suspense 예시이다.

// 이 컴포넌트는 동적으로 불러옵니다.
const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    // Displays <Spinner> until OtherComponent loads
    <React.Suspense fallback={<Spinner />}>
      <div>
        <OtherComponent />
      </div>
    </React.Suspense>
  );
}

MyComponent 컴포넌트는 OtherComponent 컴포넌트를 화면에 렌더링하는 역할을 한다. lazy를 사용해 OtherComponent 컴포넌트를 비동기적으로 불러오도록 한다.

import React, { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./routes/Home'));
const About = lazy(() => import('./routes/About'));

const App = () => (
  <Router>
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </Suspense>
  </Router>
);

Suspense와 lazy를 사용한 ‘동적으로 컴포넌트 불러오기’는 Router와 함께 Code Splitting에 주로 사용된다.

 

비동기 에러는 어떻게 처리하지?

비동기 처리 중일 경우는 Suspense를 이용해 처리할 수 있다는 것을 알게 되었다. 그렇다면 비동기 요청 시 에러가 발생했을 때는 어떻게 처리하는지 궁금했다. 비동기 요청 시 발생한 에러는 Error Boundary로 해결할 수 있었다.

 

Error Boundary

하위 컴포넌트 렌더링 과정에서 에러가 발생하면 상위 Error Boundary에서 에러를 받아 Fallback UI를 처리하거나 Error tracker로 에러 리포팅을 할 수 있다.

아래는 리액트 공식문서에 언급된 Error Boundary 구현체이다.

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // 다음 렌더링에서 폴백 UI가 보이도록 상태를 업데이트 합니다.
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // 에러 리포팅 서비스에 에러를 기록할 수도 있습니다.
    logErrorToMyService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // 폴백 UI를 커스텀하여 렌더링할 수 있습니다.
      return <h1>Something went wrong.</h1>;
    }

    return this.props.children;
  }
}
<ErrorBoundary>
  <MyWidget />
</ErrorBoundary>

 

장점

기존의 try/catch 에러 처리 방식은 명령형 코드이다.

try {
  showButton();
} catch (error) {
  // ...
}

하지만 리액트 컴포넌트는 선언형 컴포넌트로, 무엇을 렌더링할지에 집중한다.

<Button />

Error Boundary는 React의 선언적 특성을 유지하고 예상한 대로 동작한다. 예를 들어, 컴포넌트 트리 매우 깊숙히 setState에 의해 유발된 componentDidUpdate 메서드에서 에러가 발생해도 가장 가까운 에러 경계에 올바르게 전달된다.

💡 Error Boundary에서 에러를 받아 Fallback UI를 처리 ⇒ 선언형

Error Boundary를 잘 사용하면 애플리케이션 내부에서 발생한 에러 상황을 사용자에게 우아하게 보여줄 수 있다.

컴포넌트 내부에서 state를 통해 에러 UI를 관리하고 사용자에게 보여주는 것이 아니라, 에러가 발생한 상황에 ‘어떤 화면을 Fallback으로 보여줄 것인지’를 고민할 수 있게 된다.

 

주의할 점

Error Boundary는 Error Boundary로 감싼 하위 컴포넌트의 에러만 포착한다. Error Boundary가 에러 메시지를 렌더링 하는데 실패한다면, 에러는 그 위의 가장 가까운 Error Boundary로 전파될 것이다. 이는 catch {} 구문이 동작하는 방식과 유사하다.

 

react-error-boundary

react v16에서 Error Boundary가 도입되면서 getDerivedStateFromError 또는 componentDidCatch 메서드로 에러 UI를 보여주도록 구현할 수 있게 되었다.

react-error-boundary 라이브러리는 이를 미리 추상화해서 <ErrorBoundary /> 컴포넌트에서 다양한 props로 에러 핸들링을 할 수 있는 기능을 제공한다.

yarn add react-error-boundary

 

Todo App에서 React Query, Suspense, Error Boundary 사용하기

1. queryClient에서 Suspense, Error Boundary 옵션 활성화

  • React Query는 비동기 데이터 요청 시 Suspense와 Error boundary를 활용할 수 있는 옵션을 제공한다.
  • queryClient 인스턴스를 통해 이러한 옵션을 설정한다.
import { QueryClient } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // data fetching을 위해 Suspense 활성화
      suspense: true, 
      // Error Boundary를 위한 옵션, suspense 옵션 true인 경우에는 기본값이 true로 설정된다.
      // useErrorBoundary: true, 
    },
  },
});

export default queryClient;
  • <QueryClientProvider /> 컴포넌트를 통해 하위 컴포넌트가 컨텍스트를 구독하도록 한다.
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

import Router from './router/Router';
import queryClient from './queries/queryClient';

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Router />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

export default App;

 

2. Suspense 적용하기

Suspense로 하위 컴포넌트들을 Wrapping하면 하위 컴포넌트 내의 비동기 요청 상태에 따라 다른 UI를 보여준다. 투두 앱에서 발생한 오류를 해결해보자.
todo 상세 데이터가 불러와지기 전에는 로딩중 컴포넌트를 보여준다. lazy loading을 통해 TodoDetail 컴포넌트가 로드되는 동안 fallback prop으로 전달된 스켈레톤 컴포넌트를 렌더링한다.

import TodoLayout from '../../components/todos/TodoLayout';
import { lazy, Suspense } from 'react';
import TodoSkeleton from '../../components/todos/TodoSkeleton';

const TodoDetail = lazy(() => import('../../components/todos/TodoDetail'));

const TodoDetailPage = () => {
  return (
    <TodoLayout>
      <Suspense fallback={<TodoSkeleton />}>
        <TodoDetail />
      </Suspense>
    </TodoLayout>
  );
};

export default TodoDetailPage;

 

3. Error Boundary 적용하기
잘못된 페이지에 접근했을 때 400 에러 발생한다. 잘못된 페이지에 접근한 사용자가 올바른 url로 이동하도록 적절한 UI를 보여줘야 한다.

import { useState, useEffect, lazy, Suspense, PropsWithChildren } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import TodoFallback from '../../components/todos/TodoFallback';
import TodoLayout from '../../components/todos/TodoLayout';
import TodoSkeleton from '../../components/todos/TodoSkeleton';

const TodoDetail = lazy(() => import('../../components/todos/TodoDetail'));

const DefferedComponent = ({ children }: PropsWithChildren<{}>) => {
    // 코드 생략
};

const TodoDetailPage = () => {
  return (
    <TodoLayout>
      <ErrorBoundary FallbackComponent={TodoFallback}>
        <Suspense
          fallback={
            <DefferedComponent>
              <TodoSkeleton />
            </DefferedComponent>
          }
        >
          <TodoDetail />
        </Suspense>
      </ErrorBoundary>
    </TodoLayout>
  );
};

export default TodoDetailPage;
import { useNavigate } from 'react-router-dom';

import Button from '../../common/Button';

const TodoFallback = () => {
  const navigate = useNavigate();
  return (
    <div>
      <p>잘못된 페이지 접근입니다.</p>
      <Button onClick={() => navigate(-1)}>뒤로 가기</Button>
    </div>
  );
};

export default TodoFallback;

잘못된 url을 다시 새로고침 시 렌더링 에러 발생했다. reset을 사용해 에러 발생 여부를 리셋시켜 하위 컴포넌트 트리들이 다시 리렌더링 될 수 있도록 해준다.

const initialState: ErrorBoundaryState = {error: null}

class ErrorBoundary extends React.Component<
  React.PropsWithRef<React.PropsWithChildren<ErrorBoundaryProps>>,
  ErrorBoundaryState
  > {
    static getDerivedStateFromError(error: Error) {
      return {error}
    }

    state = initialState
    resetErrorBoundary = (...args: Array<unknown>) => {
      this.props.onReset?.(...args)
      this.reset()
    }

   reset() {
    this.setState(initialState)
   }
}
💡 onReset

This will be called immediately before the `ErrorBoundary` resets it's internal state (which will result in rendering the `children` again). You should use this to ensure that re-rendering the children will not result in a repeat of the same error happening again.

`onReset` will be called with whatever `resetErrorBoundary` is called with.

Important: `onReset` will *not* be called when reset happens from a change in `resetKeys`. Use `onResetKeysChange` for that

 

비동기 요청 에러 발생 시 3번의 재요청 발생 이슈 해결하기

문제 상황

💡 잘못된 todo id로 상세 페이지 접근 → 투두 상세 데이터 API 파라미터도 잘못됨 → 비동기 요청 에러 발생 → 재요청 3번 이루어짐

fallback UI가 3번의 재요청으로 인해 늦게 나타난다. 응답 결과를 바로 UI로 보여주고 싶었다.

왜 3번의 재요청이 발생할까?

axios로 처리했을 때는 재요청이 발생하지 않으므로 React Query에 연관된 이슈가 아닐까 생각했다. React Query의 실패 시 재요청 현상에 대해 검색해보았다.

Query Retries | TanStack Query Docs

useQuery 쿼리가 실패할 경우 즉, 쿼리 함수에서 오류가 발생할 경우 TanStack Query는 해당 쿼리의 요청이 최대 연속 재시도 횟수만큼 쿼리 요청을 재시도한다. 이때, 기본값이 3이어서 3번의 재요청이 일어나는 것이다.

import { QueryClient } from '@tanstack/react-query';

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

export default queryClient;

결과

이제 재요청이 일어나지 않고, 응답 결과를 빠르게 UI로 나타낼 수 있게 되었다.