[React] React Query 를 사용하여 무한 스크롤 기능 구현하기 (useInfiniteQuery, TypeScript)
📌 React Query란?
리액트 쿼리(react-query)는 리액트 애플리케이션에서 데이터 페칭과 같은 서버 상태를 관리하는 데 도움을 주는 라이브러리입니다.
리액트 쿼리를 통해 외부 데이터를 효율적이고 간편하게 관리할 수 있습니다.
npm install @tanstack/react-query
✅ React Query 장점
- 간편한 데이터 관리: 데이터 페칭, 캐싱, 리패치, 에러 처리 등을 자동으로 처리할 수 있어, 개발자가 간편하게 데이터 관리할 수 있도록 도와줍니다.
- 캐싱과 성능 최적화: 내부적으로 캐싱을 사용하여 동일한 데이터 요청을 최적화하고, 데이터의 유효성을 자동으로 관리하여 성능을 향상시킵니다.
- 자동 리패치: 데이터가 만료되거나, 유효하지 않을 경우 자동으로 데이터를 다시 가져오는 기능을 제공합니다.
- 서버 요청 최적화: 데이터 요청을 관리하여 중복된 요청을 방지하고, 필요한 경우에만 서버와의 통신을 수행하여 네트워크 사용을 최적화합니다.
- 간결하고 일관된 코드: 데이터 페칭과 관련된 복잡한 로직을 추상화하고, 간결하고 일관된 코드 작성을 촉진하여 유지보수성을 높여줍니다.
- 빠르고 간단한 데이터 업데이트: 뮤테이션(mutation) 기능을 사용하여 데이터를 빠르고 쉽게 업데이트할 수 있습니다.
- 강력한 DevTools: React Query DevTools를 사용하면 애플리케이션의 데이터 페칭 상태를 쉽게 모니터링하고 디버그할 수 있습니다.
✅ React Query Client 설정
리액트 쿼리를 사용하기 위해선, React Query Client를 설정하고 애플리케이션에 제공해야 합니다.
src 폴더 아래 main.tsx 파일 내부에서 React Query Client 를 설정하는 코드를 작성합니다.
이때 QueryClientProvider 로 애플리케이션 전체를 감싸주어야 합니다. (react-router-dom 사용 시 BrowserRouter 도 감싸주세요)
만약, React.StrictMode 로 애플리케이션을 감싸고 있다면, QueryClientProvider 를 포함한 다른 컨텍스트를 React.StrictMode로 감싸주세요.
이것은 애플리케이션의 개발 과정에서 잠재적인 문제를 감지하고, 이를 경고하여 수정할 수 있도록 도와줍니다. 따라서 일반적으로 애플리케이션의 최상위 컴포넌트를 감싸는 방식으로 사용됩니다.
// src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom";
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
);
🤔 QueryClient
React Query에서 데이터 fetching을 위한 주요 객체
🤔 QueryClientProvider
애플리케이션의 모든 컴포넌트들에게 QueryClient를 제공하는 역할
✅ React Query 사용 예제
React Query 를 사용해서 데이터 fetching 하는 예제를 보겠습니다.
학습 할 때 데이터가 필요하시면, jsonplaceholder을 활용해보세요!
// src/type/post.ts
export interface PostType {
userId: number | string;
id: number | string;
title: string;
body: string;
}
// src/api/post.ts
import axios from "axios";
import { PostType } from "../type/post";
export const fetchPosts = async (): Promise<PostType[]> => {
const res = await axios.get("https://jsonplaceholder.typicode.com/posts");
return res.data as PostType[];
};
// src/hooks/queries/post.ts
import { useQuery } from "@tanstack/react-query";
import { fetchPosts } from "../../api/post";
export const usePostsQuery = () => {
return useQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
});
};
// src/componets/Posts.tsx
import styled from "styled-components";
import { usePostsQuery } from "../hooks/queries/post";
const Posts = () => {
const { data, isLoading, isError, error } = usePostsQuery();
if (isLoading) return <>Loading....</>;
if (isError) return <>Error... {error.message}</>;
console.log(data);
return (
<Container>
{data?.map((post) => (
<Post key={post.id}>
<Title>{post.title}</Title>
<Body>{post.body}</Body>
</Post>
))}
</Container>
);
};
export default Posts;
const Container = styled.div`
width: 50%;
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
`;
const Post = styled.div`
border: 1px solid black;
border-radius: 10px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
`;
const Title = styled.p`
font-weight: bold;
font-size: 18px;
`;
const Body = styled.p``;
📌 무한 스크롤을 위한 useInfiniteQuery
React Query 의 useInfiniteQuery 훅은 페이지 기반 데이터 페칭을 간편하게 관리할 수 있도록 도와줍니다.
주로 무한 스크롤이나 페이지네이션을 구현할 때 유용하며, 여러 페이지의 데이터를 무한히 불러오는 기능을 제공합니다.
각 페이지의 데이터는 비동기 함수로 페칭되며, 필요할 때마다 다음 페이지의 데이터를 계속해서 가져올 수 있습니다.
큰 데이터를 소량의 페이지로 나누어 클라이언트에게 제공함으로써, 사용자가 스크롤을 통해 추가 데이터를 요청할 때마다 다음 페이지를 불러오는 구조를 갖습니다.
이는 모든 데이터를 한 번에 가져오는 것보다 훨씬 효율적입니다.
✅ useInfiniteQuery의 장점
- 자동화된 데이터 페칭 및 캐싱: 각 페이지의 데이터를 자동으로 페칭하고 캐싱합니다. 이미 불러온 데이터는 캐시에서 재사용되어 불필요한 네트워크 요청을 줄입니다.
- 간편한 무한 스크롤 구현: fetchNextPage 함수를 통해 다음 페이지의 데이터를 쉽게 불러올 수 있습니다.
- 성능 최적화: 리액트 쿼리의 캐싱 기능을 통해 데이터 페칭 성능이 최적화됩니다
- 편리한 상태 관리: 로딩 상태, 오류 상태, 데이터 상태 등을 쉽게 관리할 수 있습니다.
📌 useInfiniteQuery 훅
const {
data,
error,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
isLoading,
isError,
refetch,
status
} = useInfiniteQuery({
queryKey: ['쿼리키'],
queryFn: 데이처페칭함수,
initialPageParam: 초기pageParam,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
다음 pageParam 을 반환하는 로직 수행
}
});
✅ useInfiniteQuery 훅의 주요 반환값
- data: queryFn을 통해 얻은 쿼리의 데이터로, 페이지 단위로 나누어져 있습니다.
- data 객체는 두 개의 프로퍼티를 갖고 있습니다.
- pages: 페이지 데이터의 배열로, 각 요소는 한 페이지의 데이터를 나타냅니다. (타입: TData[])
- pageParams: 각 페이지를 가져오기 위해 사용되었던 파라미터 배열을 나타냅니다. (타입: unknown[])
- error: 해당 쿼리에서 에러가 발생했을 때의 에러 객체를 나타냅니다.
- fetchNextPage: 다음 페이지를 가져오는 함수입니다.
- hasNextPage: 다음 페이지가 있는지 여부를 나타내는 boolean 값입니다.
- isFetching: 데이터를 가져오는 중인지 여부를 나타내는 boolean 값입니다.
- isFetchingNextPage: 다음 페이지를 가져오는 중인지 여부를 나타내는 boolean 값입니다.
- isLoading: 첫번째 데이터 페칭이 진행 중인지 여부를 나타내는 boolean 값입니다.
- isError: 데이터 페칭 작업에 에러가 발생했는 지 여부를 나타내는 boolean 값입니다.
- refetch: 쿼리를 다시 실행하는 함수입니다.
- status: 쿼리의 상태를 나타냅니다. 'pending', 'error', 'success' 값 중 하나입니다. (타입: QueryStatus)
✅ useInfiniteQuery 훅의 주요 Options
- queryKey: 쿼리를 식별하는 키로서, 배열 형태로 전달합니다.
- 타입: unknown[]
- required
- queryFn: 데이터 페칭을 수행하는 함수입니다.
- 타입: (context: QueryFunctionContext) => Promise<TData>
- required
- initialPageParam: 첫 번째 페이지 요청 시, 디폴트로 전달되는 파라미터 입니다.
- 타입: TPageParam
- required
- getNextPageParam: 다음 페이지의 파라미터를 결정하는 함수입니다.
- 타입: (lastPage, allPages, lastPageParam, allPageParams) => TPageParam | undefined | null
- required
📌 궁금점...
useInfiniteQuery 의 반환값과 options 를 살펴보았는데요!
글로만 보면 완전히 이해가 가진 않으시죠? 저도 처음에 학습하며 들었던 궁금점에 대해 공유하고자 합니다!
✅ 어떻게 데이터 캐싱이 이루어질까?
React Query 는 queryKey 를 기반으로 유니크한 캐시 키를 생성하여 캐싱을 합니다.
이때 queryFn의 파라미터는 캐싱에 직접적인 영향을 주지 않지만, 데이터 페칭을 위한 중요한 요소로 작용합니다.
무슨소리일까요? queryFn 함수의 파라미터 타입을 보시면 QueryFunctionContext 라고 명시되어 있습니다.
해당 타입안에는 queryKey 와 pageParam 이 존재합니다.
이 queryKey 는 useInfiniteQuery의 옵션으로 지정한 queryKey 와 동일하며, pageParam 은 intialPageParam과 getNextPageParam 함수를 통해 지정한 페이지 파라미터와 동일합니다.
queryFn 은 pageParam 을 통해 해당 페이지의 데이터를 페칭하고, queryFn이 반환하는 데이터는 queryKey 를 기반으로 캐싱됩니다.
✅ 어떻게 hasNextPage 가 있는지 알까?
공부를 하면서 백엔드가 응답하는 데이터의 형태는 모두 다를텐데 어떻게 hasNextPage 값을 결정하는 것일까? 라는 의문점이 들었습니다.
결론은 hasNextPage 는 getNextPageParam 함수의 반환 값에 따라 결정됩니다.
getNextPageParam 함수는 마지막 페이지의 데이터와 모든 페이지의 데이터를 기반으로, 다음 페이지가 있는지 여부를 판별하는 로직을 포함해야 합니다.
즉, 개발자가 직접 다음 페이지가 존재하는지를 판별하는 로직을 getNextPageParam 내부에 작성해야 합니다.
getNextPageParam 함수에서 undefined 를 반환하면 hasNextPage 는 false 가 되며,
그렇지 않고 개발자가 지정한 pageParam 데이터가 반환되면 hasNextPage 는 true 가 됩니다.
✅ useInfiniteQuery 훅의 전반적인 동작 흐름
- 초기 데이터 요청: useInfiniteQuery 훅이 처음 실행 될 때, initialPageParam 을 기반으로 queryFn 함수를 실행합니다.
- 초기 데이터 저장: 처음 가져온 데이터는 data 객체에 저장됩니다. 이 데이터는 data.pages[0] 에 저장됩니다.
- 스크롤 이벤트 감지: 사용자가 스크롤의 끝에 도달하면, 개발자는 fetchNextPage 함수를 호출합니다.
- 다음 페이지 파라미터 계산: getNextPageParam 함수가 호출되고, 다음 페이지의 파라미터를 반환합니다. 반환된 파라미터는 queryFn에 전달됩니다.
- 다음 페이지 요청: 전달받은 파라미터를 기반으로 queryFn 함수가 실행되고 다음 페이지의 데이터가 data 객체에 저장됩니다.
📌 useInfiniteQuery 를 통한 무한 스크롤 예제
jsonplaceholder 에서 posts 데이터를 통해 무한 스크롤 예제를 구현해보겠습니다.
jsonplaceholder 에서 무한 스크롤 또는 페이지네이션을 위한 요청 url 은 다음과 같습니다.
`https://jsonplaceholder.typicode.com/posts?_page=${페이지번호}&_limit=${가져올데이터개수}`
보시면 요청에 필요한 쿼리 스트링 파라미터에 _page 와 _limit이 있는 것을 확인할 수 있습니다.
이 두개를 우리는 pageParam 으로서 사용하게 되는 것이지요!
그럼 우리가 최종적으로 사용하게 될 타입은 post 데이터의 타입과, pageParam 데이터의 타입입니다.
// src/type/post.ts
export interface PostType {
userId: number | string;
id: number | string;
title: string;
body: string;
}
export interface PostPageParamType {
_page: number;
_limit: number;
}
자 이제 queryFn 함수를 작성해보겠습니다.
전달받은 pageParam 파라미터를 기반으로 해당하는 페이지의 데이터를 요청합니다.
// src/api/post.ts
import axios from "axios";
import { QueryFunctionContext } from "@tanstack/react-query";
import { PostType, PostPageParamType } from "../type/post";
export const fetchPosts = async ({
queryKey,
pageParam,
}: QueryFunctionContext): Promise<PostType[]> => {
let url = "https://jsonplaceholder.typicode.com/posts";
if (pageParam) {
const { _limit, _page } = pageParam as PostPageParamType;
url += `?_page=${_page}&_limit=${_limit}`;
}
const res = await axios.get(url);
return res.data as PostType[];
};
이제 useInfiniteQuery 를 사용하여 쿼리를 정의하겠습니다.
// src/hooks/queries/post.ts
import { useInfiniteQuery } from "@tanstack/react-query";
import { fetchPosts } from "../../api/post";
import { PostPageParamType, PostType } from "../../type/post";
export const useInfinitePostsQuery = () => {
return useInfiniteQuery({
queryKey: ["postList"],
queryFn: fetchPosts,
initialPageParam: { _page: 1, _limit: 10 } as PostPageParamType,
getNextPageParam: (
lastPage: PostType[],
allPages: PostType[][],
lastPageParam: PostPageParamType,
allPageParams: PostPageParamType[]
): PostPageParamType | undefined => {
if (lastPage.length === 0) return undefined;
return {
_page: lastPageParam._page + 1,
_limit: 10,
} as PostPageParamType;
},
});
};
이제 최종적으로 useInfinitePostsQuery 커스텀 훅을 호출하여, 화면에 게시글 목록 무한 스크롤을 구현하겠습니다.
data 객체의 pages의 각 요소에는 각 페이지 요청마다 응답받은 게시글 목록 데이터가 들어있습니다.
또한, 스크롤의 마지막에 도달한다면 다음 페이지가 존재하는지(hasNextPage)와 다음 페이지 요청 작업이 수행중인지(isFetchingNextPage) 를 보고 fetchNextPage() 함수를 호출합니다.
import React, { useEffect, useRef } from "react";
import styled from "styled-components";
import { useInfinitePostsQuery } from "../hooks/queries/post";
const Posts = () => {
const {
data,
isLoading,
isError,
error,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useInfinitePostsQuery();
const scrollRef = useRef<HTMLDivElement>(null);
const handleScroll = () => {
if (!scrollRef.current) return;
const { clientHeight, scrollTop, scrollHeight } = scrollRef.current;
if (clientHeight + scrollTop >= scrollHeight) { // 스크롤 마지막 도달
console.log("End Reached");
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}
};
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.addEventListener("scroll", handleScroll);
}
return () => {
if (scrollRef.current) {
scrollRef.current.removeEventListener("scroll", handleScroll);
}
};
}, [isFetchingNextPage, hasNextPage, fetchNextPage]);
if (isLoading) return <>Loading....</>;
if (isError) return <>Error... {error.message}</>;
if (!data || !data?.pages) return;
return (
<Container>
<Pages ref={scrollRef}>
{data?.pages?.length > 0 &&
data?.pages.map((page, idx) => (
<Page key={idx}>
{page.map((post) => (
<Post key={post.id}>
<Title>{post.title}</Title>
<Body>{post.body}</Body>
</Post>
))}
</Page>
))}
{isFetchingNextPage && <>Loading....</>}
</Pages>
</Container>
);
};
export default Posts;
const Container = styled.div`
width: 50%;
height: 100vh;
padding: 20px;
margin: 0 auto;
background-color: whitesmoke;
`;
const Pages = styled.div`
width: 100%;
height: 100%;
overflow-y: scroll;
display: flex;
flex-direction: column;
gap: 10px;
`;
const Page = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
`;
const Post = styled.div`
border: 1px solid black;
border-radius: 10px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
`;
const Title = styled.p`
font-weight: bold;
font-size: 18px;
`;
const Body = styled.p``;