관리자 페이지에서 피드백 처리나 삭제를 하면 서버에 API 요청을 보내고, 낙관적 업데이트를 적용한 뒤 오류 시 되돌리는 방식을 사용하고 있었다.
하지만 이렇게 하면 다른 컴포넌트에서 피드백 리스트와 연관된 데이터(패널의 완료·미처리 건수, 총 건의 수)가 갱신되지 않는 문제가 발생했다.
몹프로그래밍을 하면서 POST 요청 이후에는 대부분 리패칭이 필요하다는 점을 알게 되었고, 이를 위해 리패칭 함수를 만들기로 했다

피드백 상태 변경되어도 통계가 변경되지 않는 문제
피드백 리스트는 무한 스크롤 구조로 되어 있어, 데이터를 불러올 때마다 서버에서 hasNext와 nextCursorId를 받아 상태로 저장한다.
const fetchMore = async (size: number = DEFAULT_SIZE) => {
const response = await apiClient.get<{ data: ResponseData }>(requestUrl);
const responseData = response.data;
setItems((prev) => [...prev, ...responseData[key]]);
setHasNext(responseData.hasNext);
setCursorId(responseData.nextCursorId);
};
문제는 리패칭을 하려면 처음부터 현재 페이지까지 전부 요청해야 한다는 점이다.
예를 들어, 10번의 무한스크롤 요청이 있었다면 리패칭 시 10번의 API 호출을 순차적으로 해야 한다.
이 경우 POST 요청 이후 데이터를 다시 불러오는 동안 긴 로딩 상태가 발생한다.
이를 해결하기 위해 매 요청 시 받은 nextCursorId를 useRef로 따로 저장하는 방법을 고려했다. 이렇게 하면 리패칭 시 상태값 대신 참조값을 이용해, 이전 페이지들의 커서 아이디를 추적할 수 있다.
하지만 이 방식도 다음과 같은 문제를 가진다.
이런 한계를 보완하기 위해, 이전 데이터를 캐싱해 두고 리패칭 중에는 캐싱된 데이터를 우선 보여주는 방법도 고민했다.
이렇게 하면 사용자는 로딩 없이 기존 화면을 볼 수 있고, 백그라운드에서 새로운 데이터를 받아온 뒤 교체할 수 있다.
다만, 이 역시 캐싱 로직을 직접 구현해야 하고, API별로 별도의 캐싱·리패칭 처리가 필요해 중복 코드와 유지보수 부담이 커질 수 있다.
이러한 문제를 해결하기 위해 Tanstack Query를 도입하게 되었다.
Tanstack Query는 다음과 같은 이점을 제공한다.
결과적으로 Tanstack Query를 사용하면 낙관적 업데이트와 리패칭 문제를 단순화할 수 있고,
무한스크롤 데이터도 기존 데이터를 유지한 채 새 데이터를 가져오는 UX를 구현하기가 훨씬 수월해진다.
@tanstack/react-query 와 @tanstack/react-query-devtools를 다운받는다.
react-query-devtools은 리액트 쿼리 개발 도구를 지원한다.
react-query-devtools 사용법 보기
기본적으로 npm run dev로 실행하면 오른쪽 하단에 야자수 아이콘이 생성된다.

해당 버튼을 클릭하면 현재 캐싱되고 있는 데이터 목록을 보여준다.

배열로 들어가 있는 값들이 캐싱 키값이다. 해당 리스트를 클릭하면 아래와 같이 보이게 된다.

