React Query as a State Manager

content-logo

TkDodoReact Query as a State Manager를 번역한 문서입니다.


React Query는 React 어플리케이션에서 데이터 불러오기를 매우 단순화 한다는 점에서 많은 이들에게 사랑받고 있습니다. 따라서 제가 React Query는 데이터 불러오기 라이브러리가 아니라고 한다면 놀라실 수도 있을 것 같아요.

React Query는 아무런 데이터도 직접 불러오지 않습니다. React Query의 기능 중 아주 작은 부분만이 네트워크와 직접적으로 연결되어있습니다. (예를 들면 the OnlineManager, refetchOnReconnect 또는 오프라인 mutation 재시도 등등) 이는 실제로 queryFn을 처음 작성해보면 더 명확해집니다. 실제로 데이터를 불러오는 무언가를 같이 사용해야 합니다. fetch, axios, ky 또는 심지어 graphql-request 같은 것들이 있습니다.

비동기 상태 관리자 (An Async State Manager)

React Query는 비동기 상태 관리자 입니다. 비동기 상태라면 어떠한 형태이던지 모두 상태 관리를 할 수 있습니다. 그저 Promise가 전달되기만 하면 됩니다. 맞아요. 대부분의 경우 데이터 불러오기를 통해 Promise를 생성합니다. React Query는 이런 방식에서 빛을 발합니다. 하지만 React Query는 단순히 로딩과 에러 상태를 다루는 것보다 더 많은 일을 합니다. React Query는 적절한 진짜 “전역 상태 관리자” 입니다. QueryKey는 쿼리를 고유하게 식별하기 때문에, 다른 곳에서 같은 키로 쿼리를 호출할 경우, 같은 데이터를 얻을 것입니다. 이 방식은 커스텀 훅으로 잘 추상화하여 실제 데이터 불러오기 함수에 두 번 접근하지 않도록 할 수 있습니다.

// 비동기 상태 관리자

export const useTodos = () => useQuery({ queryKey: ['todos'], queryFn: fetchTodos });

function ComponentOne() {
  const { data } = useTodos();
}

function ComponentTwo() {
  // ✅ ComponentOne과 정확히 동일한 데이터를 받을 것입니다.
  const { data } = useTodos();
}

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <ComponentOne />
      <ComponentTwo />
    </QueryClientProvider>
  );
}

해당 컴포넌트는 컴포넌트 트리 상에서 어디에나 위치할 수 있습니다. 동일한 QueryClientProvider 하위에 있는 한, 모두 동일한 데이터를 가질 것입니다. React Query는 또한 동시에 발생하는 요청을 중복으로 처리합니다. 따라서 위의 시나리오에서는 두 개의 컴포넌트가 동일한 데이터를 요청했어도, 단 한 번의 네트워크 요청만 있을 것입니다.

데이터 동기화 도구 (A data synchronization tool)

React Query는 비동기 상태를 (또는, 데이터 불러오기 관점에서 보면 서버 상태를) 관리하기 때문에, 프론트엔드 어플리케이션에서는 데이터를 “소유”하고 있지 않는 것으로 가정합니다. 그리고 이는 전적으로 맞습니다. API에서 데이터를 불러온 후 화면에 보여줄 때, 그 보여지는 데이터는 실제 데이터의 “스냅샷” 입니다. 즉, 사용자가 해당 데이터를 검색하는 시점의 해당 데이터의 모습입니다. 따라서 우리는 스스로에게 다음과 같이 질문을 던져야 합니다.

데이터를 불러온 다음에도 해당 데이터가 정확한 데이터인가?

대답은 전적으로 문제 상황에 따라서 달라집니다. 만약 좋아요와 댓글이 있는 트위터 게시물을 불러온다면, 해당 데이터는 빠른 속도로 오래된 데이터가 될 것입니다. 만약 하루 주기로 업데이트되는 환율 정보를 불러온다면, 글쎄요, 해당 데이터는 굳이 다시 불러오지 않아도 꽤 긴 시간 동안 정확한 데이터 일 것입니다.

React Query는 화면을 실제 데이터의 소유자 즉, 백엔드와 동기화 할 수 있는 수단을 제공합니다. 이렇게 함으로써, 자주 업데이트되는 곳에서 그렇지 않은 곳 보다 더 많은 작업을 진행합니다.

React Query 등장 이전 (Before React Query)

