항해 16기/Today I Learned

[항해 34일차] TIL_React 심화주차: React Query

해갈 2023. 9. 12. 16:56

학습 목표

  1. 기존 Thunk 가 가지고 있었던 한계를 이해합니다.
  2. React Query 의 개념과 사용방법을 실습을 통해 익힙니다.
  3. 서버 사이드 데이터와 클라이언트 사이드 데이터를 이해합니다.

 

React Quert 란?

react-query 는 서버의 값을 클라이언트에 가져오거나, 캐싱, 값, 업데이트, 에러핸들링 등 비동기 과정을 편하게 하는 데 사용합니다.

기존 미들웨어의 한계

다른 서버와의 API 통신과 비동기 데이터 관리를 위해 Redux-toolkit, Redux-Saga 등 미들웨어를 채택해서 사용할 수 있었습니다. 하지만, 다음과 같은 문제가 있습니다.

  1. 보일러 플레이트 : 코드량이 너무 많습니다.
  2. 규격화 문제 : Redux 가 비동기 데이터 관리를 위한 전문 라이브러리가 아닙니다.
  3.  

React Query 의 강점

  1. 보일러 플레이트 만들면서 생기는 휴먼에러를 줄일 수 있습니다.
  2. 사용방법이 기존 thunk 대비 굉장히 쉽고, 직관적입니다.
  3. React 애플리케이션 내에서 데이터 패칭, 캐싱, 동기적, 그리고 서버의 상태의 업데이트를 좀 더 용이하게 만들어줍니다.

 

주요 키워드

useQuery

react-query 에서 제공하는 훅으로 데이터를 가져오는 목적으로 사용되며 API 나 다른 데이터 소스로부터 데이터를 가져오고 처리하는 작업을 간편하고 호율적으로 처리할 수 있는 선언적인 방식을 제공합니다. 데이터 조회가 아닌 데이터 변경 작업을 할 때는 useMutation 을 사용합니다.

useQuery 훅은 두 개의 매개변수를 받습니다.

첫 번째는 고유한 키인 "쿼리 키" 이고, 두 번째는 데이터를 가져오는 fetchData 함수입니다.

키는 쿼리를 식별하고 캐시하기 위해 사용되며, fetchData 함수는 데이터를 가져오는 로직을 수행합니다. React Query 는 캐싱, refetch 등을 자동으로 처리합니다.

 

쿼리함수(fetchData)

함수는 쿼리를 실행하거나 리패치해야할 때마다 호출합니다. 비동기 함수나 프로미스 기반 함수로 작성할 수 있으며, 데이터를 가져오는 로직을 처리합니다.

쿼리 상태

useQuery 훅은 data, isLoading, error 등의 속성을 반환합니다. 이러한 속성은 쿼리의 상태를 나타냅니다. 예를 들어 data 는 가져온 데이터를 담고 있고, isLoading 은 쿼리가 진행 중인지를 나타내며, error 는 발생한 오류를 포함합니다.

쿼리 구성

useQuery 에는 쿼리를 구성하기 위해 추가적인 옵션을 제공할 수도 있는ㄴ데, 이 옵션을 사용해 캐시 유지 시간 설정, 캐시 무효화 제어, 쿼리의 의존성 정의 등을 할 수 있습니다. 이러한 옵션을 사용해 쿼리의 동작을 필요에 맞게 커스터마이징할 수 있습니다.

 

useMutation

useMutation 훅은 변이(mutations) / 데이터 업데이트, 삭제를 처리하는 데 사용됩니다. mutate 는 서버의 데이터를 수정하거나 업데이트하는 작업을 의미하죠. useMutation 훅은 뮤테이션 요청을 보내고, 변이 상태를 관리하며, 낙관적 업데이트를 처리하는 등 뮤테이트를 처리하는 과정을 간편하게 해줍니다.

아래 예시에서 useMutation 훅은 createData 함수를 받습니다. createData 함수는 뮤테이션을 수행하는 역할을 담당합니다. mutate 함수를 사용해 createData 함수를 호출하고, 필요한 데이터를 전달할 수 있습니다.

