React Query, 왜 내 눈앞에 나타나 (프로젝트 리펙토링)

1

리펙토링

목록 보기
2/5
post-thumbnail

이번 중급 프로젝트때 꽤 많은 팀에서 React Query를 채택했더라. 😲
그래서 그런가 플젝 진행 중에도 자꾸만 이놈이 눈에 아른거렸다...
또, 멘토님이 티어 1로 줘도 될 만큼 아주 중요하다고 말씀해 주셨던게 생각나버렸다.

그래서 프로젝트가 끝난 지금 React Query를 공부해서 마이그레이션 해보려 한다.

🎀 클라이언트 상태 vs 서버 상태

클라이언트 상태 데이터는 서버와 상관없이 웹 브라우저 안에서 사용하는 데이터를 의미한다.

웹사이트의 어떤 메뉴가 열렸는지 닫혔는지, 혹은 사용자가 어떤 버튼을 눌렀는지 아닌지와 같은 UI 상태 값등이 여기에 해당한다.

서버 상태 데이터는 서버와 클라이언트가 비동기적으로 공유하는 데이터를 의미한다.
지금 이 블로그 포스팅도 서버의 데이터로 저장되어 있다가 클라이언트 쪽에서 뿌려진 것이다.

📌 로그인을 예로 들어 보겠다

서버 상태

  • 유저 인증 정보 (이메일, 비밀번호 등) 저장해 둠
  • 클라이언트 요청에 맞춰 인증 토큰 등을 발급한다.

클라이언트 상태

  • 토큰을 서버로부터 받아와서 인증 상태를 유지시킨다.

🎀 React Query란?

React Query는 위에서 설명한 서버의 상태(로딩, 에러 등)를 불러오고, 캐싱하며, 동기화하고 지속적으로 최신 데이터를 업데이트 해주는 라이브러리다.

API로 데이터를 가져오는 와중에 로딩, 결과 값, 에러 처리를 쉽게 할 수 있도록 도와준다.
또, 항상 최신의 상태를 알아서 가져와주며
캐시(cache)라는 걸 사용해서 매번 서버에서 데이터를 가져올 필요 없이 유저에게 더 빠르게 데이터를 보여주기도 한다.

가장 귀찮은 (?) Optimistic Update(사용자의 동작에 대한 응답을 기다리지 않고 미리 UI를 업데이트하는 것)를 손쉽게 처리할 수 있도록 도와준다는 이점이 있다고 하니...

안 써볼 이유가 전혀 없잖아?

드가자.

🎀 React Query 시작하기

npm install @tanstack/react-query @tanstack/react-query-devtools

@tanstack/react-query가 최신 버전이기 때문에
위 명령어로 리액트 쿼리를 설치해 주었다.

@tanstack/react-query-devtools는 리액트 쿼리 개발자 도구이다.

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}> // Context Provider
      <App />
      <ReactQueryDevtools initialIsOpen={false} /> // 개발자 도구
    </QueryClientProvider>
  </React.StrictMode>,
);

그리고 App 컴포넌트를 QueryClientProvider로 감싸주었다.
QueryClientProvider를 통해 쿼리 클라이언트를 제공해 줘야 그 안에 있는 자손 컴포넌트에서 리액트 쿼리를 사용할 수 있게 된다.

반드시 @tanstack/react-query에서 import하자.

마지막으로 리액트 쿼리 개발자 도구도 추가해 주면 된다!

npm run dev

명령어 실행 시켜줬더니 페이지 오른쪽 하단에 버튼이 하나 생겨있다.

저게 바로 리액트 쿼리 개발자 도구를 켜는 버튼이다.

초기 설정은 끄읏! 🫡

🎀 react-query 적용 전

src/api/getComments.ts

export const getComments = async (size: number, cardId: number) => {
  try {
    const response = await axiosInstance.get(`${API.COMMENTS.COMMENTS}?size=${size}&cardId=${cardId}`);

    return response.data;
  } catch (e) {
    const error = e as AxiosError;
    return error.response;
  }
};

api를 사용해서 댓글 목록을 받아오는 getComments 비동기 함수 생성

src/components/detail-comment-area/index.tsx

