프로젝트 이모저모/HoduMarket 프로젝트

오픈마켓 버그) 페이지네이션 다음 버튼 클릭하면 화면 렌더링 되지 않음

Ella Seon 2023. 7. 25. 17:12

0. 버그 현황

- React-Query 로 리팩토링 하는 도중에 페이지네이션의 버튼을 클릭하면 화면이 렌더링 되지 않는 문제를 겪었다.

분명 createAsyncThunk 리덕스 툴킷으로 할때는 잘 되었는데...? 왜그런걸까

 

기존 createAsyncThunk 로 할때의 코드는 아래에 첨부한다.

https://ella951230.tistory.com/entry/%EC%98%A4%ED%94%88%EB%A7%88%EC%BC%93-%EB%A6%AC%EC%95%A1%ED%8A%B8%EB%A6%AC%EB%8D%95%EC%8A%A4%ED%88%B4%ED%82%B7%EC%9C%BC%EB%A1%9C-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98-%EA%B5%AC%ED%98%84

 

totalPage 를 전역상태에 저장했을 때는 PageNation 컴포넌트의  아래 코드가 잘 먹혔었다. 

function PageNation({ currentPage, setCurrentPage }: PageNationProps) {
  const totalPage = useAppSelector(
    (state: RootState) => state.products.totalPage
  );


  const handleClick = (page: number) => {
    setCurrentPage(page);
  };

  const pages = [...Array(totalPage).keys()].map((i) => i + 1);

  return (
 {/* 코드 중략 */}
      {pages.map((page) => (
        <S.PageNumber
          key={page}
          onClick={() => handleClick(page)}
          active={currentPage === page}
        >
          {page}
        </S.PageNumber>
      ))}
   
  );
}

export default PageNation;

 

헌데...react-query 로 서버 상태를 totalPage props 로 넘겨줄 때는

function HomePage() {
  const [currentPage, setCurrentPage] = useState(1);

  const { data, isLoading } = useProduct(currentPage);
  console.log(data);

  if (isLoading) {
    return <div>Loading...</div>;
  }

  return (
    <>
      <Carousel />
      <S.ProductSection>
        <h2 className="hidden">상품리스트</h2>
        <S.ProductLists>
          {data.results.map((product: Product) => {
            return <ProductCard key={product.product_id} product={product} />;
          })}
        </S.ProductLists>
        <PageNation
          currentPage={currentPage}
          totalPage={Math.floor(data.count / 15 + 1)}
          setCurrentPage={setCurrentPage}
        />
      </S.ProductSection>
    </>
  );
}

export default HomePage;

 

이 아래 코드가 실행되지 않았다.

  const handleClick = (page: number) => {
    setCurrentPage(page);
  };

 

- 콘솔창에 나타난 오류를 먼저 살펴보자

 


1. 해결과정

- 도와줘 챗지피티!

결론 : 이벤트 핸들러는 동기적이다. 동기적인 작동방식은 데이터를 로드하는 등의 비동기 작업이 필요한 경우, 이 작업이 완료 될때까지 화면의 업데이트를 기다려야 한다. 화면이 멈춘것처럼 보일 수 있고, 사용자에게 좋지 않은 경험을 준다.

따라서, startTransition 을 통해 상태 업데이트를 비동기적으로 처리할 수 있게 하면 된다. 

Consider adding an error boundary to your tree to customize error handling behavior. Visit https://reactjs.org/link/error-boundaries to learn more about error boundaries. 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.

이 메시지는 컴포넌트가 동기적 입력에 응답하는 도중에 Suspense 상태가 되었음을 나타냅니다.
즉, 어떤 사용자의 동작(예를 들어 버튼 클릭)에 따라 컴포넌트가 업데이트되는 도중에, 그 컴포넌트가 비동기적으로 데이터를 로딩하고 있어 Suspense 상태가 되었을 때 발생하는 문제를 말합니다.

