[번역] 실용적인 React Query

January·2023년 8월 14일
1

번역

목록 보기
3/3

GraphQL 및 특히 Apollo Client가 2018년경에 인기를 얻으면서 Redux를 완전히 대체할 것이라는 많은 소란이 있었고 "Redux는 이미 죽었나요?"라는 질문이 자주 던져졌습니다.

나는 명확하게 그게 무슨 뜻인지 이해하지 못했습니다. 왜 어떤 데이터 페칭 라이브러리가 글로벌 상태 관리자를 대체해야 하며, 무슨 관련이 있는 걸까요?

GraphQL 클라이언트인 Apollo 같은 거들이 단순히 데이터를 가져온다고 생각했습니다. REST의 axios와 유사하게 데이터를 가져오며, 이 데이터를 애플리케이션에서 사용 가능하게 만드는 방법이 여전히 필요할 것이라고 생각했습니다.

하지만 이게 정말로 틀렸던 것 같습니다.

Client State vs. Server State

Apollo가 제공하는 것은 단순히 어떤 데이터를 원하는지 설명하고 그 데이터를 가져오는 능력만이 아닙니다. 이는 서버 데이터를 위한 캐시와 함께 제공됩니다. 이것은 동일한 useQuery 훅을 여러 컴포넌트에서 사용할 수 있게 해줍니다. 데이터를 한 번만 가져온 다음 캐시에서 데이터를 반환합니다.

이것은 우리와 아마도 많은 다른 팀들이 주로 redux를 사용해왔던 것과 매우 유사합니다. 서버에서 데이터를 가져와서 어디서든 사용할 수 있게 만드는 것입니다.

그래서 우리는 항상 이 서버 상태를 다른 클라이언트 상태와 같이 다뤄왔던 것 같습니다. 다만 서버 상태(예: 가져온 기사 목록, 표시하려는 사용자의 세부 정보 등)에 대해서는 우리 앱이 그것을 소유하지 않습니다. 우리는 화면에 사용자에게 가장 최신 버전을 표시하기 위해 빌려왔을 뿐입니다. 데이터를 소유한 것은 서버입니다.

이것은 데이터를 생각하는 방식에서 패러다임 전환을 도입했습니다. 우리가 캐시를 활용하여 소유하지 않는 데이터를 표시할 수 있다면, 실제 클라이언트 상태는 전체 앱에 사용 가능해야 하는 것보다 그리 많이 남아있지 않습니다. 그것은 왜 많은 사람들이 Apollo가 많은 경우에 redux를 대체할 수 있다고 생각하는지를 이해하게 해주었습니다.

React Query

저희는 기존 REST API를 사용하고 있으며, 오버 패칭과 관련된 문제가 크게 발생하지 않으며 잘 작동하는 상태입니다. 명확하게 말하자면, 변경을 할 필요가 없을 정도로 충분한 만족스러운 상황입니다. 더구나 백엔드를 적응시켜야 한다는 점을 감안하면 전환이 필요한 만큼 문제가 되지 않습니다. 특히 백엔드를 변경하는 일이 그리 간단하지 않은 점을 고려해야 합니다.

하지만 여전히 프론트엔드에서 데이터 페칭이 얼마나 간편하게 이루어지는지, 그리고 로딩 및 오류 상태를 다루는 간결함을 부러워했습니다. 만약 REST API에도 이와 유사한 방식이 있다면 얼마나 좋을까요...

그래서 등장한 것이 바로 React Query입니다.

React Query는 2019년 말에 오픈소스 개발자 Tanner Linsley가 개발한 라이브러리로, Apollo의 좋은 부분을 가져와서 REST에 적용한 것입니다. 이는 Promise를 반환하는 모든 함수와 작동하며, stale-while-revalidate 캐싱 전략을 채택합니다. 이 라이브러리는 합리적인 기본값을 사용하여 데이터를 가능한 한 최신 상태로 유지하려고 하면서 동시에 사용자에게 데이터를 최대한 빨리 보여주어 때로는 거의 즉시 반응하는 것처럼 느껴지며 훌륭한 사용자 경험을 제공합니다. 또한 매우 유연하며, 기본값만으로는 충분하지 않을 때 다양한 설정을 사용자 정의할 수 있습니다.

하지만 이 글은 React Query의 소개글은 아닙니다.

제 생각에 문서는 가이드 및 컨셉을 설명하는 데 훌륭합니다. 다양한 강연 동영상을 시청할 수 있으며, React Query Essentials 코스를 통해 라이브러리에 익숙해질 수 있습니다.

저는 문서를 넘어서 라이브러리를 이미 사용하고 있는 경우에 유용할 수 있는 몇 가지 실용적인 팁에 더 초점을 맞추려고 합니다. 이러한 내용들은 제가 최근 몇 달 동안 업무에서 라이브러리를 적극적으로 사용하면서 얻은 것들입니다. 또한 React Query 커뮤니티에 참여하여 Discord와 GitHub 토론에서 질문에 답하는 등의 경험도 있습니다.

