React Query (1)

현진·2022년 9월 15일
17
post-thumbnail

TL;DR

이 글은 리액트 쿼리 maintainer TkDodo 블로그의 리액트 쿼리에 관한 16가지 글을 번역한 글입니다.

저는 프로젝트에서 리액트 쿼리를 사용하면서 다양한 옵션과 리액트 쿼리의 의도를 명확하게 파악하지 못한채 사용한다고 느꼈고, 공부를 하다가 TkDodo의 블로그에 있는 리액트 쿼리에 관한 16가지 글을 발견하였고 하나씩 읽어보면서 번역 및 정리한 내용을 공유하고자 글을 쓰게 되었습니다. 번역이 어색할 수도 있고 문맥상 이해가 어려운 문장이 있을 수도 있지만 도움이 되었으면 좋겠습니다.

16개의 글을 4개의 블로그 포스팅으로 나누어서 작성하려고 합니다.

목차

Practical React Query

2018년에 GraphQL, 특히 Apollo Client가 유명해졌을 때, Redux를 완전히 대체하는 것에 대해 많은 소란이 있었고, "Redux는 죽었나요?"라는 질문이 많았습니다.

저는 이것이 무엇에 관한 것인지 이해하지 못한 것을 분명히 기억합니다. "왜 데이터 fetching 라이브러리가 Redux와 같은 전역 상태 관리자를 대체하는 것일까? 이 둘이 무슨 상관일까?"

저는 Apollo와 같은 GraphQL 클라이언트는 REST API에서의 axios와 같이 데이터만 가져올 것이라는 인상을 받았으며, 여전히 애플리케이션에서 해당 데이터에 엑세스 할 수 있는 방법이 필요하다고 생각했습니다.

저는 완전히 틀렸습니다.

Client State vs Server State

Apollo가 제공하는 것은 원하는 데이터를 설명할 수 있는 방법(어떤 데이터를 원하는지 선언)과 그 데이터를 가져오는 기능뿐만 아니라, 해당 서버 데이터에 대한 캐시도 함께 제공됩니다. 이 의미는 여러 컴포넌트에서 같은 useQuery라는 훅을 썼을 때 데이터를 한 번만 가져온 다음 캐시에서 반환한다는 뜻입니다.

이것은 우리가 주로 Redux를 사용하는 용도와 매우 유사하게 들립니다. 우리가 Redxu를 사용하는 용도는 다음과 같습니다.

서버로 부터 데이터를 가져와 전역에서 사용가능하게 한다.

따라서 우리는 항상 이 서버 상태를 클라이언트 상태처럼 취급해왔습니다. 애플리케이션은 서버 상태를 소유하지 않습니다. 애플리케이션은 단지 화면에 최신 버전의 데이터를 표시하기위해 데이터를 빌린 것입니다. 데이터를 소유하는 것은 서버입니다.

저에게 위 사실은 데이터에 대해 생각하는 방식의 패러다임을 바꿔놓았습니다. 캐시를 활용하여 우리가 소유하지 않는 데이터를 표시할 수 있다면 전체 앱에서 사용할 수 있어야 하는 실제 클라이언트 상태가 별로 남지 않습니다.(캐시를 활용해 기존 전역 상태를 제거합니다.) 이 개념은 저에게 Apollo가 Redux를 대체할 수 있다고 생각하게 한 이유를 이해하게 되었습니다.

React Query

저는 GraphQL을 사용해보지 않았습니다. 우리는 기존 REST API를 잘 사용하고 있으며, over-fetching과 같은 문제를 많이 경험하지 않습니다. 분명히, 우리가 GraphQL로 전환하기에 충분한 pain point가 없습니다. 특히 GraphQL을 사용하면 백엔드도 변경해야 하기 때문에 전환하기 쉽지 않습니다.

저는 로드 및 오류 상태를 포함하여 프론트엔드에서 데이터 가져오기가 단순해지는 것이 부러웠습니다. React에서 REST API를 사용할 때도 비슷한 것이 있었다면...

그게 바로 React Query입니다.

React Query는 Tanner Linsley에 의해 2019년에 만들어졌으며, React Query는 Apollo의 좋은 부분을 REST로 가져옵니다. React Query는 Promise를 반환하고 stale-while-revalidate 캐싱 전략을 수용하는 모든 함수와 함께 작동합니다.

이 라이브러리는 기본 설정 값으로도 데이터를 가능한 한 최신 상태로 유지하는 동시에 가능한 한 빨리 사용자에게 데이터를 표시하여 훌룡한 UX를 제공합니다. 또한 매우 유연하며 기본값이 충분하지 않을 때 다양한 설정을 사용자가 지정할 수 있습니다.

