[RTK Query] useQuery 리렌더링 문제 해결기 - useLazyQuery 사용

[RTK Query] useQuery 리렌더링 문제 해결기 - useLazyQuery 사용
Photo by Artem Gavrysh / Unsplash

[요약]

  1. RTK Query의 useQuery는 호출이 되는 시점에 데이터를 fetch한다. 그리고 그 과정에서 컴포넌트의 렌더링이 여러 번 발생할 수 있다.
  2. 이로 인해 개발자가 예상하지 못한 시점에 컴포넌트가 렌더링되며 사이드 이펙트가 생길 수 있다.
  3. RTK Query의 useLazyQuery를 사용하면 데이터 fetch 시점을 조절할 수 있어 사이드 이펙트를 줄일 수 있다.
  4. 답은 공식 문서에 있다.

사이드 프로젝트로 친구들과 블로그를 만들고 있다. 팀원 모두 프론트엔드 엔지니어가 아니라 이번 기회를 통해 React 및 Redux를 공부하며 써보고 있다.

최근 프로젝트에 Redux toolkit 적용을 했는데 여러 시행착오가 있었다. 이번에 코드 리뷰하며 깔끔하게 개선이 된 사례가 있어 이를 공유한다.


무엇을 구현하려 했는가

  • 블로그 Home에 들어갔을 때 작성한 포스트들이 나와야 한다.
  • 모든 포스트가 나오면 안되고, 스크롤의 위치(pageNumber)에 따라 필요한 포스트만 서버에서 쿼리하여 보여준다.
  • 무한 스크롤을 구현한다.

기본 구조

  • redux toolkit의 createApi로 서버로부터 포스트를 가져오는 쿼리를 생성해 사용한다. Home에 들어가면 pageNumber에 따라 알맞은 포스트를 가져온다.(usePageQuery)
  • pageNumber는 스크롤의 위치에 따라 달라진다.
  • createSlice로 Home에서 보여줄 정보를 담는 slice를 만든다. pageNumber와 보여줄 포스트로 구성된다.

문제점

const data = useAppSelector((state) => state.home);
const [pageNumber, setPageNumber] = useState(data.pageNumber);
const currentResult = usePageQuery(pageNumber); // query
const hasDuplicatePage = {
  ...
}

const ref = useIntersect(async (entry, observer) => {
  if (currentResult.isSuccess && currentResult.data.hasNextPage) {
      setPageNumber(currentResult.data.nextPage);
    }
});

useEffect(() => {
  if (currentResult.isSuccess && !hasDuplicatePage(currentResult.data)) {
    dispatch(
      action({
        pageNumber: pageNumber,
        posts: currentResult.data.nextPosts,
      })
    );
  }
}, [currentResult, action, pageNumber, dispatch, hasDuplicatePage]);
home에서 post들을 가져오는 코드

data는 다음의 형태로 구성이 되어 있다.

{pageNumber: 1, posts: Array(0)}
data

로직의 순서는 다음과 같다.

  1. redux slice에서 pageNumber를 가져온다
  2. pageNumber로 쿼리하여 포스트를 가져온다.
  3. 1) 스크롤의 위치가 적절하고 2) 쿼리가 성공했고 3)다음 페이지가 존재하면 pageNumber를 바꿔준다.
  4. useEffect에서 1) 쿼리가 성공했고 2) hasDuplicatePage가 false면 dispatch한다.

맘에 들지 않는 함수: hasDuplicatePage

useEffect에서 호출되는 hasDuplicatePage 함수가 없으면 slice에 같은 post들이 무한으로 query되어 인입된다.

hasDuplicatePage가 없으면 같은 post들이 무한으로 인입되는 대참사가 일어난다

무한으로 같은 post들이 담기는 이유는 currentResult의 상태변화 때문이다. usePageQuery로 얻은 currentResult는 "isSuccess"외 여러 필드를 가지고 있다. 시간이 지남에 따라 currentResult의 상태는 다음 순서대로 변경된다.

// 1
{status: 'pending', isSuccess: false, isFetching: true ...}
// 2
{status: 'pending', isSuccess: true, isFetching: true, requestId: "~~"}
// 3
{status: 'fulfilled', isSuccess: true, isFetching: false, requestId: "~~"}

currentResult의 상태가 바뀔 때 컴포넌트는 리렌더링된다. 문제는 두 번째 렌더링이다. dispatch 검사 로직은 isSuccess만 검사하기 때문에 status가 pending임에도 Home 정보를 담는 slice에 포스트를 입력한다. 이때 입력되는 포스트는 이전에 쿼리했던 이미 입력된 포스트이다.

hasDuplicatePage 함수는 입력되는 포스트가 slice가 이미 가지고 있는 포스트인지 확인하여 '중복 포스트 무한 인입 현상'을 막는다. 하지만 useEffect에서 사용해 컴포넌트가 렌더링이 될 때마다 호출이 되는 단점이 있었다. 포스트가 만약 수십만 개 이상이면 렌더링을 할 때 꽤 부담이 될 것이다.

