React Query 심화 - 캐싱 전략과 무한 스크롤, 에러 핸들링 | 백틱reactreact-querynext.js
강경원· 2026년 1월 24일· 읽는 데 약 5분 조회 0
React Query는 클라이언트에서 서버 상태(데이터)를 관리하는 데 특화된 라이브러리입니다.
이 글에서는 기본 useQuery/useMutation에서 한 단계 나아가, 캐싱 전략, 무한 스크롤, 에러 핸들링을 중심으로 정리합니다.
1. 캐시 전략: staleTime, cacheTime
const { data, isFetching } = useQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
staleTime: 1000 * 60,
cacheTime: 1000 * 60 * 5,
});
- staleTime: 이 시간 동안은 데이터를 “신선(fresh)”하다고 보고,
같은 컴포넌트가 마운트되어도 다시 요청하지 않습니다.
- cacheTime: 컴포넌트에서 더 이상 쓰지 않더라도, 이 시간 동안은 캐시로 남겨뒀다가 재사용합니다.
리스트 페이지처럼 “자주 왔다 갔다 하는 화면”은 staleTime을 적당히 길게 두면 사용자 경험이 좋아집니다.
2. prefetch와 초기 데이터
2-1. 미리 가져오기(prefetch)
queryClient.prefetchQuery({
queryKey: ["post", postId],
queryFn: () => fetchPost(postId),
});
- 사용자가 상세 페이지로 이동할 것이 거의 확실한 상황이라면, 미리 데이터를 가져와서 체감 속도를 높일 수 있습니다.
2-2. initialData 활용
const { data } = useQuery({
queryKey: ["post", post.id],
queryFn: () => fetchPost(post.id),
initialData: post,
});
- 리스트 → 상세 화면으로 이동할 때, 리스트에서 이미 일부 데이터를 들고 있다면 initialData로 먼저 보여주고 나서 최신 데이터를 fetch하는 패턴이 유용합니다.
3. 무한 스크롤: useInfiniteQuery
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ["posts", "infinite"],
queryFn: ({ pageParam = 0 }) => fetchPosts({ cursor: pageParam }),
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
return (
<>
{data?.pages.map((page, i) => (
<Fragment key={i}>
{page.items.map((post) => (
<PostItem key={post.id} post={post} />
))}
</Fragment>
))}
<button
disabled={!hasNextPage || isFetchingNextPage}
onClick={() => fetchNextPage()}
>
{isFetchingNextPage ? "불러오는 중..." : hasNextPage ? "더 보기" : "끝"}
</button>
</>
);
getNextPageParam에서 다음 페이지를 가져오기 위한 커서(cursor) 또는 페이지 번호를 반환합니다.
- 백엔드에서 커서 기반 페이징을 제공하면, offset 방식보다 더 안정적인 무한 스크롤을 만들 수 있습니다.
4. 낙관적 업데이트(Optimistic Update)
사용자 경험을 위해, 서버 응답을 기다리기 전에 UI를 먼저 바꾸고 나중에 롤백하는 패턴입니다.
const queryClient = useQueryClient();
const { mutate } = useMutation({
mutationFn: toggleLike,
onMutate: async (postId: number) => {
await queryClient.cancelQueries({ queryKey: ["post", postId] });
const prev = queryClient.getQueryData<Post>(["post", postId]);
if (prev) {
queryClient.setQueryData<Post>(["post", postId], {
...prev,
liked: !prev.liked,
likeCount: prev.likeCount + (prev.liked ? -1 : 1),
});
}
return { prev };
},
onError: (_error, postId, context) => {
if (context?.prev) {
queryClient.setQueryData(["post", postId], context.prev);
}
},
onSettled: (data, error, postId) => {
queryClient.invalidateQueries({ queryKey: ["post", postId] });
},
});
onMutate에서 이전 값 백업 + 즉시 UI 업데이트
- 실패 시
onError에서 롤백
- 최종적으로
invalidateQueries로 서버 값과 동기화
5. 에러 핸들링과 전역 상태
React Query는 각 쿼리에서 에러를 다룰 수도 있고, 전역 리스너로 공통 처리도 할 수 있습니다.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
onError: (error) => {
console.error("Query Error", error);
},
},
},
});
retry를 너무 크게 두면, 이미 실패가 명확한 요청에서 사용자에게 지연된 실패 경험을 주게 될 수 있습니다.
- 인증 에러(401/403)처럼 공통 처리해야 하는 에러는 Axios 인터셉터 + React Query onError 조합으로 설계하는 게 좋습니다.
6. 실무에서의 React Query 쿼리 설계 팁
- 쿼리 키 설계를 신중하게 (
["posts", filter], ["user", userId] 등)
- staleTime / cacheTime을 화면 성격에 맞게 구분
- 리스트/상세, 모달 등에서 같은 데이터를 공유할 수 있게 쿼리 키를 일관되게 사용
- 낙관적 업데이트는 “취소/롤백 가능해야 한다”는 전제 하에 도입
- 에러/로딩 UI는 최대한 공통 컴포넌트나 레이아웃 레벨로 끌어올려 관리
React Query의 핵심은 “서버 상태를 캐시로 관리하고, 서버와의 동기화를 최대한 자동화한다”는 점입니다.
쿼리 키와 캐시 전략만 잘 설계해 두면, 페이지가 많아져도 코드가 크게 복잡해지지 않습니다.
백엔드 엔지니어. Java/Spring으로 밥 벌고, 사이드로 Next.js와 실시간 서비스를 만듭니다. 만들면서 배운 것들을 기록해요.