가상스크롤
가상스크롤이란?
가상스크롤은 유저의 입장에서 직접 보이는 요소들만 렌더링 하고, 스크롤에 가려지는 부분은 렌더링 하지 않는 프런트엔드 최적화기법입니다. 데이터 수가 많지 않다면 브라우저가 DOM을 그리는데 무리가 없지만, 그려야 하는 DOM의 개수가 수천, 수만단위를 넘어가게 된다면 점점 부하가 발생하게 됩니다. 이때, 가상스크롤을 활용해서 보이는 부분만 렌더링 하고 나머지 데이터는 유저의 스크롤에 맞춰 렌더링 한다면 DOM의 수가 많아져 브라우저의 부하가 발생하는 문제를 막을 수 있습니다.
직접 개발 이유
메신저 프로젝트를 개발하면서 하나의 채팅방에 수천, 수만단위에 데이터가 쌓일 수 있고, 메시지 양의 상한선이 없기 때문에 가상스크롤 사용이 필수적입니다. 오픈소스로 대표적인 가상스크롤 라이브러리는 react-window
, react-virtualized
,tanstack/virtual-core
등이 있었지만, 저희는 직접 가상스크롤 기능을 개발하기로 했습니다. 그 이유는 아래와 같습니다.
- 외부라이브러리를 사용했을 때, 발생되는 버그를 직접 제어할 수 없음
- 최초에는 외부라이브러리를 이용해서 개발했지만, 잦은 버그 발생
- 채팅이 주 기능인 메신저인 만큼 우리 서비스에 특화된 요구사항이 많았고, 이러한 요구사항을 모두 만족하는 라이브러리가 존재하지 않음
- 가상스크롤 라이브러리 제공 기능 비교
dynamic-height find-target-item reverse-infinite-scroll sticky react virtualized
X X O X react virtuoso
O O O X tanstack/virtual-core
O X X O
- 가상스크롤 라이브러리 제공 기능 비교
채팅 기능이 가상스크롤 자체에 많이 의존할 수밖에 없기 때문에, 원하는 기능이 없고 그마저도 버그가 자주 발생한다면 치명적이라 판단했습니다. 이에 직접 개발하여 기능 요구사항을 충족하고, 버그 픽스, 그리고 성능개선까지 이루어 보고자 하였습니다.
가상스크롤 기능 요구 리스트
저희 프로젝트에서 가상스크롤은 다음과 같은 기능이 필요했습니다. (옆에 불꽃은 해당 기능 개발의 난이도를 표현했습니다.)
dynamic-height
🔥🔥🔥- 모든 요소들의 높이가 일정하지 않으며, 가변적임
- -> 일반적인 가상스크롤을 개발할 때에는 렌더할 요소의
height
를 미리 제공해주어서 각 요소들을 렌더링 합니다. 하지만, 각 요소들의height
가 가변적인 경우에는 미리height
를 알아내기 위한 선행 작업이 필요했고, 이는 가상스크롤 개발 난이도를 높였습니다. - 리액션, 메시지 삭제 등 스크롤에 높이가 렌더이후에도 변경될 수 있음
- 양방향 무한 스크롤 🔥🔥
- 아래, 위 무한스크롤로 스크롤바가 끝에 도달했을 때, 새로운 메시지를 호출하여 렌더 해야 함
- 특정 메시지(아이템)로 이동 🔥
- 북마크 클릭, 검색 등의 동작으로 특정 메시지로 이동
- 해당 메시지가 아직 렌더 되어있지 않은 상태라면, 그 메시지 기준으로 위아래를 렌더 하여 해당 메시지가 중앙에 오도록 해야 함
- 날짜별로 타임스탬프를 채팅방 상단에 표현 🔥🔥
- 유저가 보고 있는 채팅의 날짜를 채팅방 상단에
floating
요소처럼 보여주어야 합니다. - 각 타임스탬프가 서로를 미뤄내는 동작을 보여주는데
slack
에 타임스탬프처럼 구현되어야 했습니다.- 실제로
slack
코드를 많이 참고하여 구현할 수 있었습니다
- 실제로
- 유저가 보고 있는 채팅의 날짜를 채팅방 상단에
- 그 외...
- 유저가 읽은 마지막 메시지를 기준으로 "여기까지 읽었습니다" 표시 밑 해당 메시지가 중심으로 오도록 이동 🔥
- 최하단으로 이동 🔥
이 외에도 채팅과 관련한 많은 기능요구사항이 있었습니다. 의존성을 최대한 제거한 라이브러리에서는 이러한 모든 기능을 넣을 수 없었고, 저희는 의존성을 주입해서라도 모든 기능을 충족할 수 있는 가상스크롤 기능을 만들고자 했습니다.
아래에서는 기본적으로 가상스크롤을 어떻게 구현했는지부터 각 요구사항을 어떻게 해결했는지 설명해 보겠습니다.
구현 과정
가상스크롤
1. 가상스크롤 기본 원리
가상스크롤은 유저가 보이는 부분만 실제로 렌더링 하고, 보이지 않는 부분은 스크롤뒤에 숨기는 일종의 트릭입니다. 결국은 실제 렌더링 하는 만큼 가상의 요소를 만들어서 실제와 동일한 스크롤을 만들고 현재 스크롤 위치에 맞는 요소들만 렌더링하는 작업이 필요합니다. 이를 그림으로 표현하면 아래와 같습니다.
결국 가상 요소들에 대한 스크롤을 만들기 위해서는 렌더링이 될 모든 요소들의 총높이를 알 필요가 있습니다. (보통의 가상스크롤라이브러리들이 각 요소들의 height
정보나 렌더링 할 요소들의 총개수를 요구하는 이유입니다.) 일단은 렌더링 했을 때와 동일한 스크롤바를 만들고 그 스크롤에 따른 렌더 요소들을 결정해야 하기 때문입니다.
2. 가상의 스크롤 만들기
모든 요소들의 height
정보를 알고 있다면, 가상의 스크롤을 만드는 것은 어렵지 않습니다. padding
값을 통해서 스크롤 요소의 높이를 만들어주거나 직접 total height
를 구해서 넣어주는 방법이 있습니다. 어쨌든 height
정보만 있다면, 렌더링 된 것은 없지만 가상의 스크롤을 만들어 기본을 만들 수 있습니다.
3. 스크롤 높이에 따른 현재 렌더링 할 요소들 결정하기
가상의 스크롤을 만들었다면, 이 스크롤이 가상이 아닌 진짜처럼 동작하도록 하여야 합니다. 즉 스크롤 위치에 따라서 보여야 하는 요소들을 렌더링 해야 합니다. 이를 위해 scrollTop
정보를 리액트 상태로 관리하여 리렌더를 일으키도록 했습니다.
const [scrollTop, setScrollTop] = useState<number>(0)
const onScroll = useCallback((e: Event) => {
if (animationFrame.current) {
cancelAnimationFrame(animationFrame.current);
}
const scroll = e.target as HTMLDivElement;
setScrollTop(scroll.scrollTop);
});
}, []);
useEffect(() => {
const scrollContainer = containerRef.current;
if (!scrollContainer) return;
scrollContainer.addEventListener("scroll", onScroll);
return () => scrollContainer.removeEventListener("scroll", onScroll);
}, []);
이제, scrollTop
에 따라서 어떤 요소들을 렌더링 할지 정해야 합니다.
우선은 각 요소들에 height
정보를 알고 있다는 가정을 바탕으로 각 요소들의 누적합 배열을 만들어 두겠습니다. 누적합 배열을 구하는 이유는 결국 누적합이 각 요소들이 위치하는 y좌표가 될 수 있기 때문입니다. 모든 요소들의 height
가 40px이라는 가정을 하겠습니다. 그렇다면, 아래 그림처럼 첫 번째 요소는 40px
위치에 존재하게 되는 것이고, n
번째 요소는 40 * (n-1) 위치에
존재하게 됨을 알 수 있습니다. 높이가 일정하지 않더라도 n번째 요소의 위치를 알고 싶다면 누적합 배열에 인덱스로 접근하면 알 수 있게 됩니다.
const 요소들의_누적합 = useMemo(() => {
const results = [0];
for (let i = 0; i < 요소배열.length; i++) {
const messageId = 요소배열[i].id;
results.push(results[i] + 요소의높이);
}
return results;
}, [요소의높이, 요소배열]);
이제 요소들의 위치를 알게 되었습니다. 그러면 scrollTop
에 따라 어떤 요소들을 렌더링 할지 결정할 수만 있다면 끝입니다.
그렇다면 scrollTop
이 정확히 의미하는 것이 무엇인지 알아야 할 것 같습니다. scrollTop
은 요소의 가장 위쪽부터 현재 보이는 부분까지의 거리를 픽셀단위로 표현한 값입니다.
scrollTop
이 1000px이라 하면 스크롤 가장 위에서부터 현재 보이는 요소까지 1000px을 의미하는 것입니다. 이렇게 보니 scrollTop
이 의미하는 값이 방금 구한 누적합과도 매우 유사하다는 것을 알 수 있습니다.
즉, scrollTop
이 1000px이라면, 요소들의_누적합[특정요소의 인덱스]= 1000
이 될 때에 특정요소가 유저의 view port
에서 가장 먼저 렌더링 되어야 하는 요소임을 의미하는 것입니다.
이제는, 요소들의 누적합을 인덱스로 접근했을 때, 가장 scrollTop
과 가장 비슷하지만, scollTop
보다는 작은 값이 렌더링 될 첫 번째 요소임을 알 수 있게 됩니다.
요소들의_누적합[렌더링_될_첫 번째_요소의_인덱스] <= scrollTop
이제 배열을 순회하면서 현재 scrollTop
과 가장 가깝고도 작은 값을 찾으면 됩니다. 하지만, scrollTop
은 유저가 스크롤을 조금만 하더라도 휙휙 바뀌는 값이고 그때마다 O(n)
의 탐색을 하기에는 매우 부담스러운 작업입니다. 이를 해결하기 위해서 이분탐색을 활용해 O(log n)
의 시간복잡도로 탐색을 진행했습니다
/**
* scroll Top 정보와 각 행의 위치정보를 바탕으로 렌더링할 첫번째 노드를 찾는 함수
*
* @param scrollTop 스크롤의 top 위치
* @param nodePositions 데이터 배열의 위치정보(누적합)
* @param itemCount 현재 배열의 총 길이
* */
export function generateStartNodeIndex(
scrollTop: number,
nodePositions: number[],
itemCount: number
): number {
let startRange = 0;
let endRange = itemCount - 1;
if (endRange < 0) return 0;
while (endRange !== startRange) {
// console.log(startRange, endRange);
const middle = Math.floor((endRange - startRange) / 2 + startRange);
const nodeCenter = (nodePositions[middle] + nodePositions[middle + 1]) / 2;
// scrollTop 높이를 기준으로 스크롤 탑과 시작위치가 가장 가까운 노드 return
if (nodeCenter <= scrollTop && nodePositions[middle + 1] > scrollTop) {
return middle;
}
if (middle === startRange) {
return endRange;
} else {
// 스크롤탑보다 작아서 보이지 않아야되는 노드 (nodePostions[middle] <= scrollTop)
if (nodeCenter <= scrollTop) {
startRange = middle;
} else {
// 스크롤 탑보다 아래에 있는 경우
endRange = middle;
}
}
}
return itemCount;
}
middle
의 값이 scrollTop
보다 큰 경우에는 endRange를
줄이고, middle
값이 scrollTop
보다 작은 경우에는 이전의 값보다 큰지 확인하고, 그다음노드의 시작위치까지 확인하며 탐색을 진행합니다.
이를 통해서 현재 위치에서 가장 먼저 렌더링 될 노드의 인덱스를 구할 수 있습니다. 그다음은, 마지막 인덱스입니다. 마지막 인덱스는 현재 유저의 viewport
만 안다면 쉽게 구할 수 있습니다.
/**
* 각 행의 위치와 현재 viewport 높이를 바탕으로 렌더링할 마지막 노드를 찾는 함수
*
* @param nodePositions 스크롤의 top 위치
* @param startNodeIndex 데이터 배열의 위치정보
* @param itemCount 현재 배열의 총 길이
* @param viewportHeight 현재 viewport의 높이
* */
export function generateEndNodeIndex(
nodePositions: number[],
startNodeIndex: number,
itemCount: number,
viewportHeight: number
): number {
let endNodeIndex;
for (
endNodeIndex = startNodeIndex;
endNodeIndex < itemCount;
endNodeIndex++
) {
if (
nodePositions[endNodeIndex] - nodePositions[startNodeIndex + 1] >
viewportHeight
) {
return endNodeIndex;
}
}
return endNodeIndex;
}
4. 렌더링 할 요소들의 위치 정해주기
렌더링할 요소들의 처음과 끝을 찾았으니, 이제 이 요소들을 원하는 위치에 렌더링 해주면 끝입니다. 이 역시 이미 누적합을 통해 각 요소들의 y좌표 위치를 알고 있으니 어렵지 않습니다
const visibleChildren = useMemo(() => {
return new Array(visibleNodeCount + 1).fill(null).map((_, idx) => {
return (
<렌더링요소
key={key}
style={{
position : "absolute",
top: 요소들의누적합[idx_startNode]
}}
messageId={messageId}
message={messages[index + startNode]}
/>
);
});
}, [첫번째노드, 마지막노드, ...]);
여기서도 여러 가지 방법이 있지만, 저는 absolute
와 top으로
명시하는 방법을 사용했습니다. (비슷한 방법으로 offsetY
를 사용하는 방법, padding top, bottom
을 활용하는 경우에는 각각에 요소들의 위치는 신경 쓰지 않고 패딩에만 의존하기도 합니다.)
5. 전체코드
위 네 가지 기준으로 일단 기본적인 가상스크롤을 개발할 수 있었습니다. 아직 채팅방 메신저에 필요한 기능들은 넣지 못했지만, 이 형태에서 확장하는 방식으로 각각의 기능들을 추가할 수 있었습니다.
저의 전체코드는 아니지만 제가 참고한 글에서 전체코드를 공유하자면 다음과 같습니다 (렌더링 할 요소들의 위치를 offsetY
로 지정한 것을 제외하고는 거의 일치합니다)
const VirtualScroll = ({
renderItem,
itemCount,
viewportHeight,
rowHeight,
nodePadding,
}) => {
const totalContentHeight = itemCount * rowHeight;
let startNode = Math.floor(scrollTop / rowHeight) - nodePadding;
startNode = Math.max(0, startNode);
let visibleNodesCount = Math.ceil(viewportHeight / rowHeight) + 2 * nodePadding;
visibleNodesCount = Math.min(itemCount - startNode, visibleNodesCount);
const offsetY = startNode * rowHeight;
const visibleChildren = new Array(visibleNodeCount)
.fill(null)
.map((_, index) => renderItem(index + startNode));
return `
<div ${/* viewport */}
style="
height: ${viewportHeight};
overflow: "auto";
"
>
<div ${/* content */}
style="
height: ${totalContentHeight};
overflow: "hidden";
"
>
<div ${/* offset for visible nodes */}
style="
transform: translateY(${offsetY}px);
"
>
${visibleChildren} ${/* actual nodes */}
</div>
</div>
</div>
);
};
저는 기본적인 가상스크롤 틀을 이렇게 잡고, 추가적인 저희 프로젝트의 기능 요구사항을 붙여나갔습니다. 각 기능들의 구현과정은 추가적으로 정리하며 공유하겠습니다.
참고 사이트
'개발' 카테고리의 다른 글
[UI 툴팁 컴포넌트 (feat: UI 직접 만들기) (0) | 2025.01.18 |
---|---|
[UI] 아코디언 컴포넌트 (feat: UI 요소 직접 만들기) (0) | 2025.01.06 |
[SARAMARA] 비즈니스로직 분리하기 (0) | 2023.12.11 |
코드 리팩토링 (액션, 데이터, 계산 나누기) (0) | 2023.12.08 |
CORS (0) | 2023.12.06 |