🔥 React Query의 실용적인 활용

1624자
22분

2018년경 GraphQL과 특히 Apollo Client가 인기를 얻으면서, 이들이 Redux를 완전히 대체할 것이라는 이야기가 많았습니다. Redux는 아직 죽지 않았나요?라는 질문이 자주 등장했죠.

당시에 저는 이런 논란을 이해하지 못했습니다. 데이터 가져오기 라이브러리가 어떻게 전역 상태 관리자를 대체할 수 있을까요? 둘은 서로 무슨 관계가 있는 걸까요?

저는 Apollo 같은 GraphQL 클라이언트가 단순히 REST API를 위한 axios처럼 데이터만 가져올 뿐이라고 생각했습니다. 그리고 여전히 그 데이터를 애플리케이션 전체에서 사용할 수 있게 만드는 방법이 필요할 거라고 여겼죠.

하지만 제 생각은 완전히 틀렸습니다.

클라이언트 상태 vs 서버 상태

Apollo가 제공하는 것은 단순히 원하는 데이터를 설명하고 가져오는 기능만이 아닙니다. 서버 데이터를 위한 캐시도 함께 제공합니다. 이는 여러 컴포넌트에서 동일한 useQuery 훅을 사용할 수 있고, 데이터는 한 번만 가져온 뒤 이후에는 캐시에서 반환한다는 뜻입니다.

이 개념은 우리 팀을 포함해 많은 팀들이 주로 redux를 사용해 온 방식과 매우 유사합니다. 서버에서 데이터를 가져와 어디서든 사용할 수 있게 만드는 것이죠.

그렇다면 우리는 이 서버 상태를 다른 클라이언트 상태와 같은 방식으로 다뤄왔다는 걸 알 수 있습니다. 하지만 서버 상태(예를 들어 가져온 글 목록이나 표시하려는 사용자 상세 정보 등)는 애플리케이션이 소유하지 않습니다. 우리는 단지 사용자에게 가장 최신 버전을 화면에 표시하기 위해 잠시 빌려온 것일 뿐입니다. 실제 데이터의 주인은 서버입니다.

이는 제게 데이터를 바라보는 관점의 큰 변화를 가져왔습니다. 우리가 소유하지 않은 데이터를 표시하기 위해 캐시를 활용할 수 있다면, 전체 앱에서 사용해야 하는 진정한 클라이언트 상태는 그리 많지 않을 것입니다. 이로 인해 많은 사람들이 Apollo가 여러 경우에 Redux를 대체할 수 있다고 생각하는 이유를 이해하게 되었습니다.

React Query

저는 GraphQL을 사용해 볼 기회가 없었습니다. 우리는 기존의 REST API를 사용하고 있고, 과도한 데이터 가져오기 문제도 없으며, 잘 작동하고 있습니다. 백엔드도 함께 수정해야 하는 등의 복잡한 과정을 감안하면, 전환을 위한 충분한 이유가 없었죠.

그럼에도 불구하고 저는 GraphQL과 Apollo Client가 제공하는 프론트엔드 데이터 처리 방식을 부러워했습니다. 특히 데이터 가져오기의 단순함, 로딩 상태와 에러 상태 처리의 용이성이 눈길을 끌었죠. REST API를 사용하는 React 환경에서도 이와 같은 편리한 기능을 사용할 수 있다면 좋겠다고 생각했습니다.

그리고 React Query가 등장했습니다.

2019년 말, 오픈 소스 개발자 Tanner Linsley가 만든 React Query는 Apollo의 장점을 가져와 REST에 적용했습니다. Promise를 반환하는 어떤 함수와도 작동하며, stale-while-revalidate 캐싱 전략을 채택했습니다. 이 라이브러리는 합리적인 기본 설정을 통해 데이터를 최신 상태로 유지하면서도 사용자에게 가능한 빨리 데이터를 보여주려고 노력합니다. 때로는 거의 즉각적으로 느껴질 정도로 빠르게 작동하여 뛰어난 사용자 경험을 제공합니다. 또한 매우 유연하여 기본 설정으로 충분하지 않을 때 다양한 설정을 커스터마이즈할 수 있습니다.

하지만 이 글에서 React Query를 소개하지는 않겠습니다.

React Query의 문서는 가이드와 개념을 설명하는 데 훌륭합니다. 다양한 강연 동영상을 볼 수 있고, Tanner가 만든 React Query 필수 과정을 통해 라이브러리에 익숙해질 수 있습니다.

저는 문서를 넘어서는 실용적인 팁에 더 초점을 맞추고 싶습니다. 이 팁들은 이미 라이브러리를 사용하고 있을 때 유용할 수 있습니다. 이는 제가 지난 몇 달 동안 직장에서 라이브러리를 적극적으로 사용하면서 얻은 것들이며, React Query 커뮤니티에 참여하여 Discord와 GitHub Discussions에서 질문에 답변하면서 알게 된 내용들입니다.

