본문 바로가기

Client/Front-end

<FE> React-Query 의 필요성

1. 소개

* 기존의 상태관리

 - 모던한 FE에서 상태를 관리하는 것은 필수이다. 단순한 체크여부 뿐만 아니라 다크모드, 인풋 값, 로딩상태, 에러상태, 눈에 보이는 리스트 데이터부터 눈에 보이지 않는 비동기적인 상태들 까지 상태 관리의 대상은 점점 커지고 있다. 

 

 - 데이터의 관리가 점점 프론트로 넘어오고 있으며 실시간에 가까워진다는 얘기를 심심치않게 들을 수 있다. 만일 이러한 상태관리의 대한 내용과 역사가 궁금하다면 필자의 이전 글을 참고하길 바란다. 어쨌든 이런 상태들 중 오늘 할 내용은 주로 비동기 상태와 관련된 내용이다.

 

 - 예를 들어 SNS의 게시글이나 할일 목록을 생각해보자. 이들은 처음에 서버에서 받아와 관리되며, 주로 전역 store에 담기곤 한다. 이 상태들을 관리하기 위해 redux, mobX등의 라이브러리와 비동기를 위한 추가 미들웨어 등이 사용되고 있다.

 

* 문제점

 - 비동기 데이터들을 상태로 관리하다보면 문득 의문이 들게된다. 과연 이것이 맞는 방법인가하는 것이다. 크게 네가지 문제점이 있다. 이를 알아보기위해 간단한 TODO코드를 준비했다. 상태는 일단 라이브러리에 종속되지 않고 간단하게 react 의 state를 활용하였다. 하지만 전역상태로 봐도 무관하다.

 

function App() {
  const [isLoading, setLoading] = useState(false);
  const [isSubmitLoading, setSubmitLoading] = useState(false);
  const [isError, setError] = useState(false);
  const [todos, setTodos] = useState([]);
  const [newTodo, setNewTodo] = useState('');
  const [name, setName] = useState('harry');
  
  const getTodos = async () => { 
    //... 
  }
  
  useEffect(() => {
    getTodos();
  }, []);
  
  const onChange = (e) => {
    setNewTodo(e.target.value);
  };
  
  const onSubmit = async (e) => { 
    //... 
  }

  const onDelete = async (id) => { 
    //... 
  }

  return (
    <>
      <h1>TODO LIST</h1>
      {isError && <div>에러 발생!!</div>}
      <InputForm onSubmit={onSubmit} newTodo={newTodo} onChange={onChange} />
      {isLoading || isSubmitLoading ? (
        <div>로딩 중...</div>
      ) : (
        <ul>
          {todos.map((todo) => (
            <TodoItem key={todo._id} obj={todo} onDelete={onDelete} />
          ))}
        </ul>
      )}
    </>
  );

 

 - 첫째, 코드가 너무 비대해진다. redux를 써봤거나 라이브러리를 쓰지 않더라도 비동기데이터를 불러오는 코드를 작성해보았다면 느꼈을 것이다. isLoading 과 같은 코드부터 다양한 비동기 로직을 작성하고 useEffect 등을 활용해야한다. 라이브러리를 쓴다면 saga와 같은 미들웨어까지 쓰면서 작성해야하는 코드가 너무 늘어난다. 

 

 - 둘째, 목적에 부합하지 않는다. 위에 문제에 이어지는 내용인데 과연 상태관리 라이브러리가 비동기 데이터를 불러오는 목적에 적합한가이다. redux, mobX는 클라이언트의 전역 상태를 관리하는 라이브러리이다. 이 라이브러리들이 과연 비동기 데이터와 서버의 상태를 관리하는데 부합할까하는 의문이다.

 

 - 셋째, 데이터를 불러오고 접근하는 시점이 불일치한다. 전역상태로 데이터를 로딩하게되면 데이터가 필요한 컴포넌트 입장에서 해당 데이터가 있는지부터 확인해야한다. 즉 불러오는 시점과 접근하는 시점이 달라지게 되면서 고려해야할 사항이 늘어난다. 이는 디버깅과 테스트를 곤란하게 할 가능성이 높다.

 

 - 넷째, 최신 데이터를 보장하기 어렵다. 실제로 비동기 데이터는 서버에서 관리되고 있다. 만약 사용자A가 데이터를 보고있을 때, 사용자B가 데이터를 변경시켰다면 어떻게 될까. 사용자 A가 보는 데이터는 정말 올바른 데이터인가하는 의문이 들게 된다. 현대의 웹이 점점 실시간으로 진화됨에 따라 상태의 Out-of-date문제는 더 중요한 과제가 되었다. 

 

2. 도입

 - 위와 같은 문제에 적합한 것이 Stale-While-Revalidate 컨셉이다. 이는 간단하게 말해 stale하다고 판단되면 새로 최신화하도록 서버에 요청하는 것을 의미한다. 이것이 잘 구현된 라이브러리 중 하나가 react-query이다. react-query의 공식 사이트에서는 현대의 프론트 상태를 다음과 같이 얘기하고 있다.

 

 " While most traditional state management libraries are great for working with client state, they are not so great at working with async or server state. This is because server state is totally different. "

 

 - react-query는 서버측의 상태는 완전히 다르게 구분지어 생각해야한다고 주장한다. 그렇다면 어떻게 구현하는지 알아보자. 참고로 필자가 작성한 코드는 링크에서 확인할 수 있다.

 

// index.jsx

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60000,
      refetchOnWindowFocus: false,
      refetchInterval: 120000,
    },
  },
});

