목차
- 가상스크롤 알아보기
- 가상스크롤 만들기
- 설계하기
- 무한스크롤과 비교하기
- 더 생각해보기
- 요소 높이와 스타일링
- 라이브러리 사용하기
- 참고
가상스크롤 알아보기
이번에는 가상 스크롤에 대해 알아보겠습니다. 지난 편에서는 화면에 많은 요소가 있는 상황을 가정하며 가상 스크롤을 소개했고, 대표적인 예로 SNS에서 콘텐츠 목록을 끊임없이 스크롤하는 경우를 들 수 있습니다. 그러나 실제로 사용자가 보는 요소는 전체 중 극히 일부에 불과합니다. 이러한 점을 활용해 가상 스크롤은 보이는 요소만 렌더링하는 방식을 무한 스크롤 기법에 적용한 기술입니다.
먼저 화면에 많은 요소가 표시된 상황을 구현해 직접 그 느낌을 체험해보겠습니다. 이를 위해 이전에 구현했던 무한 스크롤의 목록을 대폭 늘려보겠습니다.
현재 40만 개의 DOM 노드를 렌더링해 보았습니다. 페이지에 진입하자마자 CPU 사용량이 100%에 도달하며 화면을 렌더링하는 데 집중합니다. 그러나 개발자 도구 없이 화면만 보면, 마치 화면이 멈춘 것처럼 느껴집니다. 40만 개의 요소가 모두 표시되기까지 약 1분 이상이 소요되었으며, 브라우저가 멈추는 경우도 많았습니다. 드물게 5분 정도 기다려 렌더링이 완료된 사례도 있었습니다. 하지만 실제 서비스에서 사용자가 1분 이상 기다려줄 가능성은 거의 없습니다.
이처럼 가상 스크롤이 필요한 상황을 간단히 체험해 보았습니다. 물론 사용자의 기기에 따라 문제가 발생하는 시점은 다를 수 있지만, 이러한 상황이 완전히 발생하지 않을 가능성은 없습니다. 이를 해결하기 위해 가상 스크롤 기법이 등장한 것입니다.
가상스크롤 만들기
설계하기
그럼 어떻게 화면에 보이는 요소들만 렌더링할 수 있을까요? 가장 직관적인 방법은 ‘현재 스크롤 위치’를 계산해 ‘현재 보이는 요소들’을 추려내는 것입니다. 이전 편에서 다뤘던 스크롤 위치를 계산하는 방법을 활용하면, 이를 통해 다음과 같은 공식을 도출할 수 있습니다.
- 화면에 보이는 첫번째 요소 = Math.floor(전체목록.length * scrollTop / scrollHeight)
- 화면에 보이는 마지막 요소 = Math.floor(전체목록.length * (scrollTop + clientHeight) / scrollHeight)
그런데 이상한 점이 있습니다. 위 식은 화면에 그릴 요소들을 선별하기 위해 사용되는 식입니다. 즉, 화면을 그리기 전에 계산하는 식입니다. 그런데 식에 있는 scrollTop, scrollHeight, clientHeight는 화면을 그린 후에 구할 수 있는 값입니다. 그 중에서 scrollTop, clientHeight은 데이터 추가와 무관한값입니다. 결국 데이터가 추가되었을때 올바른 scrollHeight를 못 구하니 잘못된 결과값을 얻게 됩니다. 그래서 이번에는 스크롤의 위치가 아닌 각 아이템의 높이를 고정하고 구해보고자 합니다.
그런데 한 가지 이상한 점이 있습니다. 위 공식은 화면에 렌더링할 요소를 선별하기 위해 사용하는 것으로, 화면을 그리기 전에 계산되어야 합니다. 그러나 공식에 포함된 scrollTop, scrollHeight, clientHeight 값은 화면이 렌더링된 후에야 얻을 수 있습니다. 스크롤중 렌더링이 일어나지만 데이터가 추가되었을때 추가된 데이터를 기준으로 렌더링 전에 계산되어야 합니다. 하지만 이 중에서 scrollTop과 clientHeight는 데이터 추가와 무관한 값이지만, 데이터가 추가되었을 때 scrollHeight 는 위의 식으로 구할 수 없으므로 잘못된 결과를 초래하게 됩니다. 따라서 이번에는 스크롤 위치를 기준으로 계산하는 대신, 각 아이템의 높이를 고정하여 문제를 해결해보고자 합니다.
scrollTop, scrollTop + clientHeight를 아이템 높이로 나누면 화면의 보이는 첫번째 아이템, 마지막 아이템을 구할 수 있습니다.
- 화면에 보이는 첫번째 요소 index = Math.floor(scrollTop / itemHeight)
- 화면에 보이는 마지막 요소 index = Math.floor((scrollTop + containerHeight) / itemHeight)
그렇다면 화면에 보이는 요소만 렌더링하면 스크롤은 어떻게 될까요? 보이는 목록만큼만 스크롤이 생성되기 때문에, 전체 스크롤 길이에 해당하는 영역이 만들어지지 않습니다. 이를 해결하기 위해 일반적인 무한 스크롤 방식처럼 전체 스크롤 길이를 유지하도록 조정하는 빈 요소를 추가합니다. 이 빈 요소는 화면에 보이는 목록을 기준으로 상단과 하단에 배치되며, 높이를 조절해 스크롤 크기가 정상적으로 계산되도록 합니다. 그렇다면 상단과 하단의 빈 요소 높이는 어떻게 계산할 수 있을까요? 이는 화면에 보이는 요소를 계산할 때 사용한 값을 활용해 구할 수 있습니다.
- 상단 빈요소 높이 = (화면에 보이는 첫번째 요소 직전까지 요소의 갯수) * 요소 높이
- 하단 빈요소 높이 = (전체 데이터 갯수 - 처음부터 화면에 보이는 마지막 요소까지 갯수) * 요소 높이
// NOTE 화면에 보여질 data목록을 구하는 hook의 매개변수
const { dataList, itemHeight, bufferCount } = params;
const containerRef = useRef(null);
const [visibleDataList, setVisibleDataList] = useState([]);
const [topHeight, setTopHeight] = useState(0);
const [bottomHeight, setBottomHeight] = useState(0);
const calculateVisibleDataList = () => {
const scrollTop = containerRef.current.scrollTop;
const containerHeight = containerRef.current.clientHeight;
const startIndex = Math.floor(scrollTop / Number(itemHeight));
const endIndex = Math.min(
dataList.length - 1,
Math.floor((scrollTop + containerHeight) / Number(itemHeight))
);
const visibleStartIndex = Math.max(0, startIndex - bufferCount);
const visibleEndIndex = Math.min(
dataList.length - 1,
endIndex + bufferCount
);
const newVisibleDataList = dataList.slice(
visibleStartIndex,
visibleEndIndex + 1
);
setVisibleDataList(newVisibleDataList);
// NOTE 상단, 하단의 빈요소 높이 구하기
setTopHeight(visibleStartIndex * Number(itemHeight));
setBottomHeight(
Math.max(dataList.length - 1 - visibleEndIndex, 0) * Number(itemHeight)
);
};
return { containerRef, visibleDataList, topHeight, bottomHeight };
지금까지 소개한 설계 방식은 여러 가지 방법 중 하나일 뿐입니다. 화면에 보이는 요소들의 목록을 계산하는 방법이나 스크롤 영역을 만들기 위해 빈 부분을 처리하는 방식은 다양하기 때문에, 오히려 설계에 혼란을 줄 수 있을까 봐 전체 코드는 포함하지 않았습니다.
무한스크롤과 비교하기
이제 완성된 가상 스크롤과 기존 무한 스크롤을 비교해보겠습니다. 일반적인 서비스에서는 첫 진입 시 수십만 개의 목록을 가져오는 경우는 드물지만, 매우 많은 목록이 쌓였다고 가정하기 위해 첫 진입에 백만 개 이상의 목록을 불러왔다고 설정해보겠습니다. 무한 스크롤의 경우, DOM 노드를 렌더링하기 위해 CPU 사용량과 JS 힙 메모리 사용량이 급격히 증가하는 것을 확인할 수 있습니다. 반면, 가상 스크롤은 필요한 노드만 렌더링하므로 CPU와 JS 힙 메모리를 최소한으로 사용합니다. 무엇보다 무한 스크롤은 사용자를 오랜 시간 기다리게 하는 반면, 가상 스크롤은 거의 즉각적으로 화면을 사용할 수 있게 만듭니다. 실제로 백만 개 이상의 요소를 렌더링하려는 무한 스크롤은 끝까지 작업을 완료하지 못하고 에러를 발생시키는 경우가 많습니다.
더 생각해보기
요소 높이와 스타일링
앞서 설계 과정에서 화면에 보이는 요소들을 선별하기 위해 각 요소의 높이를 미리 정해두고 사용했습니다. 하지만 직접 구현해보면 이상한 점을 느낄 수 있습니다. 일반적으로 목록의 각 요소는 여백을 두고 배치되는데, 예를 들어 gap: 8px과 같은 간격이 설정되어 있을 수 있습니다. 이때, 여백을 요소의 크기에 포함하지 않는 것이 더 적절하다고 생각할 수도 있습니다. 만약 그렇게 생각한다면, 초기 설계에서 요소 높이에 여백까지 포함한 것이 어색하게 느껴질 수 있습니다. 심지어 설계 자체가 잘못되었다고 판단할 수도 있을 것입니다. 설계에 정답은 없다고 생각하지만, 이러한 점에서 사용자를 충분히 고려하지 못한 설계라는 의견에는 공감합니다. 이와 관련된 개선 경험이 생기면 다음 편에서 다룰 예정입니다.
라이브러리 사용하기
이 외에도 고려해야 할 여러 기능들이 있습니다. 예를 들어, iOS 상태바 터치 처리, 스크롤 위치 기억하기, 요소 높이가 각각 다를 경우의 처리 등이 있습니다. 특히, 앞서 언급한 요소 높이 계산과 관련된 부분은 가장 까다롭고 중요한 문제 중 하나입니다. 현업에서 충분한 시간이 주어진다면 이러한 문제에 도전해볼 수 있겠지만, 더 중요한 비즈니스에 집중해야 한다면 잘 만들어진 라이브러리를 사용하는 것도 현명한 선택이라고 생각합니다.
아래 소개할 라이브러리는 가상 스크롤을 설계하고 구현하는 과정에서 발견한 것으로, 서버 상태 관리 라이브러리인 @tanstack/react-query로 잘 알려진 TanStack에서 개발한 라이브러리입니다. 이 라이브러리는 2024년부터 많은 관심과 인기를 얻으며 활발히 개발 및 관리되고 있어 추천드릴 만합니다. 이번 편은 이 라이브러리 소개를 끝으로 마무리하겠습니다.
'Developer > Javascript 해부학' 카테고리의 다른 글
무한스크롤에서 가상스크롤까지 (무한스크롤편) (8) | 2024.10.13 |
---|---|
클래스 컴포넌트에서 함수 컴포넌트로 전환기 (1) | 2024.03.17 |
fireEvent와 userEvent 구현체 살펴보기 (0) | 2024.01.21 |
컴포넌트 상태 변이 흐름에 대한 고찰 (1) | 2023.12.09 |
리액트의 렌더링이란? (0) | 2023.02.28 |
Javascript Module System (0) | 2023.01.23 |
[JavaScript] Hoisting 이란? (0) | 2022.01.27 |
댓글