Overview

Web Frontend 개발을 할 때 React 를 사용하면서 마주하게 되는 여러 가지 문제점 중 하나는 state, 상태 관리에 관한 부분입니다. 프론트엔드 개발자라면 state 와 뗄 수 없는 인연을 맺고 있습니다.

오늘 이 글이 작성되는 이유는 바로 state 와 깊은 관련이 있습니다. React 에서 상태 관리를 하기 위해서는 대부분 Redux 라는 상태 관리 매니저를 사용합니다. (물론 Redux 는 React 뿐만 아닌, Vanilla JS, Vue.js 에서도 활용할 수 있습니다.) Redux 를 활용해 프로젝트에서 전역 상태 (state) 를 관리를 할 때 서버 데이터를 활용하려면 반드시 Redux-saga, Redux-Thunk 혹은 RTK-Query 같은 또 다른 미들웨어를 사용해야만 합니다.

최근 Kakao tech blog 에 hugo 님께서 작성해주신 React-query 전환기나, 지난 2월 우아한 테크 세션에서 발표 되었던 React-Query 를 살펴보면 Redux 를 활용하고 있는 많은 프로젝트에서 대부분은 비슷하거나 같은 문제를 마주치고 있습니다.

카카오 'Hugo' 님의 'My구독의 React Query 전환기'
우아한형제들 '배민근' 님의 'Store에서 비동기 통신 분리하기 (feat. React Query)'

바로 서버 데이터를 위한 로직이 과도하게 커지고, 그로 인해서 Redux 를 활용하기 위한 보일러 플레이트가 비대해 진다는 점 입니다.

이를 해결하기 위해서 대체 기술들을 찾아보던 중, 여러 기업에서는 이미 React-Query 를 접목해서 활용하고 있다는 걸 확인하였고, Playce Dev팀의 Frontend 파트에서는 다음 아키텍처에 React-Query 와, Redux 보다 훨씬 간편하고 React 스럽게 상태관리 할 수 있는 Recoil 을 사용할 수 있는 방법에 대해 탐구하고, 탐구한 결과를 여러분들께 공유해보도록 하겠습니다. (이 글은 React-Query 에 중점을 두고 작성한 글입니다.)

- 이 글은 React-Query 에 대한 기본적인 지식이 있어야 이해하기 쉽습니다.
- React-Query 는 공식문서가 잘 정리되어 있기 때문에 어렵지 않게 학습할 수 있습니다.
- 공식문서 바로가기 >



React-Query, Recoil 을 도입하기 까지의 고민들


#1. 캐싱 (Caching) 에 관한 오해 풀기

React-Query 의 장점 중 하나는 데이터를 캐싱한다는 점입니다. 공식 문서의 대문에서 캐싱 에 대한 내용을 확인할 수 있으며 실제로 활용해 봤을 때 “캐싱 된 데이터로 인해서 API 콜을 줄여주며 서버에 대한 부담을 줄여줄 수 있겠구나” 라는 생각을 했습니다.


그럼에도 불구하고 처음 마주쳤던 고민은 캐싱에 대한 고민이었습니다.

React-Query 는 기본적으로 데이터를 fetching 해온 후 데이터를 캐싱 하게 되며, 해당 데이터가 stale 하다고 판단될 때 데이터를 refetching 해오게 됩니다.

React-Query 에서 stale 한 상태란 것은 쉽게 말해서 유통기한이 지났다고 생각하면 됩니다.

React-Query 는 stale 상태의 데이터를 리패칭 해옵니다.

Client Side 에서 캐싱은 유용 하면서 굉장히 위험한 기술입니다. 서버 데이터를 fetching 해 데이터를 캐싱한 후, 해당 데이터를 확인할 때 만약 서버 상에서 데이터의 상태가 변경 되었다면, 사용자는 잘못된 데이터를 확인하게 되며, 개발자의 입장에서는 이는 데이터의 무결성을 해치는 경우로 인지될 수 있습니다.

🧐 캐싱에 관한 의구심을 풀기 위해서 아래와 같은 사고 과정을 거쳐봤습니다.


브라우저에서 사용자가 최신 데이터를 바라봐야 하는 상황은 ?

  • 근본적으로 화면을 보고 있을 때
  • 페이지가 전환 될 때 (새로운 페이지를 마주 했을 때)
  • 페이지 전환 없이 뭔가의 데이터를 요청할 때 (예를 들면 클릭 이벤트)

즉, 위 세 가지 경우를 제외하고는 데이터는 사용자 입장에서는 신선한(fresh) 상태가 아니어도 된다는 뜻입니다. 아래는 React-Query 가 기본적으로 제공하고 있는 옵션들 입니다.

refetchOnWindowFocus, //default: true
refetchOnMount, //default: true
refetchOnReconnect, //default: true
staleTime, //default: 0
cacheTime, //default: 5분 (60 * 5 * 1000)


