🔥 React Query 렌더링 최적화

957자
12분

주의 사항

렌더링 최적화는 모든 앱에서 고급 개념에 속합니다. React Query는 이미 뛰어난 최적화와 기본 설정을 제공하므로 대부분의 경우 추가 최적화가 필요하지 않습니다. 하지만 많은 개발자들이 "불필요한 재렌더링"에 지나치게 집중하는 경향이 있어 이 주제를 다루기로 했습니다.

다시 한 번 강조하지만, 대부분의 앱에서 렌더링 최적화는 생각만큼 중요하지 않을 수 있습니다. 재렌더링은 오히려 좋은 것입니다. 앱을 최신 상태로 유지하는 데 도움이 되기 때문입니다. "필요한 렌더링이 누락된 경우"보다는 "불필요한 재렌더링"이 훨씬 낫습니다.

이 주제에 대해 더 자세히 알고 싶다면 다음 글을 참고하세요:

React Query 데이터 변환 글에서 select 옵션을 설명하며 렌더링 최적화에 대해 이미 많은 내용을 다뤘습니다. 그러나 "데이터가 변경되지 않았는데 왜 React Query가 내 컴포넌트를 두 번 재렌더링하나요?"라는 질문에 가장 자주 답변해야 했습니다(아마도 "v2 문서는 어디서 찾을 수 있나요?"라는 질문 다음으로요 😅). 그래서 이번에는 이 문제를 자세히 설명해 보겠습니다.

isFetching 전환

이전 예제에서 컴포넌트가 할 일의 개수가 변경될 때만 다시 렌더링된다고 설명했지만, 완전히 정확한 설명은 아닙니다. 백그라운드에서 데이터를 다시 가져올 때마다 이 컴포넌트는 다음과 같은 쿼리 정보로 두 번 다시 렌더링됩니다:

{ status: 'success', data: 2, isFetching: true }
{ status: 'success', data: 2, isFetching: false }
 
javascript

React Query는 각 쿼리에 대해 많은 메타 정보를 제공하며, isFetching은 그 중 하나입니다. 이 플래그는 요청이 진행 중일 때 항상 true입니다. 백그라운드 로딩 표시기를 표시하려는 경우에 유용하지만, 그렇지 않은 경우에는 불필요할 수 있습니다.

notifyOnChangeProps

이런 경우를 위해 React Query는 notifyOnChangeProps 옵션을 제공합니다. 이 옵션을 사용하면 특정 속성이 변경될 때만 옵저버에게 알리도록 React Query에 지시할 수 있습니다. 이 옵션을 ['data']로 설정하면 원하는 최적화를 얻을 수 있습니다:

export const useTodosQuery = (select, notifyOnChangeProps) =>
  useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    select,
    notifyOnChangeProps,
  })
export const useTodosCount = () =>
  useTodosQuery((data) => data.length, ['data'])
 
javascript

이 방식의 실제 사용 예는 문서의 optimistic-updates-typescript 예제에서 확인할 수 있습니다.

동기화 유지하기

위의 코드는 잘 작동하지만 쉽게 동기화가 깨질 수 있습니다. error에 반응하거나 isLoading 플래그를 사용하기 시작하면 어떻게 될까요? 컴포넌트에서 실제로 사용하는 필드와 notifyOnChangeProps 목록을 계속 동기화해야 합니다. 이를 잊어버리고 data 속성만 관찰하지만 표시하는 error도 있다면, 컴포넌트가 다시 렌더링되지 않아 오래된 상태가 됩니다. 특히 이를 커스텀 훅에 하드코딩하면 훅이 컴포넌트에서 실제로 무엇을 사용할지 알 수 없기 때문에 문제가 됩니다:

export const useTodosCount = () =>
  useTodosQuery((data) => data.length, ['data'])
 
function TodosCount() {
  // 🚨 error를 사용하고 있지만,
  // error가 변경되어도 알림을 받지 않습니다!
  const { error, data } = useTodosCount()
 
  return (
    <div>
      {error ? error : null}
      {data ? data : null}
    </div>
  )
}
 
javascript

앞서 언급했듯이, 이는 가끔 발생하는 불필요한 리렌더링보다 훨씬 더 나쁜 상황입니다. 물론 이 옵션을 커스텀 훅에 전달할 수 있지만, 이는 여전히 수동적이고 반복적인 작업처럼 느껴집니다. 이를 자동으로 수행할 방법은 없을까요? 다행히도 있습니다:

추적된 쿼리

