팀원들과 스터디를 진행하면서 어떤 프로젝트를 진행할 지 고민하였고, 구현 보다는 설계 측면에서 어떻게 api를 연동하고 최적화를 진행할 지에 대한 고민을 하였다.
그래서 구글링을 하던 중 개발자 한 분이 웹툰 정보를 모아서 뿌려주는 api를 개발하셨고, 포스팅 해둔 글을 발견하였고, 이 api를 활용해보자고 결정하였다.
(감사합니다 개발자님...)
다들 다양하게 api를 활용해서 구현을 진행하였는데, 나는 리액트 쿼리를 활용해서 페이지네이션을 하는데스크롤이 아래쯤으로 내려오면 데이터를 로드해서 무한 스크롤 비슷하게 만들어보면 어떨까 생각하였다.
그래서 tanstack-qeury의 useInfiniteQuery와 IntersectionObserver를 통한 무한스크롤 기능에 중점을 두고 스터디를 임하였다.
// webtoonApi.ts
...
export const fetchWebtoons = async ({
keyword,
provider,
isFree,
updateDays,
isUpdated,
page,
}: webtoonQueryType) => {
const params = Object.fromEntries(
Object.entries({ keyword, provider, isFree, updateDays, isUpdated }).filter(
([, value]) => value !== "" && value !== "ALL" && value !== false
)
);
const { data } = await axiosInstance.get("/webtoons", {
params: {
...params,
page: page,
},
});
return {
data: data.webtoons, // 웹툰 리스트 반환
isLastPage: data.isLastPage, // 마지막 페이지 여부 반환
};
};
API를 보면 쿼리스트링을 통해 데이터를 필터링한 후 가져올 수 있다. 이미 페이지네이션 기능도 제공을 하기 때문에 저렇게 page를 넣어주었다.
Object를 필터링하는 이유는 사용자가 값을 입력하지 않았다면 쿼리스트링에 포함하지 않기 위해서이다.
예를 들어서, provider는 KAKAO, KAKAO_PAGE, NAVER 이렇게 인데 사용자가 아무런 입력을 하지 않았다면 ALL로 설정을 해두었다. 하지만 파라미터 값이 들어갈 때 ALL이 들어가면 API에서 제공하는 값이 아니기 때문에 오류가 날 수 있다. 그래서 API를 요청하기 전에 한 번 필터링을 거치는 것이다.
(ALL이라는 값은 클라이언트에서 사용하는 값이라 서버 쪽에서는 어떤 값인지 알 수 없다는 뜻)
// useWebtoonQuery.ts
...
export const useWebtoonsQuery = (filters: webtoonQueryType) => {
const { keyword, provider, isFree, updateDays, isUpdated } = useSearchStore();
return useInfiniteQuery({
queryKey: ["webtoons", keyword, provider, isFree, updateDays, isUpdated],
queryFn: ({ pageParam = 1 }) =>
fetchWebtoons({ ...filters, page: pageParam }),
getNextPageParam: (lastPage, allPages) => {
// 마지막 페이지인지 확인 후 증가
return lastPage.isLastPage ? undefined : allPages.length + 1;
},
initialPageParam: 1,
});
};
쿼리 부분에서는 기본적인 useQuery를 사용하지 않고, useInfiniteQuery를 사용하였다.
useQuery는 요청을 한 번 보내는 쿼리이고 useInfiniteQuery는 요청을 보낸 다음에 추가 요청을 보낼 수 있는 쿼리이다.
getNextPageParam 메서드를 통해 추가 요청을 보내고 데이터를 쌓아나갈 수 있다. 또 특이한 점은 useInfiniteQuery는 queryFn에 pageParam을 받아야한다. 그리고 옵션으로 initialPageParam 통해 초기 페이지 값을 정해줄 수 있다.
useQuery | useInfiniteQuery | |
|---|---|---|
| 데이터 요청 방식 | 한 번만 가져옴 | 추가 데이터를 계속 요청 가능 |
| 사용 예시 | 유저 정보, 특정 게시물 상세 | 무한 스크롤, 더 보기 버튼 |
| 다음 페이지 로딩 | ❌ (지원 안 함) | ✅ (fetchNextPage 사용) |
| 데이터 저장 방식 | 한 번만 저장 | 여러 페이지 데이터를 누적 |
다음 페이지를 가져오도록 쿼리를 사용했으니 트리거를 달아줘야한다.
스크롤이 끝 지점이 다다랐을 때, fetchNextPage를 호출해서 다음 페이지를 불러오는 작업을 진행한다.
...
const WebtoonList = () => {
...
// 스크롤 감지 로직
useEffect(() => {
if (!observerRef.current) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
fetchNextPage();
}
},
{ threshold: 0.8 } // 80% 지점에서 감지
);
observer.observe(observerRef.current);
return () => observer.disconnect();
}, [fetchNextPage, isLoading]);
if (isLoading) return <p>로딩 중...</p>;
if (!webtoons || webtoons.pages.length === 0)
return <p>검색 결과가 없습니다.</p>;
return (
<>
<div className="flex flex-wrap justify-center gap-x-3 gap-y-3">
{webtoons.pages.map((page) =>
page.data.map((webtoon: webtoonType) => (
<WebtoonItem key={webtoon.id} webtoon={webtoon} />
))
)}
{/* 스크롤 감지 요소 */}
<div className="h-[10px]" ref={observerRef} />
</div>
{/* 로딩 상태 표시 */}
{isFetchingNextPage && (
<p className="text-center text-gray-500">로딩 중...</p>
)}
</>
);
};
export default WebtoonList;
IntersectionObserver는 요소가 화면에 보이는지 감지하는 웹 API로, 뷰포트에 감지가 되면 특정한 콜백 함수를 호출하는 기능을 가지고 있다. 그래서 스크롤 이벤트를 하지 않아도 특정 구간에서 함수를 호출할 수 있다. (성능 우위)
IntersectionObserver는 두 가지 매개 변수를 받는데 콜백 함수와 옵션 값을 받는다. 먼저 콜백함수는 인자로 감지한 요소들의 배열과 옵저버를 받을 수 있다.
entries → 감지한 요소들의 배열
entry.isIntersecting → 뷰포트에 보이면 true
observer.observe(target) → 감지할 요소 지정
observer.unobserve(target) → 감지 중지 (필요 없으면 해제 가능)
위와 같이 특정한 조건에서 감지를 중지할 수도 있고, 감지가 되었는지 판단할 수도 있다.
const options = {
root: null, // 뷰포트 기준 (null이면 화면 자체가 기준)
rootMargin: "0px", // 감지 범위 확장/축소 가능 (ex. "100px 0px" → 위로 100px 더 감지)
threshold: 0.5, // 50% 이상 보이면 감지
};
두번째 매개변수인 옵션은 객체의 형태로 넣어주어야하고 위와 같은 옵션이 있다. threshold를 자주 사용한다.
이러한 기술들을 이용하여 무한 스크롤 기능을 구현한 프로젝트는 아래와 같이 동작을 하게 됐다!

(디자인은 신경쓰지 말아주세요.. 기능만 고려를 해봤습니다..!)