useMutation 훅은 mutate, isLoading, isError, error 등의 속성을 반환합니다. 이러한 속성을 사용해 컴포넌트에서 로딩 상태, 오류 상태, 성공 상태를 처리할 수 있습니다.

import { useMutation } from 'react-query';

const MyComponent = () => {
  const { mutate, isLoading, isError, error } = useMutation(createData);

  const handleSubmit = (formData) => {
    mutate(formData);
  };

  if (isLoading) {
    return <div>Creating...</div>;
  }

  if (isError) {
    return <div>Error: {error.message}</div>;
  }

  return (
    <form onSubmit={handleSubmit}>
      {/* Form fields */}
      <button type="submit">Create</button>
    </form>
  );
const createData = async (formData) => {
  try {
    // Make a request to the server to create the data
    const response = await axios.post('/api/data', formData);
    return response.data; // Return the created data
  } catch (error) {
    throw new Error('Failed to create data.'); // Throw an error if the request fails
  }
};
낙관적 업데이트 : 
React Query의 낙관적 업데이트는 실제 서버 응답이 수신되기 전에 성공에 대한 낙관적 가정으로 사용자 인터페이스가 즉시 업데이 트는 걸 말합니다. 사용자에게 즉각적인 피드백을 제공하여 보다 원활하고 반응이 빠른 사용자 경험을 제공할 수 있습니다.
하지만 만약 서버 업데이트가 실패할 경우는 업데이트 이전의 데이터로 변경해야 되기 때문에 useMutation 훅에 onMutate함수를 사용하여 뮤테이션 되기 전에 실행될 콜백함수를 지정하여 해당 이슈를 방지할 수 있습니다.
const { mutate } = useMutation(createData, {
  onMutate: onMutate,
});


const handleSubmit = (formData) => {
  // Perform pre-mutation tasks using onMutate
  onMutate(formData);

  // Perform the actual mutation
  mutate(formData);
};​

 

실습

조회기능 구현 : 기존 TodoList 프로젝트 변경해보기

실습을 위해 다음 github respository 에서 clone 을 받아왔습니다.

https://github.com/wonee09/redux-basic-todo.git

 

GitHub - wonee09/redux-basic-todo

Contribute to wonee09/redux-basic-todo development by creating an account on GitHub.

github.com

위 기본 redux 프로젝트를 react-query 로 변경해보았습니다.

우선, 아래 명령어를 통해 react-query 를 설치합니다.

yarn add react-query

 

App.jsx

import React from "react";
import Router from "./shared/Router";
import { QueryClient, QueryClientProvider } from "react-query";

const queryClient = useQueryClient();

const App = () => {

  return (
    <QueryClientProvider client={queryClient}>
      <Router />;
    </QueryClientProvider>
  );
};

export default App;

json server 의 설정이 안되었다면, 먼저 설정을 해주어야 합니다.(yarn add axios)

useQuery 를 이용해 조회기능을 구현했습니다. 그러기 위해 먼저 src > api 폴더를 만들고, 아래에 todos 관련 api 를 관리할 파일을 만들었습니다.

 

src/api/todos.js

import axios from "axios";

// 모든 todos를 가져오는 api
const getTodos = async () => {
  const response = await axios.get("http://localhost:3000/todos");
  return response;
};

export { getTodos };

TodoList 컴포넌트의 코드는 아래와 같이 변경했습니다.

TodoList.jsx

import React from "react";
import { StyledDiv, StyledTodoListHeader, StyledTodoListBox } from "./styles";
import Todo from "../Todo";
import { __getTodosThunk } from "../../modules/todosSlice";

import { getTodos } from "../../../api/todos";
import { useQuery } from "react-query";

/**
 * 컴포넌트 개요 : 메인 > TODOLIST. 할 일의 목록을 가지고 있는 컴포넌트
 * 2022.12.16 : 최초 작성
 *
 * @returns TodoList 컴포넌트
 */
function TodoList({ isActive }) {
  const { isLoading, isError, data } = useQuery("todos", getTodos);

  if (isLoading) {
    return <p>로딩중입니다....!</p>;
  }

  if (isError) {
    return <p>오류가 발생하였습니다...!</p>;
  }

  return (
    <StyledDiv>
      <StyledTodoListHeader>
        {isActive ? "해야 할 일 ⛱" : "완료한 일 ✅"}
      </StyledTodoListHeader>
      <StyledTodoListBox>
        {data
          .filter((item) => item.isDone === !isActive)
          .map((item) => {
            return <Todo key={item.id} todo={item} isActive={isActive} />;
          })}
      </StyledTodoListBox>
    </StyledDiv>
  );
}

export default TodoList;
const { isLoading, isError, data } = useQuery("todos", getTodos);

이 부분이 React Query 가 갖고 있는 큰 장점이라 할 수 있습니다. Thunk 를 이용하면 isLoading, isError 등을 개발자가 직접 state 에서 만들어줬어야 합니다. React Query 는 서버 데이터를 위한 표준을 이미 제시하고 있기 때문에 개발자들마다 특성에 따라 바뀔 염려가 없습니다.

return 문에 도착하기 전에 isLoading 또는 isError 에 따라 별도의 처리를 해주기 때문에 대기상태 처리 / 오류 처리에 대한 부부도 아주 쉽게 해결이 되었습니다.

 

추가 기능 구현(: invalidateQueries )

todo 를 추가하는 부분을 해보았습니다.

src/api/todos.js

import axios from "axios";

// 공통으로 뺐어요(물론 .env를 쓰는게 더 바람직해요)
const SERVER_URI = "http://localhost:4000";

const getTodos = async () => {
  const response = await axios.get(`${SERVER_URI}/todos`);
  return response.data;
};

const addTodo = async (newTodo) => {
  await axios.post(`${SERVER_URI}/todos`, newTodo);
};

export { getTodos, addTodo };

Input.jsx

...
import { addTodo } from "../../../api/todos";
import { QueryClient, useMutation } from "react-query";
...


function Input() {
...
	const queryClient = new QueryClient();
	
	const mutation = useMutation(addTodo, {
	  onSuccess: () => {
	    // Invalidate and refresh
	    // 이렇게 하면, todos라는 이름으로 만들었던 query를
	    // invalidate 할 수 있어요.
	    queryClient.invalidateQueries("todos");
	  },
  });

[invalidateQueries 의 과정]

Input.jsx 에서 값 입력으로 인해 서버 데이터가 변경되었다고 가정했을 때,

  1. onSuccess 가 일어나면 기존의 Query 인 "todos" 는 무효화합니다.
  2. 새로운 데이터를 가져와서 "todos" 를 최신화시킵니다.
  3. TodoList.jsx 를 갱신합니다.

따라서 계속해서 리액트 앱은 최신 상태의 서버 데이터를 유지할 수 있게 되는 거죠.

 

useQuery 에 대해

useQuery hook 의 사용방법에 대해 

import { useQuery } from 'react-query';
import { fetchTodoList } from '../api/fetchTodoList';

function App() {
	const info = useQuery('todos', fetchTodoList);
}

 

useQuery 인자에 대해

첫 번째 인자, 'todos' 를 쿼리의 키(Query Keys)라고 부릅니다.

  • refetching 하는 데에 쓰이고,
  • 캐싱(caching) 처리를 하는 데에도 쓰입니다.
  • 애플리케이션 전체 맥락에서 이 쿼리를 공유하는 방법으로 쓰입니다. ▶ 어느 컴포넌트에 뿌려져 있어도 같은 key 면, 같은 쿼리 및 데이터가 보장되죠.

 

Query Keys 에 대해

   1. 쿼리 키는 위 예제처럼 한 단어일 수도 있고, 배열의 형태일 수도 있고, 심지어 nested 객체일 수도 있습니다. Key 라는 말이 의미하듯, 모든 쿼리 키는 Unique 해야 합니다.

const query1 = useQuery('qk', api); // unique
const query2 = useQuery('qk2', api); // not unique
const query3 = useQuery('qk2', api); // not unique

 

 2. 단어 한 개로 이루어진 Query Keys

만일 다음과 같은 코드가 있다고 한다면,

useQuery('todos', ...)

내부적으로는 다음과 같이 해석됩니다.(배열 형태로 갖고 있습니다.)

queryKey === ['todos']

 

   3. 배열 형태의 Query Keys

정보를 유일하게 식별하기 위해 하나의 단어보다 더 많은 '표현' 이 필요하다면, 문자, 숫자, object 등등 여러가지를 조합한 배열 형태의 key 도 사용이 가능합니다.

공식 문서에서는 다음과 같은 예시를 제시하고 있습니다.

// 💥주의! key는 표현이 그렇다는거지, api 로직과는 관련이 없어요!

// ID가 5인 todo 아이템 1개
useQuery(['todo', 5], ...)
// queryKey === ['todo', 5]

// ID가 5인 todo 아이템 1개인데, preview 속성은 true야
useQuery(['todo', 5, { preview: true }], ...)
// queryKey === ['todo', 5, { preview: true }]

// todolist 전체인데, type은 done이야
useQuery(['todos', { type: 'done' }], ...)
// queryKey === ['todos', { type: 'done' }]

 

두 번째 인자, 'fetchTodoList' 를 쿼리 함수(Query Function) 이라고 부릅니다.

1. 쿼리 함수는 promise 객체를 return 합니다.

2. promise 객체는 반드시 data 를 resolve 하거나 에러를 내야 합니다.

resolve 는 정상적으로 통신이 되었음을 의미하는데요. 원했던 상황이 아닌 경우, 즉 오류가 발생한 경우에는 그에 맞는 적절한 오류 처리 관련 로직을 삽입해서 처리를 해줘야만 합니다. axios, fetch, graphql 중 어떤 방법을 이용하던지 적절한 오류 처리를 통해 사용자가 혼란에 빠지지 않도록 해줘야 합니다.

3. useQuery 의 결과물에 대해

useQuery 를 통해 얻은 결과물은 객체(object)입니다. 그 안에는 '조회' 를 요청한 결과에 대한 거의 모든 정보가 들어있고, 그 과정에 대한 정보도 다음과 같이 들어있습니다.

  • 시작하면, isLoading 이 true 가 됩니다.
  • 조회 결과 오류가 나면, isError 가 true 가 됩니다. isLoading 은 false 가 되겠죠. error 객체를 통해 좀 더 상세한 오류 내용을 확인할 수 있습니다.
  • 조회 결과 정상이 되면, isSuccess 가 true 가 됩니다. isLoading 은 false 가 되겠죠. data 객체를 통해 좀 더 상세한 조회 결과를 확인할 수 있습니다.

 

mutaitions

위에서 언급했듯이, query 와는 다르게 mutation 은 CUD 에서 사용됩니다.

useMutation 예제를 다시 한 번 짚어보겠습니다.

function App() {
   const mutation = useMutation(newTodo => {
     return axios.post('/todos', newTodo)
   })
 
   return (
     <div>
       {mutation.isLoading ? (
         'Adding todo...'
       ) : (
         <>
           {mutation.isError ? (
             <div>An error occurred: {mutation.error.message}</div>
           ) : null}
 
           {mutation.isSuccess ? <div>Todo added!</div> : null}
 
           <button
             onClick={() => {
               mutation.mutate({ id: new Date(), title: 'Do Laundry' })
             }}
           >
             Create Todo
           </button>
         </>
       )}
     </div>
   )
 }

useMutation 은 hook 이고, 함수이자 API 입니다.

  • mutation.mutate(인자)
    • 인자는 반드시 한 개의 변수 또는 객체여야 합니다.
    • mutation.mutate(인자1, 인자2) → 오류
  • 결과를 객체(object 형태로) 갖고 있습니다.
  • 그 결과물 객체는 항상 어느 상태 중 하나에 속합니다.
    • isIdle : 가동되지 않는
    • isLoading
    • isError : error 객체를 항상 품고 있습니다.
    • isSuccess : data 객체를 항상 품고 있습니다.