Eun_Frontend
  • [트러블 슈팅] React Suspense + TanStack Query 무한루프 문제 해결하기
    2025년 01월 09일 22시 46분 39초에 업로드 된 글입니다.
    작성자: 동혁이

     

    [트러블 슈팅]

    React Suspense + TanStack Query 무한루프 문제 해결하기

     

     

    게시글 리스트를 Tanstack Query에서 제공하는 useInfiniteQuery를 사용해 무한 스크롤을 구현했습니다.

     

    그러면서 당연하게 로딩과 에러 처리를 쉽게 도와주는 isLoading, isError를 사용해서 관리를 해야겠다는 생각을 했습니다.

     

    isLoading, isError를 사용하게 되면 아래와 같이 명령적 데이터 패칭 방식을 사용합니다.

    // useQuery 부분은 공식문서 코드를 예시로 가져왔습니다!
    const { data, isLoading, isError } = useQuery({
      queryKey: ["super-heroes"],
      queryFn: getAllSuperHero,
    });
    
    if (isLoading) {
      return <div>Loading...</div>;
    }
    
    if (isError) {
      return <div>Error...</div>;
    }

     

    컴포넌트마다 데이터 요청 상태에 따라 다른 UI/UX를 설계하고 싶다는 생각을 하게 되었습니다.

     

    얼마전 최적화 공부를 하면서  React에서 기본으로 제공해주는 Suspense와 ErrorBoundary를 사용해서 선언적 데이터 패칭 방식을 사용해보려고 합니다. (해당 게시글에서는 Suspense 이야기만 다룹니다!)

    // 예시코드 입니다.
    function App() {
      return (
        <ErrorBoundary fallback={<ErrorMessage />}>
          <Suspense fallback={<LoadingSpinner />}>
            <Component />
          </Suspense>
        </ErrorBoundary>
      )
    }
    
    function Component() {
      const { data } = useQuery({...})
      return <div>{data}</div>
    }

     

    위 예시코드와 같이 isLoading, isError 말고 Suspense와 ErrorBoundary를 사용했을때 몇가지 장점이 있습니다.

    1. 선언형 프로그래밍
    - 로딩/에러 관련 로직을 선언적으로 작성 가능
    - 복잡한 상태관리 로직 단순화 가능
    
    2. 사용자 경험 향상
    - 일관된 로딩/에러 UI 가능
    - 에러가 발생해도 전체 앱이 중단되지 않고 따로 분리 및 재호출 가능
    
    3. 성능 최적화
    - 코드 분할과 동적 로딩으로 초기 로딩 성능 향상
    
    +)
    Suspense로 감싼 컴포넌트는 데이터 로딩이 완료된 후에만 렌더링되기 때문에 
    데이터가 undefined를 갖고있지 않다고 보장 받을 수 있습니다.

     

     

    이러한 장점들은 유저에게 좋은 사용자 경험을 줄 수 있다고 생각해 사용해볼 가치가 충분하다고 판단, 적용 해보기로 했습니다.

     

     

     

    기존 코드

    App.tsx

    // 코드 일부
    export default function App({ Component, pageProps: { dehydratedState, ...pageProps } }: AppPropsWithLayout) {
      const getLayout = Component.getLayout || ((page) => <PageLayout>{page}</PageLayout>);
    
      return (
        <QueryProvider dehydratedState={dehydratedState}>
          <LazyMotion features={domAnimation}>
            <ToastContainer limit={1} transition={Zoom} />
            {getLayout(<Component {...pageProps} />)}
            {process.env.NODE_ENV === 'development' && <ReactQueryDevtools initialIsOpen />}
          </LazyMotion>
        </QueryProvider>
      );
    }

     

    QeuryProvider.tsx

    export default function QueryProvider({ children, dehydratedState }: QueryProviderProps) {
      const [client] = useState(
        new QueryClient({
          defaultOptions: {
            queries: {
              / options / 
            },
            mutations: {
              / options / 
            },
          },
        }),
      );
    
      return (
        <QueryClientProvider client={client}>
          <HydrationBoundary state={dehydratedState}>{children}</HydrationBoundary>
        </QueryClientProvider>
      );
    }

     

    데이터 호출 로직

    // 코드 일부
    const useGetGatheringData = (request: GetGatheringRequest) => {
      const {
        data,
        hasNextPage,
        fetchNextPage,
      } = useInfiniteQuery({queryKey: [ /.../ ], queryFn: () => // });
    
      return { data, hasNextPage, fetchNextPage };
    };
    
    export default useGetGatheringData;

     

     

    CardSection.tsx

    export default function CardSection({ mainData }: CardSectionProps) {
      const { mainData, isError, hasNextPage, fetchNextPage } = useGetGatheringData({
        / ... /
      });
    
      return (
        <div className="">
          <ul className="">
              {mainData?.map((data) => <CardItem key={data.gatheringId} data={data} />)}
          </ul>
        </div>
      );
    }

     

     

     계획

     

    RootLayout(이미지 전체)안에 CardSection(빨간색 테두리 박스)안에 CardItem에 Suspense를 적용하고 싶습니다. (Carousel 컴포넌트 포함 x)

     

     

     

     

    적용

     

    공부도 했고 직전 프로젝트에 적용해 본 경험도 있기에 바로 실행에 옮겼습니다.

     

    1. useInfiniteQuery -> useSuspenseInfiniteQuery 변경

    // 코드 일부
    const useGetGatheringData = (request: GetGatheringRequest) => {
      const {
        data,
        hasNextPage,
        fetchNextPage,
      } = useSuspenseInfiniteQuery({queryKey: [ /.../ ], queryFn: () => // });
    
      return { data, hasNextPage, fetchNextPage };
    };
    
    export default useGetGatheringData;

     

    2. Suspense 적용

    export default function CardSection({ mainData }: CardSectionProps) {
      const { mainData, isError, hasNextPage, fetchNextPage } = useGetGatheringData({
        / ... /
      });
    
      return (
        <div className="">
          <ul className="">
            <Suspense fallback={<CardSkeleton />}>
              {mainData?.map((data) => <CardItem key={data.gatheringId} data={data} />)}
            </Suspense>
          </ul>
        </div>
      );
    }

     

     

    ❗️트러블 발생

    결과는 아래와 같이 무한호출이 발생했습니다.

     

     

     

     

     

    ❔문제 원인 추측

     

    무한 호출이 발생하면서 흰색 화면만 보이고 기존에 잘 보이던 React Query Devtools가 보이지 않았습니다.

    정상적인 모습

     

    Tanstack Query를 건드렸기에 queryClient 부분에 문제가 발생한 것 같다는 생각을 하게 되었습니다.

    결과적으로 제가 충분히 공부하지 못한 것이 원인이었습니다.

     

    CardItem 컴포넌트에 적용하고 싶어하기 때문에 CardSection(빨간색 테두리 박스)안에 있는 아이템 컴포넌트에 Suspense 하나만 적용한게 문제였습니다.

    export default function CardSection({ mainData }: CardSectionProps) {
      return (
        <div className="">
          <ul className="">
            <Suspense fallback={<CardSkeleton />}>
              {mainData?.map((data) => <CardItem key={data.gatheringId} data={data} />)}
            </Suspense>
          </ul>
        </div>
      );
    }

     

     

     

    ❔이게 왜 문제일까

     

    저는 RootLayout 안에 있는 CardSection 안에있는 CardItem에 Suspense를 사용했습니다.

     

    useGetGatheringData에서 데이터를 불러오는 과정에서 프로미스(Promise)를 던지게 됩니다.

    그런데 이 프로미스를 잡는 상위 컴포넌트에서 Suspense가 없기 때문에 문제가 발생하게 됩니다.

     

     

     

    ❗️Suspense 문제 해결

     

    여기서 감이 오신분은 이해하실겁니다.

    바로 프로미스를 던지는 데이터 호출 상위 컴포넌트에 Suspense를 배치해야 합니다.

     

    그렇다면 Suspense가 프로미스를 잡을 수 없어서 Suspense가 동작을 안하는 문제는 해결됐습니다.

     

    그럼 무한 루프에 빠지는 이유는 뭘까?

     

    아주 기본적인 부분을 놓치고 있었습니다.

     

    무한 루프에 빠지는 이유는, 프로미스를 던졌지만 Suspense 경계가 없기 때문에 React는 해당 컴포넌트를 렌더링 할 수 없게 되고 부분적으로 컴포넌트 React Component Tree를 폐기하게 됩니다.

     

    컴포넌트 트리가 폐기되면 문제가 될 수 있는 부분이 바로 queryClient가 있는 QueryClientProvider 부분입니다.

     

     

    QueryProvider.tsx (_app.tsx에서 사용)

    export default function QueryProvider({ children, dehydratedState }: QueryProviderProps) {
      const [client] = useState(
        new QueryClient({
          defaultOptions: {
            queries: {
              / options / 
            },
            mutations: {
              / options / 
            },
          },
        }),
      );
    
      return (
        <QueryClientProvider client={client}>
          <HydrationBoundary state={dehydratedState}>{children}</HydrationBoundary>
        </QueryClientProvider>
      );
    }

     

     

    컴포넌트 트리가 폐기가 queryClient에 미치는 영향을 순차적으로 살펴보겠습니다.

    1. 초기에 useGetGatheringData에서 queryClient에 캐시 된 데이터가 없음을 확인하고 서버에 데이터 요청을 보내고 프로미스를 던지게 됩니다.

    2. Suspense 경계의 부재로 프로미스를 잡지 못하고 트리가 폐기됩니다.
    3. 데이터를 가져오게 되면 트리가 다시 렌더링 된다. 그리고 완전히 새로운 queryClient(캐시에 데이터 없음)가 다시 생성됩니다.
    4. useGetGatheringData는 캐시에 저장된 데이터가 없기 때문에 또다시 요청을 보내게 되고 이 과정이 반복되며 무한 루프에 빠지게 됩니다.

     

    말 그대로 계속 무한 호출하는거죠..

     

     

     

    ❗️무한루프 해결 - QueryClient 재생성 방지

     

    앞서 언급했던 방법을 사용하면 Suspense 없이도 무한 루프를 해결할 수 있다.

    이제 무한루프를 해결해 보겠습니다.

    아래 코드를 보시면 서버 사이드 에서는 항상 새로운 QueryClient 인스턴스를 생성하지만, 클라이언트 사이드 에서는 한 번만 생성하고 동일한 인스턴스를 재사용하도록 합니다.

    import type { DehydratedState } from '@tanstack/react-query';
    import { HydrationBoundary, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
    
    interface QueryProviderProps {
      children: React.ReactNode;
      dehydratedState: DehydratedState;
    }
    
    function makeQueryClient() {
      return new QueryClient({
        defaultOptions: {
          queries: {
            / ... /
          },
          mutations: {
            / ... /
          },
        },
      });
    }
    
    let clientQueryClient: QueryClient | undefined;
    
    function getQueryClient() {
      if (typeof window === 'undefined') makeQueryClient();
      if (!clientQueryClient) clientQueryClient = makeQueryClient();
    
      return clientQueryClient;
    }
    
    export default function QueryProvider({ children, dehydratedState }: QueryProviderProps) {
      const client = getQueryClient();
    
      return (
        <QueryClientProvider client={client}>
          <HydrationBoundary state={dehydratedState}>{children}</HydrationBoundary>
        </QueryClientProvider>
      );
    }

     

     

    결론

    최적화 공부를 하면서 Suspense를 사용할때는 한 번에 정상 작동했는데 프로젝트 크기가 커지고 깊이가 생기다 보니 내가 부족했던 부분이 바로 나온것 같다.

     

    경험이 있어 할 수 있다 생각했는데 자세하게 모르고 있었던 것 같고

    이번 기회를 통해 Suspense, React Component Tree 등과 관련된 지식을 정리할 수 있는 기회가 생겨서 좋았다.

     

    하지만 이 방법은 무한루프 현상은 막을 수 있지만 React component tree가 버려지는 과정이 발생할 수 있기 때문에 지양하는 것이 좋다고 생각한다.

    댓글