[React Query] 리액트 쿼리 조건부 렌더링 주의할 점 그리고 렌더링 최적화

김효선·2022년 7월 22일
50

React Query 사용기

목록 보기
5/5

지난 포스팅에서는 리액트 쿼리 기본 옵션 설정이나 커스텀 훅, 쿼리 키 관리 등에 대해 소개를 해드렸는데요! 이번 포스팅에서는 리액트 쿼리를 사용하면서 실제로 제가 마주쳤던 잦은 api 요청과 데이터가 변한 것이 없는데도 발생하는 리렌더링, 도대체 왜 이런건지 이유와 개선하는 방법에 대해 알아보았던 내용을 소개하겠습니다.

useQuery

// 부모 컴포넌트
const { data: userList } = useQuery(['user'], queryFn, queryOptions);
// 자식 컴포넌트
const { data: userList } = useQuery(['user'], queryFn, queryOptions);

위 코드에서['user'] 라는 키 값을 설정했는데 이 키 값을 가진 동일한 useQuery 가 부모 컴포넌트, 자식 컴포넌트에서 같이 호출하고 있는 상태이면 리액트 쿼리는 한 번의 api 요청 후 지정해둔 키를 식별자로 데이터를 캐싱합니다.

그런데,
조건부 렌더링이 들어가있다면 의도와 다르게 api 요청이 여러번 일어나는 것을 볼 수 있는데요..! 조건부 렌더링이 무엇인지부터 코드를 살펴볼게요!

조건부 렌더링

const { data, isLoading, isError, error } = useQuery(queryKey, queryFn, queryOptions);

if (isLoading) return <span>isLoading...</span>;
if (isError) return <span>Error: {error.message}</span>;
if (!data) return null;

return (
  // ...
)

리액트 쿼리를 사용하다보면 위와 같은 조건부 렌더링 로직을 많이 접하게 됩니다. isLoading 중일 때 보여줄 화면, data가 없을 때 보여줄 화면 등 상태에 따라 다른 화면을 보여줄 때 많이 사용되고 있는 로직이죠.
부모 컴포넌트에서 이렇게 조건부 렌더링이 작성되어있고 자식 컴포넌트에서 같은 쿼리를 호출하는 상황에서 네트워크 탭을 보면 api 요청이 2번 된 것을 볼 수 있습니다. 왜 그럴까요? 재밌는 포인트는 staleTimecacheTime 그리고 refetchOnMount 입니다.

메인 예제 코드

useQuery 를 커스텀 훅으로 만들고 부모와 자식 컴포넌트에서 호출하는 로직을 예제 코드로 만나볼게요.

// user 정보를 가져오는 쿼리 커스텀 훅
import { AxiosError } from 'axios';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import { getUserInfoById } from 'src/api/user';
import { UserInfoType } from 'src/types/userType';
import { queryKeys } from 'src/types/commonType';

export default function useGetUserInfoByIdQuery(
  id: number,
  options?: UseQueryOptions<UserInfoType, AxiosError>
): UseQueryResult<UserInfoType, AxiosError> {
  return useQuery<UserInfoType, AxiosError>(queryKeys.userInfo(id), () => getUserInfoById(id),
  {
    enabled: !!id,
    ...options
  });
}
// 부모 컴포넌트인 Modal.tsx
export default function Modal({ id }: Props) {
 const { data, isLoading, isError, error } = useGetUserInfoByIdQuery(id);
  console.log('(1) Modal Component');
  
  if (isLoading) { 
    console.log('isLoading', isLoading);
    return <span>Loading...</span>;
  }
  
  if (isError) {
    return <span>Error: {error.message}</span>;
  }
  
  if (!data) {
    return <span>No data found</span>;
  }
  
  return (
    <Container>
      <UserInfoSection id={id} />
    </Container>
  );
}

부모 컴포넌트인 Modal.tsx 에서 조건부 렌더링을 하고 있습니다.
자식 컴포넌트인 <UserInfoSection /> 코드도 보겠습니다.

// UserInfoSection.tsx
export default function UserInfoSection({ id }: Props) {
  const { data, isLoading } = useGetUserInfoByIdQuery(id);
  console.log('(2) UserInfoSection Component');
  
    if (isLoading) { 
    console.log('isLoading2');
    return <span>Loading...</span>;
  }
  
  return (
    // data 렌더링...
  )    
}

코드를 실행하고 네트워크 탭을 보면 api를 2번 요청하는 것을 볼 수 있습니다.

리액트 쿼리 라이프 사이클

요청이 2번 일어나는 이유를 알기 전에 리액트 쿼리의 라이프 사이클을 짚고 넘어가면 이해하기 더 쉽습니다!