이 글은 React Query에 대한 소개가 아닙니다. 저는 공식문서가 Guides & Concept을 설명하는데 훌륭하다고 생각합니다. 다양한 Talk에서 볼 수 있는 비디오가 있으며 Tanner에는 라이브러리에 익숙해지고 싶다면 들을 수 있는 React Query Essentails 코스가 있습니다.

저는 문서 넘어에 있는 더 실용적인 팁들에 집중하고 싶습니다. 이 팁들은 제가 회사에서 라이브러리를 사용했을 때 뿐만 아니라 Discord 및 Github 토론에서 질문에 답변하면서 React Query 커뮤니티에 참여했을 때 지난 몇달 동안 수집한 것들 입니다.

The Defaults explained

저는 React Query의 기본값들이 매우 잘 선택되었다고 생각하지만, 특히 처음에는 때때로 당신을 당황하게 할 수 있습니다.

우선, React Query는 기본 staleTime이 0인 경우에도 다시 렌더링할 때마다 queryFn을 호출하지 않습니다. 앱은 언제든지 다양한 이유로 다시 렌더링 될 수 있으므로 매번 가져오는 것은 미친 짓 입니다.

Always code for re-renders, and a lot of them. I like to call it render resiliency.(항상 리렌더링을 위한 코딩을 하세요. 저는 그것을 렌더링 탄력성이라고 부르고 싶습니다.) - Tanner Linsley

위 뜻을 해석해보면 렌더링 최적화를 고민하기 앞서서 렌더링이 많이 되더라도 일단 동작하는 코드를 작성하자는 의미인 것 같다.

예상하지 못한 refetch를 본다면, 당신이 방금 창에 초점을 맞추고 React Query가 refetchOnWindowFocus를 수행하고 있기 때문일 수 있습니다.(이 기능은 production에서는 훌륭한 기능입니다.) 사용자가 다른 브라우저 탭으로 이동했다가 앱으로 돌아오면 background refetch가 자동으로 실행되고, 그 동안 서버에서 변경 사항이 있으면 화면에 데이터가 업데이트됩니다.

이 모든 것은 로딩 스피너가 표시되지 않고 발생하며 데이터가 현재 캐시에 있는 것과 동일한 경우 컴포넌트가 리렌더링되지 않습니다.

개발중에는 refetch 현상이 더 빈번하게 발생하는데, DevTools와 애플리케이션을 왔다갔다하는 행위만으로도 refetch가 발생하기 때문입니다. 이 현상에 주의해야합니다.

다음으로, cacheTimestaleTime 사이에 약간의 혼동이 있는 것 같으므로 이를 정리해보겠습니다.

  • StaleTime: 쿼리가 최신 상태에서 신선하지 않은 상태로 전환될 때까지의 기간입니다. 쿼리가 최신 상태인한, 데이터는 항상 캐시에서만 읽힙니다. 네트워크 요청은 발생하지 않습니다. 쿼리가 오래된 경우(기본 값은 즉시 stale), 캐시에서 데이터를 계속 가져오지만 특정 조건에서 backgroud refetch가 발생할 수 있습니다.
  • CacheTime: 비활성화된 쿼리가 캐시에서 제거될 때까지의 기간입니다. 기본값은 5분입니다. 등록된 observer(관찰자)가 없는 쿼리는 즉시 비활성 상태로 전환되므로 해당 쿼리를 사용하는 모든 구성 요소가 마운트 해제됩니다.

대부분의 경우 이러한 설정 중 하나를 변경하려면 staleTime을 조정해야합니다. 저는 cacheTime을 조작할 필요가 거의 없었습니다. 공식 문서에도 cacheTime에 대한 설명이 자세히 나와있습니다.

Use the React Query DevTools

DevTools는 쿼리의 상태를 이해하는 데 큰 도움이 됩니다. 또한 DevTools는 현재 캐시에 있는 데이터를 알려주므로 디버깅 시간이 단축됩니다. 그 외에도 개발 서버는 일반적으로 빠르기 때문에 background refetch를 더 잘 인식하려면 브라우저 DevTools에서 네트워크 연결을 조절하는 것이 도움이 됩니다.

Treat the query key like a dependency array

저는 아마 여러분이 친숙할 useEffect 훅의 dependency array와 비슷한 query key와 관련해 말해보겠습니다.

왜 이 둘은 비슷할까요?

React Query는 쿼리 키가 변경될 때마다 다시 refetch를 수행하기 때문입니다. 따라서 queryFn에 변수 매개변수를 전달할 때 항상 해당 값이 변경될 때 fetch를 수행합니다. 수동으로 refetch를 트리거하기 위해 복잡한 효과를 조정하는 대신 쿼리키를 사용할 수 있습니다.

type State = "all" | "open" | "done"
type Todo = {
  id: number
  state: State
}
type Todos = ReadonlyArray<Todo>