React-Query 가 데이터를 Refetching 해오는 상황은 ?

  • 브라우저에 포커스가 들어왔을 경우 (refetchOnWindowFocus)
  • 새로 마운트가 되었을 경우 (refetchOnMount)
  • 네트워크가 끊어졌다가 다시 연결된 경우 (refetchOnReconnect)
  • React-Query 는 캐싱 된 데이터는 항상 stale 하다고 판단하며, stale 상태인 데이터를 Refetching

결국 서버 데이터를 패칭해 온 데이터를 캐싱했어도, 사용자가 화면을 바라보고 있을 때는 그 시점에 있어서 가장 최신의 데이터를 바라보고 있는 상황이며, 페이지가 전환이 되었을 경우에도 해당 데이터의 상태가 stale 하다고 판단하여 리패칭 하며, 페이지에서 어떤 이벤트가 발생했을 경우엔 개발자가 트리거 를 심어줌으로 써 데이터를 리패칭 할 수 있습니다.

즉, 위와 같은 React-Query 의 컨셉으로 인해서 사용자는 항상 신선한 (fresh) 데이터를 바라볼 수 있습니다.



#2. Client 데이터와 Server 데이터의 분리

Redux, Recoil 은 Client 에서 전역 상태를 관리하고자 사용하는 라이브러리 입니다. React-Query 를 활용한다면 전자의 라이브러리들이 본연의 역할에만 집중할 수 있도록 할 수 있습니다. 즉 서버 데이터와, 클라이언트 데이터를 분리하는 것입니다.

서버 데이터는 알겠는데, 클라이언트에서 전역으로 관리해야 할 데이터 (Global state) 는 어떤 것들이 있을까요?

예를 들자면, 화면에서 단계별로 진행하는 컨텐츠 에서 단계별로 입력 받는 값 들, 혹은 값을 입력받은 후 훗날에 저장된 값을 활용해야 하는 데이터들이 있을 수 있습니다. 서버를 거치느냐, 브라우저에만 국한되는 사용자 혼자서만 사용할 데이터냐 로 구분할 수 있겠습니다. 쉽게 예로 들자면, 게임에서 싱글 플레이를 할 때는 혼자 게임을 즐기므로 서버에서 데이터를 가져올 필요도, 서버에 데이터를 보낼 필요도 없습니다 (클라이언트 데이터). 하지만 멀티 플레이를 한다면 여럿에서 게임을 즐기게 되므로 서버에서 데이터를 가져오고 서버에 데이터를 보내야 합니다 (서버 데이터).

RORO 의 Server Preconfiguration 의 Step 데이터들은 Global State 로 관리되고 있습니다.

Redux 를 사용하다 보면 서버 데이터를 받아와 상태 관리를 하기 위해 Redux-saga 를 사용하게 되고, Client Side 에서 전역 상태 관리를 위해서 사용하는 라이브러리가 의도와는 다르게 비동기 요청을 위한 로직으로 Store 혹은 module 이 비대해지게 됩니다. 비동기 요청을 위해 request.success, request.fail 상태의 로직도 다뤄야 하며, 이 전체적인 과정을 Redux 모듈에서 관리하려니 보일러 플레이트가 관리하기 어려울 정도로 커지는건 당연한 상황입니다.

리스트를 fetch 하기 위한 Reducer
fetch 를 위한 Saga 코드

여기서 개발자들은 고민에 빠지게 됩니다.

“과연 Redux 가 본연의 역할 (State Manager) 에 충실하고 있을까?”

이를 해결하기 위해서 React-Query 를 도입하여, 서버 데이터와 클라이언트 데이터를 완전하게 분리할 수 있습니다.

컴포넌트 내부에서 useQuery 훅을 활용해 서버 데이터를 핸들링 하고 있습니다. React-Query 의 기능을 활용하여 서버 데이터를 마치 Global State 처럼 활용할 수도 있습니다.

코드를 살펴보면 Global State 를 위한 코드는 전혀 확인할 수 없습니다. 서버 데이터를 사용할 땐 React-Query 에 의존함으로써 Client data 와 Server data 를 완전히 분리한 모습을 볼 수 있습니다!

정말 만약 필요하다면.. 서버 데이터를 Global state 로 사용할 수 있습니다. 윗 코드는 데이터를 성공적으로 불러왔을 때 아톰 값을 넣어주고 있습니다.


#3. Success 혹은 Error 상황을 공통적으로 핸들링 할 수 있을까?

Redux 와 Redux-saga 를 활용할 때 API Call 오류가 났을 시 이를 중앙에서 핸들링하여 통제할 수 있습니다.
React-Query 를 활용한다면 에러 상황을 중앙에서 한번에 핸들링 할 수 있을까요?

공통 success, error 부수 효과를 핸들링 하고 싶다면, QueryClient 의 defaultOptions 를 통해서 핸들링 할 수 있습니다.


Error 핸들링 예제

비동기 요청을 했을 때 요청에 대한 응답이 실패일 때 error 코드를 atom 으로 핸들링 하고 싶습니다.

위의 코드를 봤을 때 onError 를 위한 로직이 index.tsx 에 위치하기에, atom 을 호출할 hook 을 사용할 수 없습니다. 이런 경우 어떻게 처리할 수 있을까요?

//app.tsx

