프론트엔드 개발자는 백엔드 개발자들이 구현해놓은 api를 호출하여 얻은 데이터를 UI에 띄워주곤 한다.
아마 대부분의 경우 GET method를 통해 데이터를 얻어올 것이다.
불러오는 데이터의 양이 그리 많지 않다면 큰 문제는 없을 것이지만, 만약 불러와야 할 데이터의 양이 매우 크면 문제가 생기게 된다.
사용자는 어쩌면 데이터를 확인하기 위해 많은 시간을 기다려야 할 수도있다.
데이터에 이미지가 포함되어 있다면 그 시간은 엄청나게 늘어날 것이다!
이를 방지하기 위해 무한 스크롤은 프론트엔드에서 필수적인 기술이라고 할 수 있다.
무한 스크롤이란?
말 그대로 무한으로 스크롤되게 만드는 기법을 뜻한다.
무한으로 스크롤이 된다는 것은, 보여줄 데이터가 아직 남아있음을 뜻한다.
즉 한번에 모든 데이터를 불러오는 것이 아니라, 사용자가 스크롤을 일정 부분까지 내릴 때마다 데이터의 전체가 아닌 일부분만 가져오는 기술을 뜻한다.
이를 구현하기 위해 일반적으로 react-query의 useInfiniteQuery훅을 많이 사용한다.
useInfiniteQuery
무한 스크롤을 최대한 간편하게 구현할 수 있도록 react-query에서는 해당 훅을 지원해준다.
const { data, isLoading, ...queryInfo } =
useInfiniteQuery<SearchPoolResultResponse>({
queryKey: ['useSearchPool', nameQuery],
queryFn: ({ pageParam = undefined }) => searchPool(nameQuery, pageParam),
initialPageParam: undefined,
getNextPageParam: (lastPage) =>
lastPage.data.hasNext ? lastPage.data.cursorId : undefined,
});
const { hasNextPage, isFetchingNextPage, fetchNextPage } = queryInfo;
다음은 useInfiniteQuery 훅을 사용하는 코드의 예시이다.
queryKey와 queryFn은 일반적인 useQuery훅의 사용법과 같다.
눈여겨봐야할 다른 점은 queryFn에서 pageParam을 전달받는다는 것이다.
예시를 통해 좀 더 쉽게 알아보자.
export type SearchPoolResultResponse = Response<{
poolInfos: PoolProps[];
pageSize: number;
cursorId: number;
hasNext: boolean;
}>;
위는 백엔드에서 내려준 응답 dto이다.
cursorId와 hasNext 필드를 사용하여 queryFn에 pageParam을 전달해주어야 한다.
cursorId는 백엔드에서 프론트에게 다음 전달해줄 데이터를 판단하기 위해 사용된다.
이름은 백엔드와 협의하여 자유롭게 맞추면 된다.
프론트엔드에서는 현재 fetch한 부분 데이터로 넘어온 cursorId를 queryFn의 pageParam으로 넘기고, 해당 pageParam 값을 api 통신에 사용하게 된다.
그럼 백엔드에서는 해당 값을 받아서, 해당 값에 맞는 다음 데이터를 내려주게 되는 것이다.
hasNext는 더 넘겨줄 데이터가 있는지 백엔드에서 확인 후 return해주는 값이다.
역시 필드 이름은 자유롭게 맞춰줘도 좋다.
useInfiniteQuery훅의 getNextPageParam 메소드를 보면 lastPage라는 값을 매개변수로 받고 있다.
lastPage에는 현재 fetching한 부분 데이터가 들어가게된다.
즉, 현재 fetching한 부분 데이터의 hasNext가 true이면 사용자가 특정 부분까지 스크롤 시 다음 데이터를 가져와야 하므로 return된 cursorId값을 넘겨주고, 만약 false라면 undefined로 넘겨주면 된다.
react-query v5에서는 initialPageParam을 넘겨주지 않으면 오류가 발생한다.
우리 서비스에서는 처음 부분 데이터를 가져올 때는 pageParam을 넘겨줄 필요가 없기 때문에 undefined로 지정해주었다.
업데이트되는 pageParam 값을 api 통신에 사용하기 위해 queryFn 내부에 api 통신에 쓰일 함수를 호출해주었다.
api 통신에 쓰일 함수 내용은 다음과 같다.
async function searchPool(nameQuery: string, cursorId: unknown) {
const cursorIdQuery = cursorId ? `&cursorId=${cursorId as number}` : '';
const res = await fetch(
`/api/pool/search?nameQuery=${nameQuery}${cursorIdQuery}`,
{
headers: {
'Content-Type': 'application/json',
},
},
);
return res.json();
}
이제 다음 문제는, hasNext가 true이면 사용자가 어디까지 스크롤을 했을 때 다음 데이터를 불러오냐는 것이다.
이를 편리하게 감지하기 위해 react-intersection-observer 라이브러리를 사용하였다.
react-intersection-observer
해당 라이브러리는 특정 컴포넌트가 view 내부로 들어왔을 때를 감지하기 위해 사용되는 매우 편리한 도구이다.
const { ref, inView } = useInView({
rootMargin: '100px 0px 0px 0px',
});
react-intersection-observer는 useInView라는 훅을 제공해준다.
해당 훅에서 return되는 ref를 컴포넌트에 부여하고, 특정 컴포넌트가 화면에 보이게 되면 inView값이 false에서 true로 변하게된다.
만약 특정 컴포넌트가 화면에 완전히 보이기 전에 조금 미리 inView를 true로 바꾸고 싶다면, 위처럼 rootMargin을 통해 조정해주면 된다.
해당 ref를 현재 fetching된 데이터의 끝 값을 띄워주는 컴포넌트에 부여하면, 사용자가 스크롤을 통해 데이터 끝 부분에 도달했는지 판단할 수 있을 것이다!
useEffect(() => {
if (inView) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
hasNextPage && fetchNextPage();
}
}, [inView, hasNextPage, fetchNextPage]);
이제 inView 값을 추적하고, inView가 true로 됐을 때 다음 데이터를 fetching 해와야 한다.
해당 로직을 위해 useEffect 내부에서 inView가 true일 때, useInfiniteQuery훅에서 제공하는 fetchNextPage를 호출하도록 구현하였다.
마지막으로 현재까지 fetch한 모든 데이터를 UI에 띄워주기 위해 취합해 주어야 한다.
const getByFarPoolData: PoolProps[] =
data?.pages.map((page) => page.data.poolInfos).flat() || [];
return { ref, isLoading, isFetchingNextPage, getByFarPoolData };
useInfiniteQuery가 return해주는 data 값에는 현재까지 불러온 모든 data가 들어가있다.
여기서 실제 데이터 값들이 담겨있는 부분만 가져오기 위해 map 메소드를 사용해준 뒤, 하나의 배열로 묶어주기 위해 flat 메소드를 사용하였다.
그 후 외부에서 사용할 값들만 return해주면 훅 파일이 완성된다.
해당 훅 파일의 모든 내용을 모으면 아래과 같다.
'use client';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
import { PoolProps, SearchPoolResultResponse } from './dto';
async function searchPool(nameQuery: string, cursorId: unknown) {
const cursorIdQuery = cursorId ? `&cursorId=${cursorId as number}` : '';
const res = await fetch(
`/api/pool/search?nameQuery=${nameQuery}${cursorIdQuery}`,
{
headers: {
'Content-Type': 'application/json',
},
},
);
return res.json();
}
export default function useSearchPool(nameQuery: string) {
const { data, isLoading, ...queryInfo } =
useInfiniteQuery<SearchPoolResultResponse>({
queryKey: ['useSearchPool', nameQuery],
queryFn: ({ pageParam = undefined }) => searchPool(nameQuery, pageParam),
initialPageParam: undefined,
getNextPageParam: (lastPage) =>
lastPage.data.hasNext ? lastPage.data.cursorId : undefined,
});
const { hasNextPage, isFetchingNextPage, fetchNextPage } = queryInfo;
const { ref, inView } = useInView({
rootMargin: '100px 0px 0px 0px',
});
useEffect(() => {
if (inView) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
hasNextPage && fetchNextPage();
}
}, [inView, hasNextPage, fetchNextPage]);
const getByFarPoolData: PoolProps[] =
data?.pages.map((page) => page.data.poolInfos).flat() || [];
return { ref, isLoading, isFetchingNextPage, getByFarPoolData };
}
이제 해당 훅에서 return해준 값들을 사용하여 기능을 구현해보자!
const { ref, isLoading, isFetchingNextPage, getByFarPoolData } =
useSearchPool(poolSearchText);
위처럼 우선 훅을 호출해준 뒤, 사용할 값들도 세팅해준다.
{!isLoading &&
getByFarPoolData.length > 0 &&
getByFarPoolData.map((result, i) => (
<PoolSearchResultElement
key={result.poolId}
{...result}
poolSearchText={poolSearchText}
assignRef={i === getByFarPoolData.length - 1}
ref={ref}
className={resultStyles.element}
/>
))}
{isFetchingNextPage && <LoadingArea width={30} height={30} />}
그 후 위처럼 코드를 작성해주었다.
나는 PoolSearchResultElement라는 컴포넌트를 통해 데이터들을 UI에 띄워주고 있다.
react-intersection-observer 라이브러리에서 제공하는 useInView 훅이 return하는 ref를 현재까지 fetching한 데이터 중 마지막 값을 띄우는 컴포넌트에 부여해주었다.
그러면 사용자가 스크롤을 현재의 마지막 값까지 내렸다면, 다음 데이터를 호출하는 식이다.
PoolSearchResultElement 컴포넌트 내부에는 assignRef prop이 true일때만 해당 컴포넌트에 ref를 부여해주는 로직이 짜여져있다.
마지막으로 isFetchingNextPage를 통해 다음 데이터를 호출하고 있을 때 보여줄 로딩 UI까지 추가해주었다.
최종 결과물은 다음과 같다.
아주 잘 동작하는 것을 확인할 수 있다.
이제 불러와야 할 데이터의 크기에 관계 없이, 초기 데이터를 매우 빠르게 불러올 수 있게 되었다!
해당 방식으로 해도 너무너무 좋은 방법이고 효과적인 것은 맞다.
위 수영장 검색은 수영장 개수가 제한적이기 때문에 해당 방법대로 가도 무방하다.
하지만 무한스크롤을 사용하고 있는 알림 페이지에서는 문제가 발생할 확률이 높았다.
현재 알림 페이지이다.
지금은 알림이 54개 정도밖에 있지 않아서 위처럼 무한스크롤을 도입해도 문제가 되지 않는다.
하지만 알림은 말 그대로 무한대로 쌓일 수 있는 값이기 때문에 엄청난 양의 알림이 쌓이면 문제가 될 수 있다.
현재 알림이 54개 밖에 되지 않는데도 불구하고 스크롤을 끝까지 내릴 시, 그려지는 엘리먼트의 개수는 1000개가 넘는 것을 확인할 수 있다.
알림이 더욱 많아진다면 이는 성능에 엄청난 영향을 끼칠 수 있다.
해당 현상을 피하기 위해 리스트 가상화, 또는 윈도잉이라는 기법을 통해 이루어낼 수 있다.
리스트 가상화란, 전체 리스트 중에서 보이는 엘리먼트만 렌더링을 하고, 보이지 않는 엘리먼트는 DOM 트리에서 빼버리는 기법이다.
나는 이를 위해 react-virtuoso 라이브러리를 활용하였다.
사실 사용법은 매우매우 간단하다.
<Virtuoso
data={getByFarNotificationData}
overscan={200}
useWindowScroll
rangeChanged={handleRangeChanged}
itemContent={(_, notification) =>
'memoryId' in notification ? (
<CheerNotification
key={notification.notificationId}
{...notification}
/>
) : (
<FollowNotification
key={notification.notificationId}
{...notification}
/>
)
}
components={{
Footer: () =>
isFetchingNextPage ? <LoadingArea width={30} height={30} /> : <></>,
}}
/>
다음은 알림 페이지 리스트 컴포넌트의 코드 중 일부이다.
- data: 현재까지 fetch된 데이터 리스트 값이 들어간다.
- overscan: 현재 보이는 영역 밖에서 추가로 렌더링되는 항목들의 양을 정의(px 단)
- useWindowScroll : 컴포넌트가 내부 스크롤이 아닌 윈도우의 스크롤을 사용할 지 여부
- rangeChanged: 리스트의 현재 보이는 영역의 범위가 변경될 때 호출되는 콜백 함수
- itemContent: 각 항목을 렌더링하는 데 사용되는 함수. 첫 번째 인수는 인덱스이고, 두 번째 인수는 데이터 배열의 항목이다
- components: Virtuoso에서 제공하는 커스텀 컴포넌트들
각 prop들을 간단하게 설명하였다.
위처럼만 설정해주면, react-intersection-observer 라이브러리를 사용하지 않고도 무한 스크롤을 무사히 구현할 수 있다.
추가로 불필요한 엘리먼트들을 그리지 않음으로써, 많은 양의 데이터에도 무사히 대응할 수 있다.
위와 같이 스크롤을 끝까지 내릴 시, 그려지는 요소가 반 이상 가량 줄어든 것을 확인할 수 있다!
해당 효과는 알림의 개수가 많아질수록 더 빛을 발할 것 같다.
이처럼 무한스크롤에대해 알아보고 구현해 보았다.
개수가 무한히 늘어날 수 있는 곳에는 리스트 가상화도 진행해보았다.
앞으로도 더 큰 그림을 그려나갈 줄 아는 프론트엔드 개발자가 되어야겠다!!
참고
https://tanstack.com/query/v4/docs/framework/react/reference/useInfiniteQuery
useInfiniteQuery | TanStack Query React Docs
This ad helps to keep us from burning out and rage-quitting OSS just *that* much more, so chill. 😉
tanstack.com
https://ridicorp.com/story/ridi-markdown-improvements/
예상보다 24배 많은 콘텐츠에 프론트가 대처하는 법 - 리디주식회사
리디는 매년 연말 한 번에 많은 종수의 작품을 할인 판매하는 마크다운 이벤트를 진행합니다. 2022년 마크다운 이벤트는 총 6,107개의 작품을 가지고 진행되었는데요. 이 이벤트를 개선하는 과정
ridicorp.com
'FrontEnd > React' 카테고리의 다른 글
효율적인 협업을 위하여 - 2 (Feat: 컨벤션) (0) | 2024.07.04 |
---|---|
효율적인 협업을 위하여 - 1 (Feat: Git Merge) (2) | 2024.07.03 |
HTTP 캐시 다루기 (2) | 2024.06.13 |
아토믹 디자인(Atomic Design) 도입하기 (0) | 2024.05.08 |
React 렌더링 최적화에 대한 고찰 (2) | 2024.04.10 |