const [commentList, setCommentList] = useState<CommentListType[]>([]);

const setCommentReadBox = useCallback(async () => {
  try {
    const result = await getComments(size, cardId);
    if (result.status === 404) {
      return toast.error(DASHBOARD_ERROR_MESSAGES.NOT_A_MEMBER);
    }
    preventLoadRef.current = true;
    setCommentList(result.comments);
    setCursorId(result.cursorId);
  } catch (error) {
    console.error(error);
  }
}, [cardId, setCommentList, size]);

getComments 함수를 try catch 문 활용해서 상태값을 관리하고 데이터 뿌려줬었다.

🎀 react-query 적용 후

src/components/detail-comment-area/index.tsx

const result = useQuery({
  queryKey: ['comments', size, cardId],
  queryFn: () => getComments(size, cardId),
});

console.log(result);

이거 하나면 끝이다.

useQuery() 필요한 데이터를 백엔드에 요청해서 받아 오는 React Hook이다.

queryKeyqueryFn를 활용해서 받아온 result 값은 무엇이 찍히는지 console.log로 확인해 보았다.

data 안에는 우리가 필요로하는 response 데이터 값들이 잘 들어가있다.

그리고 데이터를 받아온 시간은 물론이고,
다양한 상태 정보까지 한번에 받아오고 있었다.

너,,, 왜 이제야 나타난거야?????

const { isLoading, data: commentData } = useQuery({
  queryKey: ['comments', size, cardId],
  queryFn: () => getComments(size, cardId),
});

const commentArray: CommentListType[] = commentData?.comments;

...
const DetailCommentArea = ({ idGroup, cardId }: DetailCommentAreaProps) => {
  return ( {commentArray.length > 0 &&
    commentArray.map((content) => (
    <CommentReadBox
      key={content?.id}
      commentId={content?.id}
      content={content}
      />
  ))} )
}
...

참고로 위와 같이 디스트럭쳐링으로 원하는 반환값만 불러올 수 있다.
그리고 변수 지정해서 원하는 댓글 배열만 사용하여 반복문으로 출력해 주었다.

너무 쉽다 ㄷㄷ!!

📌 캐싱 해준다.

캐시란 데이터를 미리 복사해 놓는 임시 장소를 말한다.

queryKey로 설정해준 데이터 값이 캐시로 저장되어 있다.

useQuery()는 전달 받은 queryKey로 우선 캐시에 저장된 데이터가 있는지 확인한다.
없다면? 그제야 백엔드에서 데이터를 받아온다.

📌 캐시 자동 삭제도 해준다

리액트 쿼리는 백엔드에서 이제 막 데이터를 받아와 캐시에 저장된 데이터는 fresh, 즉 신선한 상태로 우선 판단한다.
그러다 stale time이라고 불리는 특정 시간(default: 0)이 지나면 데이터는 신선하지 않은 stale 상태로 판단한다고 한다.

stale 상태일때는 캐시에 저장된 데이터가 있더라도 백그라운드에서 refetch를 진행해서 최신 상태로 업데이트 해준다고 한다. 😲😲😲

그런데, staleTime의 default는 0이고, gcTime(Garbage Collection Time : 특정 시간 이후에 필요 없는 캐시 데이터를 삭제해준다.)은 5분이다.

즉, default 값 그대로라면 매번 서버에 데이터를 재요청해서 받아오고 5분이 지나면 쓰지 않는 캐시 데이터는 삭제된다는 뜻.

관련하여 더 자세하게 알아보기

📌 status 에 관해

✨ Query Status

'pending', 'success', 'error'의 상태값 중 하나를 가지게 된다.

그리고 이 값들은 isPending, isError, isSuccess와 매칭이되니 앞서 보여준 코드처럼 꺼내 쓰면 된다.

✨ Fetch Status

queryFn에 넣어준 함수의 상태를 나타내준다. ex) getComment 함수

'fetching', 'paused', 'idle' 세가지 상태가 존재한다.

  • fetching : 쿼리 함수가 실행 중일때
  • paused : 실행은 했는데 네트워크 오류 등으로 실제로 실행되고 있지 않은 상태
  • idle : fetching도 paused도 아닌 상태

