Published on

useSuspenseInfiniteQuery + Suspense를 적용하며 겪은 무한 루프 트러블 슈팅 (24.08.23 수정)

아이템 리뷰 목록을 React-Query에서 제공하는 useSuspenseInfiniteQuery를 사용해 성공적으로 구현을 마치고 isLoading을 사용하지 않고,

Suspense를 사용해 fallback을 처리하려고 하던 중 서버에 계속된 요청으로 인한 무한 루프가 발생해 큰 어려움을 겪게 됐다.

무한 스크롤과 Suspense는 이미 직전 토이 프로젝트에서 이미 사용해 봤던 경험이 있었기 때문에 혼란이 더 가중되었는데, 일단은 Suspense가 문제를 일으키고 있다는 것은 확실하다.

하지만 코드를 계속 살펴보아도 문제가 될만한 부분을 찾을 수 없었기 때문에 관련 내용을 검색해 봤지만 도무지 원인을 찾을 수가 없었다.


Suspense를 사용하는데 왜 무한 루프에 빠지는 걸까?



프로젝트 환경

  • Next14 - app Router
  • tanstack/react-query v5
  • tailwind CSS


🔍 단서 찾아보기


useSuspenseInfiniteQuery 훅을 적용하면 계속된 요청으로 인해 무한 루프에 빠지게 된다. 네트워크 속도를 지연시키고 문제점을 찾아보면서 한 가지 이상한 부분을 발견했다.

바로 React Query Devtools가 보이지 않았는 것이다. Suspense를 적용하지 않았을 때는 정상적으로 Devtools를 발견할 수 있었다. 이때부터 queryClient에 문제가 있다는 의혹을 가지게 되었다.


우리에게 익숙한 이 아이콘(React Query Devtools)이 보이질 않는다?



기존 코드

( 아래 코드는 app/items/itemId 하위에 위치한다. )


layout.tsx

export default function DetailItemLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <RQProvider>{children}</RQProvider>
  )
}

page.tsx

export default async function DetailPage() {
  return (
    ...
      <ReviewSection itemInfo={itemData.itemInfo} />
    ...
  )
}

ReviewSection.tsx

export default function ReviewSection({ itemInfo }: ItemInfo) {
  const { reviewList, fetchNextPage, isFetchingNextPage } =
    useSearchItemQuery(itemInfo.id, sortOption)

  return (
    ...
    {reviewList.map((review: ReviewResponse, index: number) => (
      <Review
        key={review.reviewSummary.reviewId}
        review={review}
        itemId={itemInfo.id}
        isFirst={index === 0}
      />
    ))}
    ...
  )
}

Suspense를 사용했다고 하는데, 위 코드에서는 Suspensefullback을 찾아볼 수가 없다. 사실 글을 작성하기 전에 한 가지 방법을 발견했지만 글을 작성하며 근본적인 문제의 원인을 찾게 되었다.

결과적으로 말도 안 되는 나의 실수였고, 정상적인 코드 작성으로 해결이 가능하지만 한 가지 방법을 더 발견하게 된 것이다. 따라서 이번 포스팅에서는 두 가지 모두 정리해 볼 계획이다.

우선 문제의 근본적인 문제의 원인은 Suspense를 올바른 위치에 사용하지 않아서 발생한 경우를 살펴보자,

다시 생각해도 정말 황당하지만 위 코드는 Suspense를 사용했을 때에도 같은 문제가 발생해서 Suspense 없이 사용 가능한 방법을 찾다보니 Suspense 없이 작성된 초기 코드다.



🔍 문제 상황 분석


Suspense를 적용하지 않은 부분은 설명이 필요가 없다. 그렇다면 적용 위치에 문제가 있었다는 부분을 살펴볼 필요가 있다.

초기 목적은 3. 리뷰 컴포넌트Suspense를 사용해 SkeletonUI를 적용할 계획이었다. 그렇다면 사진 기준 1, 2, 3번 컴포넌트 중 어느 위치에 Suspense 경계를 위치시켜야 할까?

무한 스크롤로 리뷰 데이터를 받아오는 hook은 2번 컴포넌트에 위치하고 있다. 처음에 나는 2번 컴포넌트 안에 3번 컴포넌트를 감싸도록 Suspense를 사용했다.


