[FE] React-Query

김용현·2023년 6월 16일
0

React 그리고 FE 작업을 진행하다 보면 비동기 처리에 대한 까다로움, 그리고 데이터를 관리하는 것의 복잡함은 누구나 겪는 문제라고 생각합니다. 저는저의 이러한 고민을 해결하기 위해 React-Query를 이번 프로젝트에 도입해보았습니다.

개요

React Query는 React Application에서 서버의 상태를 불러오고, 캐싱하며, 지속적으로 동기화하고 업데이트 하는 작업을 도와주는 라이브러리

  • 클라이언트 상태(state)와 서버 상태(state)를 분리하고 서버 상태를 관리하기 위한 목적으로 설계
  • React 어플리케이션 내에서 데이터 fetching, caching, 동기화 그리고, 서버 상태의 업데이트를 용이하게 만들어 준다.

React-Query 장점

  • 캐싱과 동기화를 자동으로 처리하기 때문에 데이터 관리 용이
  • 데이터 fetching을 최적화하고 중복 요청을 방지하여 네트워크 부하를 최적화
  • 서버와의 통신이 실패할 경우 재시도 로직을 자동화로 실행
  • 여러 API 엔드포인트를 효과적으로 관리하면서 코드를 간결하게 유지 가능

참고) 캐싱과 React-Query

  • 캐싱(Caching)
    • 데이터나 값을 임시로 저장해 둠으로 데이터 접근 속도롤 높이는 기술
    • ex) 인터넷에서 웹페이지를 불러올 때, 이미 불러왔던 페이지는 서버로 부터 다시 요청하지 않고 캐시에 저장한 내역을 빠르게 불러온다.
  • React-Query와 캐싱
    • React-Query는 기본적으로 데이터를 fetching 후 데이터를 캐싱
    • 캐싱한 데이터가 stale하다고 판단되면 React-Query는 데이터를 새롭게 fetching
    • Client에서 캐싱은 굉장히 주의해서 사용해야 한다
      • client에서 데이터를 fetching한 이후, 서버에서 해당 데이터의 상태가 변경되었다면
        • 사용자 : 잘못된 데이터를 확인하게 된다.
        • 개발자 : 데이터의 무결성을 해치는 경우로 인지될 수 잇다.
  • React-Query는 다음의 상황에서 데이터를 Refetching하여 데이터를 신선한 상태로 유지
    • 브라우저에 포커스가 들어왔을 경우(refetchOnWindowFocus)

    • 새로 마운트가 되었을 경우(refetchOnMount)

    • 네트워크가 끊어졌다가 다시 연결된 경우(refetchOnReconnect)

    • (기본 옵션 설정으로 인해) 캐싱에 저장된 파일은 stale로 판단되어, 새롭게 mount 될 때마다 데이터를 fetching

      // React-Query의 default 옵션값
      // 1. data가 stale한 상태일 때, 브라우저에 포커스가 들어왔을 경우 refetch
      refetchOnWindowFocus, //default: true
      // 2. data가 stale한 상태일 때, 새로 마운트 되었을 경우 refetch
      refetchOnMount, //default: true
      // 3. data가 stale한 상태일 때, 네트워크가 끊어졌다가 다시 연결된 경우 refetch
      refetchOnReconnect, //default: true
      // staleTime : 캐싱 데이터의 상태를 fresh에서 stale로 변경하는데 걸리는 시간
      staleTime, //default: 0
      // cacheTime : 데이터가 inactive 상태일 때 캐싱된 상태로 남아있는 시간
      // inactive : 쿼리 instance가 unmount 된 상태를 의미하며, 만일 새롭게 렌더링 될 경우 사용자에게 캐시에 있는 데이터를 보여준다
      // cahceTime이 지나게 되면 데이터는 가비지 콜렉터로 수집된다
      cacheTime, //default: 5분 (60 * 5 * 1000)
    • staleTime이 지났지만, ****cacheTime이 아직 지나지 않은 경우

      1. staleTime이 지났으므로 해당 데이터는 stale 상태 → 새롭게 data를 fetch(refetch)
      2. cacheTime이 지나지 않았기 때문에, refetch 하는 동안 캐시에 남아있는 데이터를 보여준다
      3. refetch가 완료된 경우, 새로운 데이터로 rerendering
    • cacheTimestaleTime과 무관하게 inactive가 된 시점을 기준으로 데이터 삭제를 결정