hasDuplicatePage 안쓰고 isFetching을 검사하면 왜 안돼?

currentResult를 보면 데이터가 완전히 담겼을 때 isFetching이 false로 변경된다. isFetching을 검사하면 쿼리가 완료된 시점을 알 수 있는 것이다.

그래서 isFetching이 false일 때 action을 dispatch한다면 중복 포스트 확인 작업을 하지 않아도 해결할 수 있다고 생각할 수 있다.

그러나 isFetching만 검사하면 다른 페이지로 갔다가 뒤로가기로 돌아왔을 때 문제가 생긴다. slice에 이전에 쿼리한 포스트가 담겨있지만 중복 포스트를 다시 입력한다.

그래서 컴포넌트를 렌더링할 때마다 쿼리한 post가 새로운지 검사하는 방법을 겨자 먹기로 채택한 것이다.

근본적인 원인은 무엇인가?

포스트 중복 검사 함수를 사용한 이유를 정리해보자.

  1. isFetching과 isSuccess를 검사하면 데이터 쿼리가 완료된 시점을 알 수 있다.
  2. 그러나 데이터 쿼리가 완료되었다고 해당 데이터를 다 slice에 저장하면 안된다. (ex 다른 페이지로 갔다가 뒤로가기로 오는 경우)
  3. 그 이유는 쿼리를 우리가 원하는 시점에 하지 못했기 때문이다. (뒤로 가기로 오는 경우는 쿼리를 하지 않고 싶지만 자동으로 호출됨)
  4. 쿼리가 원하지 않은 시점에 실행되니 가져온 데이터를 검사해야 했다.

해결법 - useLazyQuery를 사용하다

RTK Query가 제공하는 쿼리의 종류는 앞서 사용한 useQuery를 포함한 5가지이다. 우리는 useLazyQuery를 사용해 이를 해결하기로 하였다.

useLazyQuery는 useQuery와 흡사하지만 data를 fetch하는 시점을 조절할 수 있다.

type UseLazyQuery = (
  options?: UseLazyQueryOptions
) => [UseLazyQueryTrigger, UseQueryStateResult, UseLazyQueryLastPromiseInfo]

useLazyQuery를 사용하면 trigger 함수와 result를 반환하는데, trigger 함수를 사용해야 data가 fetch되어 result에 값이 담기게 된다.

useLazyQuery를 사용해 고친 코드는 다음과 같다.

const [trigger, currentResult] = usePageQuery();
const dispatch = useAppDispatch();

const hasNextPage = ...
const nextPageKey = ...

const ref = useIntersect(async (entry, observer) => {
    if (hasNextPage && !currentResult.isFetching) {
      trigger(nextPageKey);
    }
});

useEffect(() => {
  const { currentData, isSuccess, isFetching } = currentResult;
  if (isSuccess && !isFetching) {
    dispatch(
      action({
        nextPage: currentData.hasNextPage ? currentData.nextPage : null,
        posts: currentData.nextPosts,
      })
    );
  }
}, [currentResult, action, dispatch]);

우리가 쿼리를 하고 싶은 시점은 바로 스크롤이 페이지 끝으로 내려가 새로운 포스트가 필요한 때다. 이전 코드는 그렇지 못했다. useQuery는 호출 즉시 쿼리해 컴포넌트가 렌더링될 때마다 원하지 않는 쿼리가 호출되었다.

useLazyQuery는 trigger 함수를 제공하기 때문에 우리가 원하는 시점에 쿼리를 할 수 있다. 이제 useIntersect 함수 안에 trigger가 담겨 스크롤이 페이지 끝에 있을 때만 쿼리를 호출한다.

이제 우리는 쿼리한 포스트를 검사할 필요가 없다. 뒤로 가기를 눌러도 상관이 없다. 그때는 쿼리가 호출이 되지 않을 테니까.

마무리하며

구현을 하면서 문제가 생겼을 때 해결하는 방법은 많다. 처음에 우리가 적용했던 방법도 사용자가 수십 만개의 글을 작성하지 않는다면 별 문제가 없을 것이다.

하지만 우리가 처음 적용했던 방법은 근본적인 원인을 해결하는 방법이 아니었다. 지금은 문제가 없지만 프로젝트가 복잡해지면 같은 원인으로 또 다른 문제들이 발생할 수 있다. 컴포넌트 렌더링마다 쿼리하니 서버로 문제가 전이될 수도 있다.

결국 문제를 발생시킨 원인을 정확히 파악하고 해결하는 것이 개발자의 실력이다. 겉으로 보기에는 잘 돌아가는 프로덕트여도 내부는 언제 터질지 모르는 시한폭탄일 수 있다. 구현하기 쉬운 방법을 택하지 말고, 근본적인 원인을 파악하고 해결하자.

이번 사례는 RTK Query 문서만 잘 읽었어도 충분히 해결할 수 있는 문제였다. 공식 문서를 가까이 하자.

마지막으로 치열하게 논의하며 좋은 코드를 완성한 우리 팀원들에게 감사를 전한다(나는 사실 이 코드에 기여한 게 없다ㅎㅎ).