ReviewSection.tsx (2번 리뷰 파트)

export default function ReviewSection({ itemInfo }: ItemInfo) {
  const { reviewList, fetchNextPage, isFetchingNextPage } =
    useSearchItemQuery(itemInfo.id, sortOption)

  return (
    ...
  <Suspense fallback={<ReviewSkeleton />}>
    {reviewList.map((review: ReviewResponse, index: number) => (
      <Review
        key={review.reviewSummary.reviewId}
        review={review}
        itemId={itemInfo.id}
        isFirst={index === 0}
      />
    ))}
   </Suspense>
  )
}

위와 같이 사용하게 되면, useSearchItemQuery에서 데이터를 불러오는 과정에서 프로미스(Promise)를 던지게 된다. 그러나 이 프로미스를 잡는 상위 컴포넌트에서 Suspense가 없기 때문에 문제가 발생하게 된다. 그렇다, 바로 Suspense를 프로미스를 던지는 데이터 호출 상위 컴포넌트에 배치해야 했던 것이다.

Suspense가 프로미스를 잡을 수 없으면 문제가 발생한다는 것은 알겠는데, 무한 루프에 빠지게 되는 이유가 궁금했다. 저번 게시글에 Suspense를 주제로 다뤘던 경험이 있었다. 어느 정도 Suspense에 대해 이해하고 있다고 생각했지만 아직 기본적인 부분도 놓치고 있었다.

무한 루프에 빠지는 이유는, 프로미스는 던져졌지만 Suspense 경계가 없기 때문에 React는 해당 컴포넌트를 렌더링 할 수 없게 되고 부분적으로 컴포넌트 React Components Tree를 폐기하게 된다. 컴포넌트 트리가 폐기되면 문제가 될 수 있는 부분이 바로 queryClient가 있는 React-Query provider 부분이다.


RQProvider 코드

'use client'

import React, { useState } from 'react'
import { QueryClientProvider, QueryClient } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

type Props = {
  children: React.ReactNode
}

function RQProvider({ children }: Props) {
  const [client] = useState(
    new QueryClient({
      defaultOptions: {
        queries: {
          refetchOnWindowFocus: false,
          retryOnMount: true,
          refetchOnReconnect: false,
          retry: false,
        },
      },
    }),
  )

  return (
    <QueryClientProvider client={client}>
      {children}
      <ReactQueryDevtools
        initialIsOpen={process.env.NEXT_PUBLIC_MODE === 'local'}
      />
    </QueryClientProvider>
  )
}

export default RQProvider

RQProvider이라는 provider를 만들고 layout.tsx에 React Query를 사용하는 컴포넌트 상위에 배치해 사용하고 있었다. 컴포넌트 트리가 폐기가 queryClient에 미치는 영향을 순차적으로 살펴보자.


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

이제 문제 상황을 인지했고, 1번 컴포넌트에서 Suspense를 사용해 2번 컴포넌트를 감싸면 문제를 간단하게 해결할 수 있다는 것을 알게 되었다. 하지만 이때 앞서 언급했던 방법을 사용하면 Suspense 없이도 무한 루프를 해결할 수 있다.



💡 Solution - queryClient 재생성 방지하기


현재 queryClient가 재생성 되고 있기 때문에 해결책은 클라이언트에서 하나만 만들고 유지하면 된다. queryClient를 전역 변수에 저장하여 초기 렌더링 중에 다시 초기화하지 않도록 하는 것이다. 이 경우 트리가 폐기되어도 queryClient가 유지된다.


RQProvider 코드 변경하기

"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactNode } from "react";


function makeQueryClient() {
  return new QueryClient({ /* ...opts */ });
}

let clientQueryClient: QueryClient | undefined = undefined;

function getQueryClient() {
  if (typeof window === "undefined") {
    // Server: always make a new query client
    return makeQueryClient();
  } else {
    // Browser: make a new query client if we don't already have one
    if (!clientQueryClient) clientQueryClient = makeQueryClient();
    return clientQueryClient;
  }
}

