ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 스크롤을 최적화하고 싶은데..
    Web/자바스크립트 2022. 12. 30. 17:47
    반응형

    "스크롤 애니메이션이 뭔가 이상해요 😭"

    스크롤 애니메이션을 고쳐달라는 요구사항이 새로 들어왔다.
    정확히 말하면 애니메이션은 아니고, 스크롤할 때 동작하는 간단한 로직에 문제가 있었다.
    문제가 된 스크롤 동작은 fixed 상태인 특정 컴포넌트가 스크롤로 인해 지정된 범위를 벗어나려고 하면
    해당 범위의 끄트머리를 넘어서지 않도록 위치를 고정해주는 동작이었다.

    정확히는 '컴포넌트가 움직여야 하는 범위' 가 맞겠다.

    사실 요 동작 자체만으로는 문제가 없었지만, 여기에 throttle이 더해지자 문제가 생겼다.
    원래는 스크롤을 할 때마다 컴포넌트의 위치를 지속적으로 감시하면서 위치를 고정시켜줘야 하는데,
    throttle로 인해 로직이 띄엄띄엄 실행되는 바람에 감시를 못하고 놓치는 구간이 발생했다.
    그래서 컴포넌트가 범위 밖으로 튀어나갔다가 다시 들어오는 현상이 나타났고, 요 부분이 문제가 되었던 것.

    사실, throttle만 없애면 간단하게 해결되는 문제였다.

    하지만 throttle을 없애고 아무런 최적화 없이 스크롤 로직을 그대로 내보내기에는
    우리 팀이 키워낸 이 아들래미같은 프로젝트가 누군가에게 트집 잡히지는 않을까 무서웠다.
    그래도 뭔가 최소한의 최적화는 해둬야하지 않을까 하는 생각.
    아들래미를 학교에 보내는데, 책가방도 안 맨 채로 보낼 수는 없는 노릇이었다.

    결국 throttle은 없애되, 다른 기법을 통해 스크롤 동작을 최적화해주기로 했다.
    마침 팀 동료인 아그라작님께서 과거에 유사한 이슈로 고생을 하신 적이 있던 터라
    반가워하며 옆자리로 달려와 도와주셨다.
    매번 느끼지만, 팀 동료를 잘 만나는 건 정말 큰 행운이 아닐 수 없다.


    우선, 아그라작님께서 소개해주신 최적화 방법은 passive event listener를 활용하는 것이었다.
    Passive event listener는 터치/휠을 통한 스크롤에 있어 성능적인 이득을 볼 수 있게 해주는 스펙이다.

    보통 브라우저는 사용자가 e.preventDefault로 이벤트의 기본 동작을 차단하는지 여부를 검사하는 시간이 필요하다.
    이로 인해, 자바스크립트의 이벤트 I/O는 기본적으로, 아주 미세하지만 약간의 방해를 받을 수 밖에 없다.
    브라우저가 해당 이벤트(특히 스크롤 이벤트)를 처리하는 과정에서 약간의 동작이 더해지고,
    그걸 처리하는 시간만큼 메인 쓰레드가 추가로 시간을 쓰게 되는 것.

    이 동작을 생략함으로써 이벤트 성능을 개선하기 위해 만들어진 스펙이 바로 passive event listener.
    addEventListener를 호출할 때 세 번째 인자로 { passive: true } 를 넣어주면 된다.
    해당 이벤트 리스너에서는 e.preventDefault를 호출하지 않을거라고 브라우저에게 미리 알려주는 것.
    (만약 해당 옵션을 적용하고 preventDefault를 호출하면 에러 콘솔 정도만 뜬다고 함)
    그러면 이벤트 동작이 조금 더 빨라진다고 한다.

    window.addEventListener('scroll', onScroll, { passive: true });

    참고로, 사파리와 IE를 제외한 나머지 브라우저들에서는
    wheel, mousewheel, touchstart, touchmove 이벤트에서 passive 옵션의 기본값이 true라고 한다.


    사실, passive event listener 정도만 적용해줘도 크게 문제는 없을 정도의 간단한 로직이긴 하다.
    하지만 아까전에 혼자 조사하면서 봤던 다른 최적화 방법도 한 번 적용해보고 싶은 마음에
    찝찝함이 가시지 않던 찰나, 아그라작님께서 먼저 말을 꺼내주셨다.

    "아, 그리고 사실 여기서 최적화를 한 번 더 할 수는 있는데.."
    "오, 혹시 requestAnimationFrame을 이용하는 방법!?"
    "오 맞습니다. 가시죠"

    내 마음을 읽으신 것일까. 척 하면 착이다.

    requestAnimationFrame(이하 rAF) API는 콜백함수를
    task queue, microtask queue와 구분되는 별도의 큐를 사용하여 비동기로 처리한다.
    큐를 부르는 명칭은 다양한 것 같은데, 요기에서는 그냥 animation frame request callback list 라고 주절주절 부르더라.
    어차피 내 블로그니까 그냥 내 맘대로 줄여서 animationFrames 큐라고 부르겠다.

    rAF을 사용하면 인자로 들어오는 콜백함수를 브라우저의 렌더링 주기에 맞춰, 다음 번 repaint가 실행되기 전에 호출한다.
    보통 일반적으로 60fps(1초에 60번, 약 16ms에 한 번)이지만 모니터의 주사율과 일치하게끔 유동적으로 맞춰진다고 한다.
    그래서 주사율에 딱 맞춘 부드러운 애니메이션 재생이 가능하다.
    심지어 백그라운드 탭에 있거나 하는 경우에는 함수 실행을 중단해주기까지 한단다.

    const isScrolling = useRef(false);
    const timerIdRef = useRef<number | null>(null);
    
    const scrollHandler = () => {
      // 스크롤 어쩌구 저쩌구
      
      if (isScrolling.current) {
        requestAnimationFrame(scrollHandler);
      }
    };
    
    useEffect(() => {
      const onScroll = () => {
        isScrolling.current = true;
        requestAnimationFrame(scrollHandler);
    
        if (timerIdRef.current) window.clearTimeout(timerIdRef.current);
    
        timerIdRef.current = window.setTimeout(() => {
          isScrolling.current = false;
        }, 100);
      };
      
      window.addEventListener("scroll", onScroll, { passive: true });
    
      return () => {
        window.removeEventListener("scroll", onScroll);
      };
    }, []);

    위 코드를 보면, 단순히 rAF 말고도 아그라작님께서 약간의 최적화를 더해주셨다.

    요 스크롤 로직이 붙어있는 페이지는 저 위에 GIF에서도 보이듯이,
    보내는 사람이나 받는 사람같은 몇 가지 정보를 입력하는 form이 포함된 페이지다.
    그래서 사실상 스크롤이 멈춰있는 시간이 훨씬 많다고 볼 수 있는데, 그 동안 계속 rAF이 돌아가는 건 상당한 비효율이다.
    (rAF은 인자로 들어가는 콜백함수 내부에서 또 다시 rAF을 호출하는 것이 원칙이므로,
    번 실행하고 나면 해당 콜백이 재귀로 계속 실행된다)
    그래서 별도의 타이머를 두어 스크롤 이후 0.1초까지만 콜백을 호출하도록 처리해준 것.


    그런데 웬걸, 요렇게 하고 나서 다시 CDD(Console.log Driven Development)를 통해 확인을 해봤더니..
    휠로 스크롤 한 틱 굴렸을 뿐인데 로그가 거의 180개 가까이 찍혔다.

    나중에 사진을 찍으려니 또 170개로 줄었네

    분명 rAF는 주사율에 맞게 최적화를 해주는 함수라고 했는데..
    운 좋게도 180Hz의 주사율을 가진 초고사양 게이밍 맥북프로에 당첨된 것일까?
    그럴리는 없으니, 아무래도 뭔가 잘못된 것이 틀림없다.

    로그가 주사율을 훌쩍 뛰어넘도록 반복해서 찍힌 이유는 뭘까.
    rAF 함수가 실행되는 도중에 rAF가 또 호출되어서 멀티 스레딩이 되는 것일까? 하지만 호출 스택은 하나 뿐이다.
    궁금증을 가지고 조사를 하던 중, 캘리포니아에 거주 중이신 구글 크롬 팀의 폴 아이리쉬씨에게 힌트를 얻을 수 있었다.
    거기에 MDN과 약간의 실험을 더하고 나니, 다음과 같은 결론에 도달했다.

    여러 개의 콜백들이 한 프레임 안에서 한꺼번에 실행될 수 있다는 뜻?

    1. animationFrames 큐에는 rAF에 의해 실행된 콜백들이 들어간다.
    2. 이벤트 핸들러에서 처음 실행된 rAF는 이번 프레임에, 해당 rAF 내에서 실행된 또 다른 rAF는 다음 프레임에 실행된다.
    즉, 기본적으로 한 프레임 당 한 번 콜백 함수를 실행시킨 후 해당 결과를 반영하여 화면을 업데이트한다.
    3. 콜백 함수는 반드시 의도된 프레임 내에 모두 실행된다.
    4. 즉, 만약 콜백의 실행 시간이 한 프레임보다 길거나, 여러 개의 rAF를 실행하여 큐에 여러 개의 콜백들이 쌓이게 되면
    브라우저는 이 콜백(들)을 프레임에 맞게 나누어 실행하는 게 아니다. 그냥 전부 실행할 때까지 화면을 업데이트하지 않는다.
    (즉, 화면 업데이트도 미뤄진다. 이는 animation jank로 이어짐)

    여기서 내 경우는 스크롤 이벤트가 좌라라락 발생하면서 스크롤 이벤트 핸들러를 여러 번 실행시키는데,
    이 때 여러 개의 rAF 콜백들이 큐에 들어가게 되면서 해당 콜백들이 한 프레임 내에서 함께 실행된 것이렷다.

    즉, 주사율보다 훨씬 많은 횟수의 호출이 가능했던 건 한 프레임동안 콜백 함수가 여러 번 실행됐기 때문이라는 결론.

    어차피 화면 업데이트는 한 프레임에 한 번인데, 그 동안 동일한 콜백함수를 여럿 실행하는 건 너무 낭비라는 생각이 든다.
    그렇다면, 화면 업데이트 횟수에 맞춰 한 프레임에 콜백함수를 딱 한 번만 실행하는 방법은 없을까?
    이를 위해 MDNDOMHighResTimeStamp를 해결책으로 제시하고 있다. (MDN 보면 예제도 있음)

    rAF의 콜백 함수는 첫 번째 인자로 'DOMHighResTimeStamp'를 받는다.
    이름이 뭔 뜻인지는 모르겠고, 대충 현재 시각을 의미하는 timestamp라고 한다.
    큐에 들어가있다가 동일한 프레임 내에 실행되는 콜백함수들은 전부 동일한 timestamp를 부여받는데,
    이는 곧 프레임이 달라지면 이 timestamp도 달라진다는 것을 의미한다.

    그리하야, 이 timestamp를 비교함으로써 동일한 프레임 내에 실행되는 나머지 콜백들을 전부 걸러주면 되는 것이렷다.
    참고로, 이 timestamp는 performance.now() 값으로 대신해서 사용할 수 있다고 함.

    const isScrolling = useRef(false);
    const timerIdRef = useRef<number | null>(null);
    const prevTimestampRef = useRef<number | null>(null);
    
    const scrollAnimationHandler = (timestamp: number) => {
      // timestamp가 이전과 동일하다면 걸러주는 로직 추가 
      if (prevTimestampRef.current === timestamp) return;
      
      // 스크롤 어쩌구 저쩌구
      
      if (isScrolling.current) {
        // timestamp 갱신
        prevTimestampRef.current = timestamp;
        requestAnimationFrame(scrollAnimationHandler);
      }
    };
    
    // ...

    짜잔! 요렇게 호출 횟수를 훨씬 줄일 수 있게 되었다.


    실제로 구현한 건 requestAnimationFrame 까지지만, 이것저것 보다보니 frame lifecycle에 관심이 생겼다.
    그리고 '한 프레임 동안 rAF 콜백을 실행하고 남은 시간을 활용하는 방법은 없을까?' 라는 생각에 도달하게 됐는데,
    이는 requestIdleCallback API를 통해 구현 가능하다고 한다.

    rAF 콜백을 실행하고나면 그 뒤에는 스타일 계산, 레이아웃, 페인트 등의 브라우저 작업들이 뒤따라오게 되는데,
    이 작업들을 처리하고 남는 시간이 어느 정도인지는 우리가 정확히 알 수 없다.
    이 때 rIC를 활용해주면, 위의 작업들을 모두 실행하고 프레임의 끝에 남는 시간에 처리할 작업을 지정할 수 있게 된다.
    (브라우저의 메인 스레드가 잠깐 비게 되면 그 때를 틈타 콜백을 실행시켜주는 것)
    프레임 별로 남는 시간을 효율적으로 활용할 수 있게끔 도와준다고 할 수 있다.

    보통 스크립트들은 가능한 한 바로바로 실행되는데,
    몇 가지 작업들은 굳이 유저가 상호작용 중이거나 할 때는 바로바로 처리할 필요가 없는 작업들도 있다.
    이런 경우, 우리는 유저가 상호작용 중인지를 알려면 지구 상 존재하는 모든 이벤트 리스너를 달아서 판별해야 하지만,
    브라우저는 이를 손쉽고 정확하게 판단할 수 있기 때문에 rIC를 통해 처리해주면 훨씬 일이 간단해진다.

    예를 들면 Google analytics 데이터를 보낸다던가 하는 작업들은
    유저가 스크롤을 하는 중에 보내진다던가 하면 유저의 사용성을 저해할 수 있다.
    그래서 유저의 동작을 방해하지 않고 남는 시간에 처리하게끔 하는 용도로 rIC를 활용할 수 있다고 한다.

    얘는 딱히 적용해보지는 않았고, 나중에 기회가 될 때 적용해 보는 걸로..
    참고 자료.
    Chrome developers: https://developer.chrome.com/blog/using-requestidlecallback/
    W3C: https://www.w3.org/TR/requestidlecallback/
    MDN:  https://developer.mozilla.org/ko/docs/Web/API/Window/requestIdleCallback
    LINE Engineering: https://engineering.linecorp.com/ko/blog/line-securities-frontend-4/


    여기까지..
    틀린 부분 있으면 알려주십쇼

    혹시 스크롤이 개선된 모습을 보고 싶으시다면..
    겸사겸사 선물하기 PC 버전 으로 와서 구경하는 동시에 주변인들과 따뜻한 밥 한끼 나눠보는 것은 어떨까요..?
    그냥 말해봤습니다..

    옙 수고하십쇼

    반응형

    댓글

Designed by Tistory.