캐싱된 데이터를 확인할 수 있고 useQuery를 사용할 때 설정한 세팅값도 확인할 수 있다.
index.tsx에 사용
root.render(
**// QueryClientProvider 사용**
<QueryClientProvider client={queryClient}>
<ErrorModalProvider>
<ThemeProvider theme={theme}>
<Sentry.ErrorBoundary>
<ModalProvider>
<RouterProvider router={router} />
**// 개발 환경일 때만 ReactQueryDevtools 사용**
{process.env.NODE_ENV === 'development' && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</ModalProvider>
</Sentry.ErrorBoundary>
</ThemeProvider>
</ErrorModalProvider>
</QueryClientProvider>
);
현재 데이터 동기화가 필요한 피드백 리스트 불러오기(무한 스크롤)과 조직 통계 가져오는 것(일단 get)을 useQuery와 useInfinityScroll을 이용하여 수정해보자.
export default function useUserOrganizationsStatistics() {
const [statistics, setStatistics] = useState<StatisticsProps>({
reflectionRate: '0',
confirmedCount: '0',
waitingCount: '0',
totalCount: '0',
});
useEffect(() => {
const getData = async () => {
const response = (await getOrganizationStatistics({
organizationId: 1,
})) as GetOrganizationStatistics;
setStatistics(response.data);
};
getData();
}, []);
return { statistics };
}
const EMPTY: StatisticsProps = {
reflectionRate: '0',
confirmedCount: '0',
waitingCount: '0',
totalCount: '0',
};
export default function useUserOrganizationsStatistics() {
const { data = EMPTY } = useQuery({
queryKey: ['organizationStatistics', 1],
queryFn: () => getOrganizationStatistics({ organizationId: 1 }),
select: (res: GetOrganizationStatistics) => res.data,
});
return { statistics: data };
}
이전에는 useEffect를 사용해 마운트 시 데이터를 가져오고, useState로 직접 상태를 관리했지만, useQuery를 사용하면 훨씬 간단하고 직관적인 코드로 변경할 수 있다.
useQuery는 다음과 같이 동작한다.
queryKey

queryKey와 동일하게 저장된 모습
queryFn
select
import { useInfiniteQuery } from '@tanstack/react-query';
import { apiClient } from '@/apis/apiClient';
import { ApiResponse } from '@/types/notification.types';
const DEFAULT_SIZE = 10;
const MAX_RETRY_COUNT = 3;
interface UseCursorInfiniteScrollParams<Key extends string> {
url: string; // 예: /api/items?foo=bar
key: Key; // 응답에서 아이템 배열의 키
size?: number; // 페이지 사이즈 (기본 10)
enabled?: boolean; // false면 패칭 비활성화
}
export default function useCursorInfiniteScroll<
T extends object,
Key extends string,
ResponseData extends Record<Key, T[]> & {
hasNext: boolean;
nextCursorId: number;
},
>({
url,
key,
size = DEFAULT_SIZE,
enabled = true,
}: UseCursorInfiniteScrollParams<Key>) {
const query = useInfiniteQuery({
queryKey: ['infinity', key, url],
enabled: enabled && Boolean(url),
retry: MAX_RETRY_COUNT,
initialPageParam: null as number | null,
queryFn: ({ pageParam }) =>
fetchCursorPage<ResponseData>({
url,
size,
pageParam,
}),
getNextPageParam: (lastPage) =>
lastPage?.hasNext ? lastPage?.nextCursorId : undefined,
});
const items = query.data?.pages.flatMap((p) => p[key] as T[]) ?? [];
return {
items,
fetchMore: query.fetchNextPage,
hasNext: query.hasNextPage,
loading: query.isFetchingNextPage,
};
}
export async function fetchCursorPage<ResponseData>(params: {
url: string;
size: number;
pageParam: number | null;
}): Promise<ResponseData> {
const { url, size, pageParam } = params;
const queryString = new URLSearchParams({ size: String(size) });
if (pageParam != null) queryString.append('cursorId', String(pageParam));
const sep = url.includes('?') ? '&' : '?';
const requestUrl = `${url}${sep}${queryString.toString()}`;
const res = await apiClient.get<ApiResponse<ResponseData>>(requestUrl);
const payload = res?.data as ResponseData;
return payload;
}
initialPageParam: null as number | null,null as number을 사용한 이유: pageParam의 값이 초기에는 null이고, 다음부터는 숫자가 들어온다. 그래서 as number를 사용하여 number | null 이라고 알려줌기존 코드
const handleConfirmFeedback = async (comment: string) => {
const { feedbackId } = modalState;
if (feedbackId) {
onConfirmFeedback?.(feedbackId, comment);
try {
await patchFeedbackStatus({
feedbackId,
comment,
});
} catch (e) {
showErrorModal(e, '에러');
}
}
closeModal();
};
useMutation을 사용한 코드
const queryClient = useQueryClient();
const confirmMutation = useMutation({
mutationFn: ({
feedbackId,
comment,
}: {
feedbackId: number;
comment: string;
}) => patchFeedbackStatus({ feedbackId, comment }),
onError: (e) => {
showErrorModal(e, '에러');
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['organizationStatistics', organizationId],
});
queryClient.invalidateQueries({ queryKey: ['infinity', 'feedbacks'] });
},
});
const handleConfirmFeedback = async (comment: string) => {
const { feedbackId } = modalState;
if (!feedbackId) return;
await confirmMutation.mutateAsync({ feedbackId, comment });
closeModal();
};
코드는 길어졌지만 선언형으로 작성되어 이해하기 쉬워졌다.

데이터 동기화 완료!
멋있어요..