console.log 를 확인해볼게요.

[queryOptions] staleTime

기본 값이 0초staleTime 은 fetch 이후 데이터가 바로 fresh 상태에서 오래된 상태인 stale 상태로 변경됩니다.

[queryOptions] refetchOnMount 개념

refetchOnMount 는 기본 값이 true 로 설정되어있는데요! 데이터가 stale 상태일 때 마운트 시 refetch 를 하는 옵션입니다.

흐름은 이렇습니다!

  1. 부모 컴포넌트를 렌더링한다.
  2. useGetUserInfoByIdQuery(id) 이 호출된다. (isFetching, isLoading : true)
  3. 요청이 완료되면
    • isFetching 과 isLoading 은 false가 된다.
    • key 값을 식별자로 데이터를 캐싱한다.
    • 데이터는 fresh 상태이며 staleTime 이 지나면 stale 상태가 된다.
  4. 부모 컴포넌트가 다시 리렌더링 되면서 자식 컴포넌트의 useGetUserInfoByIdQuery(id) 가 호출된다.
    • 데이터가 stale 상태이고 refetchOnMount 가 true 이므로 background refetch 가 진행된다.

첫 fetching 이후 staleTimerefetchOnMount로 인해 refetching 을 하면서 총 2번 요청하게 되는 것 입니다.

제가 원했던 건 자식 컴포넌트까지 api를 요청할 게 아니라, 처음 한 번의 요청과 추가적으로 mount 시 캐싱된 데이터를 사용하는 것 입니다.

staleTime 을 적용해보자

자식 컴포넌트에서 커스텀 훅에 옵션을 파라미터로 전달해서 적용해도 되고,

 const { data, isLoading } = useGetUserInfoByIdQuery(id, {
   staleTime: 60 * 1000 // 1분
 });

커스텀 훅 파일에서 직접 적용해도 됩니다.

const useGetUserInfoByIdQuery = (id) => {
  return useQuery(['userInfo', id], () => getUserInfoById(id), {
    enabled: !!id,
    staleTime: 60 * 1000,
    ...options
  });
};

staleTime 을 1분으로 설정해봤는데요! 이렇게 되면 위의 과정에서
데이터가 staleTime 1분이 지나기 전에 자식 컴포넌트의 쿼리가 mount 될 때 데이터가 fresh 상태이기 때문에 refetchOnMount 가 일어나지 않습니다.
다시 네트워크 탭을 확인해보면 api 요청이 1번만 일어나는 것을 확인할 수 있습니다!

저는 여기서 좀 이상함을 느꼈던 부분이 있는데요,

  1. 자식 컴포넌트의 console.log(isLoading2) 는 왜 안찍히는 걸까?
  2. console.log 는 왜 이렇게 많이 찍힐까?

cacheTime

리액트 쿼리에서 cacheTime 은 기본 5분으로 설정되어있습니다. 처음 fetch 를 하고나서 query key 값을 식별자로 5분동안 데이터가 캐싱되어있는데요, 쿼리가 unmount 되면 inactive 상태가 되고 그 상태로 5분이 지나면 가비지 콜렉터로 수집 됩니다.
5분이 지나기 전에 같은 key 값의 쿼리가 마운트되어 재실행되면 isFetching 은 true 가 되지만 isLoading 은 그대로 false 입니다.

즉, 캐싱된 데이터가 없는 상태에서의 fetch가 실행될 때 isLoading 이 true 로 되는거에요!

렌더링 최적화

두 번째 의문을 살펴보겠습니다. 위에서 console.log 가 많이 찍혀있는 것을 확인했는데요! 데이터가 변한게 없는데 왜 컴포넌트가 리렌더링 될까요?

{ status: 'success', data: [...], isFetching: true }
{ status: 'success', data: [...], isFetching: false }

리액트 쿼리가 제공하는 많은 메타 정보 중에 isFetching 이 있는데 이 상태가 변경되면서 해당 쿼리를 갖고 있는 컴포넌트들이 리렌더링 되는 것입니다.

음, 그러면 staleTime 을 설정해서 refetch 가 진행되지 않게 하면 리렌더링이 안 일어날까요?

테스트를 위해 staleTime3초로 지정해보았습니다.

3초가 지나자 추가 리렌더링이 발생하였습니다 isFetching 은 계속 false 인데 말이죠!

isStale 을 찍어보니, 3초가 지난 뒤 isStale 이 true 가 되면서 리렌더링이 발생하는 것을 확인할 수 있습니다.

  1. 부모 컴포넌트의 첫 fetch 후 isStale 이 false 가 되고 리렌더링
  2. 다시 부모 컴포넌트의 콘솔이 찍히고 자식 컴포넌트가 렌더링 되면서 자식 컴포넌트의 콘솔이 찍힘
  3. 3초 후 isStale이 true 가 되면서 다시 해당 쿼리를 품고 있는 부모 컴포넌트와 자식 컴포넌트가 리렌더링 된다.

