스크롤 등의 이벤트 최적화하기

개발

스크롤 등의 이벤트 최적화하기
최종 수정일:

사용자의 스크롤, 클릭 등에 반응하는 페이지를 만들면, 1초에도 몇십 번씩 특정 함수가 동작해야 할 때가 많습니다.
간단한 애니메이션을 출력하는 정도라면 상관없겠지만, 복잡한 그래픽 연산이 들어가는 작업을 아무런 최적화 없이 이벤트 리스너만 추가해두면 시스템 자원을 갉아먹고 디스플레이의 주사율보다 더 많이 실행되어 오히려 프레임 방어를 못 해주는 상황까지 발생하기도 합니다.

이렇게 무거운 함수를 돌리는 와중에도 event.preventDefault() 사용 여부를 확인해야 하니, 브라우저의 메인 쓰레드에까지 악영향을 끼쳐 사용자가 페이지 전체가 버벅인다 느낄 수 있습니다.

개인적으로 스크롤 이벤트에선 한 번도 문제가 생긴 적 없는데, 슬라이더 등을 제작하며 마우스 / 터치에 반응해 여러 돔 요소의 스타일을 조작해야 하는 상황에선 절실하더라고요.

Passive Listener

window.addEventListener("scroll", foo, { passive: true });

요즘엔 scroll이나 touchstart 같은 이벤트에 passive 옵션을 추가하지 않으면 콘솔에 경고까지 뜨기에, 아마 대부분 사용하는 옵션일 거라 봅니다.

passivetrue인 이벤트 리스너는 preventDefault()를 호출할 수 없습니다.
preventDefault()를 호출할 일이 없으니, 상술한 것처럼 브라우저의 메인 쓰레드에까지 악영향을 끼치는 참사는 막을 수 있습니다.

Window.requestAnimationFrame()

아직 브라우저가 렌더링할 수 있는 능력보다 함수가 실행되는 횟수가 더 많단 문제를 해결하지 못했습니다.
이는 requestAnimationFrame(MDN)을 이용해 해결할 수 있습니다.

requestanimationframe 실행

requestAnimationFrame 없인 200회가량 실행됨

위 스크린샷처럼 requestAnimationFrame에 콜백을 넘겨주면 브라우저가 화면을 다시 그리기 전에 해당 함수를 호출하니, 함수가 초당 200회씩 호출되는 참사를 방지할 수 있습니다.

!(function () {
    let ticking = false;
 
    function foo() {
        if (!ticking) {
            ticking = true;
            requestAnimationFrame(() => {
                console.log("Scrolled!");
                ticking = false;
            });
        }
    }
 
    window.addEventListener("scroll", foo, { passive: true });
})();

함수의 실행이 끝났는지 확인하기 위해 ticking이란 변수를 추가했습니다.
매 스크롤에 foo가 실행되고, tickingfalse이면 requestAnimationFrame에 실행할 함수를 콜백으로 넘겨줍니다.
콜백으로 넘겨준 함수의 마지막에 ticking을 다시 false로 변경하는 코드를 추가해, 함수의 실행이 끝나고 다음 스크롤에 foo가 호출되면 다시 함수를 콜백으로 넘겨줄 수 있게 합니다.

재사용 가능한 함수 만들기

매번 상술한 방식으로 이벤트를 등록하는 건 상당히 귀찮을뿐더러 직관적이지 않으니, 어떤 상황에서건 쓸 수 있는 함수를 제작해보겠습니다.

function outer() {
    let counter = 0;
 
    function increaseCounter() {
        counter++;
        console.log(counter);
    }
 
    return increaseCounter;
}
 
const myFunction = outer();
 
myFunction(); // 1
myFunction(); // 2
myFunction(); // 3
myFunction(); // 4

그에 앞서 알아야하는 개념이 자바스크립트의 클로저(Closure)(MDN)입니다.
위 코드는 언뜻 보면 counter가 실행 컨텍스트에도, window에도 없어 제대로 작동하지 않을 것처럼 보이지만, 실제론 outer에서 increaseCounter를 넘겨줄 때 해당 함수가 couter를 사용한단 걸 판단하고 counter도 함께 넘겨줍니다.

function optimizeAnimation(callback) {
    let ticking = false;
 
    return () => {
        if (!ticking) {
            ticking = true;
            requestAnimationFrame(() => {
                callback();
                ticking = false;
            });
        }
    };
}

위를 활용해 만든 함수입니다.

window.addEventListener(
    "scroll",
    optimizeAnimation(() => {
        console.log("Hi there 👋");
    }),
    { passive: true }
);

다소 이상해 보일 수 있지만, 이렇게 optimizeAnimation 함수가 아닌 optimizeAnimation의 실행 결과를 넘겨주면, ticking이 동봉되어 함수가 브라우저가 렌더링할 수 있는 능력을 벗어나는 횟수만큼 실행되는 것을 방지할 수 있습니다.

Report an issue