React Application에서 서버 상태를 불러오고, 캐싱하며, 지속적으로 동기화하고 업데이트하는 작업을 도와주는 라이브러리입니다.
기존에는 API를 직접 호출하여 요청을 처리했으나, 중복된 요청으로 인한 성능 저하 문제와 코드 복잡도, 유지보수의 문제를 해결하기 위해 등장하게 되었습니다.
ReactQuery의 등장으로 React Component 내부에서 간단하고 직관적으로 API를 사용할 수 있습니다. 더 나아가 React Query에서 제공하는 캐싱, Window Focus Refetching 등 다양한 기능을 활용하여 API 요청과 관련된 번잡한 작업 없이 “핵심 로직”에 집중할 수 있습니다.
API를 직접 호출하여 요청을 처리하다보니 중복된 요청으로 인한 성능 저하 문제가 발생할 가능성이 높고 기능이 많아짐에 따라 코드 복잡도가 올라가 유지보수의 문제가 발생하게 되어 사용하게 되었습니다.
추가로, 댓글이나 게시글처럼 업데이트가 일어나자마자 최신상태가 반영되어야 하는데 Refetching을 통해 필요한 상황에 반영이 되기 때문에 적용하고자 하였습니다.
현재 프로젝트에서 React-Query는 다양하게 사용되고 있지만, 그 중 댓글 기능을 예시로 설명해보고자 합니다.
해당 사진처럼 현재 프로젝트에서는 댓글 → 대댓글 구조로 이루어져 있으며, 모달 내에서 작업이 이루어지기에 요청에 따른 페이지 변환은 없는 상태입니다.
기존에는 useEffect를 이용해 댓글 컴포넌트가 Mount될 때 데이터를 Fetch하여 useState를 업데이트 하는 식으로 구현하였습니다.
하지만, 아래와 같은 문제점이 있었습니다.
해당 프로젝트에서는 Next 13, React-Query v4를 사용하고 있습니다.
$ npm i @tanstack/react-query
# or
$ pnpm add @tanstack/react-query
# or
$ yarn add @tanstack/react-query
"use client";
import React from "react";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
type Props = {
children: React.ReactNode;
};
function ReactQuery({ children }: Props) {
const [client] = React.useState(
new QueryClient({
defaultOptions: {
queries: {
// 창이 다시 포커스 될 때 쿼리를 자동으로 다시 가져오는 옵션을 비활성화
refetchOnWindowFocus: false,
// 쿼리 재시도를 비활성화
retry: false,
},
},
}),
);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
export default ReactQuery;
여기서 QueryClientProvider
는 React Query에서 제공하는 컴포넌트 중 하나로, React 트리 안에서 React Query의 상태를 관리하기 위한 컨텍스트를 제공합니다. 이 컨텍스트는 하위 컴포넌트에서 React Query를 사용할 수 있도록 만들어줍니다.
QueryClient
는 React Query에서 제공하는 핵심 클래스로, 데이터 쿼리와 관리를 담당합니다. QueryClient
인스턴스를 생성하고 초기 설정을 하면, 이를 QueryClientProvider
에 제공하여 애플리케이션의 컨텍스트에 포함시킵니다. 이를 통해 애플리케이션 전역에서 React Query를 사용할 수 있게 됩니다.
백엔드 서버에서 해당 글에 작성된 댓글 정보를 가져오기 위해 useQuery Hook을 사용합니다.
GET 메서드로 해당 게시글(board) ID으로 API 요청을 보내는 getComment
함수를 선언합니다.
// app/api/comment.ts
import { csrFetch } from "@/api/utils/csrFetch";
export const getComment = async (id: number | undefined) => {
const endpoint = `comments?board_id=${id}`;
const data = await csrFetch(endpoint, {
method: "GET",
});
return data;
};
csrFetch
함수는 클라이언트 환경에서 쿠키 정보에 접근하는 fetch 함수입니다.
import Cookies from "js-cookie";
import { baseFetch } from "./baseFetch";
export const csrFetch = async <T = any>(
endpoint: string,
options?: RequestInit,
): Promise<T> => {
const accessToken = Cookies.get("accessToken");
const authHeader: Record<string, string> = {};
if (accessToken) {
authHeader.user_id = `${accessToken}`;
}
let headers: Record<string, any> = {
...authHeader,
...options?.headers,
};
const skipDefaultHeaders = options?.body instanceof FormData;
return baseFetch<T>(endpoint, headers, options, skipDefaultHeaders);
};
아래와 같이 도메인 별로 쿼리키를 분리해서 사용하도록 하였습니다.
export const COMMENT_QUERIES = {
BOARD_COMMENT: (boardId: number | undefined) => ["BOARD_COMMENT", boardId],
}
쿼리키를 중앙에서 관리하고 상수로 정의하면, 향후에 특정 쿼리 키의 형식이나 구조를 변경해야 할 경우 해당 변경을 한 곳에서만 수행하면 됩니다.
이는 코드의 유지보수와 가독성을 향상시킵니다. 쿼리키를 동적으로 생성할 때, 함수를 사용하여 키를 생성하면 해당 키를 사용하는 여러 곳에서 일관된 방식으로 생성됩니다.
// app/api/hooks/queries/comment.ts
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { COMMENT_QUERIES } from "@/constants/queryKeys";
import {
getComment,
} from "@/api/comment";
import { APIErrorResponse } from "@/constants/types";
export const useBoardCommentGetQuery = (id: number | undefined) => {
return useQuery([COMMENT_QUERIES.BOARD_COMMENT(id)], () => getComment(id));
};
useQuery
훅을 사용하여 데이터를 가져오는 React Query 쿼리 훅을 정의합니다.
쿼리키는 [COMMENT_QUERIES.BOARD_COMMENT(id)]
로 지정되어 있습니다. 이 키는 게시판 ID에 기반하여 동적으로 생성됩니다.
...
const { data: commentData } = useBoardCommentGetQuery(isSelectedTable);
...
<CommentModal
isOpen={commentModalOpen}
initialValue={commentData}
boardId={boardData.id}
/>
...
작성한 함수는 위와 같이 사용할 수 있습니다.
useMutation
는 서버의 데이터를 변경할 때 사용합니다.
HTTP POST, PUT, DELETE
요청에서 사용할 수 있습니다.
// app/api/comment.ts
import { csrFetch } from "@/api/utils/csrFetch";
import { CreateCommentRequest, UpdateCommentRequest } from "@/constants/types";
import { createQueryString } from "@/api/utils/fetchUtils";
...
export const createComment = async ({
boardId,
commentId,
content,
}: CreateCommentRequest) => {
const queryString = createQueryString({
board_id: boardId,
comment_id: commentId,
});
const endpoint = `comments?${queryString}`;
const body = JSON.stringify({
content,
});
await csrFetch(endpoint, {
method: "POST",
body: body,
});
};
export const updateComment = async ({
commentId,
content,
}: UpdateCommentRequest) => {
const endpoint = `comments/${commentId}`;
const body = JSON.stringify({
content,
});
await csrFetch(endpoint, {
method: "PUT",
body: body,
});
};
export const deleteComment = async (id: number) => {
const endpoint = `comments/${id}`;
await csrFetch(endpoint, {
method: "DELETE",
});
};
위에서 사용했던 것처럼 csrFetch
함수를 사용하여 서버와의 HTTP 통신을 처리하고 있습니다.
// app/api/hooks/queries/comment.ts
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { COMMENT_QUERIES } from "@/constants/queryKeys";
import {
createComment,
deleteComment,
getComment,
updateComment,
} from "@/api/comment";
import { APIErrorResponse } from "@/constants/types";
...
export const useCreateCommentMutation = (
successAction: () => void,
errorAction: (message: string) => void,
) => {
const queryClient = useQueryClient();
return useMutation(createComment, {
onSuccess: (_data, variables) => {
queryClient.invalidateQueries([
COMMENT_QUERIES.BOARD_COMMENT(variables.boardId),
]);
successAction();
},
onError: (error: APIErrorResponse) => {
errorAction(error.message);
},
});
};
export const useUpdateCommentMutation = (
successAction: () => void,
errorAction: (message: string) => void,
) => {
const queryClient = useQueryClient();
return useMutation(updateComment, {
onSuccess: (_data, variables) => {
queryClient.invalidateQueries([
COMMENT_QUERIES.BOARD_COMMENT(variables.boardId),
]);
successAction();
},
onError: (error: APIErrorResponse) => {
errorAction(error.message);
},
});
};
export const useDeleteCommentMutation = (
boardId: number,
successAction: () => void,
errorAction: (message: string) => void,
) => {
const queryClient = useQueryClient();
return useMutation(deleteComment, {
onSuccess: () => {
queryClient.invalidateQueries([COMMENT_QUERIES.BOARD_COMMENT(boardId)]);
successAction();
},
onError: (error: APIErrorResponse) => {
errorAction(error.message);
},
});
};
useMutation
훅을 사용하여 댓글을 생성(create), 수정(update), 삭제(delete)하는 세 가지 동작에 대한 mutation을 정의하고 있습니다.
이러한 mutation들은 성공 또는 실패 시에 적절한 액션을 수행하고, React Query의 queryClient
를 사용하여 관련된 쿼리들을 다시 불러오는 등의 기능을 구현하고 있습니다.
queryClient.invalidateQueries
는 전달받은 queryKey의 Query를 invalid 처리하고 해당 Query가 active할 경우 다시 refetch 해줍니다. 특정 API 호출 후, 데이터를 갱신하고 싶을 때 사용할 수 있습니다.
생성(create), 수정(update)으로 예시를 들자면,
...
const { mutate: updateCommentMutation } = useUpdateCommentMutation(
successUpdateAction,
errorUpdateAction,
);
const { mutate: createCommentMutation } = useCreateCommentMutation(
successCreateAction,
errorCreateAction,
);
// 대댓글
const createReplyComment = (
boardId: number,
commentId: number,
content: string,
) => {
createCommentMutation({
boardId: boardId,
commentId: commentId,
content: content,
});
};
...
// 댓글
const createNewComment = (boardId: number, content: string) => {
createCommentMutation({
boardId: boardId,
content: content,
});
};
...
위와 같이 사용할 수 있습니다.
참고
https://tanstack.com/query/v4/docs/react/overview