본문 바로가기

개발

React Query 사용할 때 주의할 점

이슈 내용

우선 해당 이슈는 여기서(https://github.com/TanStack/query/issues/3772) 볼 수 있다.

 

쉽게 말하면 랜더링이 한번 더 되는 문제인데.. React Query 자체의 문제가 아니라 React에서 비롯된 것이라고 결론이 난 듯하다. (그래서 RTK Query에서도 같은 현상이 발생한다고 함)

 

현상을 간단히 설명하면 useQuery를 사용한 컴포넌트가 첫 요청시에는 예측 가능한대로 두 번 랜더링되는데, 그 다음 요청부터는 세 번 랜더링이 된다는 것이다.

첫 요청: loading -> success
그 뒤: loading -> loading (❌) -> success

 

❌ 표시한 두 번째 랜더링에서 useQuery의 반환값들(status, isLoading 등)은 앞선 랜더링의 값들과 모두 일치한다. 즉 전혀 불필요한 랜더링이 한번 더 발생하는 셈이다.

 

문제를 발견하고 처음 검색을 했을 때 놀랐던 점은 이게 생각보다 많이 알려져있지 않다는 것이었다. 아무래도 많이들 사용하는 라이브러리다보니 '그냥 원래 그런가보다'하고 넘어갔던 게 아닐까싶다. (나도 그랬고)

 

그런데 경우에 따라 랜더링 속도에 꽤 영향을 줄 수 있어서 기록을 해두려고 한다.

문제가 되는 경우

예시

import { useState } from "react";
import { usePosts } from "./hooks"; // useQuery를 사용하는 커스텀 훅

import Posts from "./components/Posts";
import Loading from "./components/Loading";
import Error from "./components/Error";

const PostPage = () => {
  const [date, setDate] = useState("2022-11-19");

  // date에 해당하는 post 목록 조회
  const { posts, status, error } = usePosts({ date });

  return (
    <Layout>
      {/* date 표시 및 변경 컴포넌트 */}
      {posts && <Posts data={posts} />}
      {status === "loading" && <Loading />}
      {status === "error" && <Error message={error.message} />}
    </Layout>
  );
}

 

자연스러워보이는 코드지만, 위 이슈와 React Query의 디폴트인 cache 기능 때문에 다음과 같은 일이 벌어진다.

위에서 date가 변경되었다고 가정해보자. 해당 KEY가 첫 요청이거나 쿼리의 cacheTime을 0으로 설정해서 캐싱되지 않았다면 문제가 없다.

 

  1. 첫 번째 랜더(정상 랜더)에서 postsundefined일 것이므로, Posts 는 랜더링되지 않음
  2. 두 번째 랜더(이슈 설명에서 ❌ 표시한 것)에서도 마찬가지로 랜더링되지 않음
  3. 데이터 fetching이 끝나고 posts가 들어오면, 그제서야 Posts 가 랜더링됨

그런데 만약 해당 KEY가 캐싱되어있었다면?

  1. 첫 번째 랜더에서 posts 는 캐시에 있던 값으로 즉시 리턴. Posts 는 랜더링됨 (prop이 변경됐으므로)
  2. 두 번째 랜더에서 posts 는 같은 값이어도 Posts 입장에서 부모(PostPage)가 랜더링 되었으므로 다시 랜더링됨
  3. 데이터 fetching이 끝나고 posts 가 들어오면, 마지막으로 랜더링됨

이렇게 Posts가 총 세 번이나 랜더링된다. 실제로 업무 중에 Posts에 해당하는 컴포넌트가 랜더링에 시간이 걸리는 편이었어서 버벅임이 발생하기도 했었다. (참고: 페이지 로딩 속도 개선)

 

아무튼 Posts를 리팩토링한다고 해도 불필요한 랜더링이 발생하는 것은 여전하기 때문에 이것을 해결하기 위해 몇 가지 시도들을 해보았다.

시도해본 것

React.memo

Postsmemo하는 것이다. prop인 posts가 이전 랜더링과 같다면 부모가 리랜더링되더라도 랜더링되지 않는다.

 

즉 위의 예시에서 불필요한 랜더링(2)을 막을 수 있다. 만약 3에서 새로 fetch된 posts가 기존에서 변경이 없다면 같은 레퍼런스(기존 posts)를 유지하기 때문에, memo가 깨질 것을 걱정하지 않아도 된다고 한다.

Query results by default are structurally shared to detect if data has actually changed and if not, the data reference remains unchanged to better help with value stabilization with regards to useMemo and useCallback. If this concept sounds foreign, then don't worry about it! 99.9% of the time you will not need to disable this and it makes your app more performant at zero cost to you. (출처: 공식문서)

*실제로는 어떻게 하는지 궁금해서 별도 포스트에 정리해둠

 

React.memo를 사용할 때 주의할 점은 posts 외에 다른 prop들에 대해서도 처리(equality 보장)를 해줘야 한다는 것이다. 그러려면 useCallback이나 useMemo 등을 사용해야 할텐데, 이것들도 비용이 있기 때문에 성능상 이점이 확인된 경우에만 memo하는 것이 좋다.

cacheTime을 0으로 한다

더 단순한 방법이다. 쿼리 KEY가 바뀔 때마다 postsundefined가 되기 때문에 Posts 가 불필요하게 랜더링되지 않는다.

단점은 말 그대로 cache를 사용하지 않기 때문에 같은 조건(date)으로 데이터를 다시 조회해도 처음과 계속 같은 시간이 걸린다는 것이다.

랜더링 시간 측정

예시에서 PostPage에 해당하는 컴포넌트가 랜더링되는 데 걸리는 시간을 기존 / React.memo / 캐시 안 하기 세 가지 방법으로 각각 측정해봤다.

 

이슈가 발생하는 두 번째 요청을 기준으로 실험을 진행했다. Posts가 랜더링 될 때를 기준으로 PostPage는 120ms가 소요되었고, Posts가 랜더링되지 않는 경우에는 memo 여부에 따라 각각 35ms, 25ms이 소요되었다. (10ms 차이는 memo의 비용 때문인 것으로 추정됨)

 

  • 기존
    • 데이터 변경 여부와 무관하게: 120 + 120 + 120 = 360ms
  • React.memo
    • 데이터 변경X: 120 + 35 + 35 = 190ms
    • 데이터 변경O: 120 + 35 + 120 = 275ms
  • 캐시 안 하기
    • 데이터 변경 여부와 무관하게: 25 + 25 + 120 = 170ms

 

두 방식 모두 기존과 비교했을 때 '문제의 두 번째 랜더링'에 걸리는 시간이 줄어든 것(120 -> 35 또는 25)을 확인할 수 있었다.

 

memo를 사용한 경우 사용자가 상대적으로 빨리(첫 랜더링 - 120ms, 캐시X: 170ms) 데이터를 확인할 수 있지만, 데이터가 변경되었을 때는 Posts가 두 번 랜더링되어 각 방식의 장단점을 좀 더 확실히 알 수 있었다.

결론

업무에서는 posts에 해당하는 데이터가 매우 자주 변경이 일어나는 편이어서 캐시를 하지 않는 방식을 적용했다.

 

다만 API 응답에 소요되는 시간, Posts에 해당하는 컴포넌트 랜더링 시간, 실제로 데이터가 얼마나 자주 변경되는지 등에 따라 상황마다 적절한 방식이 있을 것으로 생각된다.