The Defaults explained

저는 React Query의 기본 설정이 매우 잘 선택되었다고 믿지만, 특히 처음 사용할 때 가끔 당황스러울 수 있습니다.

먼저, React Query는 기본적으로 staleTime이 0이더라도 queryFn을 매번 재렌더링할 때마다 호출하지 않습니다. 앱은 언제든지 다양한 이유로 재렌더링될 수 있으므로 매번 데이터를 가져오는 것은 무리입니다!

항상 재렌더링을 고려하여 코드를 작성하고, 그리고 많은 재렌더링을 염두에 두세요. 이를 "렌더 강도"라고 부르는 것을 좋아합니다.
-Tanner Linsley-

만약 예상하지 않은 다시 불러오기(refetch)가 보인다면, 아마도 방금 창에 포커스를 맞추었을 때 React Query가 refetchOnWindowFocus를 수행하고 있는 것일 것입니다. 이것은 프로덕션에서 훌륭한 기능입니다: 사용자가 다른 브라우저 탭으로 이동한 다음 다시 앱으로 돌아오면, 백그라운드에서 자동으로 다시 불러오기가 트리거되고, 그동안 서버에서 무언가가 변경되었다면 화면의 데이터가 자동으로 업데이트될 것입니다. 이 모든 과정은 로딩 스피너를 표시하지 않고 발생하며, 캐시에 현재와 동일한 데이터가 있는 경우 컴포넌트가 다시 렌더링되지 않을 것입니다.

개발 중에는 특히 브라우저 개발 도구와 앱 간의 포커스를 맞추는 것도 불러오기를 자주 발생시킬 것입니다. 그러므로 이 점을 유념하시기 바랍니다.

두 번째로, cacheTimestaleTime 사이에 약간의 혼란이 있는 것 같습니다. 이에 대해 명확하게 설명해보겠습니다.

  • staleTime : 쿼리가 최신 상태에서 더 이상 최신이 아닌 상태로 전환하는 기간을 나타냅니다. 쿼리가 최신 상태인 동안에는 데이터는 항상 캐시에서만 읽히며 네트워크 요청은 발생하지 않습니다! 쿼리가 최신 상태가 되지 않은 경우 (기본적값은 즉시), 여전히 데이터는 캐시에서 가져오게 되지만 일부 조건 하에서는 백그라운드 리페치가 발생할 수 있습니다.
  • cacheTime : 비활성 상태의 쿼리가 캐시에서 제거되는 기간입니다. 기본값은 5분입니다. 쿼리는 모든 해당 컴포넌트가 언마운트되어 관찰자가 없는 상태가 되는 즉시 비활성 상태로 전환됩니다.

대부분의 경우, 이 설정 중 하나를 변경하려면 staleTime을 조정하는 경우가 대부분입니다. cacheTime을 조작해야 하는 일은 거의 없었습니다. 문서에는 예시를 통한 잘 설명된 내용이 있습니다.

Use the React Query DevTools

이는 쿼리의 상태를 이해하는 데 매우 도움이 될 것입니다. DevTools는 현재 캐시에 있는 데이터도 표시해주므로 디버깅을 더 쉽게 할 수 있습니다. 또한, 브라우저 DevTools에서 네트워크 연결을 제한하는 것이 도움이 될 수 있습니다. 이렇게 하면 백그라운드 리페치를 더 잘 인식할 수 있습니다. 개발 서버는 일반적으로 매우 빠르기 때문입니다.

Treat the query key like a dependency array

여기서 말하는 것은 useEffect 훅의 의존성 배열을 말하는 것이며, 아마도 익숙하실 것으로 생각합니다.

왜 이 두 가지가 비슷한 것일까요?

왜냐하면 React Query는 쿼리 키가 변경될 때마다 리페치를 트리거합니다. 따라서 queryFn에 변수 매개변수를 전달할 때 대부분 해당 값이 변경될 때 데이터를 가져오려고 합니다. 수동으로 리페치를 트리거하기 위해 복잡한 효과를 조율하는 대신, 우리는 쿼리 키를 활용할 수 있습니다.

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({ queryKey: ['todos', state], queryFn: () => fetchTodos(state) })

여기에서 우리의 UI가 할일 목록과 필터 옵션을 표시한다고 상상해보세요. 우리는 해당 필터링을 저장하기 위한 로컬 상태가 있을 것이며, 사용자가 선택을 변경하면 즉시 로컬 상태를 업데이트하고 React Query가 자동으로 리페치를 트리거할 것입니다. 왜냐하면 쿼리 키가 변경되기 때문입니다. 따라서 사용자의 필터 선택을 쿼리 함수와 동기화하게 되며, 이는 useEffect의 의존성 배열이 무엇을 나타내는지와 매우 유사합니다. 저는 queryFnqueryKey의 일부가 아닌 변수를 전달한 적이 없는 것 같습니다.