기본 설정 설명

React Query의 기본 설정은 매우 잘 선택되었다고 생각합니다. 하지만 특히 처음에는 때때로 예상치 못한 상황에 빠질 수 있습니다.

우선, React Query는 기본 staleTime이 0이더라도 모든 리렌더링마다 queryFn을 호출하지 않습니다. 앱은 다양한 이유로 언제든 리렌더링될 수 있으므로, 매번 데이터를 가져오는 것은 말이 되지 않습니다!

항상 리렌더링을 고려하여 코딩하세요, 그것도 많이요. 저는 이를 '렌더링 회복력'이라고 부릅니다.

— Tanner Linsley

예상치 못한 리페치를 보게 된다면, 아마도 창에 포커스를 맞췄기 때문일 것입니다. React Query는 refetchOnWindowFocus를 수행하는데, 이는 프로덕션 환경에서 매우 유용한 기능입니다. 사용자가 다른 브라우저 탭으로 이동했다가 다시 앱으로 돌아오면 자동으로 백그라운드 리페치가 트리거되어 서버에서 데이터가 변경되었다면 화면의 데이터가 업데이트됩니다. 이 모든 과정은 로딩 스피너 없이 이루어지며, 캐시의 데이터와 동일하다면 컴포넌트는 리렌더링되지 않습니다.

개발 중에는 이 기능이 더 자주 트리거될 수 있습니다. 특히 브라우저 개발자 도구와 앱 사이를 오가며 포커스를 변경할 때마다 페치가 발생할 수 있으므로 이 점을 유의해야 합니다.

업데이트
React Query v5부터 refetchOnWindowFocus는 더 이상 focus 이벤트를 수신하지 않고 visibilitychange 이벤트만을 사용합니다. 이로 인해 개발 모드에서 불필요한 리페치가 줄어들면서도 대부분의 프로덕션 사례에 대한 트리거는 유지됩니다. 또한 여기에서 볼 수 있듯이 여러 문제를 해결합니다.

둘째, gcTimestaleTime 사이에 약간의 혼란이 있는 것 같습니다. 이를 명확히 해보겠습니다:

  • staleTime: 쿼리가 신선한 상태에서 오래된 상태로 전환되는 기간입니다. 쿼리가 신선한 동안에는 항상 캐시에서만 데이터를 읽습니다 - 네트워크 요청은 발생하지 않습니다! 쿼리가 오래된 상태(기본적으로는 즉시)라면 여전히 캐시에서 데이터를 가져오지만, 특정 조건에서 백그라운드 리페치가 발생할 수 있습니다.
  • gcTime: 비활성 쿼리가 캐시에서 제거되기까지의 기간입니다. 기본값은 5분입니다. 쿼리는 등록된 관찰자가 없을 때, 즉 해당 쿼리를 사용하는 모든 컴포넌트가 언마운트되었을 때 비활성 상태로 전환됩니다.

대부분의 경우, 이 설정들 중 하나를 변경하고 싶다면 staleTime을 조정하는 것입니다. gcTime을 건드릴 필요는 거의 없습니다. 문서에도 예시를 통한 좋은 설명이 있습니다.

업데이트
gcTime은 이전에 cacheTime으로 알려졌지만, v5에서 그 기능을 더 잘 반영하도록 이름이 변경되었습니다.

React Query DevTools 사용하기

이는 쿼리의 상태를 이해하는 데 크게 도움이 될 것입니다. DevTools는 현재 캐시에 있는 데이터도 알려주므로 디버깅이 더 쉬워집니다. 또한 백그라운드 리페치를 더 잘 인식하려면 브라우저 개발자 도구에서 네트워크 연결을 스로틀링하는 것이 도움이 된다는 것을 알게 되었습니다. 개발 서버는 보통 꽤 빠르기 때문입니다.

쿼리 키를 의존성 배열처럼 다루기

여기서 저는 여러분이 익숙할 것이라 가정하는 useEffect 훅의 의존성 배열을 언급하고 있습니다.

이 둘이 왜 비슷할까요?

React Query는 쿼리 키가 변경될 때마다 리페치를 트리거하기 때문입니다. 따라서 queryFn에 변수 매개변수를 전달할 때, 우리는 거의 항상 그 값이 변경될 때 데이터를 가져오고 싶어 합니다. 복잡한 효과를 조율하여 수동으로 리페치를 트리거하는 대신, 쿼리 키를 활용할 수 있습니다:

feature/todos/queries.ts

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),
  })
 
typescript