ReactDOM.render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>,
  rootElement,
);

 

 - 기본적인 react-query의 설정이다. react-query는 서버의 상태를 캐시로 관리한다. 같은 상태를 바라볼 수 있도록 queryClient 인스턴스를 생성하고, App에서 사용할 수 있도록 provider을 통해 제공한다.

 

 - defaultOptions는 아래의 react-query에서 제공하는 hooks에서도 사용할 수 있지만, 전체적으로 사용할 디폴트 값을 설정하도록 돕는다. staleTime을 통해 어느정도의 시간이 지났을 때 캐시된 값을 stale하다고 볼 것인지 정할 수 있다. 위는 1분내에는 값이 최신이라 판단하겠다는 의미이다. RefetchOnWindowFocus 는 기본적으로 true인데 다른 창을 갔다가 돌아올 경우 새로 Fetch를 하겠다는 의미이다. 하지만 위의 staleTime 설정에 의해 1분내에 돌아올 경우 캐시된 값을 그대로 사용한다.

 

 - refetchInterval은 주기적으로 패치를 하는 시간을 의미한다. 별다른 이상이 없더라도 2분마다 새로 패칭을 진행하겠다는 의미이다.

 

* 읽기

 - 가장 기본적인 읽기를 먼저 알아보자. 아래는 todo 배열을 받아오는 기존의 코드이다.  

 

function App() {
  const [isLoading, setLoading] = useState(false);
  const [isError, setError] = useState(false);
  const [todos, setTodos] = useState([]);
  const getTodos = async () => {
    setLoading(true);
    setError(false);
    let result = '';
    try {
      result = await axios.get('http://localhost:3000/api/todo');
    } catch (error) {
      setError(true);
    }
    setLoading(false);
    setTodos(result.data.sort((a, b) => b.timeStamp - a.timeStamp));
    return result.data;
  };
  useEffect(() => {
    getTodos();
  }, []);
  
  return (
  // <> ... </>
  )
}

 

 - 위 코드는 위에서 봤듯이 비동기 데이터를 다루기 위해 loading이나 Error 상태도 별도로 정의하였고, 이를 위해 데이터를 불러오는 로직 코드가 불필요하게 비대해졌다. react-query를 사용하면 다음과 같이 작성할 수 있다.

 

const myKey = 'todos';

function App() {
  const [todos, setTodos] = useState([]);
  
  const { isLoading, error, data } = useQuery(myKey, async () => {
    let { data } = await axios.get('http://localhost:3000/api/todo');
    return data.sort((a, b) => b.timeStamp - a.timeStamp);
  });
  
  useEffect(() => {
    if (!data) return;
    setTodos(data);
  }, [data]);
  
  return (
  <> ... </>
  )
}

 

 - 먼저 키값이 있다. 이는 캐시된 값의 키값(String or Array) 을 의미한다. 같은 키 값을 가지면 공유된 쿼리를 사용할 수 있게된다. useQuery는 다양한 값들을 제공하는데 여기서는 isLoading, error, data만을 활용하였다. (axios의 data와 별개이다. ) data는 return 결과이다. 이를 바로 활용해도 되지만 기존 코드와의 유사성을 위해 useEffect를 활용하였다. 바로 data를 상태로써도 무관하다.

 

 - 여기서 이미 눈치챘겠지만, 언급했던 첫째, 둘째 문제를 해결함과 동시에 셋째 문제도 해결되었다. react-query는 서버로부터 데이터를 가져오는 코드와 데이터에 접근하는 인터페이스가 동일하다. 위 useQuery가 다른 컴포넌트에 있더라도 stale하지 않다고 판단된다면 캐시된 값을 그대로 사용할 것이고 stale하거나 처음 사용한다면 서버로부터 데이터를 fetch할 것이다.

 