React Query가 등장해서 구원해주기 전까지는 데이터를 불러오는 방법은 크게 2가지가 자주 사용됐었습니다.

  • 단 한 번만 불러오고, 전역에서 분배하며, 아주 가끔 업데이트 하기 제가 리덕스를 사용하면서 많이 사용해왔던 방식입니다. 어디선가, 데이터 불러오기를 시작하는 액션을 디스패치 합니다. 보통은 어플리케이션이 마운트 되는 곳에서 합니다. 데이터를 불러온 후, 어플리케이션 상의 아무 곳에서 사용하기 위해 전역 상태 관리자에 집어넣습니다. 결과적으로 많은 컴포넌트가 우리의 할 일 목록에 접근해야 합니다. 해당 데이터를 다시 불러오나요? 아니요. 해당 데이터를 이미 “다운로드” 해서 갖고 있는데 왜 그래야 하나요? 만약 백엔드에 POST 요청을 보내면, 친절하게 “최신” 상태를 보내줄 것입니다. 좀 더 정확한 것을 원하신다면, 브라우저 창을 그냥 새로고침 할 수도 있습니다…
  • 마운트 할 때마다 불러온 후 로컬에서 관리하기 때때로 데이터를 전역 상태에 넣는 것이 “과하다”고 생각하기도 합니다. 해당 데이터가 모달 다이얼로그 안에서만 필요하다면, 모달 다이얼로그가 열리는 즉시 불러와도 되겠죠. 이미 익숙하실겁니다. useEffect 와 빈 의존성 배열. (린트가 뭐라고 한다면 eslint-disable도 적용하구요) 그리고 setLoading(true) 를 하는 등등 … 물론, 이제부터는 다이얼로그가 열릴 때마다 데이터를 가져오기 전까지 로딩 스피너가 출력될 것입니다. 그리고 로컬 상태가 사라지면 또 다른 일을 더 할 수 있겠죠 …

이 두 가지 방법은 모두 최적화와는 거리가 있습니다. 첫 번째 방법은 로컬 캐시를 충분할만큼의 빈도수로 업데이트 하지 않습니다. 두 번째 방법은 데이터를 과할 정도의 빈도수로 다시 불러올 수 있습니다. 또한 후자의 방법은 두 번째로 데이터를 가져올 때 데이터가 없기 때문에 사용자 경험이 의문스럽습니다.

그렇다면 React Query는 해당 문제에 어떤 식으로 접근할까요?

오래된 데이터를 사용하면서 다시 불러오기 (Stale While Revalidate)

해당 용어는 이전에 들어보셨을 수도 있을 것 같습니다. 이는 React Query가 사용하는 캐싱 메커니즘입니다. 이는 새로운 개념이 아닙니다. 이 곳에서 오래된 컨텐츠에 대한 HTTP Cache-Control 확장을 읽을 수 있습니다. 요약하자면, React Query는 데이터를 캐싱하고 필요할 때 제공합니다. 해당 데이터가 더이상 최신의 상태가 아닐지라도 (오래된) 말이죠. 이 원칙은 데이터가 아예 없는 것 보다는 오래된 데이터가 더 낫다는 것을 나타냅니다. 데이터가 아예 없다는 것은 로딩 스피너를 보여줘야 한다는 것을 의미하며, 이는 사용자에게 “느리다”는 인식을 줄 수 있기 때문입니다. 동시에, React Query는 해당 데이터를 무효화하기 위해 백그라운드에서 다시 불러오려고 할 것입니다.

데이터를 똑똑하게 다시 불러오기 (Smart refetches)

캐시 무효화는 상당히 어려운 부분입니다. 백엔드에 새로운 데이터를 요청하는 시점은 언제로 잡아야 할까요? 분명 이 작업을 useQuery를 사용하는 컴포넌트가 리렌더링 될 때마다 할 수는 없을 것입니다. 이는 현대의 기준에서 봐도 비용이 말도 안되게 높기 때문입니다.