const fetchTodos = async (state: State): Promise<Todos> => {
  const response = await axios.get(`todos/${state}`)
  return response.data
}

export const useTodosQuery = (state: State) =>
  useQuery(["todos", state], () => fetchTodos(state))

여기에서 우리의 UI가 필터 옵션과 함께 할 일 목록을 표시한다고 상상해보겠습니다. 해당 필터링을 저장할 로컬 상태가 있고 사용자가 선택을 변경하는 즉시 해당 로컬 상태를 업데이트하고 쿼리 키가 변경되기 때문에 React Query가 자동으로 refetch를 트리거합니다. 따라서 우리는 사용자의 필터 선택을 쿼리 함수와 동기화하도록 유지하고 있습니다. 이는 종속성 배열이 useEffect에 대해 나타내는 것과 매우 유사합니다.

A new cache entry

쿼리 키가 캐시의 키로 사용되기 때문에 'all'에서 'done'으로 전환하면 새 캐시 항목이 표시되며, 만약 상태를 처음 바꾼 것이라면 로딩 상태로 바뀔 것이다.(아마 로딩 스피너를 보여줌) 이것은 확실히 이상적이지 않으므로 이러한 경우에 keepPreviousData 옵션을 사용하거나 가능하면 새로 생성된 캐시 항목을 initialData로 미리 채울 수 있습니다. 위 예는 할 일에 대해 클라이언트 측 사전 필터링을 수행할 수 있기 때문에 완벽합니다.

// pre-filtering
type State = "all" | "open" | "done"
type Todo = {
  id: number
  state: State
}
type Todos = ReadonlyArray<Todo>

const fetchTodos = async (state: State): Promise<Todos> => {
  const response = await axios.get(`todos/${state}`)
  return response.data
}

export const useTodosQuery = (state: State) =>
  useQuery(["todos", state], () => fetchTodos(state), {
    initialData: () => {
      const allTodos = queryClient.getQueryData<Todos>(["todos", "all"])
      const filteredData =
        allTodos?.filter((todo) => todo.state === state) ?? []

      return filteredData.length > 0 ? filteredData : undefined
    },
  })

이제 사용자가 상태를 전환할 때마다 아직 데이터가 없으면 '모든 할일' 캐시의 데이터로 미리 채웁니다. 사용자에게 '완료된' 할 일을 즉시 표시할 수 있으며 백그라운드 fetching이 완료되면 업데이트된 목록이 계속 표시됩니다. React Query v3 이전에는 백그라운드 fetching을 트리거하기 위해 initialStale 속성도 설정해야합니다.

저는 이것이 단지 몇줄의 코드를 작성하는 것으로 훌륭한 UX 개선을 불러올 수 있다고 생각합니다.

Keep server and client state seperate

useQuery에서 데이터를 가져오는 경우 해당 데이터를 로컬 상태에 두지 마십시오.

이는 Form을 위한 기본 값들을 가져오고 그 기본값들로 Form을 렌더링할 때는 상관없습니다. 백그라운드 업데이트는 새로운 것을 생성할 가능성이 거의 없으며 그렇다 하더라도 당신의 Form은 이미 초기화되어있을 것 입니다. 따라서 의도적으로 그렇게 하는 경우 staleTime을 설정하여 불필요한 백그라운드 다시 가져오기를 실행하지 않도록 하십시오.

const App = () => {
  const { data } = useQuery('key', queryFn, { staleTime: Infinity })

  return data ? <MyForm initialData={data} /> : null
}

const MyForm = ({ initialData} ) => {
  const [data, setData] = React.useState(initialData)
  ...
}

이 컨셉은 사용자가 편집할 수 있도록 하려는 데이터를 표시할 때 수행하기가 조금 더 어렵지만 많은 이점이 있습니다.

The enabled option is very powerful

useQuery 훅에는 사용자가 정의할 수 있는 많은 옵션들이 있으며, enabled option은 쿼리의 실행 시점을 설정할 수 있게 해줍니다.

  • Dependent Queries(의존 쿼리): 한 쿼리에서 데이터를 가져오고 첫번째 쿼리에서 데이터를 성공적으로 얻은 후에만 두 번째 쿼리를 실행합니다.
  • Turn queries on and off: refetchInterval 덕분에 정기적으로 데이터를 폴링하는 쿼리가 하나 있지만 모달이 열려 있으면 화면 뒷면의 업데이트를 피하기 위해 일시적으로 중지할 수 있습니다.
  • Wait for user input: 쿼리 키에 일부 필터 기준이 있지만 사용자 필터를 적용하지 않는 한 비활성화하십시오.
  • Disable a query after some user input

Don't use the queryCache as a local state manager