* 수정

 - 위의 useQuery까지 이해했다면 수정은 어떻게 하는 것일지 궁금해질 것이다. 메모리에 캐시하고 있는 것을 어떻게 수정할 것인가.

 - 이 때 사용하는 훅이 useMutation이다. 이를 알아보기 이전에 기존 코드를 살펴보면 다음과 같다.

 

  const onSubmit = async (e) => {
    e.preventDefault();
    if (!newTodo) return;
    setLoading(true);
    setError(false);
    let result = '';

    try {
      result = await axios.post('http://localhost:3000/api/todo', { name, todo: newTodo });
      setTodos((prev) => [result.data, ...prev]);
    } catch (error) {
      setError(true);
    }
    
    setLoading(false);
  };

 

 - 새로운 todo 아이템을 추가하기 위한 onSubmit 함수이다. 로딩상태와 에러상태와 관련된 로직이 들어있고, 데이터를 넣어서 서버에 요청하는 형태이다. 데이터를 받아온 후 기존 데이터의 앞에 붙이도록 하였다. 이를 react-query로 작성해보자.

 

  const queryClient = useQueryClient();
  const { mutate: onSubmit,isError,isLoading: isSubmitLoading} = useMutation(
    (e) => {
      e.preventDefault();
      if (!newTodo) return;
      return axios.post('http://localhost:3000/api/todo', { name, todo: newTodo });
    },{
      onSuccess: (result) => {
        // queryClient.setQueryData(myKey, (prev) => [result.data, ...prev]);
        queryClient.invalidateQueries(myKey);
        setNewTodo('');
      },
    },
  );

 

 - 초기에 같은 상태를 바라보도록 쿼리 프로바이더를 App에 감싼 것을 기억할 것이다. 이 상태를 사용하기위해 useQueryClient 훅을 사용한다. 

 

 - useMutation훅은 mutate라는 함수를 갖고 있다. 이를 기존 코드의 구조와 동일하게 만들기 위해 onSubmit이라는 이름을 갖도록 하였다. 그 외에도 여러가지 상태들을 제공하는 것을 확인할 수 있다.

 

 - useMutation은 데이터를 수정하는 비동기함수를 인자로 갖는다. 여기까지만 작성해도 동작은 하지만 화면에서 바로 업데이트 되지는 않는다. stale하다고 판단되는 시간이 지나지 않는다면 리패치를 하지 않을 것이기 때문이다. 그래서 제공되는 여러가지 옵션중 onSuccess를 정의하였다. 성공적으로 비동기 요청을 수행한 후 무엇을 할 것인지 정의할 수 있다.

 

 - onSucces는 비동기 호출의 결과를 갖고 있다. 여기서 queryClient의 메서드를 사용할 수 있다. 메서드의 첫 번째 인자에 useQuery에서 사용했던 키를 동일하게 입력하면 같은 상태 데이터를 바라볼 수 있다. 주석은 직접적으로 캐시데이터를 수정하는 로직이다. 그러나 더 간단한 방법은 저장된 값이 더이상 유효하지 않다고 (stale 하다고) 알리면 react query가 다시 최신화를 하게 된다.

 

 - 이와 같은 방법으로 삭제도 작성해보면 다음과 같이 매우 간단하게 변경할 수 있다.

 

  const { mutate: onDelete } = useMutation((id) => axios.delete(`http://localhost:3000/api/todo/${id}`), {
    onSuccess: () => {
      queryClient.invalidateQueries(myKey);
    },
  });

 

- 전체 코드를 직접 보고싶다면 이 링크를 통해 필자의 깃허브에서 확인할 수 있다.

 


참고

 

 

React Query

Hooks for fetching, caching and updating asynchronous data in React

react-query.tanstack.com

 

Store에서 비동기 통신 분리하기 (feat. React Query) | 우아한형제들 기술블로그

오늘은 주문에서 사용하는 FE 프로덕트의 구조 개편을 준비하며 FE에서 사용하는 Store에 대해 개인적인 고민 및 팀원들과 검토하고 논의했던 내용을 소개합니다. 이 과정에서 생긴 여러 가지 의

techblog.woowahan.com

 

[번역] : React Query - 왜, 그리고 어떻게 사용할 수 있을까?

이 글은 Nathan Sebhastian 님의 How and Why You Should Use React Query 를 번역한 글입니다. 요즘은 axios 와 더불어 react query 역시 많이 쓴다는 말을 들어서... 큰 개념만 정리해보고자 번역을 진행해 봤습..

merrily-code.tistory.com

 

blog.rhostem.com

프론트엔드 웹 개발 기술 블로그

blog.rhostem.com

 

전역 상태 관리에 대한 단상 (stale-while-revalidate)

이 글의 부제는 Stop Using Redux for Caching for API Response이다. 한 때 전역 상태 관리 도구로 Redux를 즐겨썼던 개발자로서 이제 더이상 Redux를 사용하지 않게 된 이유와 회고를 담은 글이다. Table of Contents

jbee.io

 

React Query에서 staleTime과 cacheTime의 차이

A 쿼리 인스턴스가 mount 됨네트워크에서 데이터 fetch 하고 A라는 쿼리키로 캐싱함이 데이터는 fresh 상태에서 staleTime(기본값 0) 이후 stale 상태로 변경됨A 쿼리 인스턴스가 unmount 됨캐시는 cacheTime(기

velog.io