참고) Client 데이터와 Server 데이터의 분리

  • Redux, Recoil과 라이브러리는 클라이언트의 전역 상태를 관리하는 역할을 담당
  • React-Query를 활용한다면, 전역 상태를 관리하는 라이브러리들의 본연의 역할을 집중할 수 있도록 환경을 구성해야 한다.
  • 즉, 서버데이터와 클라이언트 데이터를 분리해야 함
    • 서버 데이터 : 서버를 거쳐 가지고 오거나, 서버로 송신하는 데이터
    • 클라이언트 데이터 : 서버에 영향을 주지 않는 데이터

React-Query 사용하기

1. useQuery

// 가장 기본적인 형태의 React Query useQuery Hook 사용 예시
const { data } = useQuery(
  queryKey, // 이 Query 요청에 대한 응답 데이터를 캐시할 때 사용할 Unique Key (required)
  fetchFn, // 이 Query 요청을 수행하기 위한 Promise를 Return 하는 함수 (required)
  options, // useQuery에서 사용되는 Option 객체 (optional)
);
  • HTTP METHOD GET 요청과 같이 서버에 저장된 “상태”를 불러와 사용할 때 Query 요청을 사용

  • Unique Key(query key)

    • 내부적으로 데이터 재요청, 캐싱, 쿼리를 공유하기 위해 사용
    • 유일한 값을 가진 데이터(string, array, 객체)
      • unique key를 배열로 넣으면 query 함수 내부에서 변수로 사용 가능

        const riot = {
          version: "12.1.1"
        };
        
        const result = useQueries([
          {
            queryKey: ["getRune", riot.version],
            queryFn: params => {
              console.log(params); // {queryKey: ['getRune', '12.1.1'], pageParam: undefined, meta: undefined}
        
              return api.getRunInfo(riot.version);
            }
          },
          {
            queryKey: ["getSpell", riot.version],
            queryFn: () => api.getSpellInfo(riot.version)
          }
        ]);
  • Query Function(fetch function)

    • Promise를 반환하는 모든 함수를 입력할 수 있다.
    • 개발자가 요청할 비동기 함수로 데이터 혹은 에러를 반환
  • useQuery가 반환하는 주요 states

    • isLoading or status === 'loading' : 현재 데이터를 요청 중이거나 아직 데이터가 없는 경우
    • isError or status === 'error' : 쿼리에서 에러가 났을 경우
      • error : 해당 property로 에러 메세지를 확인할 수 있다
    • isSuccess or status === 'success : 쿼리 요청 성공
      • data : 해당 property로 성공한 데이터를 확인할 수 있음
    • isIdle or status === 'idle' : 이 쿼리는 현재 사용할 수 없을 때
    • isFetching : 데이터 요청 중일 때 True를 반환 (내부적으로 리패칭하는 경우 포함)
  • 예시 코드1(state를 boolean으로 반환 받는 경우)

