[React] 데이터 페칭과 렌더링을 위한 3가지 방법
여러 개의 데이터를 담고 있는 리스트를 렌더링 하는 기능은 생각보다 많다. 어쩌면 어떤 프로젝트에도 1개쯤은 있기 마련이다. 채팅 서비스를 만들면서 처음 기획할 때에는 채팅방 목록은 페이지네이션으로, 메시지창은 무한스크롤로 적용하려고 했다. 어느 정도 기능 구현이 완료된 다음에 이 2가지 기능을 적용하려고 보니 이 또한 너무 일반적이고 자주 사용되는 기능이라 라이브러리보다 직접 경험해 보는 것이 낫다고 판단했다.
오픈 API를 이용하여 여러 방법을 테스트해 보았는데 그 순서는 아래와 같다.
구현순서
Chapter.01
- 페이지네이션
- 무한스크롤
- 가상스크롤
Chapter.02
- TanStack-Query + 페이지네이션
- TanStack-Query + 무한스크롤
- TanStack-Query + 무한스크롤 + 가상스크롤
기본 예제로 구현해 보고 TanStack-Query를 도입해서 함께 구현하는 방법을 다음 글에서 다루도록 한다.
1. 페이지네이션
가장 기본이 되는 형태의 UI를 가져왔다. 보이는 것처럼 1페이지부터 시작해서 페이지마다 원하는 게시글수에 맞게 불러오면 된다. 처음 구현을 고민할 때에는 항상 해오듯이 전체 리스트를 통째로 불러와서 page마다 데이터를 잘라서 상태로 관리하면 될까? 라는 생각을 했다. 물론 가능은 하지만 페이지네이션의 역할이 오롯이 UI를 위한 기능이 된다. 원하는 것은 효율적인 데이터 페칭과 렌더링 최적화이므로 이 방법은 맞지 않다.
제대로 된 페이지네이션을 위해서는 데이터를 가져올 때 `offset`과 `size`(이름은 달라질 수 있음) 정보를 알면 된다. 이를 간단히 확인하기 위해 오픈 API(https://jsonplaceholder.typicode.com/)를 활용했다.
그전에 이 기능을 구현하기 위해서는 쿼리 스트링에 대해 알아야 한다. 쿼리 스트링은 간단히 URL에 부가적인 정보를 포함하여 전달할 수 있게 한다. `key=value` 형식으로 구성되고, `?`로 시작하며, `&`로 구분할 수 있다. 그리고 이 형식이 포함된 URL을 전달하기만 하면 된다.
api 호출
const fetchTodoList = async (offset: number, limit: number) =>
await instance.get<Todo[]>('/todos', { params: { _start: offset, _limit: limit } });
Jsonplaceholder Guide에는 페이지를 나눌 수 있는 정보가 나와있지 않지만, `_start`와 `_limit`으로 쿼리 스트링을 이용해 원하는 대로 데이터를 가져올 수 있다.
offset을 어떻게 활용하지?
이제 원하는 페이지, 원하는 개수만큼 데이터를 가져오라고 요청한다는 것을 알았다. 처음엔 무조건 1페이지에서 시작한다고 하면 첫 렌더링 시 1페이지 데이터를 받는 것은 무리 없지만 다음 페이지를 어떻게 전달해야 하는지 고민했는데 위에 말했던 것처럼 쿼리 스트링은 URL에 포함되어 있으므로 라우팅이 필요한 경로에 원하는 offset을 포함해서 전달하기만 하면 된다.
데이터 상태 관리
이제 렌더링 할 컴포넌트에서 `fetchTodoList` api를 호출하고, 통신 성공 시 limit 개수만큼의 todo가 담긴 목록을 받아 상태로 저장한다.
const fetchData = async () => {
const { data } = await fetchTodoList(offset, 10);
if (!data.length) {
setIsEmpty(true);
}
setTodos(data);
};
이때 offset 값은 URL에 포함되어 있기 때문에 useSearchParams를 이용해 얻는다.
const [searchParams] = useSearchParams();
const offset = parseInt(searchParams.get('_start') ?? '1');
Pagination 컴포넌트 추상화
이제 데이터는 준비되었으니 UI에 맞게 페이지 번호를 누를 때마다 쿼리 스트링을 바꿔가면서 api 호출을 요청하면 된다. 페이지네이션 UI가 서비스 어디에서든 재사용이 가능하도록 분리하여 추상화한다.
컴포넌트에 전달해야 하는 정보를 총 4개로 잡았다.
1. 전체 아이템 수(totalItems)
2. 페이지에 표시할 아이템 수(itemsPerPage)
3. 블록(페이지를 표시하고 있는 박스)에 표시할 페이지 수(pagesInBlock)
4. 현재 선택된 페이지(currentPage)
기능 구현을 위한 주요 데이터 처리
// 전체 페이지수
const totalPages = Math.ceil(totalItems / itemsPerPage);
// 블록 내 첫번째 페이지 번호
const [firstPageInBlock, setFirstPageInBlock] = useState(1);
// 현재 페이지가 첫번째인지 마지막인지 판별
const isFirst = firstPageInBlock === 1;
const isLast = firstPageInBlock + pagesInBlock - 1 >= totalPages;
컴포넌트 리렌더링 조건
페이지네이션이 리렌더링 되려면 현재 페이지가 1. 다음 블록의 첫 페이지와 동일할 때와 2. 현재 첫 페이지보다 작을 때로 구분할 수 있다.
// 1번
if (currentPage === firstPageInBlock + pagesInBlock) setFirstPageInBlock((prev) => prev + pagesInBlock);
// 2번
if (currentPage < firstPageInBlock) setFirstPageInBlock((prev) => prev - pagesInBlock);
1번: 다음 블록으로 넘어가기 위해 기존 블록의 첫 번째 페이지에 `pagesInBlock`수를 더하면 다음 블록의 첫 번째 페이지 번호가 된다.
2번: 이전 블록으로 넘어가기 위해 기존 블록의 첫 번째 페이지에 `pagesInBlock`수를 빼면 이전 블록의 첫 번째 페이지 번호가 된다.
페이지네이션 UI
{[...Array(pagesInBlock)].map((_, idx) => {
const pageNum = firstPageInBlock + idx;
return (
pageNum <= totalPages && (
<li key={pageNum} className={cx('page', { active: currentPage === pageNum })}>
<Link to={`?_start=${pageNum}`}>{pageNum}</Link>
</li>
)
);
})}
블록 안에 5개의 page를 보여준다고 생각해 본다. 그럼 `1 2 3 4 5 다음`의 구조가 되어야 한다. pagesInBlock만큼의 원소를 가진 배열을 만들어 이 구조가 될 수 있도록 새로운 배열을 반환한다. 그리고 각 페이지 번호마다 연결하는 url 부분을 쿼리스트링 + 템플릿 리터럴을 이용해 URL에 선택한 페이지 정보를 전달하면 이동과 함께 리렌더링 되면서 페이지에 맞는 새로운 데이터를 보여줄 수 있다.
짚어보기
개인적으로 페이지네이션 구현의 핵심은 쿼리스트링이라는 생각이 들었다. 물론 내부 동작도 중요하지만 "페이지 번호에 맞는 데이터 페칭"에 초점을 두고 생각해 보면 쿼리스트링을 떠올렸을 때 동작원리가 크게 와닿는 느낌이다.
2. 무한 스크롤
무한 스크롤은 Ref로 시작해 Ref로 끝난다.
구현 핵심 3가지
1. 스크롤이 끝에 닿음을 알 수 있는 Ref
2. new IntersectionObserver 이용해 Ref 관찰
3. Ref가 감지되었을 때, 실행할 함수
스크롤을 적용할 컴포넌트에 끝임을 나타낼 태그를 두고 Ref를 설정하면 `Observer`가 이를 감지할 때마다 콜백함수를 실행한다. 이때 콜백함수가 데이터 페칭을 위한 트리거 역할을 한다.
Ref 설정
const scrollRef = useRef(null);
...
<div ref={scrollRef}>More</div>
Ref 관찰
useEffect(() => {
//observer 인스턴스 생성
const observer = new IntersectionObserver(onIntersection);
const ref = scrollRef.current;
if (ref) {
observer.observe(ref);
}
return () => {
if (ref) observer.unobserve(ref);
};
}, []);
`new IntersectionObserver`로 observer를 생성하고 관찰 대상에 Ref를 연결해 준다. 컴포넌트가 언마운트 되면 관찰을 중단해야 하므로 클린업을 꼭 해주도록 한다.
콜백함수
const onIntersection: IntersectionObserverCallback = (entries) => {
const firstEntry = entries[0];
if (firstEntry.isIntersecting && !isLoading) {
setOffset((prevOffset) => prevOffset + 1);
}
};
`new IntersectionObserver`가 받는 받는 콜백함수는 entries라는 데이터를 제공한다. 이 데이터는 `Observer`가 관찰하고 있는 요소들의 모음이다. 현재 관찰하고 있는 Ref는 1개이니 가장 첫 번째 요소를 잡고, `isIntersecting`를 확인하는데 이는 요소가 화면에 보이는지를 나타낸다.
무한스크롤도 페이지네이션과 마찬가지로 `offset`을 이용해 지정된 데이터를 페칭 한다. 다른 점이라면 페이지네이션은 버튼을 누르면 그에 맞게 라우팅 했지만, 무한스크롤은 원하는 지점(스크롤의 끝이 Ref에 닿았을 때)에서 데이터 페칭이 이루어져야 하므로 offset을 state로 관리하도록 했다. 이제 조건에 만족할 때마다 offset이 변경되면서 컴포넌트가 리렌더링 되고, offset에 맞게 데이터를 새롭게 가져와 todos state에 추가된다.
페이지네이션과 또 다른 한 가지는 무한스크롤은 스크롤을 이용해 얻은 데이터들이 todos에 누적된다. 한번 불러온 값은 저장되어 다시 스크롤을 올렸을 때 이전 데이터를 바로 볼 수 있다. 만일 정말 많은 데이터가 있고 사용자가 스크롤을 많이 내린 상태가 되면 가려져서 보이지 않더라도 DOM에는 계속해서 남아있게 된다.
3. 가상 스크롤
이때 가상스크롤이 필요해진다. DOM에 너무 많은 요소가 있으면 브라우저 메모리 문제, 렌더링 성능 저하 등의 문제가 생길 수 있다. 가상스크롤은 데이터를 보여줄 영역을 지정하면 그 영역 내에 들어오는 데이터만 DOM에 그려주고 나머지 데이터는 DOM에서 제거한다.
구조 보기
구조와 기능 구현에는 라이브러리 중 React-Virtuoso를 참고했다. 가상 스크롤 컴포넌트는 3가지 중첩 div로 표현할 수 있었는데 가장 상위에는 가상 스크롤 영역인 Container div, 중간에는 전체 데이터 높이를 갖는 div, 가장 하위에는 DOM에 그려지는 개별 아이템을 담는 div가 된다. 이 중에서 중간 div의 역할은 전체 아이템의 높이를 이용해 스크롤이 가능한 영역을 잡기 위함이다.
VirtualScorll 컴포넌트 추상화
<Virtuoso
style={{ height: 400 }}
data={users}
itemContent={(_, user) => (
<div
style={{
backgroundColor: user.bgColor,
padding: '0.5rem',
height: `${user.size}px`
}}
>
<p><strong>{user.name}</strong></p>
<div>{user.description}</div>
</div>
)}
/>
Virtuoso 사용 예시
01. Props
type Props<T> = {
data: T[]; // 보여줄 데이터
rowRenderer: (v: T, idx: number, arr: T[]) => JSX.Element; // data를 가공할 함수
visibleHeight: number; // DOM에 나타나는 영역 높이
itemHeight: number; // 전체 높이를 계산하기 위한 아이템 높이
itemWidth?: number,
itemsPerRow?: number, // grid로 1줄에 여러개의 item이 있는 경우 고려
style?: {[key: string]: string;}; // Container 스타일 수정
};
사용 예시를 참고하여 추상화를 위한 props를 정의했다. 이 값을 이용해 내부 동작 구현에 필요한 값들을 계산해 보자.
[참고] `itemsPerRow`는 쇼핑몰의 상품 목록처럼 1줄에 여러 개를 보여주는 리스트에도 구현해 보고자 추가한 옵션이다. 기본값 `1`은 내부 계산에서 모두 1로 적용되므로 기본 리스트를 구현할 때 사용할 수 있다.
02. startIndex와 endIndex
const visibleItemCount = Math.floor(visibleHeight / itemHeight) + buffer;
const startIndex = Math.floor(scrollTop / itemHeight) * itemsPerRow;
const endIndex = Math.min(itemCount, startIndex + visibleItemCount * itemsPerRow);
- visibleItemCount: DOM에 그려지는 아이템의 개수
- 보이는 영역 높이를 아이템 높이로 나누면 높이에 맞는 아이템 개수가 계산된다. `buffer`의 역할은 여기에 몇개의 아이템을 더 추가하여 데이터가 자연스럽게 이어질 수 있도록 한다.
- startIndex: 현재 스크롤 위치에서 화면에 처음으로 보이는 아이템
- 현재 스크롤 위치를 아이템의 높이로 나누면 현재 몇 번째의 아이템에 걸쳐있는지 알 수 있다. 만일 1줄에 여러 개의 아이템이 있을 때는 `itemsPerRow`를 곱해주는데 그 결과로 현재 스크롤이 걸쳐있는 행의 첫 번째 아이템이 몇 번째 아이템인지 알 수 있다.
- endIndex: 현재 스크롤 위치에서 화면에 마지막으로 보이는 아이템
- startIndex에 화면에 그리고 싶은 개수 + 여유분(buffer)을 더해서 렌더링 된 마지막 아이템이 몇 번째에 있는지 구한다.
- `Math.min` 사용 이유: endIndex가 전체 아이템의 수를 넘기지 않도록 하기 위함이다.
03. 스크롤 이벤트 등록
useEffect(() => {
const onScroll = () => {
if (containerRef.current) setScrollTop(containerRef.current.scrollTop);
}
const container = containerRef.current;
container?.addEventListener('scroll', onScroll);
return () => {
container?.removeEventListener('scroll', onScroll);
};
}, []);
이제 스크롤을 할 때마다 현재 스크롤 위치를 state로 관리해 주기 위해 이벤트리스너를 등록한다. `containerRef`는 가상 스크롤 컴포넌트의 가장 상위 Container div와 연결되어 해당 영역 내에서 스크롤 위치를 감지한다.
04. 보이는 데이터 가공하기
가상 스크롤은 원하는 높이의 영역에 그려준다. 그렇기 때문에 스크롤을 할 때마다 그에 맞는 데이터를 보여주어야 하므로 맞는 스크롤 위치에 맞는 데이터로 그때그때 바꿔주어야 한다.
useEffect(() => {
setSlicedData(data.slice(startIndex, endIndex));
}, [data, startIndex, endIndex]);
이를 위해 보이는 데이터는 계산해 둔 `startIndex`부터 `endIndex`까지를 잘라서 state로 저장한다. 스크롤에 따라 이 값들이 바뀌거나 렌더링 할 전체 data가 변경되면 화면에 보일 데이터도 변경된다.
05. 데이터 렌더링
Virtuoso는 컴포넌트의 props로 itemContent라는 prop을 받는데 data를 이용해 렌더링 하려는 item을 반환하므로 내가 만든 컴포넌트는 rowRenderer라는 이름으로 JSX.Element를 반환하도록 타입을 정의했다.
rowRenderer: (v: T, idx: number, arr: T[]) => JSX.Element;
필요한 계산은 모두 끝냈으니 `slicedData` state와 `rowRenderer`를 이용해 실제 데이터를 렌더링 해보자.
return (
// 상위 div
<div className="virtual_container" ref={containerRef} style={style}>
// 중간 div
<div style={{ height: totalHeight, position: 'relative' }}>
// 하위 div
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${itemsPerRow}, ${itemWidth}px)`,
gridAutoRows: `${itemHeight}px`,
transform: `translateY(${Math.floor(startIndex / itemsPerRow) * itemHeight}px)`,
}}
>
{slicedData.map(rowRenderer)}
</div>
</div>
</div>
);
`VirtualScroll` 컴포넌트의 return 부 코드를 가져왔다. 1줄에 여러 개의 아이템이 있는 상황을 고려했기 때문에 grid를 사용했다.
잘라낸 데이터를 map으로 반복하며 props로 받아온 rowRenderer를 콜백함수로 사용하고, 그 반환값이 1개의 Row UI가 된다.
transform: `translateY(${Math.floor(startIndex / itemsPerRow) * itemHeight}px)`
스타일 옵션 중 해당 부분의 역할은 스크롤의 위치에 따라 보여야 할 아이템의 위치도 그만큼 이동시켜 주기 위함이다.
`startIndex / itemsPerRow`로 스크롤의 위치가 몇 번째 행에 있는지를 알 수 있고, `itemHeight`을 곱했을 때 가려진 앞의 데이터들의 총수량이 되기 때문에 보여줄 위치가 된다. 즉, 실제로 화면에 보여줄 요소들의 위치를 잡기 위해 필요한 로직이다.
- `transform`: repaint나 reflow가 일어나지 않으므로 스크롤에 따라 브라우저가 요소의 위치를 변경해도 성능을 최적화할 수 있다.
무한 스크롤과 비교하기
무한 스크롤은 데이터가 추가될 때마다 그만큼 DOM에 데이터가 쌓이지만, 가상 스크롤을 적용했을 때에는 지정한 영역 내에 있는 DOM 요소에 데이터만 바뀌며 렌더링 되는 것을 확인할 수 있다.
Debounce로 최적화
하지만 가상 스크롤도 문제가 있다. 스크롤을 할 때마다 스크롤을 감지하고, 그에 따라 데이터를 가공하면서 리렌더링 횟수가 많아진다. 이럴 때 사용할 수 있는 방법은 Debounce 혹은 throttle을 이용해 함수 호출을 제어하는 것이다.
- `debounce`: 설정한 delay가 지나면 함수를 호출한다.
- `throttle`: 일정한 주기마다 함수를 호출한다.
2가지가 유사하지만, 이번 구현에는 사용자가 스크롤을 내릴 때마다 너무 많은 이벤트가 발생하지 않도록 delay를 주는 debounce를 적용했다. (useDebounce hook도 직접 구현해서 사용했는데 그 과정에서의 배움도 다음에 글로 작성하여 공유하기로 한다.)
[비교해 보기]
debounce 적용 후 렌더링이 줄어든 모습을 확인할 수 있다. 물론 다시 호출이 되기 전까지 화면이 비어있는 모습이 사용자 경험에 좋지 않기 때문에 스켈레톤 UI나 로딩 스피너 등을 적용해서 보완하는 것이 좋을 것 같다.
마치며
많은 데이터를 효율적으로 페칭 하고 렌더링 하는 3가지 방법을 직접 구현해 보았다. 4번째 시도로 무한 스크롤 + 가상 스크롤을 구현해보려고 했지만, 데이터 페칭 시 startIndex 설정 문제를 해결하지 못해 잠시 넘어가기로 했다. 이다음에는 같은 과정을 TanStack-Query를 이용해서 구현할 예정이므로 차이점을 공부해 보고 이후에 다시 도전해 보기로 한다.