백오피스의 도매매장 페이지에서 관리 중인 매장수는 대략 7000개다.
운영팀에서 주로 이 페이지에서 업무를 처리하는 만큼 "도매 매장들이 너무 늦게 뜹니다"와 같은 피드백을 받게 되었다. 네트워크 탭을 열어 확인해 본 결과 7천 개의 매장을 한 api를 통해 모두 불러오고 있었고, Timing탭을 통해 확인해 보니 최종 응답시간이 87.82ms로 측정되고 있었다.


실제로 화면에서 로딩 스피너의 대기시간이 충분히 불편함을 느낄 정도의 대기시간이라 생각했고, 개선할 필요가 있다고 판단하여 무한스크롤과 페이지네이션중 고민하고 있었다. 페이지네이션과 무한스크롤 선택에 있어서 참고한 자료는 다음과 같다.
https://www.hellodigital.kr/blog/dmkt-general-pagination-vs-infinite-scroll-02/
페이지네이션 VS 무한스크롤 : 자사에 적합한 UX 고르는 법 /2 - HelloDigital
궁극적인 목표는 화려한 메인 페이지를 디자인하거나, 유명한 사이트를 모방하는 것이 아닙니다. 사용자의 요구에 맞는 방식으로 콘텐츠를 구성하고 제시하는 것입니다. ‘제대로만’ 한다면
www.hellodigital.kr
위내용을 참고해 봤을 때 백오피스는 웹에서 동작하고 특정 도매정보를 찾기 위함이니 페이지네이션이 적합해 보이긴 하지만, 이전에 스크롤로 사용자가 학습을 해놓은 상태였고, 느리게 보이는 것 외에는 크게 불편함을 못 느끼고 있었기 때문에 굳이 페이지네이션으로 사용자 경험을 변경할 필요는 없다고 판단하여 무한스크롤을 적용하기로 결정하였다.(유일한 사용자인 운영팀이 무한스크롤을 원했던 점이 가장 크게 작용했긴 했다.)
무한스크롤 구현 예시
기존에 알고 있던 방식으로는 intersectionObserver webAPI(https://heropy.blog/2019/10/27/intersection-observer/)를 활용하여 가시성 변화에 따른 page 파라미터 값을 +1씩 추가하여 api콜을 호출하는 방식이었다.이였다. 따라서 대략적인 ui구조는 아래와 같을 것으로 예상했다. (react를 사용하고 있기 때문에 JSX로 작성하였다)
// 예시코드
return (
<>
<ul>
{msgList.map((msg) => (
<li>{msg}</li>
))}
</ul>
<div>호출해</div> // intersectionObserver에 등록된 감시대상
</>
);
1. 스크롤이 바닥까지 도달했을 때 주석처리된 <div> 호출해 </div>가 화면에 노출
2. 이를 감지하여 다음 page에 해당하는 api를 호출
3.msgList를 업데이트
추가적인 데이터가 db에 존재하는 한, 사용자 입장에서는 스크롤을 내리면서 이 과정이 반복될 것이다. 이렇게 7000개의 데이터를 나눠서 불러오게 되면 사용자에게 로딩스피너를 보여주는 시간이 훨씬 줄어들게 되기 때문에 결과적으로 더 나은 사용자 경험을 제공하게 된다
실제로 적용해 보자
백오피스는 UI라이브러리 중 antd(https://ant.design/)를 사용하고 있었고, 데이터를 antd에서 제공해 주는 Table컴포넌트를 통해 보여주고 있었다. 대략적인 구조를 다음과 같이 예상했다.
return (
<Table />
<div>호출해</div>
)
이렇게 작성하게 되었을 때 이전의 예시코드와 동일하게 <div> 호출해 </div> 부분이 화면에 보이면 추가적인 api콜을 보낼 것이라 예상했지만, 스크롤이 <Table/> 안에 생성이 되기 때문에 <div> 호출해 </div> 부분은 항상 <Table/>밖에 존재하였고, 가시성의 변화를 관찰할 수 없는 구조였다. 따라서 다른 방법을 고안해야 했다. 찾아본 결과 antd공식사이트에선 무한스크롤은 지원을 안 하고 있고, 써드파티로 제공되고 있었다.
https://github.com/wubostc/virtualized-table-for-antd
GitHub - wubostc/virtualized-table-for-antd: the virtualized table component for ant design
the virtualized table component for ant design. Contribute to wubostc/virtualized-table-for-antd development by creating an account on GitHub.
github.com
https://github.com/Leonard-Li777/antd-table-infinity
GitHub - Leonard-Li777/antd-table-infinity: An infinite scroll component based on antd-table that supports virtual scrolling
An infinite scroll component based on antd-table that supports virtual scrolling - GitHub - Leonard-Li777/antd-table-infinity: An infinite scroll component based on antd-table that supports virtual...
github.com
그럼에도 라이브러리를 사용하지 않았던 이유로는 1. 라이브러리의 의존성을 최소로 하고 싶었고, 2. 백오피스가 reactQuery로 서버상태를 관리 중인 입장에서 useInfiniteQuery를 통해 구현하는 것이 좀 더 적합할 것같다라는 생각이 들었다.
그럼 어떻게 <Table /> 컴포넌트 안에 <div> 호출해 </div>와 같은 api콜의 트리거역할을 하는 태그를 넣을 수 있을까?
DOM API를 통해 태그를 직접 만들어 붙이자.
리액트에선 DOM에 직접 접근할 수 있는 도구로 useRef훅을 제공해 준다.
<Table/> 컴포넌트를 ref로 접근하여 DOM API를 통해 생성한 태그를 직접 붙여주는 것!
코드로 작성하면 다음과 같다.
function PageBody() {
const tableRef = useRef(null);
useEffect(() => {
if (tableRef.current) {
const $fetchMoreEl = document.createElement('div'); // <div>호출해</div> 역할
const tableBodyEl = tableRef.current.querySelector('.ant-table-body');
tableBodyEl?.appendChild($fetchMoreEl); // 붙여준다.
}
}, []);
return (<Table ref ={tableRef} />)
}
이렇게 하면 위의 구현예시처럼 <div> 호출해 </div> 부분을 <Table /> 컴포넌트 안에 넣는 셈이 된다.
백오피스에서 현재 사용중인 react-query의 useInfiniteQuery를 사용한 전체 코드는 다음과 같다.
function PageBody() {
const tableRef = useRef<HTMLDivElement>(null);
const [searchQuery, setSearchQuery] = useState();
const isIntersection = useInfiniteScroll(tableRef);
const getCompanyListQuery = useInfiniteQuery(
['getCompanyListQuery',searchQuery.search_string, searchQuery.search_type],
({ pageParam = 1 }) => {
setSearchQuery({ ...searchQuery, page: pageParam });
return CompanyAPI.getList({ ...searchQuery, page: pageParam });
},
{
getNextPageParam: (lastPage, pages) =>
lastPage.company_list.length < searchQuery.page_size &&
pages.length + 1,
},
);
useEffect(() => {
if (isIntersection && getCompanyListQuery.hasNextPage) {
getCompanyListQuery.fetchNextPage();
}
return;
}, [isIntersection]);
return (<Table ref={tableRef}/>)
}
현재 페이지 이외(입금이체페이지, 세금계산서 페이지 같은 로딩 스피너가 어느정도 감지되는 페이지)에도 재사용되어야 하기 때문에 감지 부분은 useInfiniteScroll 훅으로 추출했다.
결과적으로 7000개를 300개씩 나눠서 부름으로써 아래와 같이 8.95ms로 전대비(87.82ms) 약 90%를 개선하는 효과를 볼 수 있었다.

결론
운영팀이 하나의 voc를 처리하는 과정에서 도매 매장을 찾는 시간을 단축시킬 수 있었다. 하지만 이처럼 외부 라이브러리(antd 등)를 사용하게 되면 편리한 부분도 분명 있지만 요구사항을 수용하는데 복잡도가 증가하게 된다. 따라서 라이브러리를 도입하는 데 있어서는 충분한 고민이 필요하다.