본문 바로가기

프로젝트/나무(나누고 나눔받는 무한 지식 품앗이)

[react-query] 리액트 쿼리 개념 및 사용법 간단하게 정리하기(feat. firestore)

반응형

이전의 프로젝트에서는 데이터 통신 코드를 문서화하고 useAxios 커스텀 훅을 사용하여 데이터를 실시간 반영하는 로직을 직접 구현했었다. 이것 때문에 정말 고생을 많이 했다는 슬픈 얘기... 이번에는 리액트 쿼리라는 라이브러리를 사용하여 비동기 데이터 요청 로직, 실시간 데이터 업데이트, 데이터 캐싱 등을 처리하려고 한다. 프로젝트를 진행하며 자세히 공부하도록 하겠지만, 먼저 간단하게 CRUD 예제 코드를 살펴본 뒤 이를 파이어스토어와 함께 사용하는 법까지 남겨보자.


리액트 쿼리란?

리액트 쿼리는 리액트 애플리케이션에서 데이터 상태 및 비동기 데이터 요청을 관리하기 위한 강력한 라이브러리이다. 리액트 쿼리를 사용하면 데이터를 가져오고, 업데이트하고, 캐시하며, 재사용하는 것이 간단하고 직관적이며 유연해진다. 

1. 쿼리(query)

  • 쿼리는 비동기 데이터 요청을 정의하는 객체이다.
  • useQuery 훅을 사용하여 데이터를 가져올 수 있다.
  • 쿼리는 데이터의 상태, 비동기 데이터 로드 함수, 캐시 설정 등을 포함한다.

ex) 유저 데이터 가져오기

import { useQuery } from 'react-query';

const fetchUser = async (userId) => {
  // 서버에서 사용자 데이터 가져오기
  const response = await fetch(`/api/users/${userId}`);
  const data = await response.json();
  return data;
};

const UserProfile = ({ userId }) => {
  const { data, isLoading, isError, error } = useQuery(['user', userId], () => fetchUser(userId));

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

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

  return (
    <div>
      <h2>{data.name}</h2>
      <p>{data.email}</p>
    </div>
  );
};

ex) 게시글 데이터 가져오기(firestore ver)

import { useQuery } from 'react-query';
import { collection, getDocs } from 'firebase/firestore';
import { db } from './firebase';

const fetchPosts = async () => {
  const querySnapshot = await getDocs(collection(db, 'posts'));
  const posts = [];
  querySnapshot.forEach((doc) => {
    posts.push({ id: doc.id, ...doc.data() });
  });
  return posts;
};

const PostsList = () => {
  const { data: posts, isLoading, isError } = useQuery('posts', fetchPosts);

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

  if (isError) {
    return <div>Error occurred while fetching posts</div>;
  }

  return (
    <div>
      {posts.map((post) => (
        <div key={post.id}>{post.title}</div>
      ))}
    </div>
  );
};

export default PostsList;

2. 변이(Mutation)

  • 변이는 상태를 변경하고 서버로 데이터를 보내는 데 사용된다.
  • useMutation 훅을 사용하여 변이를 수행할 수 있다.
  • 변이는 비동기 작업을 처리하고 성공 또는 실패 결과를 처리하는 로직을 포함한다.

ex) 게시글 작성하기

import { useMutation } from 'react-query';

const createPost = async (postData) => {
  // 서버에 게시글 작성 요청 보내기
  const response = await fetch('/api/posts', {
    method: 'POST',
    body: JSON.stringify(postData),
    headers: {
      'Content-Type': 'application/json',
    },
  });
  const data = await response.json();
  return data;
};

const CreatePostForm = () => {
  const mutation = useMutation(createPost, {
    onSuccess: (data) => {
      console.log('Created Post:', data);
      // 게시글 작성 완료 후 수행할 작업
    },
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    const formData = new FormData(e.target);
    const postData = Object.fromEntries(formData.entries());
    mutation.mutate(postData);
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* Form inputs */}
      <button type="submit" disabled={mutation.isLoading}>
        {mutation.isLoading ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  );
};

ex) 게시글 작성하기(firestore ver)

import { useMutation, useQueryClient } from 'react-query';
import { collection, addDoc } from 'firebase/firestore';
import { db } from './firebase';

const createPost = async (postData) => {
  const docRef = await addDoc(collection(db, 'posts'), postData);
  return { id: docRef.id, ...postData };
};

const CreatePostForm = () => {
  const queryClient = useQueryClient();
  const mutation = useMutation(createPost, {
    onSuccess: (data) => {
      queryClient.invalidateQueries('posts');
      console.log('Created Post ID:', data.id);
    },
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    const formData = new FormData(e.target);
    const postData = Object.fromEntries(formData.entries());
    mutation.mutate(postData);
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* Form inputs */}
      <button type="submit" disabled={mutation.isLoading}>
        {mutation.isLoading ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  );
};

export default CreatePostForm;

ex) 게시글 삭제하기(firestore ver)