이 기능은 제가 라이브러리에 기여한 첫 번째 주요 기능이어서 꽤 자랑스럽습니다. notifyOnChangeProps를 'tracked'로 설정하면 React Query는 렌더링 중에 사용하는 필드를 추적하고 이를 사용하여 목록을 계산합니다. 이는 수동으로 목록을 지정하는 것과 정확히 같은 방식으로 최적화되지만, 이에 대해 생각할 필요가 없습니다. 모든 쿼리에 대해 전역적으로 이 기능을 켤 수도 있습니다:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      notifyOnChangeProps: 'tracked',
    },
  },
})
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  )
}
 
javascript

이렇게 하면 리렌더링에 대해 더 이상 걱정할 필요가 없습니다. 물론 사용량을 추적하는 것도 약간의 오버헤드가 있으므로 현명하게 사용해야 합니다. 추적된 쿼리에는 몇 가지 제한 사항이 있어 이 기능은 선택적으로 사용해야 합니다:

  • 객체 나머지 구조 분해를 사용하면 모든 필드를 관찰하게 됩니다. 일반적인 구조 분해는 괜찮지만, 다음과 같이 하지 마세요:
// 🚨 모든 필드를 추적합니다
const { isLoading, ...queryInfo } = useQuery(...)
 
// ✅ 이는 완전히 괜찮습니다
const { isLoading, data } = useQuery(...)
 
javascript
  • 추적된 쿼리는 "렌더링 중"에만 작동합니다. 효과 중에만 필드에 접근하면 추적되지 않습니다. 하지만 의존성 배열 때문에 이는 꽤 드문 경우입니다:
const queryInfo = useQuery(...)
 
// 🚨 data를 올바르게 추적하지 않습니다
React.useEffect(() => {
  console.log(queryInfo.data)
})
 
// ✅ 의존성 배열이 렌더링 중에 접근되므로 괜찮습니다
React.useEffect(() => {
  console.log(queryInfo.data)
}, [queryInfo.data])
 
javascript
  • 추적된 쿼리는 각 렌더링마다 재설정되지 않으므로 한 번 필드를 추적하면 옵저버의 수명 동안 계속 추적합니다:
const queryInfo = useQuery(...)
 
if (someCondition()) {
  // 🟡 someCondition이 이전 렌더링 주기에서 한 번이라도 true였다면 data 필드를 추적합니다
  return <div>{queryInfo.data}</div>
}
 
javascript

참고: React Query v4부터는 추적된 쿼리가 기본적으로 켜져 있으며, notifyOnChangeProps: 'all'을 사용하여 이 기능을 끌 수 있습니다.

구조적 공유

React Query가 기본적으로 제공하는 또 다른 중요한 렌더링 최적화는 _구조적 공유_입니다. 이 기능은 모든 수준에서 data의 참조 동일성을 유지합니다. 예를 들어, 다음과 같은 데이터 구조가 있다고 가정해 봅시다:

[
  { "id": 1, "name": "React 배우기", "status": "active" },
  { "id": 2, "name": "React Query 배우기", "status": "todo" }
]
 
json

이제 첫 번째 할 일을 완료 상태로 변경하고 백그라운드에서 데이터를 다시 가져온다고 가정해 봅시다. 백엔드에서 완전히 새로운 JSON을 받게 됩니다:

[
  { "id": 1, "name": "React 배우기", "status": "done" },
  { "id": 2, "name": "React Query 배우기", "status": "todo" }
]
 
json

이때 React Query는 이전 상태와 새 상태를 비교하여 가능한 한 많은 이전 상태를 유지하려고 시도합니다. 이 예에서는 할 일을 업데이트했기 때문에 할 일 배열은 새로워집니다. id가 1인 객체도 새로워지지만, id가 2인 객체는 이전 상태와 동일한 참조를 유지합니다. React Query는 이 객체에 변경 사항이 없기 때문에 새 결과로 그대로 복사합니다.

이는 부분 구독을 위한 선택기를 사용할 때 매우 유용합니다:

// ✅ id가 2인 할 일 내에서 _무언가_ 변경된 경우에만 다시 렌더링됩니다
// 구조적 공유 덕분입니다
const { data } = useTodo(2)
 
javascript

앞서 언급했듯이, 선택기의 경우 구조적 공유가 두 번 수행됩니다. 한 번은 queryFn에서 반환된 결과에 대해 수행되어 무언가 변경되었는지 확인하고, 다시 한 번 선택기 함수의 _결과_에 대해 수행됩니다. 특히 매우 큰 데이터셋을 다룰 때는 구조적 공유가 병목 현상이 될 수 있습니다. 또한 JSON으로 직렬화 가능한 데이터에서만 작동합니다. 이 최적화가 필요하지 않다면 쿼리에서 structuralSharing: false를 설정하여 끌 수 있습니다.

더 자세한 내용을 알고 싶다면 replaceEqualDeep 테스트를 살펴보세요. 이를 통해 내부적으로 어떤 일이 일어나는지 알 수 있습니다.