export const ClientProviders = ({ children }: { children: ReactNode }) => {
  const queryClient = getQueryClient();

  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
};
  • getQueryClient 함수

    클라이언트 사이드에서 QueryClient 인스턴스를 관리하는 함수다. 서버 사이드에서는 항상 새로운 QueryClient 인스턴스를 생성하지만, 클라이언트 사이드에서는 한 번만 생성하고, 그 후로는 동일한 인스턴스를 재사용한다.

위와 같이 사용하면 Streaming도 정상적으로 적용되고 queryClient가 재생성 되지 않기 때문에 무한 루프 되는 상황은 막을 수 있다. 하지만, React component tree가 버려지는 과정이 발생하기 때문에 지양하는 것이 좋다고 생각한다.



Suspense 문제가 해결됐으니, 이제 네트워크 폭포 현상을 개선해 보자.

기존 아이템 상세 & 리뷰 페이지의 데이터 페칭 과정을 살펴보면 아이템 상세 데이터가 우선적으로 페칭되고, 이후 리뷰 관련 데이터 페칭이 순차적으로 진행된다.

현재 적은 데이터임에도 불구하고, 폭포 낙차가 눈에 잘 식별되는 상태이다. 아이템 상세 페이지에서 데이터 페칭이 길어질 경우 자연스럽게 리뷰 페이지 데이터를 확인하는 데까지 많은 시간이 지연될 것이다.



Suspense 적용 후

적용 전과 후의 차이를 봤을 때 가장 눈에 띄는 부분은 폭포에서 확인할 수 있다. 아이템, 리뷰 데이터 페칭하는 비동기 작업은 동시에 시작되고, 종료된다.

따라서 사용자 경험 측면에서 순차적으로 UI가 노출돼 자칫 불편할 수 있는 부분을 예방하고, 이에 더해 로딩 스피너, fallback UI 등을 추가할 수 있을 것이다.



Lighthouse에서 확인해 보기

랭킹/투표 아이템 리스트를 나타내는 페이지에 Streaming을 적용한 뒤에 Lighthouse를 사용해 성능 점수와 측정항목을 살펴보았다.

좌측은 Streaming 적용 전, 우측은 적용 후


SI(Speed Index), LCP(Largest Contentful Paint) 항목에서 눈에 띌 정도로 차이점이 있다는 것을 발견할 수 있고 이는 성능 점수에도 큰 영향을 준다는 것을 알 수 있다.



마치며


내가 Suspense를 좀 더 이해하고 실수하지 않았다면 이번 문제를 겪을 일은 없었을 것이다. 하지만 이번에 이 문제를 마주하지 않았더라도 지금과 같은 상태에서는 언제든 벌어질 일이었다고 생각한다.

이번 문제는 버전 업된 Next 프레임워크에 대한 부족한 숙련도와 무한 스크롤 구현 과정에서 무한 루프에 빠진 부분에서 느낀 혼동 때문에 해결이 쉽지 않았던 것 같다.

그리고 가장 기억에 남는 부분은 프로미스를 잡을 Suspense 경계가 존재하지 않는다면, React Components tree를 버리는 과정이 발생한다는 것이다. 거기서 연결되는 queryClient 재생성까지 내가 예상하지 못한 많은 문제가 있었다.

예전부터 문제가 발생했을 때 검색을 하면 관련 내용이 없을 때가 있었다. 그때마다 다시 코드를 살펴보면 오타 혹은 기본적인 문법의 규칙을 어겼을 때가 대다수였다. 이번 문제도 마찬가지다.

처음 Suspense를 사용했을 때 문제가 발생해 Suspense를 사용하지 않는 다른 방법을 찾고 있었지만 내가 찾는 결과에 대한 자료는 거의 없었다. 기본적으로 잘못된 위치에서 사용하고 있던 것이다. 최근에 프로젝트를 진행하며 단순 구현에만 초점을 두고 있었던 것 같다.

우연치 않게 트러블 슈팅을 진행하게 되었는데, 이번 일을 계기로 Suspense, React Components tree 등과 관련된 지식과 새로 알게 된 내용을 공부하고 정리할 수 있는 좋은 기회가 되었다.



Reference