이렇게 isFetching 이나 isStale 이 아니어도 리액트 쿼리에서 제공하는 여러 플래그 값들로 인해서 리렌더링이 발생할 수 있습니다.
이것을 최적화 하는 방법이 있는데요!

notifyOnChangeProps

const useGetUserInfoByIdQuery = (id) => {
  return useQuery(['userInfo', id], () => getUserInfoById(id), {
    enabled: !!id,
    staleTime: 3 * 1000,
    notifyOnChangeProps: 'tracked',
  });
};

notifyOnChangeProps 라는 옵션을 설정해주면 됩니다.
이 옵션은 나열된 속성들 중 하나만이라도 변경되면 다시 리렌더링을 해주는 옵션인데요

notifyOnChangeProps: ['data'],

이렇게 적용하면 const { data, isLoading } = useQuery(...);data 만 변경되었을 때 리렌더링이 됩니다. 그런데 isLoading 을 이용해서 조건부 렌더링을 해주고 있는 경우라면 문제가 생깁니다. isLoading 이 변경되었는지 감지하지 못하기 때문입니다.

tracked

notifyOnChangeProps: 'tracked',

tracked 로 설정해주면 리액트 쿼리는 렌더 중에 사용 중인 속성을 알아서 추적하고 사용 중인 속성들이 변화가 있을 때에만 리렌더링을 해줍니다.
console.log 로 테스트할 때 작성했던 isFetching 과 isStale 을 지우고 다시 확인해보면,

요렇게 staleTime 이 지나도 리렌더링이 발생하지 않는 것을 확인할 수 있습니다!

그럼 또 하나의 의문이 생깁니다.
왜 리액트 쿼리는 이것을 default 로 설정하지 않은 걸까요?
선택 옵션으로 둔 이유가 있는데 몇 가지 살펴보겠습니다.

const { data, ...queryInfo } = useQuery(...);

object rest destructuring 로 사용하면 모든 필드를 추적하기 때문에 주의해야합니다.

const queryInfo = useQuery(...);
                                        
React.useEffect(() => {
    console.log(queryInfo.data)
})

위에서 언급했듯이 "렌더 중"에 사용 중인 쿼리를 추적하는데 이렇게 의존성 배열에 사용 중인 속성을 담지 않으면 정확한 추적이 되지 않습니다.

그리고, tracked 를 설정해서 사용 중인 속성들을 추적하는 것도 약간의 오버헤드가 있으니 상황에 따라 사용해야한다고 합니다!

여기까지 리액트 쿼리의 조건부 렌더링과 렌더링 최적화에 관련된 내용과 옵션들을 살펴보았습니다. 이렇게 다양한 옵션을 설정하고 공부하는 재미가 있는 리액트 쿼리를 저는 정말정말 좋아합니다!

잘못된 내용이나 피드백은 댓글 남겨주세요 :)

리액트 쿼리 렌더링 최적화 참고 문서

profile
개발을 게임처럼!

7개의 댓글

comment-user-thumbnail
2022년 7월 22일

글 재밌게 읽었어요~
staleTimecacheTime이 초반에는 참 헷갈리는 개념이지요.
처음 쓸 때 헷갈려서 조금 고생했던 경험이 있어요.
잘 정리해주셨네요~~!

저는 기존에 notifyOnChangeProps를 써본적이 없었는데 잘 배워갑니다 !

1개의 답글
comment-user-thumbnail
2022년 9월 16일

정말 정리 잘해주셨네요 👍🏻
도움 많이 받았습니다! 감사합니다!!

1개의 답글
comment-user-thumbnail
2023년 1월 26일

react-query를 쓰면서 리렌더링에 대해 궁금했던 부분에 대해 많이 알게 되었습니다!
그런데 한가지 궁금점이 있는데 렌더링 최적화 부분에서 staleTime을 3초로 설정한 후 리렌더링 될때 isFetching이 false인 이유가 무엇때문인가요?
stale상태가 되면서 refetch가 일어나고 isFetching: true가 되었다가 false로 바뀔거라고 생각했는데 그게 아니라 이해가 잘 안 되어서요ㅠ

답글 달기
comment-user-thumbnail
2023년 2월 6일

잘 정리된 글이네요...! 도움이 많이 되었습니다 감사합니다!

답글 달기
comment-user-thumbnail
2023년 10월 24일

A Dinosaur Game Called Many Things https://dinogame.one

답글 달기