queryCache(queryClient.setQueryData)를 변조하는 경우 optimistic 업데이트 또는 mutation 후 백엔드에서 받은 데이터를 쓰기 위한 것 입니다. 모든 백그라운드 refetch가 해당 데이터를 재정의 할 수 있으므로 로컬 상태에 대해 다른 것을 사용하십시오.

Create Custom Hooks

하나의 useQuery 호출을 래핑하기 위한 것일지라도 사용자 지정 훅을 만드는 것은 다음과 같은 이유로 효과가 있습니다.

  • 실제 데이터 fetching 로직을 ui 밖으로 뺄 수 있습니다.
  • 하나의 쿼리키의 모든 사용을 하나의 파일에 보관할 수 있습니다.(type 정의 포함)
  • 일부 설정을 조정하거나 데이터 변환을 추가해야하는 경우 한 곳에서 수행할 수 있습니다.

React Query Data Transformations

우리 대부분은 GraphQL을 사용하지 않습니다. 만약 사용하고 있다면 당신은 원하는 형식으로 데이터를 요청할 수 있기 때문에 행복할 것입니다.

하지만 REST로 작업하는 경우 백엔드가 반환하는 내용에 제약을 받습니다. 그렇다면 React Query
로 작업할 때 데이터를 가장 잘 변환하는 방법과 어디에서 변환을 해야할까요? 소프트 웨어 개발에서 가치가 있는 유일한 대답은 여기에도 적용됩니다.

It depends. - Every developer, always

다음은 각각의 장단점이 있는 데이터를 변환할 수 있는 위치에 대한 3 + 1 접근 방식입니다.

0. On the backend

제가 가장 좋아하는 방법입니다. 백엔드가 정확히 우리가 원하는 구조로 데이터를 반환하면 우리가 해야할 일은 없습니다. 많은 경우에 이것이 비현실적으로 들릴 수 있지만(예를 들어 public REST API) 엔터프라이즈 애플리케이션에서는 가능합니다. 백엔드를 제어하고 정확한 사용 사례에 대한 데이터를 반환하는 엔드포인트가 있는 경우 예상한 방식으로 데이터를 전달하는 것을 선호합니다.

  • 🟢 프론트엔드에서 일 안함.
  • 🔴 항상 가능하진 않음.

1. In the queryFn

queryFn은 useQuery에 전달하는 함수입니다. 이 함수는 여러분이 작성한 함수가 Promise를 반환할 것으로 예상하고 결과 데이터는 쿼리 캐시에 저장됩니다. 그러나 백엔드가 여기에서 제공하는 구조로 데이터를 절대적으로 반환해야 한다는 의미는 아닙니다. 그렇게 하기전에 변환할 수 있습니다.

const fetchTodos = async (): Promise<Todos> => {
  const response = await axios.get("todos")
  const data: Todos = response.data

  return data.map((todo) => todo.name.toUpperCase()) // 데이터 변경
}

export const useTodosQuery = () => useQuery(["todos"], fetchTodos)

프론트엔드에서는 이 데이터를 백엔드에서 원래 변형된 구조로 백엔드가 반환한 것처럼 작업할 수 있습니다. 이렇게 하면 여러분은 원래 백엔드가 반환한 데이터 구조에 접근할 수 없습니다. react-query-devtools를 보면 변형된 구조를 볼 수 있고, 네트워크 트레이스를 보면 원래 구조를 볼 수 있습니다.

또한 react-query가 여기서 수행할 수 있는 최적화는 없습니다. 가져오기가 실행될 때마다 변환이 실행됩니다. 만약 그 변환과정이 고비용 계산이라면 다른 대안 중 하나를 고려해야합니다. 일부 회사에서는 데이터 fetching을 추상화하는 공유 API 계층도 있으므로 변환을 수행하기 위해 이 계층에 엑세스하지 못할 수도 있습니다.

정리하면 다음과 같습니다.

  • 🟡 변환된 구조는 캐시에 저장되므로 원래 구조에 엑세스할 수 없습니다.
  • 🔴 모든 fetch 요청에 대해 변환을 수행합니다.
  • 🔴 자유롭게 수정할 수 없는 공유 API 레이어가 있는 경우 불가능

2. In the render function

const fetchTodos = async (): Promise<Todos> => {
  const response = await axios.get("todos")
  return response.data
}

export const useTodosQuery = () => {
  const queryInfo = useQuery(["todos"], fetchTodos)

  return {
    ...queryInfo,
    data: queryInfo.data?.map((todo) => todo.name.toUpperCase()),
  }
}