import { useMutation, useQueryClient } from 'react-query';
import { doc, deleteDoc } from 'firebase/firestore';
import { db } from './firebase';

const deletePost = async (postId) => {
  const postRef = doc(db, 'posts', postId);
  await deleteDoc(postRef);
};

const PostItem = ({ postId }) => {
  const queryClient = useQueryClient();
  const mutation = useMutation(() => deletePost(postId), {
    onSuccess: () => {
      queryClient.invalidateQueries('posts');
    },
  });

  const handleDelete = () => {
    mutation.mutate();
  };

  return (
    <div>
      {/* Post content */}
      <button onClick={handleDelete} disabled={mutation.isLoading}>
        {mutation.isLoading ? 'Deleting...' : 'Delete Post'}
      </button>
    </div>
  );
};

export default PostItem;

3. 캐싱

  • 캐시는 이전에 로드된 데이터를 저장하는 데 사용된다.
  • 기본적으로 쿼리 결과는 자동으로 캐시되며, 동일한 쿼리를 다시 요청할 때 캐시된 데이터가 반환된다.
  • 캐시된 데이터는 자동으로 유효성 검사되고 필요한 경우 업데이트된다.

ex) 게시글 목록 가져오기

import { useQuery } from 'react-query';

const fetchPosts = async () => {
  // 서버에서 게시글 목록 가져오기
  const response = await fetch('/api/posts');
  const data = await response.json();
  return data;
};

const PostsList = () => {
  const { data, isLoading, isError, error } = useQuery('posts', fetchPosts);

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

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

  return (
    <div>
      {data.map((post) => (
        <div key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.content}</p>
        </div>
      ))}
    </div>
  );
};

ex) CRUD(firestore ver)

import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from 'react-query';
import { collection, doc, getDocs, addDoc, updateDoc, deleteDoc } from 'firebase/firestore';
import { db } from './firebase';

// 데이터 가져오기 쿼리
const fetchPosts = async () => {
  const querySnapshot = await getDocs(collection(db, 'posts'));
  const posts = querySnapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
  return posts;
};

// 게시글 생성 변이
const createPost = async (postData) => {
  const docRef = await addDoc(collection(db, 'posts'), postData);
  return { id: docRef.id, ...postData };
};

// 게시글 수정 변이
const updatePost = async (postId, postData) => {
  const postRef = doc(db, 'posts', postId);
  await updateDoc(postRef, postData);
};

// 게시글 삭제 변이
const deletePost = async (postId) => {
  const postRef = doc(db, 'posts', postId);
  await deleteDoc(postRef);
};

const PostsList = () => {
  const [selectedPost, setSelectedPost] = useState(null);
  const queryClient = useQueryClient();

  // 게시글 목록 가져오기 쿼리
  const { data: posts, isLoading, isError, error } = useQuery('posts', fetchPosts);

  // 게시글 생성 변이
  const createMutation = useMutation(createPost, {
    onSuccess: (data) => {
      queryClient.setQueryData('posts', (prevData) => [...prevData, data]);
    },
  });

  // 게시글 수정 변이
  const updateMutation = useMutation(updatePost, {
    onSuccess: () => {
      queryClient.invalidateQueries('posts');
    },
  });

  // 게시글 삭제 변이
  const deleteMutation = useMutation(deletePost, {
    onSuccess: () => {
      queryClient.invalidateQueries('posts');
    },
  });

  const handleCreatePost = async (postData) => {
    createMutation.mutate(postData);
  };

  const handleUpdatePost = async (postId, postData) => {
    updateMutation.mutate(postId, postData);
    setSelectedPost(null);
  };

  const handleDeletePost = async (postId) => {
    deleteMutation.mutate(postId);
    setSelectedPost(null);
  };

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

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

  return (
    <div>
      {selectedPost && (
        <div>
          <h2>Edit Post</h2>
          <form onSubmit={(e) => e.preventDefault()}>
            <input
              type="text"
              value={selectedPost.title}
              onChange={(e) =>
                setSelectedPost((prev) => ({ ...prev, title: e.target.value }))
              }
            />
            <textarea
              value={selectedPost.content}
              onChange={(e) =>
                setSelectedPost((prev) => ({ ...prev, content: e.target.value }))
              }
            ></textarea>
            <button onClick={() => handleUpdatePost(selectedPost.id, selectedPost)}>
              Save
            </button>
            <button onClick={() => setSelectedPost(null)}>Cancel</button>
          </form>
        </div>
      )}
      <h2>Posts</h2>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <h3>{post.title}</h3>
            <p>{post.content}</p>
            <button onClick={() => setSelectedPost(post)}>Edit</button>
            <button onClick={() => handleDeletePost(post.id)}>Delete</button>
          </li>
        ))}
      </ul>
      <h2>Create Post</h2>
      <form onSubmit={(e) => e.preventDefault()}>
        <input
          type="text"
          placeholder="Title"
          onChange={(e) =>
            setSelectedPost((prev) => ({ ...prev, title: e.target.value }))
          }
        />
        <textarea
          placeholder="Content"
          onChange={(e) =>
            setSelectedPost((prev) => ({ ...prev, content: e.target.value }))
          }
        ></textarea>
        <button onClick={() => handleCreatePost(selectedPost)}>Create</button>
      </form>
    </div>
  );
};