import { useQueryClient } from "react-query";
import { useRecoilState } from "recoil";
import { errorAtom } from "./common/atom";
import Router from "./Router";

function App() {
  const [error, setError] = useRecoilState(errorAtom);
  const queryClient = useQueryClient();
  queryClient.setDefaultOptions({
    queries: {
      onError: (err) => {
        setError((prev) => [...prev, (err as any).message as string]);
      },
    },
  });
  return (
    <>
      {error.length !== 0 &&
        error.map((err, index) => {
          return <div key={index}>{err}</div>;
        })}
      <Router />
    </>
  );
}


export default App

먼저 App.tsx 에서 useQueryClient hook 을 통해서 queryClient 를 반환받습니다.

setDefaultOptions 라는 메서드를 활용해 onError 상황을 핸들링 할 로직을 정해줍니다. 위 코드에서는 error 코드를 받아서 atom 을 변경해주고 있습니다.


#4. invalidateQueries 가 작동하지 않았던 오류 해결

Tech-day 에서 React-Query 를 소개하던 중 invalidateQueries 가 작동하지 않았습니다. 분명히 예제 코드를 작성할 때 정상적으로 동작 했는데, 왜 안될까 고민을 해봤습니다.

TypeError: Cannot read properties of undefined (reading 'queryCache')

원인은 useQueryClient 훅을 통해서 반환 받은 queryClient 에서 invalidateQueries 를 Destructured 구조로 꺼내서 사용했기 때문입니다.

// 에러가 났던 코드
const { invalidateQueries } = useQueryClient();
const { mutate } = useMutation(postPersonInList, {
  onSuccess: () => {
    invalidateQueries(KEY_LIST);
  },
  onError: (error) => {
    console.log(error);
  },

});
// 수정 후 코드
const queryCache = useQueryClient();
const { mutate } = useMutation(postPersonInList, {
  onSuccess: () => {
    queryCache.invalidateQueries(KEY_LIST);
  },
  onError: (error) => {
    console.log(error);
  },

});

useQueryClient 훅을 통해 queryCache 를 반환받고, queryCache 인스턴스의 invalidateQueries 메서드를 호출합니다.

이때, 원하는 키의 쿼리만 호출하고 싶어서 해당 키만 넣어줬습니다. (매개변수를 넣어주지 않는다면 전체 쿼리를 stale 한 상태로 만든 후 리패칭 합니다.)



예제 소스코드

React-Query 의 흐름과 컨셉을 이해하기 위해 간단히 코드를 작성해 봤습니다. 라우팅을 통해 페이지가 전환 될 때, 혹은 같은 키를 가지고 있는 Query 를 사용하는 경우, 리패칭 되었을 때 화면의 변화 등.. 다양한 상황을 실험해봤습니다.

Github 예제코드 바로가기>



끝맺음

React-Query 를 도입하기 위해 여러 날을 고민하고 공부 했었는데, 이 시간들로 인해서 React-Query 에 관한 궁금증을 모두 해소했습니다.

또한 가장 큰 고민들 이었던 위 세가지를 해결하고 다음 아키텍처에 React-Query 를 도입함 으로 Server Data 와 Client Data 의 경계를 분명하게 하면서 개발할 수 있을것 같다는 생각이 듭니다.

다만 기존에 Redux, Redux-saga를 통해서 다뤘던 코드들이 컴포넌트로 들어옴으로써 컴포넌트가 기존 코드에 비해서 무거워질 것이고, 이 코드들을 잘 분리해서 사용할 방법을 찾아야겠다는 생각도 들었습니다.

시작은 Recoil 과 React-Query 를 어떻게 혼합해서 사용할 수 있을까 로 시작했지만, 끝맺음은 React-Query의 컨셉을 정확히 이해함으로써 분리로써 결론을 맺게 되었습니다.

읽어봐 주셔서 감사합니다.

강동희
Developer

오픈소스컨설팅 Playce Dev Team 소속으로 오늘을 바탕으로 내일이 더 기대되는 프론트엔드 개발자 강동희입니다. 빠르게 변화하는 기술들에 관심이 많으며 지식 공유에 큰 가치를 두고 있습니다.

Leave a Reply

Your email address will not be published. Required fields are marked *

3 replies on “React-Query 도입을 위한 고민 (feat. Recoil)”

  • 김정환
    2022년 07월 13일 at 6:07 pm

    감사합니다 잘 읽었습니다

  • Planet
    2022년 07월 15일 at 3:16 pm

    Redux가 많은 인기를 얻었을 초창기… Redux 스토어에 서버 데이터를 넣어두는것이 맞는 패턴인가에 대해 고민했던 적이 있었습니다. 전역 상태 관리 및 전파를 위해 만든 공간에 억지로 서버데이터를 낑겨넣는듯한 느낌이 들었어요. 요즘은 data-fetching을 위한 시스템을 별도로 생각하고 있는게 디폴트라 코드관리가 깔끔해지고.. 보통 이런 시스템은 선언적인 코드로 작성하게 되어있어 생각하는게 더 편해진것같아 좋아요.

  • […] React-Query 도입을 위한 고민 (feat. Recoil) […]