이것은 fetch가 실행될 때마다 실행될 뿐 아니라 실제로 모든 렌더링(데이터 가져오기를 포함하지 않는 렌더링 포함)에서 실행됩니다. 이것이 전혀 문제가 되지 않을 수 있지만 문제가 있는 경우 useMemo를 사용하여 최적화할 수 있습니다. 이때 종속성을 가능한 한 좁게 정의하도록 주의해야합니다. queryInfo 내부의 데이터는 실제로 변경되지 않는 한 참조적으로 안정적이지만 queryInfo 자체는 그렇지 않습니다. queryInfo를 종속성으로 추가하면 변환이 모든 렌더링에서 다시 실행됩니다.

export const useTodosQuery = () => {
  const queryInfo = useQuery(["todos"], fetchTodos)

  return {
    ...queryInfo,
    // 🚨 don't do this - the useMemo does nothing at all here!
    data: React.useMemo(
      () => queryInfo.data?.map((todo) => todo.name.toUpperCase()),
      [queryInfo]
    ),

    // ✅ correctly memoizes by queryInfo.data
    data: React.useMemo(
      () => queryInfo.data?.map((todo) => todo.name.toUpperCase()),
      [queryInfo.data]
    ),
  }
}

특히 사용자 정의 훅에 데이터 변환과 결합할 추가 로직이 있는 경우 이는 좋은 옵션입니다. 데이터는 잠재적으로 정의되지 않을 수 있으므로 작업할 때 optional chaining을 사용하십시오.

정리하면 다음과 같습니다.

  • 🟢 useMemo로 최적화 가능
  • 🟡 정확한 구조는 devtools에서 검사할 수 없음
  • 🔴 조금 더 복잡한 구문
  • 🔴 데이터는 잠재적으로 정의되지 않을 수 있음

3. using the select option

v3에서는 데이터 변환에도 사용할 수 있는 내장 선택기를 도입했습니다.

export const useTodosQuery = () =>
  useQuery(["todos"], fetchTodos, {
    select: (data) => data.map((todo) => todo.name.toUpperCase()),
  })

선택자는 데이터가 존재하는 경우에만 호출되므로 여기에서 undefined에 대해서 신경쓸 필요가 없습니다. 위와 같은 선택자는 함수의 identity가 변경되기 때문에 매 렌더링마다 실행됩니다.(인라인 함수와 비슷함.)

변환에 비용이 많이 든다면 useCallback을 사용하거나 안정적인 함수 참조로 추출하여 메모화할 수 있습니다.

// select-memoizations
const transformTodoNames = (data: Todos) =>
  data.map((todo) => todo.name.toUpperCase())

export const useTodosQuery = () =>
  useQuery(["todos"], fetchTodos, {
    // ✅ uses a stable function reference
    select: transformTodoNames,
  })

export const useTodosQuery = () =>
  useQuery(["todos"], fetchTodos, {
    // ✅ memoizes with useCallback
    select: React.useCallback(
      (data: Todos) => data.map((todo) => todo.name.toUpperCase()),
      []
    ),
  })

또한 선택 옵션을 사용하여 데이터의 일부만 구독할 수도 있습니다. 이것이 이 접근 방식을 독특하게 만드는 이유입니다. 다음 예를 고려해보겠습니다.

export const useTodosQuery = (select) =>
  useQuery(["todos"], fetchTodos, { select })

export const useTodosCount = () => useTodosQuery((data) => data.length)
export const useTodo = (id) =>
  useTodosQuery((data) => data.find((todo) => todo.id === id))

여기에서 사용자 지정 선택기를 useTodosQuery에 전달하여 useSelector와 비슷한 API를 만들었습니다. 사용자 지정 훅은 여전히 이전과 같이 작동합니다. select 함수를 전달하지 않으면 select가 정의되지 않아 전체 상태가 반환되기 때문입니다. 그러나 선택자 함수를 전달하면 이제 선택자 함수의 결과만 구독할 수 있습니다. 이것은 꽤나 큰 의미가 있는데, 우리가 todo의 이름을 업데이트하더라도 우리의 컴포넌트중 todo의 count에 대해서만 구독한 컴포넌트(useTodosCount 사용)는 리렌더링되지 않습니다. 개수는 변경되지 않으므로 react-query는 업데이트에 대해 이 관잘자에게 알리지 않도록 선택할 수 있습니다.(여기서 이것은 약간 단순화되었으며 기술적으로 완전히 사실이 아님을 유의하십시오. 렌더링 최적화에 대해서는 3부에서 자세히 설명합니다.)

  • 🟢 best optimizations
  • 🟢 부분 구독을 가능하게 해줌
  • 🟡 모든 관찰자마다 구조가 다를 수 있음
  • 🟡 구조적 공유는 두 번 수행됩니다.(3부에서 이에 대해 더 자세히 설명합니다.)

React Query Render Optimizations