다양한 캐싱 관련 기능들이 있다. 캐싱 기능에 대한 자세한 내용은 공식 홈페이지를 참고해가며 적용하면 되겠다.

  1. 쿼리 캐싱(Caching Queries): 리액트 쿼리는 쿼리 결과를 자동으로 캐싱하여 이전에 가져온 데이터를 재사용할 수 있다. 이를 통해 네트워크 요청을 줄이고 성능을 향상시킬 수 있습니다. 기본적으로 쿼리 결과는 메모리에 저장되며, 필요에 따라 로컬 스토리지나 인메모리 캐시에 저장할 수도 있다.
  2. 캐시 갱신(Invalidating Cache): 데이터의 변경이 발생했을 때 캐시를 갱신할 수 있다. 이를 통해 서버로부터 최신 데이터를 다시 가져오거나, 로컬 캐시를 업데이트할 수 있다. invalidateQueries 함수를 사용하여 캐시를 갱신할 수 있으며, onSuccess 또는 onSettled 옵션을 통해 자동으로 캐시를 갱신할 수도 있다.
  3. 자동 리패칭(Automatic Refetching): 일정 시간마다 또는 컴포넌트가 활성화될 때 자동으로 데이터를 다시 가져올 수 있다. refetchInterval 또는 refetchIntervalInBackground 옵션을 사용하여 자동 리패칭을 구성할 수 있다.
  4. 인터벌 리패칭(Interval Refetching): 특정 시간 간격으로 데이터를 주기적으로 다시 가져올 수 있다. staleTime 및 refetchInterval 옵션을 사용하여 인터벌 리패칭을 구성할 수 있다.
  5. 인터셉트 요청(Intercepting Requests): 데이터 요청 전에 요청을 인터셉트하고 변경할 수 있다. 이를 통해 인증 토큰을 추가하거나 헤더를 수정하는 등의 작업을 수행할 수 있습니다. onMutate 옵션을 사용하여 요청을 인터셉트하고 수정할 수 있다.
  6. 옵티미스틱 업데이트(Optimistic Updates): 데이터 변경 요청을 즉시 반영하여 UI를 업데이트할 수 있다. 이를 통해 사용자 경험을 향상시킬 수 있습니다. onMutate 옵션과 함께 사용하여 옵티미스틱 업데이트를 구현할 수 있다.
  7. 요청 재시도(Retrying Requests): 요청이 실패했을 때 자동으로 재시도를하는 기능을 제공한다. retry, retryDelay, retryCount 등의 옵션을 사용하여 요청 재시도를 구성할 수 있다. 재시도 전략은 서버 오류, 네트워크 문제 등에 대응하여 신뢰성을 향상시킨다.
  8. 인터벌 폴링(Interval Polling): 일정한 간격으로 데이터를 주기적으로 폴링하여 변경 사항을 확인할 수 있다. pollingInterval 옵션을 사용하여 인터벌 폴링을 구성할 수 있다.
  9. 캐시 재사용(Reusing Cached Data): 여러 컴포넌트에서 동일한 쿼리를 사용하는 경우, 캐시된 데이터를 재사용할 수 있다. 이를 통해 중복된 네트워크 요청을 방지하고 애플리케이션의 성능을 향상시킬 수 있다.
  10. 의존성 관리(Dependency Management): 쿼리 간의 의존성을 설정하여 한 쿼리가 다른 쿼리의 변경에 반응하도록 할 수 있다. 이를 통해 관련된 데이터를 효율적으로 가져올 수 있습니다. useQueries 또는 useQueryClient를 사용하여 의존성 관리를 구현할 수 있다.

 

참고하기 좋은 블로그)

https://devkkiri.com/post/f14703ea-a105-46e2-89e8-26282de36a3a

 

React Query(리액트 쿼리) 개념 및 예제(1) | Kkiri Blog

이번 포스팅에서는 Server State(서버 상...

devkkiri.com

https://velog.io/@kimhyo_0218/React-Query-%EB%A6%AC%EC%95%A1%ED%8A%B8-%EC%BF%BC%EB%A6%AC-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0-useQuery

 

[React Query] 리액트 쿼리 시작하기 (useQuery)

회사에서 사용 중인 리액트 쿼리.server state를 아주 효율적으로 관리할 수 있는 라이브러리이다.기존에 isLoading, isError, refetch, 데이터 캐싱 등 개발자가 직접 만들려면 꽤 귀찮거나 까다로웠던 기

velog.io

 

반응형