JS 애니메이션 만들 땐requestAnimationFrame

nyoung·2024년 3월 27일
0
post-thumbnail

JavaScript Animation을 구현할 때, 나도 모르게 setTimeout이나 setInterval로 사용하는 사람이 참고할 수 있는 글

JS로 애니메이션 구현 시 이전에는 setInterval로 구현했다. 하지만 setInterval보다 requestAnimationFrame을 사용하는게 더 적절하다는 것을 알았다.
Toss의 useOverlay 라이브러리 구현부를 살펴보는데, requestAnimationFrame 을 사용하지 않으면 애니메이션이 누락될 수 있다고 써있는 것 아닌가.

토스의 OverlayController.tsx 중 일부

  useEffect(() => {
    // NOTE: requestAnimationFrame이 없으면 가끔 Open 애니메이션이 실행되지 않는다.
    requestAnimationFrame(() => {
      setIsOpenOverlay(true);
    });
  }, []);

그 전에 몇 번 이 함수를 마주치긴 했지만 제대로 살펴보지 않았는데, 해당 함수에 대해 알아봐야겠다는 생각을 했다.

requestAnimationFrame()

window.requestAnimationFrame() 메서드는 브라우저에게 수행하기를 원하는 애니메이션을 알리고 다음 리페인트 바로 전에 브라우저가 애니메이션을 업데이트할 지정된 함수를 호출하도록 요청한다. 이 메서드는 리페인트 이전에 호출할 인수로 콜백을 받는다.

보통 웹 페이지의 애니메이션을 구현할 때 간단한 것은 CSS의 속성을 통해 구현하곤 하지만, 비교적 복잡한 (브라우저의 높이에 따른 변화라던가,,) 애니메이션은 자바스크립트로 구현하기도 한다. 자바스크립트로 애니메이션을 구현할 때 프레임의 누락을 최소화해 자연스러운 애니메이션 처리와 최적화를 할 수 있도록 도와주는 것이 requestAnimationFrame() 이다.

브라우저의 프레임과 주사율

프레임과 주사율에 대해서 먼저 알아보자.
주사율이라는 것은, 1초 동안 모니터의 화면 출력 빈도를 나타내는 것이다.
영화를 볼 때, 화면이 부드럽게 움직이는 것 처럼 보이지만 사실 짧은 시간 간격 안에 이어지는 사진들을 연속해서 보는 것이다.

인간은 1초에 60번 이상의 사진들이 연속적으로 보여야 자연스러운 영상이라고 느낀다.
즉 60FPS이 상이 되어야 자연스럽다고 여긴다

프레임 비교 영상

따라서 자바스크립트로 사용자에게 부드러운 애니메이션을 구현하려면, 1000ms / 60fps = 16.6ms 마다 코드를 호출해야 한다.

타이머 함수와의 차이점

자바스크립트로 일정 시간마다 코드를 반복적으로 호출하는 방법에는 setTimeout과 setInterval이 있다.

애니메이션을 setInterval 함수로 만들면 다음과 같이 작성할 수 있다.

const performAnimation = () => {
	setInterval(performAnimation, 1000 / 60)
}

그런데 왜 requestAnimationFrame 을 사용하라는 것일까?

해당 방식의 문제점은, 우리가 정확한 시간에 정확하게 함술을 실행시켰다고 하더라도, 브라우저가 다른 작업때문에 바쁘거나 setTimeout 함수가 정확하게 repaint 시점에 실행되지 않으면 다음 사이클로 밀리기 때문이다. 다음 사이클(다음 리페인트 시점)로 밀린다는 것은 즉, 한 프레임이 누락된다는 것이다.

아래는 setTimeout, setInterval의 함수가 호출될 때 일어나는 일을 보여준다.

paint - 초록
render - 보라
javascript - 노랑

16.6ms의 타이밍과 관계 없이 JS코드가 실행되는 것을 볼 수 있다. 따라서 주사율과 관계 없이 실행되지 않는 때도 있고, JS코드가 레이아웃 - 페인트 과정 전에 끝나지 않으면 레이아웃- 페인트 과정도 밀리게 되기도 한다.

setTimeout, setInterval

또한 호출이 브라우저의 주사율 보다 많이 될 때에는 애니메이션이 끊기는 느낌을 준다.

간단히 생각하면 4개의 사진이 호출되는데 마지막 사진만 담기게 되고, 따라서 3개의 프레임은 누락된다고 생각하면 된다.
호출이 브라우저의 주사율 보다 많이 될 때

requestAnimationFrame의 이점

프레임 주기에 맞춰 실행된다

requestAnimationFrame은 애니메이션 코드가 rendering과 painting 이벤트 전에 실행된다. 실제 화면이 갱신되어서 표시되는 주기에 따라 함수를 호출하기 때문에, 예측 가능하고 누락이 최소화된다.