React Query는 이미 매우 우수한 최적화와 기본설정을 제공하며 대부분의 경우 더 이상의 최적화가 필요하지 않습니다. 불필요한 리렌더링은 많은 사람들이 많은 초점을 맞추는 경향이 있는 주제입니다. 그것이 제가 그것을 다루기로 결정한 이유입니다. 그러나 다시 한 번 지적하고 싶습니다. 일반적으로 대부분의 앱에서 렌더링 최적화는 생각만큼 중요하지 않습니다. 앱을 리렌더링하는 것은 좋은 일입니다. 리렌더링은 앱이 최신 상태가 될 수 있도록 해줍니다. 저는 항상 "그곳에 꼭 있어야하는 누락된 렌더링"보다 "불필요한 렌더링" 택합니다. 이 토픽에 대한 내용은 다음 글을 읽어주세요.

isFetching transition

export const useTodosQuery = (select) =>
  useQuery(["todos"], fetchTodos, { select })
export const useTodosCount = () => useTodosQuery((data) => data.length)

function TodosCount() {
  const todosCount = useTodosCount()

  return <div>{todosCount.data}</div>
}

저는 이전 글에서 이 컴포넌트는 todos의 길이가 변경되는 경우에만 다시 렌더링 된다고 말했을 때 완전히 정직하지는 않았습니다. 백그라운드 refetch를 수행할 때마다 이 구성요소는 다음 쿼리 정보로 두 번 다시 렌더링 됩니다.

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

React Query는 각 쿼리에 대한 많은 메타 정보를 노출하고 isFetching이 그 중 하나이기 때문입니다. 이 플래그는 요청이 진행 중일 때 항상 true입니다. 이것은 백그라운드 로딩 표시기를 표시하려는 경우에 매우 유용합니다. 하지만 그렇게 하지 않는다면 그것도 좀 불필요합니다.

notifyOnChangeProps

React Query에는 notifyOnChangeProps 옵션이 존재합니다. React Query에 알리기 위해 관찰자별 수준에서 설정할 수 있습니다. 이러한 props 중 하나가 변경되는 경우에만 변경 사항에 대해 관찰자에게 알릴 수 있습니다. 이 옵션을 ['data']로 설정한다면 우리가 찾는 최적화된 버전을 찾을 수 있습니다.

export const useTodosQuery = (select, notifyOnChangeProps) =>
  useQuery(["todos"], fetchTodos, { select, notifyOnChangeProps })
export const useTodosCount = () =>
  useTodosQuery((data) => data.length, ["data"])

문서의 optimistic-updates-typescript 예제에서 작동하는 것을 볼 수 있습니다.

staying in sync

위 코드는 매우 잘 동작하지만 매우 쉽게 동기화되지 않을 수 있습니다. 오류에도 대응하고 싶다면 어떻게 해야 할까요? 아니면 isLoading 플래그를 사용하기 시작할까요? 우리는 컴포넌트에서 실제로 사용하는 필드와 동기화된 notifyOnChangeProps 목록을 유지해야합니다. 그렇게 하는 것을 잊어버리고 데이터 속성만 관찰했지만 표시되는 오류가 발생하면 구성 요소가 다시 렌더링되지 않으므로 최신 데이터를 갖지 않습니다. 훅은 구성요소가 실제로 무엇을 사용할지 모르기 때문에 사용자 정의 훅에서 이것을 하드 코딩하면 문제가 됩니다.

export const useTodosCount = () =>
  useTodosQuery((data) => data.length, ["data"])

function TodosCount() {
  // 🚨 we are using error, but we are not getting notified if error changes!
  const { error, data } = useTodosCount()

  return (
    <div>
      {error ? error : null}
      {data ? data : null}
    </div>
  )
}

제가 이 글의 첫 부분에서 언급했듯이, 저는 가끔 불필요하게 다시 렌더링하는 것보다 아예 렌더링 되지 않는 것을 훨씬 더 나쁘다고 생각합니다. 물론 옵션을 사용자 정의 훅으로 전달할 수 있지만, 여전히 수동적이고 boilerplate가 많습니다. 이 작업을 자동으로 수행할 수 있습니다.

Tracked Queries

이 기능이 제 첫 번째 주요 기여였다는 점을 감안할 때 매우 자랑스럽습니다. notifyOnChangeProps를 'tracked'로 설정하면 React Query는 렌더링 중에 사용 중인 필드를 추적하고 이를 사용하여 목록을 계산합니다. 이것은 목록에 대해 생각할 필요가 없다는 점을 제외하고는 목록을 수동으로 지정하는 것과 정확히 같은 방식으로 최적화됩니다.

이 기능을 모든 쿼리에 대해 전역적으로 켤 수도 있습니다.

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