따라서 React Query는 데이터 다시 불러오기를 실행하는 시점을 전략적으로 선택합니다. 해당 시점은 “그래. 지금이 데이터를 불러오기에 좋은 시점인 것 같아.”라고 말하기 좋은 지표입니다. 해당 시점은 다음과 같습니다.

  • refetchOnMount useQuery를 사용하는 새로운 컴포넌트가 마운트 될 때마다 React Query는 무효화를 진행합니다.
  • refetchOnWindowFocus 브라우저 탭을 포커스 할 때마다 데이터가 다시 불러와질 것입니다. 제가 제일 선호하는 데이터 무효화 시점이지만, 종종 오해의 소지가 되곤 합니다. 개발 과정에서, 브라우저 탭을 자주 전환하기 때문에, 이를 “너무 잦다”고 인식할 수도 있습니다. 하지만 실제 운영 환경에서는, 사용자가 탭에서 어플리케이션을 열어두고 메일이나 트위터 등을 확인한 후 다시 돌아올 가능성이 높습니다. 이 때 최신으로 업데이트 된 데이터를 보여주는 것은 매우 이상적입니다.
  • refetchOnReconnect 네트워크 연결이 끊겼다가 다시 연결되는 경우도 화면에 보여지는 데이터를 무효화 하기 좋은 지표입니다.

마지막으로, 어플리케이션의 개발자로서, 데이터를 무효화 하기 좋은 시점을 알고 있다면, queryClient.invalidateQueries를 통해 수동으로 무효화를 할 수도 있습니다. 이는 뮤테이션이 이루어진 후에 사용하면 매우 유용합니다.

Recact Query가 마법을 부리게 하기 (Letting React Query do its magic)

저는 이러한 기본값들이 마음에 들지만, 이전에 말했듯이, 이는 최신성을 유지하기 위한 값이지, 네트워크 요청을 최소화하기 위한 값이 아닙니다. 예를 들어 staleTime의 기본값은 0이기 때문에 컴포넌트 인스턴스가 마운트되는 등의 경우 백그라운드에서 데이터 다시 불러오기가 작동하게 됩니다. 이를 자주 수행하거나, 특히 서로 다른 렌더링 사이클에 속한 짧은 주기의 마운트 등이 잦아지면, 네트워크 탭에서 많은 데이터 불러오기를 볼 수 있을 것입니다. 이는 React Query가 이러한 상황에서 중복을 제거할 수 없기 때문입니다.

// mounts-in-short-succession

function ComponentOne() {
  const { data } = useTodos();

  if (data) {
    // ⚠️ 데이터가 존재할 경우에만 조건적으로 마운트
    return <ComponentTwo />;
  }
  return <Loading />;
}

function ComponentTwo() {
  // ⚠️ 이어서 2번째 네트워크 요청이 이루어질 것입니다.
  const { data } = useTodos();
}

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <ComponentOne />
    </QueryClientProvider>
  );
}

도대체 무슨 일이 일어난거야? 2초 전에 데이터를 불러왔는데 왜 네트워크 요청이 또 발생하는거지? 이건 말도 안돼!

— React Query를 처음 사용할 때의 정상적인 반응

이 시점에서는 데이터 불러오기가 너무 잦으므로 data를 props로 전달하거나, prop 드릴링을 피하기 위해 data를 React Context에 넣거나, refetchOnMount / refetchOnWindowFocus 플래그를 비활성화 할 수 있을 것 같습니다.

일반적으로, data를 prop으로 전달하는 것은 전혀 문제가 되지 않습니다. 가장 명시적인 방식이며, 상위의 예시에서 잘 작동할 것입니다. 하지만 해당 예시를 조금 더 실제 상황에 맞게 조정해보면 어떻게 될까요?

// lazy-second-component

function ComponentOne() {
  const { data } = useTodos();
  const [showMore, toggleShowMore] = React.useReducer((value) => !value, false);

  // 맞아요. 에러 핸들링을 제외했습니다.이건 그냥 예시입니다.
  if (!data) {
    return <Loading />;
  }

  return (
    <div>
      Todo count: {data.length}
      <button onClick={toggleShowMore}>Show More</button>
      // ✅ show ComponentTwo after the button has been clicked
      {showMore ? <ComponentTwo /> : null}
    </div>
  );
}

이 예시에서, 두 번째 컴포넌트는 (할 일 데이터에 의존하고 있는) 사용자가 버튼을 클릭한 후에 마운트 될 것입니다. 이제 사용자가 해당 버튼을 몇 분 후에 클릭했다고 상상해보죠. 이 상황에서는 백그라운드에서 데이터 다시 불러오기가 작동해서 할 일 목록의 최신 상태를 볼 수 있도록 한다면 좋지 않을까요?

만약 React Query가 원하는 방향대로 하지 않았던 위의 접근을 사용했으면 이는 불가능 했을 것입니다.

그럼 어떻게 해야 이를 적절한 상황에서 실현할 수 있을까요?

staleTime을 커스터마이징 하기 (Customize staleTime)