requestAnimationFrame

별도의 Animation Queue에 저장된다

또한 SetTimeout과 SetInterval을 사용할 때에는 Task Queue에 저장되는데, AnimationRequestFrame을 사용하면 별도의 Animation Queue에 들어가기 때문에 Promise이외의 다른 비동기 작업(Micro task Queue)에 비해 밀릴 위험이 적다.

백그라운드에서 동작이 멈춘다

브라우저의 창을 숨기거나, 다른 창을 볼 때 백그라운드에서 불필요한 전력을 소모하지 않도록 브라우저에 의해서 일시중지된다.

호출 횟수가 자동적으로 디스플레이의 주사율에 맞춰진다

호출 시간을 수동으로 정해줘야 했던 setTimeout과 setInterval과 달리, 현재 사용하고 있는 디스플레이의 주사율에 맞춰 애니메이션을 자동으로 실행시킨다.
이때 주의해야 할 점은, 60fps인 디스플레이 보다 120fps의 주사율을 가진 디스플레이에서 애니메이션이 빠르게 동작할 수 있다는 점이다.

따라서 항상 프레임에서 얼마나 많은 애니메이션이 진행될 것인지 계산하기 위해 첫 번째 인수(혹은 현재 시간을 가질 수 있는 몇몇 다른 메서드)를 사용해야 한다

사용법

requestAnimationFrame사용법은 setTimeout처럼 콜백 함수 내부에서 재귀적으로 호출하는 방식이다.
브라우저는 애니메이션을 출력할 때 마다 requestAnimationFrame에 등록된 콜백 함수들을 비동기로 호출한다.

const performAnimation = () => {
  requestAnimationFrame(performAnimation) 
}

requestAnimationFrame(performAnimation);

취소하려면 cancelAnimationFrame 을 사용하면 된다.
에니메이션을 취소하지 않으면 메모리를 차지하기 때문에 메모리 누수가 발생할 수 있어 꼭 취소해줘야 한다.

const performAnimation = () => {
	if(조건){
		cancelAnimationFrame(performAnimation);
		return;
	}
	requestAnimationFrame(performAnimation);
}
requestAnimationFrame(performAnimation);

requestAnimationFrame의 콜백 Function에는 한 가직 인자가 있는데, 이 인자는 timestamp이다.
애니메이션이 실행된 이후의 시간을 리턴한다.

const performAnimation = (timestamp: DOMHighResTimeStamp) => {
  requestAnimationFrame(performAnimation) 
  
}

requestAnimationFrame(performAnimation);

프로젝트에서 사용할 때에는 requestAnimationFrame callback Function에 시간 간격을 주고 싶었다.
requestAnimationFrame은 자동적으로 브라우저 주사율에 맞춰 실행되기 때문에, 50ms 와 같은 특정 값을 주지 못한다. 이를 구현하기 위해 useAnimationFrame 훅을 만들고, 시간 입력 시 경과 시간을 재서 callbackFn이 실행되도록 구현했다.

import {useEffect, useRef, useState} from 'react'


import {useEffect, useRef} from 'react'

const useRequestAnimationFrame = (callbackFn: () => boolean, ms?: number) => {
  const idRef = useRef<number | null>(null)
  const startTimeRef = useRef<number | null>(null)

  useEffect(() => {
    const animationFn = (timestamp: DOMHighResTimeStamp) => {
      // for throttle
      if (!startTimeRef.current) {
        startTimeRef.current = timestamp
      }
      const elapsed = timestamp - startTimeRef.current

      if (!ms || elapsed >= ms) {
        const isNext = callbackFn()
        startTimeRef.current = timestamp

        // if isNext is false, cancel the animation
        if (!isNext) {
          cancelAnimationFrame(idRef.current!)
          return
        }
      }

      requestAnimationFrame(animationFn)
    }

    idRef.current = requestAnimationFrame(animationFn)

    return () => {
      if (idRef.current) cancelAnimationFrame(idRef.current)
    }
  }, [callbackFn, ms])

  return idRef.current
}

export default useRequestAnimationFrame

이때 주의해야 할 점은, 정확하게 50ms마다 불러지는게 아니라는 점이다.
실행 결과 타임스탬프
위의 결과를 보면 알겠지만 주사율에 맞춰 호출되기 때문에,

내 컴퓨터 (60fps) 기준으로 16.6 * 3 이면 거의 50ms이다.

그래서 50ms 기준 3번 ~ 4번째 호출 마다 실행되기 때문에 실제 실행 시간은 50ms ~ 66ms(50ms + 16ms)이라고 보면 된다.

profile
코드는 죄가 없다,,

0개의 댓글

관련 채용 정보