현재 진행중인 프로젝트에서 자동으로 추가 컨텐츠를 로드하는 웹 디자인 기법 중 하나인 무한 스크롤을 구현해야 하는 상황에 놓였습니다.
무한 스크롤을 구현하는 방법은 간단한데요. 스크롤 이벤트가 발생할 때 현재 스크롤 높이를 계산하여 스크롤이 맨 아래에 도착하면 다음 컨텐츠를 로드하는 api를 호출하면 됩니다. 아래는 예시 코드 입니다.데이터와 다음 페이지의 컨텐츠를 가져오는 메서드가 훅을 통해 분리되어 있다고 가정하겠습니다.
function Page() {
const {data, fetchNextPage} = useFetchData();
useEffect(() => {
const handleScroll = () => {
if("스크롤이 최하단에 도착하면"){
fetchNextPage()
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return (
{data.map("데이터를 이용하여 화면 그리기")}
);
}
그런데 여기서 한가지 문제점을 발견하였습니다. 필요 이상으로 이벤트가 많이 발생한다는 점이었는데요, 아래와 같이 스크롤 한번에 대략 12번의 이벤트가 발생하였고, 스크롤이 맨 아래에 도착하기까지 100번이 넘는 이벤트가 발생하였습니다.
스크롤이 최하단에 도착하기전까지 레이아웃의 높이를 계산하고, 스크롤이 최하단에 위치하였는지 판단하는 로직을 140번 실행하는 것은 불필요하며, 함수의 복잡성이 증가할수록 리소스를 많이 사용할 수 있기 때문에 이벤트 발생 빈도를 줄일 필요가 있겠다고 생각하였습니다.
첫 이벤트가 발생하고 이후 n초동안 발생한 이벤트는 무시하고, 마지막에 발생한 이벤트가 첫 이벤트가 발생한 후 n초가 지난 후에 실행되도록 하면 이벤트 발생 빈도를 줄일 수 있을것으로 생각하였습니다.
나중에 발생한 이벤트가 앞서 발생한 이벤트를 상쇄할 수 있도록 하기 위해서 후행 이벤트가 선행 이벤트의 타이머를 clear하는 방식을 선택하였습니다.
const eventReducer = (callback: () => void, timeout: number) => {
let timer: number;
let isFirst: boolean = true;
return () => {
if (isFirst) {
callback();
isFirst = false;
} else {
clearTimeout(timer);
timer = window.setTimeout(() => {
callback();
}, timeout);
}
};
};
이벤트 발생 빈도가 많이 감소하였습니다. 하지만 아직 문제가 있어 보입니다. 이벤트를 timeout 주기마다 listening 하고 있는 것이 아닌, 첫 이벤트와 마지막 이벤트 사이에 존재하는 이벤트들은 무시된 채, 오로지 처음과 맨 마지막 이벤트만 listening 하고 있다는 점이었습니다.
const eventReducer = (callback: () => void, timeout: number) => {
let invokedTime: number;
let timer: number;
return () => {
if (!invokedTime) {
callback();
invokedTime = Date.now();
} else {
clearTimeout(timer);
timer = window.setTimeout(() => {
if (Date.now() - invokedTime >= timeout) {
callback();
invokedTime = Date.now();
}
}, Math.max(timeout - (Date.now() - invokedTime), 0));
}
};
};
export default eventReducer;
eventReducer 함수를 수정하였습니다. 첫 이벤트인지 판단하는 isFirst 대신 invokedTime을 사용하여 이벤트가 발생한 시각을 기록합니다. 이후에 이벤트가 timeout초가 지난 후에 발생한다면 callback을 실행하고 invokedTime을 갱신합니다.
문제였던 부분은 setTimeout의 두번째 인자입니다. 그냥 timeout을 사용하게 되면 사용자가 연속해서 이벤트를 발생시킬 때마다 timeout 시간 동안 대기하고, 그 이후에 콜백 함수를 실행하게 됩니다.
이부분을 개선하여 사용자가 연속해서 이벤트를 발생시켰을 때, 기존에 설정한 timeout만큼의 시간을 정확히 기다렸다가 콜백 함수를 실행하도록 변경하였습니다.
의도한대로 첫 이벤트와 마지막 이벤트 사이의 중간 이벤트들도 정상적으로 listening 되는것을 볼 수 있습니다. 이제 스크롤 높이를 판단하여 스크롤이 최하단에 위치한다면 다음 페이지의 컨텐츠를 요청하는 api를 요청하는 callback 함수를 작성하여 eventReducer의 콜백함수로 넣어주면 되겠네요.
하지만 싱글 스레드로 동작하는 자바 스크립트 특성상, setTimeout을 통해 이벤트를 제어하면 의도치 않은 동작을 할 수 있습니다. setTimeout의 콜백함수는 task queue에 들어가게 되는데, task queue는 micro task queue보다 우선순위가 낮기 때문에 프로미스에 밀려서 callback이 실행되지 않을 수 있고, task queue 특성상 call stack이 비워져 있을 때에 실행되기 때문에 추가적인 delay가 발생할 수 있습니다.
delay가 되는것이지 callback이 실행이 안되는 것은 아니고, delay되는 시간도 사용자 경험에 영향을 미칠 수준은 아니니, 일단 eventReducer를 그대로 사용하고... 추후에 문제가 생기면 타이머 대신 다른 방법을 생각해 보려고 합니다...
이상으로 스크롤 이벤트 최적화 하기 포스팅을 마치겠습니다. 감사합니다.