aotoyae

[React] React Query : 미들웨어 대신 리액트 쿼리 본문

React

[React] React Query : 미들웨어 대신 리액트 쿼리

aotoyae 2024. 2. 20. 14:59

 

 

 

 

💡 리액트 쿼리에 대해 알아보자.

 

✳️ 기존 미들웨어의 한계

다른 서버와의 API 통신과 비동기 데이터 관리를 위해 Redux-thunk, Redux-saga 등

미들웨어를 채택해 사용할 수 있지만, 다음과 같은 문제가 있다.

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

✳️ 리액트 쿼리의 강점 : 너무 쉽고, 책임에서 자유롭다.

  • 보일러 플레이트 만들다가 오류날 일이 없다.
  • 내가 만든 부분이 아니니 잘못이 일어나도 내 잘못이 아니다.
  • 사용방법이 기존 thunk 대비 너무 쉽고, 직관적!

✳️ 주요 키워드

  • Query : 어떤 데이터에 대한 요청을 의미 - axios 의 get
  • Mutation : 어떤 데이터(데이터 그룹 자체)를 변경(추가, 수정 삭제)하는 것, CUD - axios 의 post, put, patch, delete
  • Query Invalidation : query 를 invalidatio, 즉 무효화시킨다.
    기존에 가져온 query 는 서버 데이터이기 때문에, 언제든지 변경이 있을 수 있다.
    그러니 최신 상태가 아닐 수 있다. 그런 경우, 기존의 쿼리를 무효화 시킨 후 최신화 시켜야 한다.이 과정을 리액트 쿼리에선 알아서 해준다! 바로 Query Invalidation 기능으로!

 

🤓 이제 써보자!

프로젝트 파일 만들고

npm install axios or yarn add axios ~

npm install json-server or yarn add json-server ~

db.json 만들어 데이터 넣어준 뒤 json-server --watch db.json --port 4000

그리고 yarn add react-query

or npm i @tanstack/react-query 🔗 https://velog.io/@gkj8963/React-Query

 

✚ React Query VS Tanstack Query

리액트 쿼리가 버전 4부터 탄스택 쿼리로 변경됐다. 리액트뿐 아니라 Vue 등 다른 SPA 프레임워크에 적용할 계획 ~ ?

그래서 버전별 설치 명령어가 다른 것!

yarn add react-query "^3.39.3"

yarn add@tanstack/react-query "^4.29.19"

 

✚❗️ v4, v5부터는 query key 를 반드시 배열 형태로 써줘야 한다. 

useQuery("todos", getTodos); // 에러

userQuery(["todos"], getTodos); // 성공

useQuery({queryKey:['todo'],queryFn: getTodos}) // v5 tanstack

 

 

db.json

{
  "todos": [
    { "id": "1", "title": "todo1", "contents": "react", "isDone": false },
    { "id": "2", "title": "todo2", "contents": "thunk", "isDone": true },
    { "id": "3", "title": "todo3", "contents": "query", "isDone": false }
  ]
}

 

src/App.jsx

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

const queryClient = new QueryClient(); // 쿼리클라이언트를 만들어

const App = () => {
  return (
    <QueryClientProvider client={queryClient}> // 기존에 Provider 로 감쌌던 것처럼 감싸준다.
      <Router />
    </QueryClientProvider>
  );
};

export default App;

 

루트 폴더에 .env 생성

src/api/todos.js 생성

REACT_APP_SERVER_URL = http://localhost:4000
import axios from "axios";

export const getTodos = async () => {
  const reponse = await axios.get(`${process.env.REACT_APP_SERVER_URL}/todos`);
  console.log("reponse", reponse);
  return reponse; // ** 반드시 리턴!
};

 

아래에서 useQuery 를 쓰기 전.. 잠시 알아보자.

❗️ useQuery 의 첫 번 째 인자 "todos" ➡️ 쿼리의 키! Query Keys

어느 컴포넌트에서든 이 키로 무효화 가능

이름이 꼭 유니크해야한다! 같은 이름으로 또 만들면 안됨!

// Query Keys 의 구조
useQuery('todos', ...)

// 위 코드는 내부적으로는 이렇게 해석된다. 배열 형태!
queryKey === ['todos']

// 💥주의! 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' }]

 

다음 Query Keys 는 유니크한가 ?