간편 그 잡채 ㅜㅜ 🍝

🎀 아직 나가지 마세요.

📌 hook으로 만들어 보자.

src/queries/useCommentQuery.tsx

const useCommentQuery = (size: number, cardId: number) => {
  const {
    isLoading: isCommentLoading,
    isError: isCommentError,
    data: commentResult,
    isSuccess,
  } = useQuery<CommentData, AxiosError>({
    queryKey: ['comments', size, cardId],
    queryFn: () => getComments(size, cardId),
  });

  return { isCommentLoading, isCommentError, commentResult, isSuccess };
};

export default useCommentQuery;

hook 만드는 법은 쉽다.
원래 만들어두었던 코드를 그대로 가져와서 넣고 원하는 반환값을 뽑아서 사용할 수 있도록 만들면 된다!

그리고 아래와 같이 필요한 파일에서 꺼내쓰면 끝!! 🤓

const { isCommentLoading, isCommentError, commentData, isSuccess } = useCommentQuery(size, cardId);

그런데 쿼리 훅을 hook 폴더 안으로 옮겨야할지 고민이다...
폴더 구조 진짜 어렵네 ㅜㅜ

📌 useMutation으로 데이터 업데이트

onClick이나 submit 이벤트로 댓글 리스트를 변경해주려 한다.

useMutation()을 작성하고, 댓글 쓰기 버튼을 눌렀을 때 mutate() 함수를 실행하도록 했다.

const uploadPostMutation = useMutation({
  mutationFn: () => postComment(commentValue, idGroup.columnId, cardId, idGroup.dashboardId),
});

const handleSubmitComment = () => {
  uploadPostMutation.mutate();
};

하지만 새로고침을 해야 반영된다는 문제가 있다.

이럴때는,
invalidateQueries()를 사용해서 원래 캐시에 가지고 있던 상태를 무효화시키고 새로 refetch 시켜주면 된다.

queryClient에 있는 invalidateQueries()를 실행시키면 된다.
queryClientuseQueryClient() 훅을 사용해서 가져올 수 있다.

const queryClient = useQueryClient();
const uploadPostMutation = useMutation({
  mutationFn: () => postComment(commentValue, idGroup.columnId, cardId, idGroup.dashboardId),
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['comments'] });
    setCommentValue('');
  },
});

이제 새로고침하지 않아도 refetch가 잘 되고 있다.
(댓글 7개에서 8개로 변경됨)

✨ 주의할 점

useMutation()에 등록된 콜백 함수들은 컴포넌트가 언마운트되더라도 실행이 되지만, mutate()의 콜백 함수들은 만약 뮤테이션이 끝나기 전에 해당 컴포넌트가 언마운트되면 실행되지 않는 특징을 가지고 있다고 한다.

그래서 다른 페이지로 리다이렉트한다든가, 혹은 결과를 토스트로 띄워주는 것과 같은 로직은 mutate()를 통해 등록해 주어야 한다.

📌 useMutation 훅으로 분리하기

훅으로 분리하는 법 보러가기 🤓

📌 낙관적 업데이트

내 최고 관심사는, 과연 React Query는 낙관적 업데이트를 어떻게 해줄까? 이다.
이거 때문에 이 모든 공부를 시작했다고 해도 과언이 아니다 ㅎㅎㅎ

원래 댓글 수정/삭제를 구현할 때, 낙관적 업데이트를 위해 useState 사용했었다.
사실 현재의 방식이 불편하다거나 어렵다는 생각을 해본적이 없어서 리펙토링을 굳히 해주어야하나 고민했었다.

하지만 이왕 react query 만나버린 김에 마이그레이션도 싹 해버리자 🤓

🫡 공부해서 추가할 예정








압도적 감사 (참고 자료)

react-query로 클라이언트 상태 관리 하기
서버 상태와 클라이언트 상태
React Query 소개
[React-Query] 시작하기
코드잇 강의

profile
일단 해. 그리고 잘 되면 잘 된 거, 잘 못되면 그냥 해본 거!

2개의 댓글

comment-user-thumbnail
2024년 3월 30일

ㅋㅋㅋㅋㅋㅋ글이 너무 재밌어요

1개의 답글