이런 상황에서는 해당 컴포넌트가 Suspense fallback UI (즉, 로딩 인디케이터 등)으로 대체됩니다. 이로 인해 사용자에게는 화면 전체가 로딩 상태로 보이는 불편함이 발생할 수 있습니다. 이 문제를 해결하려면, Suspense를 발생시키는 업데이트를 React의 startTransition 함수를 사용해서 감싸주어야 합니다.

startTransition은 그 안에 주어진 함수를 비동기적으로 실행하게 하여, 해당 함수가 발생시키는 업데이트가 동기적으로 화면에 반영되는 것을 방지하고, 이로 인해 UI가 "어떤 상태에서 다른 상태로 바뀌는 동안"에 더 부드럽게 보이게 합니다.

이 메시지 또한, 문제가 발생한 컴포넌트의 주변에 Error Boundary를 추가하면 에러 처리 방식을 더 세밀하게 제어할 수 있음을 알려주고 있습니다. Error Boundary는 그 하위의 컴포넌트 트리에서 에러가 발생했을 때 그 에러를 캡쳐하고, 에러 메시지를 보여주거나, 필요한 로직을 실행하도록 도와주는 컴포넌트입니다.

 

🔸Suspense 란?

Suspense 는 컴포넌트가 비동기적으로 데이터를 로드하는 동안 일시적으로 '일시중지' 상태를 나타낸다. 이 상태는 주로 데이터를 가져오는 동안 일시적인 로딩 표시나 대체 UI를 보여주는 데 사용된다. 

 

🔸그렇다면, 왜 컴포넌트가 동기적인 입력(이벤트 핸들러 버튼 클릭)에 응답하는 도중에 Suspense 상태가 되는 걸까?
컴포넌트가 사용자 입력(예: 버튼 클릭)에 응답하여 무언가를 업데이트해야 하면서, 그와 동시에 필요한 데이터를 아직 가져오지 못한 상태라는 것을 의미한다. 즉, 컴포넌트는 동기적으로 이루어지는 상태 업데이트를 처리하면서도, 필요한 데이터를 비동기적으로 로드해야 하므로 이 두 가지 작업 사이에 일시적인 불일치가 발생한다. 이런 상황에서 Suspense는 비동기 작업이 완료될 때까지 컴포넌트의 렌더링을 "일시 중지"함으로써 이 불일치를 관리한다.

그러나 이러한 방식은 사용자 경험에 문제를 일으킬 수 있다. 동기적인 사용자 입력에 응답하는 동안 UI가 일시적으로 로딩 상태로 전환되면, 사용자는 그에 따른 지연을 느끼게 된다. React가 "A component suspended while responding to synchronous input"이라는 경고를 표시하는 이유이다.

이 문제를 해결하기 위해, React는 이러한 업데이트를 startTransition 함수로 감싸서 비동기적으로 처리하도록 권장한다. 이렇게 하면 사용자 입력에 대한 응답은 즉시 이루어지고, 데이터 로드는 백그라운드에서 진행되므로 UI는 부드럽게 유지된다. 

 

🔸startTransition

React 18에 추가된 API.
"startTransition"은 사용자 인터페이스(UI)가 차단되지 않게 하면서 상태를 업데이트하게 해주는 함수이다.

startTransition 을 사용하지 않으면, 복잡한 연산이나 대량의 상태 업데이트로 인해 UI 가 일시적으로 '차단'될 수 있다. 예를들어, 사용자가 버튼을 클릭하거나 입력을 하는 등의 동작이 연산이 끝날때까지 지연될 수 있다.
"startTransition"은 이런 문제를 해결하기 위해 도입된 개념으로, 상태 업데이트를 비동기적으로 수행하게 해준다. 즉,사용자의 동작에 즉시 응답하면서도, 필요한 데이터를 백그라운드에서 로드하고 화면을 부드럽게 업데이트할 수 있다. 

 

내가 작성한 게시글 살펴보자!

https://ella951230.tistory.com/entry/React-18-Transition

 

React 18) Transition

0. Transition 브라우저에서 렌더링이 시작되면 중단될 수 없으며 렌더링이 완료되기까지 화면은 block 상태가 된다고 할 수 있다. 이로 인하여 렌더링 도중에 발생하는 텍스트 입력과 마우스 클릭과