useQuery(['todos', { status, page }], ...) // 1
useQuery(['todos', { page, status }], ...) // 2
useQuery(['todos', { page, status, other: undefined }], ...) // 3
// 객체는 순서를 따지지 않으므로 3번만 유니크하다.
useQuery(['todos', status, page], ...)
useQuery(['todos', page, status], ...)
useQuery(['todos', undefined, page, status], ...)
// 배열은 순서를 따지므로 모두 유니크하다.

 

❗️ useQuery 의 두 번 째 인자 "fetchTodoList" ➡️ 쿼리 함수! Query Functio

쿼리 함수는 Promise 객체를 return

Promise 객체는 반드시 data 를 resolve 하거나 에러를 낸다.

에러 시 적절한 오류 처리 로직을 반드시 넣어줘야 한다.

 

useQuery 로 얻은 결과물은 객체다!

그 안에는 우리가 '조회' 를 요청한 결과에 대한 거의 모든 정보가 들어가 있고,

그 과정에 대한 정보도 다음과 같이 들어가 있다. ** thunk 처럼 세팅을 하지 않아도 된다.

  • 시작하면 isLoadingtrue 가 된다.
  • 조회 결과 오류 시 is Error :true, isLoading : false
    error 개체를 통해 더 상세한 오류 내용을 확인할 수 있다.
  • 조회 결과 정상 처리 시 isSuccess : true, isLoading : false
    data 객체를 통해 더 상세한 조회 결과를 확인할 수 있다.

 

src/redux/components/TodoList/TodoList.jsx

import React from "react";
import { useSelector } from "react-redux";
import { StyledDiv, StyledTodoListHeader, StyledTodoListBox } from "./styles";
import Todo from "../Todo";
import { getTodos } from "../../../api/todos"; // **
import { useQuery } from "react-query"; // **

