토이 프로젝트로 데이터 목록을 100만개 정도 뿌려주는 간단한 사이트를 만들고 있었다. 처음에는 Pagination UI
로 만들고 있었는데, 심미적으로 Infinite Scroll
이 더 예쁠 것 같아서 개발 방향을 바꿨다. 그런데 Infinite Scroll을 적용하고 나니 문제점이 보였다.
스크롤을 한참 내리니 쌓인 DOM 때문에 컴퓨터가 힘들어하는 것이었다. 따라서 최적화 방법을 찾아야만 했다.
맨 처음으로 떠오른 생각이 여분의 DOM을 미리 생성해두고, 데이터를 불러올 때마다 DOM을 새로 생성하는 것이 아니라 기존의 DOM을 재사용하는 방안이었다. 괜찮은 생각이라 바로 적용하기로 했다.
이를 구현하기 위해선 DOM을 추가하지 않고, 기존의 DOM을 재사용하는 로직이 필요했다. 또한, 스크롤을 내리거나 올릴때 어색하지 않게 리스트를 감싸고 있는 DOM의 전체적인 높이를 변경하는 로직과 리스트의 위치를 변경하는 로직이 필요했다.
<template>
<div class="root" ref="rootRef">
<div class="container" ref="scrollContainerRef">
<div ref="loadMoreTriggerTopRef" class="load-more-trigger-top"></div>
<div v-for="(item, index) in visibleItems" :key="index" class="item">
{{
item.id +
". " +
item.first_name +
" " +
item.last_name +
" - " +
item.email
}}
</div>
<div ref="loadMoreTriggerBottomRef" class="load-more-trigger"></div>
<div v-if="loading" class="loading">Loading...</div>
</div>
</div>
</template>
템플릿 구조는 다음과 같다.
import { ref, onMounted, onUnmounted } from "vue";
import { DataType } from "@/types";
const totalItems = ref<DataType>({
first: 1,
prev: null,
next: null,
last: 0,
pages: 0,
items: 0,
data: [],
});
const visibleItems = ref<DataType["data"]>([]);
const loadMoreTriggerTopRef = ref<HTMLElement | null>(null);
const loadMoreTriggerBottomRef = ref<HTMLElement | null>(null);
const rootRef = ref<HTMLElement | null>(null);
const scrollContainerRef = ref<HTMLElement | null>(null);
const loading = ref(false);
const page = ref(0); // mount 되면서 1로 변경됨.
const pageSize = 20;
const prevDireciton = ref<"up" | "down">("down"); // 이전에 어느 방향에서 데이터를 불러왔는지 저장한다.
const bufferSize = pageSize * 3; // 화면에 표시할 총 아이템의 갯수. 화면에 보이는 것과 화면 위와 아래에 표시할 것을 고려하여 버퍼 크기를 크기의 3배로 설정한다.
한번에 20개씩 데이터를 불러오고, 재사용할 DOM의 개수(bufferSize)는 총 60개로 설정하였다.
// 호출된 observer에 따른 분기 처리
const onIntersect = (entries, observer) => {
if (entries[0].isIntersecting) {
if (observer === observerBottom) {
loadItems("down");
} else {
loadItems("up");
}
}
};
// .loadMoreTriggerTop이 화면에 들어왔을 때 (스크롤을 위로 올렸을 때)
const observerTop = new IntersectionObserver((entries) => {
if (page.value > 1) onIntersect(entries, observerTop);
});
// .loadMoreTriggerBottom이 화면에 들어왔을 때 (스크롤을 아래로 내렸을 때)
const observerBottom = new IntersectionObserver((entries) => {
if (page.value < totalItems.value.last) onIntersect(entries, observerBottom);
});
// mount하면 observer를 등록하고, 데이터를 로드한다.
onMounted(async () => {
if (loadMoreTriggerTop.value) {
observerTop.observe(loadMoreTriggerTop.value);
}
if (loadMoreTriggerBottom.value) {
observerBottom.observe(loadMoreTriggerBottom.value);
}
loadItems("down");
});
// unmount하면 지정된 observer를 해제하여 불필요한 메모리 낭비를 막는다.
onUnmounted(() => {
if (loadMoreTriggerTop.value) {
observerTop.unobserve(loadMoreTriggerTop.value);
}
if (loadMoreTriggerBottom.value) {
observerBottom.unobserve(loadMoreTriggerBottom.value);
}
});
observer를 등록하고 스크롤을 위, 아래로 올렸을때 분기처리를 해준다. loadMoreTriggerTop, loadMoreTriggerBottom이 화면에 노출되면 Observer에 의해 loadItems() 함수가 호출된다.
// 스크롤 처리 함수
const loadItems = async (direction: "up" | "down") => {
if (loading.value) return;
loading.value = true;
try {
// 이전의 스크롤 방향과 현재 방향을 비교하여 올바른 page 번호를 계산함
const newPage =
direction === "down"
? prevDireciton.value === "down"
? page.value + 1
: page.value + 3
: prevDireciton.value === "down"
? page.value - 3
: page.value - 1;
const response = await fetch(
`http://localhost:3001/items?_page=${newPage}&_per_page=${pageSize}`
);
const data: DataType = await response.json();
switch (direction) {
case "down":
// 화면을 내리면 스크롤 높이를 추가하는 로직
if (page.value >= 1) {
const item = document.getElementsByClassName("item")[0];
const rootHeight =
(page.value + 1) * pageSize * item.getClientRects()[0].height;
rootRef.value!.style.height = rootHeight + "px";
}
// 현재 아이템들과 bufferSize가 일치하는지 체크하여 데이터를 삽입할지 대체할지 분기 처리함.
if (visibleItems.value.length < bufferSize) {
visibleItems.value.push(...data.data);
} else {
// 리스트 위치 조정
const item = document.getElementsByClassName("item")[0];
scrollContainerRef.value!.style.top = "";
scrollContainerRef.value!.style.bottom =
(prevDireciton.value === "down" ? page.value - 2 : page.value) *
-pageSize *
item.getClientRects()[0].height +
"px";
// 새 데이터를 추가하고 가장 오래된 데이터를 제거
visibleItems.value = [
...visibleItems.value.slice(pageSize),
...data.data,
];
}
break;
case "up":
if (visibleItems.value.length < bufferSize) {
visibleItems.value.unshift(...data.data);
} else {
const item = document.getElementsByClassName("item")[0];
// 리스트 위치 조정
scrollContainerRef.value!.style.bottom =
Number(scrollContainerRef.value!.style.bottom.replace("px", "")) +
pageSize * item.getClientRects()[0].height +
"px";
// 새 데이터를 추가하고 가장 최근의 데이터를 제거
visibleItems.value = [
...data.data,
...visibleItems.value.slice(0, bufferSize - pageSize),
];
}
break;
}
page.value = newPage;
totalItems.value = data;
prevDireciton.value = direction; // 현재 스크롤 위치를 저장하여 다음 로드때 사용함
} catch (error) {
console.error("Failed to load items:", error);
} finally {
loading.value = false;
}
};
loadItems() 함수는 지정된 방향(up 또는 down)에 따라 데이터를 로드하고, 데이터를 서버에서 받아와 visibleItems 배열의 값을 수정하는 함수이다. 로직을 풀어서 설명하면 다음과 같다.
'prevDirection' 변수와 'direction' 파라미터를 사용하여 이전 스크롤 방향과 현재 스크롤 방향을 비교함으로써 다음에 불러올 page 번호를 계산한다. 'bufferSize'가 'pageSize'의 세 배인 점을 고려해, page 번호를 계산할 때는 스크롤 방향에 따라 3을 더하거나 빼거나, 또는 단순히 1을 조정해야 한다.
스크롤이 마지막 page 번호에 도달한 것이 아니라면, 스크롤이 계속 아래로 가능해야 하므로 .root 컨테이너의 높이를 동적으로 늘려야 한다. 사용자가 화면을 아래로 스크롤할 때, 조건 page.value >= 1 이 만족되면, render할 .item의 높이를 계산하여, 스크롤 가능 영역의 높이(rootHeight)를 설정한다. rootHeight는 현재 page 번호에 pageSize와 .item의 높이를 곱한 값으로 계산한다.
visibleItems 배열의 길이가 bufferSize보다 작은 경우, 스크롤 할만한 영역이 확보되지 않다는 뜻이므로 새로운 데이터를 배열에 추가한다.
만약 반대의 경우에는, 새 데이터를 추가하고 가장 최근의 데이터를 제거한다. 또한 사용자 입장에서 스크롤이 자연스럽게 보이도록 .scrollContainerRef의 bottom값을 수정하여 리스트 위치를 조정한다.
page, totalItems, prevDirection 값을 갱신하여 다음 데이터 로드때 사용한다.
스크롤 하니 자연스럽게 잘 되는 것을 볼 수 있다. 현재는 모든 item의 높이가 같지만, 언젠가 imgur처럼 각 item의 높이가 다른 케이스도 대응할 수 있도록 보완할 것이다.