이를 통해 다시 렌더링에 대해 생각할 필요가 없습니다. 물론 사용량을 추적하는 것도 약간의 오버헤드가 있으므로 현명하게 사용해야 합니다. 추적된 쿼리에는 몇 가지 제한 사항도 있습니다. 이것이 opt-in 기능인 이유입니다.

  • 만약 여러분이 객체를 비구조화할당 한다면, 여러분은 효율적으로 모든 필드를 관찰하고 있는 것 입니다. 일반적인 구조할당은 괜찮지만 다음과 같이는 하지마세요.
// 🚨 will track all fields
const { isLoading, ...queryInfo } = useQuery(...)

// ✅ this is totally fine
const { isLoading, data } = useQuery(...)
  • 추적된 쿼리는 "렌더링 중"에만 작동합니다. 아래와 같이 useEffect 중에 필드에 접근하면 추적되지 않습니다. Dependency Array에 넣어주어야 접근 가능합니다.
const queryInfo = useQuery(...)

// 🚨 will not corectly track data
React.useEffect(() => {
    console.log(queryInfo.data)
})

// ✅ fine because the dependency array is accessed during render
React.useEffect(() => {
    console.log(queryInfo.data)
}, [queryInfo.data])
  • 추적된 쿼리는 각 렌더링에서 리셋되지 않으므로 필드를 한 번 추적하면 관찰자의 수명 동안 추적하게 됩니다.
const queryInfo = useQuery(...)

if (someCondition()) {
    // 🟡 we will track the data field if someCondition was true in any previous render cycle
    return <div>{queryInfo.data}</div>
}

Structural sharing

React Query가 기본적으로 활성화한 렌더링 최적화는 구조적 공유입니다. 이 기능을 사용하면 모든 수준에서 데이터의 참조 정체성을 유지할 수 있습니다. 예를 들어 다음과 같은 데이터 구조가 있다고 가정해봅시다.

[
  { "id": 1, "name": "Learn React", "status": "active" },
  { "id": 2, "name": "Learn React Query", "status": "todo" }
]

이제 우리가 첫번째 Todo를 done 상태로 바꾸고 background refetch를 실행했다고 가정해보겠습니다. 그러면 우리는 백엔드로 부터 다음과 같은 새로운 json 데이터를 받을 것 입니다.

[
-  { "id": 1, "name": "Learn React", "status": "active" },
+  { "id": 1, "name": "Learn React", "status": "done" },
  { "id": 2, "name": "Learn React Query", "status": "todo" }
]

이제 React Query는 이전 상태와 새 상태를 비교하고 가능한 한 많은 이전 상태를 유지하려고 시도합니다. 우리의 예제에서는 todo를 업데이트 했기 때문에 todo배열이 새 것입니다.

id 1의 객체도 새 것이지만 id 2의 객체는 이전 상태의 객체와 동일한 참조가 됩니다. React Query는 아무 것도 변경되지 않았기 때문에 새 결과에 복사합니다.

이것은 구독 선택자를 사용할 때 매우 편리합니다.

// ✅ will only re-render if _something_ within todo with id:2 changes
// thanks to structural sharing
const { data } = useTodo(2)

이전에 암시했듯이 선택자의 경우 구조적 공유가 두번 수행됩니다. 한 번은 queryFn에서 반환된 결과에 대해 변경되어 변경된 사항이 있는지 확인한 다음 선택자 함수의 결과에 대해 한 번 더 수행됩니다. 매우 큰 데이터셋이 있는 경우 구조적 공유가 병목 현상이 될 수 있습니다. 또한 json 직렬화가 가능한 데이터에서만 작동합니다.

이 최적화가 필요하지 않으면 모든 쿼리에서 structureSharing: false를 설정하여 끌 수 있습니다.

내부에서 일어나는 일에 대해 자세히 알고 싶다면 replaceEqaulDeep 테스트를 살펴보세요.

Status Checks in React Query

React Query의 한가지 장점은 쿼리의 상태 필드에 쉽게 엑세스 할 수 있다는 것 입니다. 쿼리가 로드 중인지 아니면 오류가 있는지 즉시 알 수 있습니다. 이를 위해 라이브러리는 대부분 내부 상태 시스템에서 파생된 많은 boolean 플래그를 노출합니다. 타입을 살펴보면 쿼리가 다음 상태 중 하나일 수 있습니다.

  • success: 쿼리가 성공했으며 이에 대한 데이터가 있습니다.
  • error: 쿼리가 작동하지 않았으며 오류가 설정되었습니다.
  • loading: 쿼리에 데이터가 없으며 현재 처음으로 로드 중 입니다.
  • idle: 쿼리가 활성화되지 않았기 때문에 실행된 적이 없습니다.

Updated: React Query v4에서 idle 상태는 사라졌습니다.