function TodoList({ isActive }) {
  const todos = useSelector((state) => state.todos); // 안 쓸 예정
  const { isLoading, isError, data } = useQuery("todos", getTodos);
  // useQuery 에는 isLoading, isError, data 이 원래 포함되어 있다!

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

export default TodoList;

 

 

데이터를 잘 가져오고 있다.

 

src/api/todos.js

export const getTodos = async () => {
  const response = await axios.get(`${process.env.REACT_APP_SERVER_URL}/todos`);
  console.log("data", response.data); // [{…}, {…}, {…}]
  return response.data; // response 중 데이터만 리턴할 수 있다.
};

 

💡 useQuery 의 아주 좋은 기능

useQuery 가 아직 진행 중이라면(완료되지 않았다면) isLoading 이 트루다!

 

App.jsx

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

  if (isLoading) {
    return <h1>Loading</h1>;
  }

 

데이터를 가져오는 동안 화면에 로딩 문구가 뜬다.

에러도 추가하면 아래와 같이 뜬다 ~

 

 

 

App.jsx

이제 기존에 useSelector 로 가져왔던 todos 가 아닌

useQuery 로 가져온 data 를 map 으로 돌려보자.

import React from "react";
import { StyledDiv, StyledTodoListHeader, StyledTodoListBox } from "./styles";
import Todo from "../Todo";
import { getTodos } from "../../../api/todos";
import { useQuery } from "react-query";

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

  if (isLoading) {
    return <h1>Loading</h1>;
  }

  if (isError) {
    return <h1>Error</h1>;
  }

  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;

 

redux 에 저장해 놨던 todos / useQuery 로 가져온 data

 

🤓 이제 mutation 을 해보자. 그 중 추가하기!

todos.js 에 addTodo 함수 추가

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

 

useMutation 은 인자 두 개를 받는다.

첫 번 째 인자 비동기 함수에 api 에 만들어 둔 addTodo 를 넣어준다.

두 번 째 인자는 결과물 객체! 이 객체는 항상 어느 상태 중 하나에 속한다.

  • isldle
  • isLoading
  • isError - error 객체를 항상 품고 있음
  • isSuccess(query 에만 있는게 아니다!) - data 객체를 항상 품고 있음

mutation.mutate(newTodo) 의 인자는 반드시 한 개의 변수나 객체여야 한다.

 

바뀐 Input.jsx

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


function Input() {
  const queryClient = useQueryClient(); // new QueryClient 를 상위에서 썼으니 아래에서 useQueryClient 를 쓸 수 있다.
  const mutation = useMutation(addTodo, {
    onSuccess: () => { // 성공시 실행 함수
      queryClient.invalidateQueries(""); // 뭘 invalidate 할지
      console.log("성공");
    },
  });
  
  // ...
  const handleSubmitButtonClick = (event) => {
  
  // ...
  mutation.mutate(newTodo); // dispatch 대신 mutation

 

기존 src/redux/components/Input/Input.jsx

import React, { useState } from "react";
import LabledInput from "../common/LabledInput";
import HeightBox from "../common/HeightBox";
import { StyledButton } from "./styles";
import { FlexDiv } from "./styles";
import RightMarginBox from "../common/RightMarginBox";
import "./styles";
import { StyledDiv } from "./styles";
import { useDispatch, useSelector } from "react-redux";
import { v4 as uuidv4 } from "uuid";
import { addTodo } from "../../modules/todos";

function Input() {
  const dispatch = useDispatch();

  // useSelector를 통한, store의 값 접근
  const todos = useSelector((state) => state.todos);

  // 컴포넌트 내부에서 사용할 state 2개(제목, 내용) 정의
  const [title, setTitle] = useState("");
  const [contents, setContents] = useState("");

  // 에러 메시지 발생 함수
  const getErrorMsg = (errorCode, params) => {
    switch (errorCode) {
      case "01":
        return alert(
          `[필수 입력 값 검증 실패 안내]\n\n제목과 내용은 모두 입력돼야 합니다. 입력값을 확인해주세요.\n입력된 값(제목 : '${params.title}', 내용 : '${params.contents}')`
        );
      case "02":
        return alert(
          `[내용 중복 안내]\n\n입력하신 제목('${params.title}')및 내용('${params.contents}')과 일치하는 TODO는 이미 TODO LIST에 등록되어 있습니다.\n기 등록한 TODO ITEM의 수정을 원하시면 해당 아이템의 [상세보기]-[수정]을 이용해주세요.`
        );
      default:
        return `시스템 내부 오류가 발생하였습니다. 고객센터로 연락주세요.`;
    }
  };

  // title의 변경을 감지하는 함수
  const handleTitleChange = (event) => {
    setTitle(event.target.value);
  };

  // contents의 변경을 감지하는 함수
  const handleContentsChange = (event) => {
    setContents(event.target.value);
  };

  // form 태그 내부에서의 submit이 실행된 경우 호출되는 함수
  const handleSubmitButtonClick = (event) => {
    // submit의 고유 기능인, 새로고침(refresh)을 막아주는 역함
    event.preventDefault();

    // 제목과 내용이 모두 존재해야만 정상처리(하나라도 없는 경우 오류 발생)
    // "01" : 필수 입력값 검증 실패 안내
    if (!title || !contents) {
      return getErrorMsg("01", { title, contents });
    }

    // 이미 존재하는 todo 항목이면 오류
    const validationArr = todos.filter(
      (item) => item.title === title && item.contents === contents
    );

    // "02" : 내용 중복 안내
    if (validationArr.length > 0) {
      return getErrorMsg("02", { title, contents });
    }

    // 추가하려는 todo를 newTodo라는 객체로 세로 만듦
    const newTodo = {
      title,
      contents,
      isDone: false,
      id: uuidv4(),
    };

    // todo를 추가하는 reducer 호출
    // 인자 : payload
    dispatch(addTodo(newTodo)); // ** 기존엔 여기서 새로 받은 투두를 dispatch 함

    // state 두 개를 초기화
    setTitle("");
    setContents("");
  };

  return (
    <StyledDiv>
      <form onSubmit={handleSubmitButtonClick}>
        <FlexDiv>
          <RightMarginBox margin={10}>
            <LabledInput
              id="title"
              label="제목"
              placeholder="제목을 입력해주세요."
              value={title}
              onChange={handleTitleChange}
            />
            <HeightBox height={10} />
            <LabledInput
              id="contents"
              label="내용"
              placeholder="내용을 입력해주세요."
              value={contents}
              onChange={handleContentsChange}
            />
          </RightMarginBox>
          <StyledButton type="submit">제출</StyledButton>
        </FlexDiv>
      </form>
    </StyledDiv>
  );
}

export default Input;

 

제출을 하고 새로고침을 하면 잘 들어가있다.

 

🤓 이제  바로 화면에 반영되도록 Query Invalidation 을 해보자.

TodoList.jsx

  const { isLoading, isError, data } = useQuery("todos", getTodos); // todos 쿼리를 가져오고 있다.

 

Input.jsx

  const mutation = useMutation(addTodo, {
    onSuccess: () => {
      queryClient.invalidateQueries("todos"); // 가져오던 todos 를 무효화, 다시 실행하기 위해
      console.log("성공");
    },
  });

 

이제 제출하면 바로 새 투두가 뜬다!