A new cache entry

쿼리 키는 캐시의 키로 사용되기 때문에 'all'에서 'done'으로 전환할 때 새로운 캐시 엔트리가 생성되며, 이로 인해 처음으로 전환할 때 하드 로딩 상태(아마 로딩 스피너를 표시)가 발생합니다. 이것은 확실히 이상적이지 않기 때문에 이러한 경우에는 keepPreviousData 옵션을 사용하거나 가능한 경우 초기 데이터로 새로 생성된 캐시 엔트리를 미리 채울 수 있습니다. 위의 예제는 이에 적합합니다. 왜냐하면 우리는 할일 목록에 클라이언트 측 사전 필터링을 수행할 수 있기 때문입니다.

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({
    queryKey: ['todos', state],
    queryFn: () => fetchTodos(state),
    initialData: () => {
      const allTodos = queryClient.getQueryData<Todos>(['todos', 'all'])
      const filteredData =
        allTodos?.filter((todo) => todo.state === state) ?? []

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

이제 사용자가 상태를 전환할 때마다 데이터가 없으면 all Todos 캐시에서 데이터를 미리 채우려고 시도합니다. 우리는 사용자에게 가진 '완료된 할일'을 즉시 보여줄 수 있으며, 배경에서 데이터를 가져오는 작업이 완료되면 업데이트된 목록을 여전히 사용자가 볼 수 있을 것입니다. 이전 버전(v3 이전)에서는 백그라운드 페치를 실제로 트리거하기 위해 initialStale 속성도 설정해야 했음을 유의해주세요.

이것은 몇 줄의 코드로도 훌륭한 사용자 경험 개선이라고 생각합니다.

Keep server and client state separate

이는 내가 지난 달에 쓴 'props를 useState에 활용하다' 라는 글과 손을 잡아갑니다: useQuery로부터 데이터를 가져온다면, 그 데이터를 로컬 상태에 넣지 않는 것을 고려해보세요. 주된 이유는 로컬 상태에 데이터를 넣으면 React Query가 자동으로 수행하는 모든 백그라운드 업데이트에서 제외되기 때문입니다. 왜냐하면 상태 "복사본"이 함께 업데이트되지 않기 때문입니다.

이것은 예를 들어 양식의 기본값을 가져와서 데이터가 있는 상태에서 양식을 렌더링하려는 경우에 괜찮습니다. 백그라운드 업데이트가 매우 새로운 것을 가져올 가능성은 거의 없으며, 더구나 이미 양식이 초기화되었습니다. 따라서 의도적으로 그렇게 하는 경우에는 staleTime을 설정하여 불필요한 백그라운드 업데이트를 피하도록 해야합니다:

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

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

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

이 개념은 사용자가 편집할 수 있는 데이터를 표시할 때 조금 어려울 수 있지만 많은 장점이 있습니다. 작은 코드샌드박스 예제를 준비해봤습니다:

이 데모의 중요한 부분은 React Query에서 받은 값을 로컬 상태에 넣지 않는 것입니다. 이렇게 하면 항상 최신 데이터를 볼 수 있습니다. 왜냐하면 로컬에 "복사본"이 없기 때문입니다.

The enabled option is very powerful

useQuery 훅은 동작을 커스터마이즈하기 위해 전달할 수 있는 다양한 옵션을 가지고 있으며, enabled 옵션은 이 중에서 매우 강력한 하나로 다양한 멋진 작업을 할 수 있게 해줍니다. 다음은 이 옵션을 통해 우리가 달성할 수 있는 일부 예시입니다:

  • 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) 낙관적 업데이트나 뮤테이션 이후 백엔드에서 받은 데이터를 쓰기 위한 것으로만 사용해야 합니다. 모든 백그라운드 리페치는 해당 데이터를 덮어쓸 수 있으므로 로컬 상태 관리에 다른 방법을 사용하세요.

Create custom hooks

하나의 useQuery 호출을 감싸기 위해 생성하는 커스텀 훅은 일반적으로 이점이 있습니다. 왜냐하면:

  • 실제 데이터 가져오기를 UI 밖에 유지하면서 useQuery 호출과 함께 위치시킬 수 있습니다.
  • 하나의 쿼리 키 (그리고 잠재적으로 유형 정의)의 모든 사용법을 한 파일에 유지할 수 있습니다.
  • 일부 설정을 조정하거나 데이터 변환을 추가해야 할 경우 한 곳에서 처리할 수 있습니다.

위에서 보신 바와 같이 todos 쿼리에서 이미 그 예를 볼 수 있었습니다.

원글

기술 설명 문맥이 자연스럽기 위해 chat-GPT를 사용해 번역했습니다.

1개의 댓글

comment-user-thumbnail
2023년 8월 14일

감사합니다. 이런 정보를 나눠주셔서 좋아요.

답글 달기