현 프로젝트에서 React Query
로 API 호출 로직을 리팩토링하면 다음과 같은 이점이 있습니다:
캐싱이란 특정 데이터의 복사본을 저장하여 이후 동일한 데이터의 재접근 속도를 높이는 것을 말한다!
React-Query는 캐싱을 통해 동일한 데이터에 대한 반복적인 비동기 데이터 호출을 방지하고, 이는 불필요한 API 콜을 줄여 서버에 대한 부하를 줄이는 좋은 결과를 가져온다.
💡 최신의 데이터인지 어떻게 판별하는데??
❔여기서 궁금한 것은 데이터가 최신의 것인지 아닌지에 대한 것이다.
만일 서버 데이터를 불러와 캐싱한 후, 실제 서버 데이터를 확인했을 때 서버 상에서 데이터의 상태가 변경되어있다면, 사용자는 실제 데이터가 아닌 변경 전의 데이터를 바라볼 수밖에 없게 된다. 이는 사용자에게 잘못된 정보를 보여주는 에러를 낳는다.
💡 참고로, React-Query에서는 최신의 데이터를 fresh한 데이터, 기존의 데이터를 stale한 데이터라고 말한다!!
간소화된 코드:
useQuery
, useMutation
등을 사용하여 선언적 코드를 작성할 수 있습니다.로딩 및 에러 처리의 일관성:
isLoading
)와 에러 상태(error
)를 기본 제공하며, 이를 활용하여 UI를 간단히 구성할 수 있습니다.👉 즉, Client 데이터는 상태 관리 라이브러리가 관리하고, Server 데이터는 React-Query가 관리하는 구조라고 생각하면 된다!! 이를 통해 우리는 Client 데이터와 Server 데이터를 온전하게 분리할 수 있다.
🔎 물론 여기서 React-Query가 가져온 Server 데이터를 상태 관리 라이브러리를 통해 전역 상태로 가져올 수도 있는 건 사실이다. 그러나 refetch가 여러 번 일어나는 상황에 매번 Server 데이터를 전역 상태로 가져오는 것이 옳은지 판단하는 것은 여러분의 몫이다. 개발하는 서비스의 상황에 맞게 잘 선택해보도록 하자!!
const response = await apiRequest<PageResponse<GuestbookItem>>({
param: `api/v1/guestbooks/my?page=${page}&pageSize=${pageSize}`,
method: 'get',
});
apiRequest
로 호출 관리가 용이.서비스 레이어를 별도 관리하여 API 호출을 추상화합니다.
// api/guestbookService.ts
import { apiRequest } from './apiRequest';
import type { GuestbookRequest, PageResponse, GuestbookItem } from '@/types';
export const guestbookService = {
getMyGuestbooks: (page: number, pageSize: number) => {
return apiRequest<PageResponse<GuestbookItem>>({
param: `api/v1/guestbooks/my?page=${page}&pageSize=${pageSize}`,
method: 'get',
});
},
getAllGuestbooks: (page: number, pageSize: number) => {
return apiRequest<PageResponse<GuestbookItem>>({
param: `api/v1/guestbooks/all?page=${page}&pageSize=${pageSize}`,
method: 'get',
});
},
createGuestbook: (gatheringId: number, data: GuestbookRequest) => {
return apiRequest({
param: `api/v1/guestbooks/${gatheringId}`,
method: 'post',
body: data,
});
},
updateGuestbook: (gatheringId: number, guestbookId: number, data: GuestbookRequest) => {
return apiRequest({
param: `api/v1/guestbooks/${gatheringId}/${guestbookId}`,
method: 'put',
body: data,
});
},
deleteGuestbook: (gatheringId: number, guestbookId: number) => {
return apiRequest({
param: `api/v1/guestbooks/${gatheringId}/${guestbookId}`,
method: 'delete',
});
},
};
useGuestbooks
훅// hooks/useGuestbooks.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { guestbookService } from '@/api/guestbookService';
import type { GuestbookRequest } from '@/types';
export const useGuestbooks = (page = 0, pageSize = 10) => {
const queryClient = useQueryClient();
// 조회
const { data, isLoading, error } = useQuery({
queryKey: ['guestbooks', page, pageSize],
queryFn: () => guestbookService.getMyGuestbooks(page, pageSize),
});
// 생성
const createMutation = useMutation({
mutationFn: ({ gatheringId, data }: { gatheringId: number; data: GuestbookRequest }) =>
guestbookService.createGuestbook(gatheringId, data),
onSuccess: () => {
queryClient.invalidateQueries(['guestbooks']);
},
});
// 수정
const updateMutation = useMutation({
mutationFn: ({ gatheringId, guestbookId, data }: { gatheringId: number; guestbookId: number; data: GuestbookRequest }) =>
guestbookService.updateGuestbook(gatheringId, guestbookId, data),
onSuccess: () => {
queryClient.invalidateQueries(['guestbooks']);
},
});
// 삭제
const deleteMutation = useMutation({
mutationFn: ({ gatheringId, guestbookId }: { gatheringId: number; guestbookId: number }) =>
guestbookService.deleteGuestbook(gatheringId, guestbookId),
onSuccess: () => {
queryClient.invalidateQueries(['guestbooks']);
},
});
return {
guestbooks: data?.content || [],
pagination: {
currentPage: data?.currentPage || 0,
totalPages: data?.totalPages || 0,
totalElements: data?.totalElements || 0,
},
isLoading,
error,
createGuestbook: createMutation.mutate,
updateGuestbook: updateMutation.mutate,
deleteGuestbook: deleteMutation.mutate,
};
};
useAllGuestbooks
훅export const useAllGuestbooks = (page = 0, pageSize = 10) => {
const { data, isLoading, error } = useQuery({
queryKey: ['allGuestbooks', page, pageSize],
queryFn: () => guestbookService.getAllGuestbooks(page, pageSize),
});
return {
guestbooks: data?.content || [],
pagination: {
currentPage: data?.currentPage || 0,
totalPages: data?.totalPages || 0,
totalElements: data?.totalElements || 0,
},
isLoading,
error,
};
};
// GuestbookTab.tsx
import React, { useState, useCallback } from 'react';
import { useGuestbooks } from '@/hooks/useGuestbooks';
import Loading from '@/components/Loading';
import WrittenGuestbooks from '@/components/WrittenGuestbooks';
import AvailableGuestbooks from '@/components/AvailableGuestbooks';
export default function GuestbookTab() {
const [page, setPage] = useState(0);
const { guestbooks, pagination, isLoading, createGuestbook, updateGuestbook, deleteGuestbook } = useGuestbooks(page);
const handleModalSubmit = useCallback(async (data: { content: string; rating: number }) => {
try {
if (isEditMode) {
await updateGuestbook({ gatheringId, guestbookId, data });
} else {
await createGuestbook({ gatheringId, data });
}
} catch (e) {
console.error(e);
}
}, [createGuestbook, updateGuestbook]);
return (
<div>
{isLoading ? (
<Loading />
) : (
<>
<WrittenGuestbooks guestbooks={guestbooks} />
<AvailableGuestbooks onWriteClick={() => {}} />
</>
)}
</div>
);
}
React Query로 리팩토링하면 다음과 같은 이점이 있습니다:
useInfiniteQuery
) 구현이 간단.Feature | Zustand | React Query |
---|---|---|
주요 목적 | 클라이언트 상태 관리 | 서버 상태 관리 |
데이터 캐싱 | 수동으로 구현 필요 | 자동으로 데이터 캐싱 |
데이터 무효화 | 커스텀 로직 필요 | 자동으로 무효화 (invalidateQueries 사용) |
로딩 및 에러 상태 관리 | 수동으로 상태 변수 추가 필요 | 내장 로딩 및 에러 상태 지원 (isLoading , error ) |
페이지네이션/무한 스크롤 | 커스텀 구현 필요 | 내장 지원 (useInfiniteQuery ) |
중복 호출 방지 | 직접 관리 필요 | 캐싱된 데이터를 활용해 중복 호출 방지 |
사용 예시 | UI 상태 (모달, 토스트, 필터) | 서버 데이터 (API 호출, CRUD 작업, 실시간 데이터 동기화) |
확장성 | 단순한 상태 관리에서 유리 | 복잡한 데이터 의존성과 동기화가 필요한 경우 적합 |
이를 통해 유지보수성과 생산성을 동시에 높일 수 있습니다. Zustand
같은 상태 관리 라이브러리는 로컬 UI 상태에만 사용하는 것이 더 적합해집니다.