개발자
류준열

setTimeout의 한계와 reqeustAnimationFrame

무한스크롤 구현

무한스크롤을 구현하면서 스크롤 이벤트에 throttle을 걸어서 1초 간격으로 이벤트가 발생하도록 하였다. 그런데 조금 찾아보니 setTimeout기반의 throttle은 call Stack이 비는것을 대기하다가 다른 태스크에 의해 순서가 밀려 타이밍을 못맞추게 되는 경우가 있다고 한다.

setTimeout이 타이밍을 못맞추는 것과 이를 해결하기 위한 방법을 찾아보았다.

그리고 requestAnimationFrame을 통해 스크롤 이벤트를 최적화 하는 것은 의미가 없을수도 있다는 걸 알게 되었다.

setTimeout이 타이밍을 못맞추는 경우

싱글스레드인 JS에서는 setTimeout을 비동기로 실행시키기 위해 WebAPI를 이용한다.

이벤트 루프

  1. Call Stack에 setTimeout이 들어오면 Web API에 setTimeout을 위임하고
  2. n초후 Web API는 Queue로 setTimeout을 보내고
  3. Call Stack이 모두 완료되기만을 기다린다.
  4. 그 이후 Call Stack이 비었을때 Task Queue에서 대기하던 setTimeout을 처리한다.

이때 3~4 과정에서 n초가 지났는데도 Call Stack에 무언가 남아있으면 setTimeout은 Queue에서 계속 대기하게 되면서 타이밍을 놓칠 수 있다. (참고)

즉, 렌더링 타이밍을 setTimeout으로 맞출 경우, 개발자의 의도와 다르게 작동할 수 있다.

requestAnimationFrame

requestAnimationFrame은 시스템이 프레임을 그릴 준비가 되었을때 호출되는데 원래 목적은 효율적인 애니메이션 구현이다.

requestAnimationFrame은 화면이 갱신되는 주기에 따라 repaint 전 호출되기 때문에 타이밍과 무관하게 프레임 시작시 실행되는것이 보장된다.

requestAnimationFrame도 비동기로 분류되어 이벤트 루프에 의해 처리된다. 하지만 setTimeout과 다르게 task queue가 아니라 Animation Frame이라는 별개의 queue에서 처리된다.

이벤트 루프의 3가지 Queue

이 글에 자세히 나와 있는데 각 Queue들을 요약하면 다음과 같다.

이벤트루프

Microtask Queue

Promise, async/await 등을 처리한다. 우선순위가 가장 높다.

Animation Frames

requestAnimationFrame과 같이 브라우저 렌더링에 관련된 task를 처리한다.

Macrotask Queue(Task Queue)

우리가 흔이 아는 Task Queue이다. setTimeout, setInterval등을 처리한다.

이벤트 루프에서 Queue 처리 우선순위

이벤트 루프에서 큐 처리 우선순위는 Microtask Queue -> Animation Frame -> Task Queue 이다.
그리고 Microtask Queue, Animation Frames는 한번에 처리하지만, Macrotask Queue는 한번에 하나의 작업만 callstack으로 전달후 다른 Queue를 순회한다.

requestAnimationFrame의 작동방식 이해와 스크롤 최적화

아래 html을 복붙하고 띄워보자. 그리고 스크롤을 움직이고 콘솔을 확인하자.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body style="height: 500vh">
    ㅎㅇㅎㅇ
  </body>
  <script>
    let ticking = false;
    function onScroll() { // 1. Trigger Scroll
      if (!ticking) {
        window.requestAnimationFrame(function () { // 2.rAF -> Animation Frame에 저장
          console.log("tick1", ticking); // 4. 리페인트전 실행
          ticking = false; // 5. unblock
        });
      } else {
        console.log("tick2", ticking); // block 되었을 때 log
      }
      ticking = true; // 3. block
    }
    window.addEventListener("scroll", onScroll);
  </script>
</html>

콘솔을 확인해보면 아래와 같이 tick2가 찍히지 않는다. rafconsole

왜냐하면 requestAnimationFrame은 비동기로 실행되어 repaint 직전에 호출되기 때문이다.

  1. onScroll 실행
  2. 비동기 Queue중 하나인 Animation Frame에 callback 저장
  3. ticking = true로 변경하여 조건문에 진입 불가하도록 함
  4. repaint전에 Animation Frame에 저장된 callback이 실행된다.
  5. ticking = false로 변경하여 조건문에 진입 가능해짐
  6. 렌더링 실행

즉 3번 ticking = true 는 의미없는 코드다.

무한스크롤에서 최적화는 그럼 어떻게 하나?

이번에 알게 된건데 IntersectionObserver의 콜백도 repaint전에 호출된다. frame 라이프사이클

결국, 돌고돌아 클래식한 무한스크롤 구현방법이 가장 최적화된 방식인 것 같다.

무슨 말이나면 IntersectionObserver의 콜백으로 query를 두고 가장 아래 요소가 viewport에 들어올때마다 query를 실행시키는 것이 가장 좋은것 같다는 말이다.

  1. 로딩이 끝나면 queryTriggerRef가 viewport 하단에 나타나고,
  2. 스크롤을 다시 내리면 queryTriggerRef가 viewport에 들어와서
  3. IntersectionObserver의 callback이 실행된다.(query)
  4. 로딩하는동안에는 queryTriggerRef가 안보인다.
  5. 1~4 반복
return (
	<>
	...
	  {!loading && <span ref = {queryTriggerRef} />}
	</>)

참고

https://www.fronttigger.dev/2022/react/infinity-scroll
https://iborymagic.tistory.com/142
https://blog.naver.com/dndlab/221633637425
https://inpa.tistory.com/entry/%F0%9F%8C%90-requestAnimationFrame-%EA%B0%80%EC%9D%B4%EB%93%9C
https://ethansup.net/blog/deal-with-scroll-event