isFetching 플래그는 내부 상태 머신의 일부가 아닙니다. 요청이 진행 중일 때마다 true가 되는 추가 플래그 입니다. fetching하고 success가 될 수도 있고 fetching하고 error상태가 될 수도 있습니다. 하지만 loading과 success를 동시에 수행할 수는 없습니다. 상태 머신은 이를 확인합니다.

The standard example

const todos = useTodos()

if (todos.isLoading) {
  return "Loading..."
}
if (todos.error) {
  return "An error has occurred: " + todos.error.message
}

return <div>{todos.data.map(renderTodo)}</div>

여기에서는 먼저 로드 및 오류를 확인한 다음 데이터를 표시합니다. 이것은 일부 사용 사례에서는 괜찮지만 다른 경우에는 그렇지 않을 수 있습니다. 많은 데이터 가져오기 솔루션, 특히 직접 만든 솔루션에는 refetch 메커니즘이 없거나 명시적인 사용자 상호 작용 시에만 refetch 합니다.

하지만 React Query는 가능합니다.

기본적으로 매우 적극적으로 refetch하며 사용자가 refetch 요청을 하지 않고 수행합니다. refetchOnMount, refetchOnWindowFocus, refetchOnReconnect의 개념들은 데이터를 정확하게 유지하는데 유용하지만 이러한 자동 refetch가 실패하면 혼란스러운 ux가 발생할 수 있습니다.

Background errors

많은 상황에서 백그라운드 다시 가져오기가 실패하면 자동으로 무시될 수 있습니다. 그러나 위의 코드는 그렇게 하지 않습니다. 두 가지 예를 살펴보겠습니다.

  • 사용자가 페이지를 열고 초기 쿼리가 성공적으로 로드됩니다. 사용자는 한동안 페이지에서 작업하다가 브라우저 탭을 전환하여 이메일을 확인합니다. 몇분 후 다시 돌아오고 React Query는 background refetch를 수행합니다. 이 때 fetch가 실패합니다.
  • 사용자는 목록 보기가 있는 페이지에 있으며 하나의 항목을 클릭하여 세부 정보 보기로 들어갑니다. 이것은 잘 작동하므로 목록 보기로 돌아갑니다. 다시 세부 정보로 이동하면 캐시의 데이터가 표시됩니다. 이는 background refetch가 실패하는 경우를 제외하고는 훌륭합니다.

두 가지 상황 모두에서 쿼리는 다음과 같은 상태가 됩니다.

{
  "status": "error",
  "error": { "message": "Something went wrong" },
  "data": [{ ... }]
}

보시다시피 오류와 오래된 데이터를 모두 사용할 수 있습니다. 이것이 React Query를 훌륭하게 만드는 것 입니다. React Query는 stale-whire-revalidate 캐싱 메커니즘을 수용합니다. 즉, 데이터가 존재하는 경우 데이터가 오래된 경우에도 항상 데이터를 제공합니다.

이제 우리가 무엇을 표시할지 결정하는 것은 우리에게 달려 있습니다. 오류를 보여주는게 중요합니까? 오래된 데이터가 있는 경우에만 표시하는 것으로 충분합니까? 아니면 background 오류 표시기를 사용해 둘 다 표시해야합니까?

이 질문에 대한 명확한 답은 없습니다. 단지 사용자의 사용 사례에 따라 다릅니다. 그러나 위의 두가지 예를 고려할 때 데이터가 오류 화면으로 대체된다면 다소 혼란스러운 사용자 경험이 될 것이라고 생각합니다.

이것은 React Query가 쿼리가 실패했을 때 기본적으로 세번 시도하는 것과 관련있습니다. 따라서 stale한 데이터가 error screen으로 대체되는데는 몇 초가 걸릴 수 있습니다. 또한 background에서 fetching이 일어나는 것을 표시해주지 않는다면 당혹스러울 수 있습니다.

이것이 제가 일반적으로 데이터 가용성을 먼저 확인하는 이유입니다.

const todos = useTodos()

if (todos.data) {
  return <div>{todos.data.map(renderTodo)}</div>
}
if (todos.error) {
  return "An error has occurred: " + todos.error.message
}

return "Loading..."

다시 말하지만, 유스케이스에 크게 의존하기 때문에 무엇이 옳은지에 대한 명확한 원칙은 없습니다. 모든 분들이 공격적인 리패칭이 가져오는 결과를 알고 있어야하며 그에 따라 코드를 구성해야합니다.

느낀점

네가지 주제에 대해 한국어로 번역해가면서 읽으며 이해가되지 않은 부분도 있었지만 필자가 React Query의 어떤 컨셉을 전달하려고 하는지 명확히 이해되었다. 확실히 도움이 되긴 했는데 몇번 다시 읽어보고 코드에 활용해봐야할 것 같다.

0개의 댓글