여기서 우리 UI가 필터 옵션과 함께 할 일 목록을 표시한다고 상상해보세요. 그 필터링을 위한 로컬 상태가 있을 것이고, 사용자가 선택을 변경하자마자 우리는 그 로컬 상태를 업데이트할 것입니다. 그러면 React Query가 쿼리 키가 변경되었기 때문에 자동으로 리페치를 트리거할 것입니다. 이렇게 우리는 사용자의 필터 선택을 쿼리 함수와 동기화하고 있는데, 이는 useEffect의 의존성 배열과 매우 유사합니다. 저는 queryFn에 전달하는 변수가 queryKey의 일부가 아닌 경우를 거의 본 적이 없습니다.

새로운 캐시 항목

쿼리 키는 캐시의 키로 사용되기 때문에, 'all'에서 'done'으로 전환할 때 새로운 캐시 항목을 얻게 됩니다. 이로 인해 처음 전환할 때 하드 로딩 상태(아마도 로딩 스피너를 보여주는)가 발생할 것입니다. 이는 이상적이지 않으므로, 가능하다면 initialData로 새로 생성된 캐시 항목을 미리 채우려고 시도할 수 있습니다. 위의 예제는 이를 위해 완벽합니다. 우리의 할 일 목록에 대해 클라이언트 측 사전 필터링을 수행할 수 있기 때문입니다:

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
    },
  })
 
typescript

이제 사용자가 상태 간 전환할 때마다, 아직 데이터가 없다면 'all todos' 캐시에서 데이터를 보여주려고 시도합니다. 우리가 가진 'done' 할 일을 사용자에게 즉시 보여줄 수 있고, 백그라운드 페치가 완료되면 여전히 업데이트된 목록을 볼 수 있습니다.

이는 단 몇 줄의 코드로 사용자 경험을 크게 개선한 좋은 예입니다.

서버 상태와 클라이언트 상태를 분리하세요

이는 제가 지난 달에 작성한 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)
  // ...
}
 
typescript

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

embed

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

enabled 옵션은 매우 강력합니다

useQuery 훅에는 동작을 커스터마이즈할 수 있는 많은 옵션이 있습니다. 그중 enabled 옵션은 많은 멋진 일을 가능하게 하는 매우 강력한 옵션입니다(말장난 의도). 이 옵션 덕분에 할 수 있었던 일들의 짧은 목록을 소개합니다:

  • 종속 쿼리
    한 쿼리에서 데이터를 가져오고, 첫 번째 쿼리에서 데이터를 성공적으로 얻은 후에만 두 번째 쿼리를 실행합니다.
  • 쿼리를 켜고 끄기refetchInterval 덕분에 정기적으로 데이터를 폴링하는 쿼리가 있지만, 모달이 열려 있을 때는 화면 뒤의 업데이트를 피하기 위해 일시적으로 중단할 수 있습니다.
  • 사용자 입력 기다리기
    쿼리 키에 필터 기준을 포함하지만, 사용자가 필터를 적용하지 않은 동안에는 비활성화합니다.
  • 사용자 입력 후 쿼리 비활성화하기
    예를 들어, 서버 데이터보다 우선순위를 가져야 하는 초안 값이 있는 경우입니다. 위의 예제를 참조하세요.

쿼리 캐시를 로컬 상태 관리자로 사용하지 마세요

쿼리 캐시를 조작한다면(queryClient.setQueryData), 낙관적 업데이트나 변경 후 백엔드에서 받은 데이터를 쓰는 경우에만 해야 합니다. 모든 백그라운드 리페치가 그 데이터를 덮어쓸 수 있다는 점을 기억하세요. 로컬 상태에는 다른 것을 사용하세요.

커스텀 훅을 만드세요

단 하나의 useQuery 호출을 래핑하는 경우에도 커스텀 훅을 만드는 것은 대개 이득이 됩니다. 그 이유는 다음과 같습니다:

  • 실제 데이터 가져오기를 UI에서 분리할 수 있지만, useQuery 호출과 함께 배치할 수 있습니다.
  • 하나의 쿼리 키에 대한 모든 사용(및 잠재적인 타입 정의)을 한 파일에 유지할 수 있습니다.
  • 일부 설정을 조정하거나 데이터 변환을 추가해야 하는 경우, 한 곳에서 할 수 있습니다.

이미 위의 할 일 쿼리에서 이런 예를 보셨습니다.

React Query는 데이터 가져오기와 상태 관리를 효율적으로 처리할 수 있는 강력한 도구입니다. 이 라이브러리를 잘 활용하면 애플리케이션의 성능을 크게 향상시키고 코드를 더 깔끔하게 유지할 수 있습니다. 위에서 설명한 팁들을 적용하면 React Query를 더 효과적으로 사용할 수 있을 것입니다. 항상 기억하세요: 서버 상태와 클라이언트 상태를 분리하고, 쿼리 키를 신중히 설계하며, 필요할 때 커스텀 훅을 만드는 것이 좋은 실천 방법입니다.