ella951230.tistory.com

 

🤔그런데, react-query로 totalPage 응답받아서 props 로 넘겨줬을 때는 에러가 뜨고,  redux-toolkit 의 createAsyncThunk 로 totalPage 받아와서 전역상태에 저장했을 때는 에러 안났는데 무슨 차이인걸까...?

 

redux-toolkit을 사용할 때 totalPage의 상태 업데이트는 동기적으로 이루어진다. 즉, 데이터를 가져올 때까지 렌더링을 차단하지 않고, 데이터가 준비되면 UI가 동기적으로 업데이트된다. 이러한 동기적인 상태 업데이트는 사용자 입력에 대한 반응을 차단하지 않으므로, Suspense 에러는 발생하지 않는다. 

반면에 react-query는 데이터를 가져오는 동안 UI 렌더링을 Suspense로 차단한다.  상품데이터를 가져오는 동안 로딩 인디케이터가 표시되며, 이 과정이 동기적으로 이루어지면 사용자가 페이지를 변경할 때마다 짧은 시간동안 UI 가 '멈추는' 것처럼 보일 수 있다. 데이터 로딩 중에도 사용자 입력에 반응할 수 있지만, 이 반응이 동기적으로 발생하면 Suspense 에러가 발생한다. 이를 해결하기 위해 startTransition를 사용하여 상태 업데이트를 비동기적으로 수행하게 해야한다.

 

2. 해결책

- 가장 중요한 행위는 페이지네이션 버튼을 클릭하는 행위이다. 그리고 페이지를 렌더링 하는 행위는 startTransition 으로 감싸서 전환업데이트로 만들었다.

- setCurrentPage(page) 상태 변경은 긴급업데이트(클릭이벤트)에 의해 차단되지 않게된다. 

-  setCurrentPage를 호출함으로써 페이지 번호를 업데이트하는 것이 사용자 인터페이스의 렌더링을 차단하거나 방해하지 않도록 하기 위해 startTransition을 사용하고 있다.

function PageNation({
  currentPage,
  setCurrentPage,
  totalPage,
}: PageNationProps) {

  const displayPages = 5; // 한 번에 보여줄 페이지 수
  const handleClick = (page: number) => {
    startTransition(() => {
      setCurrentPage(page);
    });
  };


  // 가장 왼쪽에 표시될 페이지 번호를 계산
  let startPage =
    Math.floor((currentPage - 1) / displayPages) * displayPages + 1;

  // 시작 페이지부터 displayPages만큼의 페이지를 표시함
  const pages = [...Array(totalPage).keys()]
    .slice(startPage - 1, startPage - 1 + displayPages)
    .map((i) => i + 1);

  return (
    <S.PaginationWrapper>
      {pages.map((page) => (
        <S.PageNumber
          key={page}
          onClick={() => handleClick(page)}
          active={currentPage === page}
        >
          {page}
        </S.PageNumber>
      ))}
    </S.PaginationWrapper>
  );
}

export default PageNation;

=> 그런데 startTransition 을 하지 않고 <Suspense fallback={<Loading/>}> 를 사용하면 데이터 받아와지는 도중에 Loading 컴포넌트를 띄우기 때문에 화면이 차단되지 않는다. 

=> startTransition 을 사용하든가 혹은 Suspense 를 사용하든가 하면 된다.


참고자료
https://velog.io/@xiniha/React-Suspense-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0

 

React Suspense 알아보기

React 18이 정식으로 출시되었는데요, React 18은 Suspense 사용에 있어서 큰 변화를 가져왔고, 따라서 이번 글에서는 React 18에서 변한 사항들을 포함하여 Suspense의 전체적인 내용들에 대해 알아보려고

velog.io

https://developer-alle.tistory.com/428

 

[React] Suspense 이해하기

Data fetching Traditioanl Approach vs Suspense 데이터 fetching과 관련해서 3가지 접근방법이 있다. Fetch-on-render 이 방법의 문제점은 'waterfall' 이라고 불린다. 이 코드를 보면, App 컴포넌트가 마운트하고나서 to

developer-alle.tistory.com