const Todos = () => {
  const { isLoading, isError, data, error } = useQuery("todos", fetchTodoList, {
    refetchOnWindowFocus: false, // react-query는 사용자가 사용하는 윈도우가 다른 곳을 갔다가 다시 화면으로 돌아오면 이 함수를 재실행합니다. 그 재실행 여부 옵션 입니다.
    retry: 0, // 실패시 재호출 몇번 할지
    onSuccess: data => {
      // 성공시 호출
      console.log(data);
    },
    onError: e => {
      // 실패시 호출 (401, 404 같은 error가 아니라 정말 api 호출이 실패한 경우만 호출됩니다.)
      // 강제로 에러 발생시키려면 api단에서 throw Error 날립니다. (참조: https://react-query.tanstack.com/guides/query-functions#usage-with-fetch-and-other-clients-that-do-not-throw-by-default)
      console.log(e.message);
    }
  });
	// isLoading이 True인 경우 Loading 표시
  if (isLoading) {
    return <span>Loading...</span>;
  }
	// 에러가 발생한 경우 에러 메세지를 표시
  if (isError) {
    return <span>Error: {error.message}</span>;
  }
	// 정상적으로 데이터를 수신한 경우 리스트로 데이터를 표시
  return (
    <ul>
      {data.map(todo => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
};
  • 예시코드 2(status로 한번에 반환 받는 경우)
function Todos() {
  const { status, data, error } = useQuery("todos", fetchTodoList);

  if (status === "loading") {
    return <span>Loading...</span>;
  }

  if (status === "error") {
    return <span>Error: {error.message}</span>;
  }

  return (
    <ul>
      {data.map(todo => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}
  • 이전 공통 PJT 코드(이전 팀에서 사용한 코드 입니다)
    • useDebateList라는 custom hook으로 사용
// 서버에 토론방 데이터를 요청하는 코드를 custom hook으로 만들어 사용
import { useQuery } from "react-query";
import customAxios from "utils/customAxios";

const axios = customAxios();

// useQuery의 Query Function 역할을 하는 함수 
const fetchDebateList = ({ queryKey }) => {
  const url = queryKey[1];
	// axios get을 통해 받는 데이터를 반환
  return axios.get(url);
}

export const useDebateList = (url) => {
	// query key : ["debateList", url] (list 형태)
	// query function : fetchDebateList
	// 이하 options
  return useQuery(["debateList", url], fetchDebateList, {
		// select : query function이 반환한 데이터를 가공하여 대신 반환하는 데이터
    select,
		// onError : 에러 발생 시, 실행할 작업 내용
    onError,
	
		// retry: 에러 발생 시, 다시 시도하는 fetch 횟수 /default: 3
    retry: 0, 
		// reftchOnwindowFocus : 데이터가 stale 상태일 경우, 사용자가 다른 작업을 하던 후(혹은 다른 웹사이트를 보던 후) 해당 웹으로 다시 돌아올 경우 refetch 하는 옵션 /default: true
    refetchOnWindowFocus: false, 
		// refetchOnMount : 데이터가 stale 상태일 경우, 마운트될 때마다 refetch하는 코드 /default: true
    refetchOnMount: true, 
		// refetchOnReconnect : 데이터가 stale 상태일 경우, 재연결될 때마다 refetch하는 코드 / default: true
    refetchOnReconnect: true,
		// staleTime : fresh -> stale로 가는데 소요되는 시간 / default : 0
    staleTime: 60*1000,
		// cacheTime : inactive된 캐싱 데이터를 삭제하는데 소요되는 시간 / default : 5분 (60 * 5 * 1000)
    cacheTime: 60*5*1000,
  });
}

// axios response로 받은 데이터를 재가공하여 반환하는 함수
const select = (response) => {
	// 이하 데이터 가공 함수
	// 해당 코드의 경우 response 내의 response.data.body.content에 있는 데이터로 정제하여 반환
  console.log("inside useDebateList select. data fetch result >> ", response);
  if (!response.data.body || response.data.body.hasOwnProperty("content")) {
    console.log("there is content property")
    return response.data.body.content;  
  }
  console.log("there is no content property")
  return response.data.body;
}

// error가 발생할 때 수행하는 함수
const onError = (err) => {
	// err.alterData: 에러가 발생 시, 대체할 데이터를 설정하는 옵션
  err.alterData = [
    {
      "roomId": 26,
      "roomName": "라면에 하나만 넣는다면?",
      "roomCreaterName": "라면조아",
      "roomDebateType": "FORMAL",
      "roomOpinionLeft": "김치",
      "roomOpinionRight": "계란",
      "roomHashtags": "#라면,#김치,#계란,#뭐든좋아",
      "roomWatchCnt": 344,
      "roomPhase": 2,
      "roomPhaseCurrentTimeMinute": 0,
      "roomPhaseCurrentTimeSecond": 5,
      "roomStartTime": "2023-02-03T12:32:51.44181",
      "roomThumbnailUrl": "https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOd05v%2FbtrXHT3JqkJ%2Fl0Qhf5QygUqOIskZwYEqWK%2Fimg.png",
      "roomCategory": "음식",
      "roomState": false,
      "leftUserList": [
        "이동진",
        "로버트다우니주니어",
        "라면조아"
      ],
      "rightUserList": [
        "영화좋아하는사람",
        "김영한",
        "안침착맨"
      ]
    }
]
  • 본문 코드 내 사용 방법
// DebateRoom을 rendering하는 component 내에 앞에서 만든 custom hook "useDebateList"를 호출
const { isLoading, data, isError, error } = useDebateList(url);
	// isLoading이 true일 경우 <Spinner />(로딩창)을 import
  if (isLoading) return <Spinner />;
	// component내 데이터를 설정하는 코드
	// 에러일경우(isError) 대체 데이터 / 정상일 경우 : data(select로 가공된 데이터)
  const debateList = isError ? error.alterData : data;

2. useMutation

  • 서버의 데이터 변경작업(post/update/delete)을 요청할 때 사용하는 api
  • 기본적인 문법
import { useMutation } from "react-query";
// 더 많은 return 값들이 있다. 
const { data, isLoading, mutate, mutateAsync } = useMutation(mutationFn, options);

mutate(variables, {
  onError,
  onSettled,
  onSuccess,
});
  • mutationFn
    • Required
    • 비동기 작업을 수행하고 프로미스를 반환하는 함수(api 요청하는 함수)
  • Options
    • onMuatate
      • mutation 전에 실행되는 함수로, 미리 렌더링 하고자 할 떄 유용
    • onSuccess
      • mutation이 성공하고 결과를 전달할 때 실행
    • onError
      • mutation이 실패했을 때 에러를 전달
    • onSettled
      • mutation의 성공/실패 여부와 상관없이 완료 되었을 때 실행
    • 이외에 mutationKey, retry, cacheTime 등의 옵션이 존재
  • **Returns(반환값)**
    • mutate
      • mutation을 실행시키는 메서드
      • mutate에 입력되는 변수들은 mutationFn으로 전달
    • mutateAsync
      • mutate의 결과를 Promise로 반환하는 함수
      • mutation의 결과를 다루거나 비동기 연쇄 로직이 필요할 경우 유용
      • 단, catch() 로 에러를 직접 처리해야 한다.
    • mutation의 형태는 다음 아래의 형태 중 한개를 가진다
      • isIdle or status === 'idle' : mutation 내에 데이터가 비어있을 때
      • isLoading or status === 'loading' : mutation 작업이 진행중일 때
      • isError or status === 'error' : mutation에서 에러가 발생했을 때
        • error : 해당 property로 수신된 에러 메세지를 확인할 수 있다
      • isSuccess or status === 'success' : mutation이 성공적으로 마치고 해당 데이터를 사용할 수 있을 때
        • data : 해당 property가 성공적으로 수신한 데이터를 확일할 수 있다.
  • 예시 코드
    1. useAddSuperHeroMutation() Hook 만들기

      // src/hooks/apis/useSuperHeroQuery.js
      
      import { useMutation, useQuery, useQueryClient } from "react-query";
      import axios from "axios";
      
      // mutationFn에 들어갈 비동기 함수 정의
      const fetchAddSuperHero = (hero) => {
        return axios.post("http://localhost:4000/superheroes", hero)
      }
      
      // useMutation을 수행하는 custom hook 작성
      export const useAddSuperHeroMutation = () => {
        return useMutation(fetchAddSuperHero) 
      }
    2. component에서 활용하기

      import { useState } from "react";
      import { Link } from "react-router-dom";
      import { useSuperHeroesQuery, useAddSuperHeroMutation } from "../hooks/apis/useSuperHeroesQuery";
      
      export const RQSuperHeroesPage = () => {
        const [newHero, setNewHero] = useState({ name: "", alterEgo: "" });
      	
        const { isLoading, data, isError, error, refetch } = useSuperHeroesQuery();
      
        // 1) useAddSuperHeroMutation() Hooks 가져오기
        const { mutate: addHero, isLoading2, isError2, error2 } = useAddSuperHeroMutation(newHero)
      
        // 2) mutate() 함수 실행부
        const handleClickAddButton = () => {
          addHero(newHero)
          setNewHero({ name: "", alterEgo: "" })
        }
      
        if (isLoading) return <h2>Loading...!!</h2>;
      
        if (isError) return <h2>{error.message}</h2>;
      
        return (
          <>
            <h2>React Query Super Heroes Page</h2>
            <div>
              <input
                value={newHero.name}
                onChange={(e) =>
                  setNewHero((prev) => ({ ...prev, name: e.target.value }))
                }
                placeholder="name"
              />
              <input
                value={newHero.alterEgo}
                onChange={(e) =>
                  setNewHero((prev) => ({ ...prev, alterEgo: e.target.value }))
                }
                placeholder="alterEgo"
              />
              <button onClick={handleClickAddButton}>Add Hero</button>
            </div>
            <button onClick={refetch}>refresh</button>
            {data?.data.map((hero) => (
              <div key={hero.name}>
                <Link to={`/rq-super-hero/${hero.id}`}>{hero.name}</Link>
              </div>
            ))}
          </>
        );
      };
    3. useAddSuperHeroMutation() hook을 호출

      • addHero 의 이름으로 mutate() 함수를 호출
    4. mutate() 함수 실행부

      • addHero 의 argument로 입력된 newHero 변수는 useAddSuperHeroMutation() 의 훅 내부의 useMutation()의 첫번째 인자인 mutationFn에 전달된다.
    5. button을 클릭했을 때, haddleClickAddButton 핸들러 함수가 실행 ⇒ mutate() 함수 실행

React-Query 시작하기

  • React-Query 라이브러리 설치
npm install react-query
  • queryClient 생성
    • 쿼리와 서버의 캐시 데이터를 관리하는 클라이언트
const queryClient = new QueryClient();
  • 자녀 컴포넌트에 캐시와 클라이언트 구성을 제공할 QueryClientProvider를 적용
    • queryClient가 가지고 있는 캐시와 모든 기본 옵션을 QueryClientProvider의 자녀 컴포넌트도 사용할 수 있음

      // src/index.tsx
      
      import React from 'react';
      import ReactDOM from 'react-dom/client';
      import { QueryClient, QueryClientProvider } from 'react-query';
      import './index.css';
      import App from './App';
      
      const root = ReactDOM.createRoot(document.getElementById('root')!)
      const queryClient = new QueryClient();
      
      root.render(
        <React.StrictMode>
          <QueryClientProvider client={queryClient}>
            <App />
          </QueryClientProvider>
        </React.StrictMode>
      );

활용 후기

React-Query를 사용하고 나서 느낀 가장 큰 장점은 깔끔한 코드를 작성할 수 있었다는 점입니다. React-Query를 통해 커스텀훅을 만들어 보기에 더 간결한 코드를 작성할 수 있는 점 그리고 api 등의 요청을 통해 받은 데이터를 비동기로 쉽게 처리하기 때문에, 훨씬 깔끔한 코드를 작성할 수 있었습니다.

또 api 통신을 실패할 때의 재요청, 데이터 캐싱과 같은 강력한 기능들을 쉽게 사용할 수 있다는 점 또한 너무나도 매력적이었습니다. 앞으로의 많은 React 프로젝트에서 익히고 사용해나가며, 많이 배워보고 싶은 기술 이었습니다.

참조

React-Query : https://react-query-v3.tanstack.com/

카카오페이 프론트엔드 개발자들이 React Query를 선택한 이유 : https://tech.kakaopay.com/post/react-query-1/

React-Query 도입을 위한 고민 (feat. Recoil) : https://tech.osci.kr/2022/07/13/react-query/

react-query : https://kyounghwan01.github.io/blog/React/react-query/basic/#usequery

[React Query] 리액트 쿼리 useMutation 기본 편 : https://velog.io/@kimhyo_0218/React-Query-리액트-쿼리-useMutation-기본-편

React Query useMutation() : https://abangpa1ace.tistory.com/266

profile
함께 일하고 싶은 개발자가 되기위해 노력 중입니다.

0개의 댓글