아마도 제가 가려고 하는 방향을 이미 눈치 채셨을 수도 있겠습니다. 해결책은 해당하는 사용 사례에 제일 적합한 값으로 staleTime을 설정하는 것이 될 것입니다. 여기서 핵심은 다음과 같습니다.

데이터는 신선한 한 항상 캐시에서 반환될 것입니다. 신선한 데이터는 아무리 많이 반환되어도 네트워크 요청이 일어나지 않을 것입니다.

staleTime에 대한 “올바른” 값은 없습니다. 많은 상황에서 기본 설정은 잘 작동합니다. 개인적으로, 저는 요청이 중복되는 것을 방지하기 위해 최소 20초로 설정하는 것을 선호합니다만, 이건 온전히 여러분에게 달려있습니다.

보너스: setQueryDetails 사용하기 (Bonus: using setQueryDefaults)

v3부터, React Query는 QueryClient.setQueryDefaults를 통해 쿼리 키 별로 기본 값을 설정하는 훌륭한 방법을 지원합니다. 따라서 제가 개요한 #8: Effective React Query Keys의 패턴을 따른다면 Query Keys를 setQueryDefaults에 전달하는 것은 Query Filters와 같은 표준 부분 일치를 따르기 때문에 어떠한 정밀도에 대한 기본값도 원하는대로 설정할 수 있습니다.

// setQueryDefaults

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // ✅ 기본값은 전역적으로 20초로 설정되었습니다.
      staleTime: 1000 * 20,
    },
  },
});

// 🚀 할 일과 관련된 모든 것은
// 1분의 staleTime을 가질 것입니다.
queryClient.setQueryDefaults(todoKeys.all, { staleTime: 1000 * 60 });

관심사의 분리에 대한 주의사항 (A note on separation of concerns)

useQuery와 같은 훅을 어플리케이션 모든 계층의 컴포넌트에 추가했을 때 컴포넌트의 책임이 혼합되는 것을 우려하는 것은 합당한 우려인 것 같습니다. 과거에는 “smart-vs-dumb”, "container-vs-presentational" 컴포넌트 패턴이 널리 사용되었습니다. 해당 패턴은 분명한 분리, 디커플링, 재사용성 그리고 테스트의 용이함을 보장했습니다. 왜냐하면 presentational 컴포넌트는 단순히 “props를 받기만 하기” 때문입니다. 해당 패턴은 많은 수의 prop 드릴링, 보일러플레이트, 정적으로 타이핑하기 어려운 패턴 (👋 고차 컴포넌트) 그리고 임의의 컴포넌트 분할로 이루어졌습니다.

이는 훅이 등장하면서 많이 바뀌었습니다. 이제는 useContext, useQuery 또는 useSelector (리덕스를 사용한다면) 를 어디에서나 사용할 수 있으며, 컴포넌트에 의존성을 주입할 수 있습니다. 이렇게 하면 컴포넌트가 더더욱 결합된다고 반문할 수 있을 것 같습니다. 또한 컴포넌트를 어플리케이션 전역에서 자유롭게 이동시킬 수 있고, 그것 자체로 작동하기 때문에 더 독립적이라고 할 수도 있을 것 같습니다.

리덕스 관리자인 Mark EriksonHooks, HOCS, and Tradeoffs (⚡️) / React Boston 2019를 읽어보시는 것을 강력하게 추천합니다.

요약하자면 모든 것은 트레이드오프 입니다. 공짜 점심은 없습니다. 어떤 상황에서 작동하던 것이 다른 상황에서는 작동하지 않을 수 있습니딘. 재사용 가능한 Button이 데이터를 불러와야 할까요? 아마도 아닐 것입니다. DashboardDashboardViewDashboardContainer로 나누고 데이터를 아래로 전달하는게 맞는 걸까요? 이것 또한 아닐 것입니다. 따라서 트레이드오프를 인지하고 적절한 분야에 적절한 도구를 도입하는 것은 전적으로 저희에게 달렸습니다.

결론 (Takeaways)

React Query는 어플리케이션 안에서 전역 비동기 상태 관리자로 사용될 때 훌륭하게 작동합니다. 필요에 따라서만 refetch 플래그를 비활성화 하고, 서버 데이터를 다른 상태 관리자에 동기화 시키는 것을 지양하세요. 일반적으로, staleTime을 조절하는 것 만으로도 훌륭한 사용자 경험을 얻을 수 있으며, 동시에 백그라운드 업데이트의 빈도를 효과적으로 제어할 수 있습니다.