requestAnimationFrame 가이드: 브라우저에서 부드러운 애니메이션 만들기

왜 setInterval은 끊기고, requestAnimationFrame은 부드러울까?

Table Of Contents

1. requestAnimationFrame이 왜 필요할까?

브라우저에 어떤 내용이 주기적으로 변하게 만들어야 할 때가 있다.

예를 들어서, 프로그레스바가 좌에서 우로 채워지는 효과, 타이머의 시간이 밀리초 단위로 올라가는 효과 등을 주고 싶다고 하자.

아마 코딩을 해봤다면 setTimeout이나 setInterval 메소드를 이용해볼까? 라고 생각할 수 있다. (내가 그랬다)

하지만 이 메소드들은 화면의 렌더링 주기와 무관하게 동작하기 때문에, 불필요하게 연산을 많이 요구하거나 결과물 애니메이션이 부드럽지 못할 수 있다.

 

1.1. setInterval 예시

간단하게 어떤 element를 오른쪽으로 이동시키는 애니메이션을 구현해보자!

let x = 0; setInterval(() => { x += 5; // 5px씩 이동 element.style.transform = `translateX(${x}px)`; }, 16); // 약 60fps의 속도로

대부분의 디스플레이는 60Hz 주사율을 기준으로 작동하기 때문에

으로 두고 1초에 60프레임을 처리하도록, 즉 16ms마다 1프레임씩 처리하도록 두었다.

 

이렇게 애니메이션을 준다면 이론적으로는 애니메이션 로직이 초당 약 60번 실행되기 때문에 60fps처럼 부드러운 애니메이션을 만들 수 있다!

1.2. setInterval의 한계

하지만 문제는 setInterval이 정확히 16.66ms마다 실행된다고 보장할 수 없다는 것이다🤦

즉, 단순하게 작동하는 것처럼 보일 수는 있어도 실제적으로 부드럽고 효율적으로 작동하지는 않는다는 뜻이다.

이런 문제를 해결하기 위해서는 requestAnimationFrame을 이용할 수 있다.

2. requestAnimationFrame의 등장

requestAnimationFrame(RAF)은 다음 화면을 그리기 바로 전에 콜백(애니메이션 로직)을 실행하도록 브라우저에 요청하는 API다.

따라서 이런 장점이 생기게 된다!

2.1. requestAnimationFrame 사용 예시

requestAnimationFrame(callback);

requestAnimationFrame의 인자로 애니메이션 업데이트 로직을 담은 함수 callback을 넘겨주면 된다.

 

위에서 setInterval로 작성한 애니메이션을 requestAnimationFrame로 다시 작성해보자.

let x = 0; function animate() { x += 5; element.style.transform = `translateX(${x}px)`; requestAnimationFrame(animate); // 다음 프레임에 또 실행 } requestAnimationFrame(animate);

이렇게 하면 브라우저가 화면을 업데이트할 시점에만 animate 함수를 실행해주므로 애니메이션을 더 부드럽게 보여줄 수 있다!

 

callback 함수 내부에서 직접 requestAnimationFrame을 재귀 호출해서 다음 프레임의 렌더링을 요청하는 것도 잊지 말자!

2.2. requestAnimationFrame의 함수 구조와 인자 설명

2.2.1. timestamp

requestAnimationFramecallback 함수는 호출될 때 timestamp라는 숫자값을 전달받는다.

timestamp를 사용하면 이전 프레임 이후로 시간이 얼마나 지났는지 계산할 수 있다.

이를 이용하면 기기의 성능이나 실제 프레임 속도와는 상관없이 일정한 속도의 애니메이션을 구현할 수 있다!

 

mdn에서 가져온 예시를 하나 보자.

RAF를 이용해서 element를 2초동안 오른쪽으로 200px 이동하는 예시이다.

const element = document.getElementById("some-element-you-want-to-animate"); let start; function step(timestamp) { if (start === undefined) { start = timestamp; } const elapsed = timestamp - start; // Math.min() is used here to make sure the element stops at exactly 200px const shift = Math.min(0.1 * elapsed, 200); element.style.transform = `translateX(${shift}px)`; if (shift < 200) { requestAnimationFrame(step); } } requestAnimationFrame(step);

 

애니메이션의 시작점, 즉 위 예시에서의 start를 어떻게 지정하는지에도 3가지 방법이 있다.

  1. 첫 콜백에서 timestamp 받아오기

    • 만약 애니메이션이 시작할 때 값이 튀는 현상이 있다면, 이 구조를 사용해서 막을 수 있다.
    • 외부 시간과 동기화할 필요가 없다면 이 방법을 권장한다.
      • 일부 브라우저에서는 requestAnimationFrame을 처음 호출한 후 콜백 함수가 실행되기까지 여러 프레임이 지연될 수 있기 때문이다.
    let zero; requestAnimationFrame(firstFrame); function firstFrame(timestamp) { zero = timestamp; animate(timestamp); } function animate(timestamp) { const value = (timestamp - zero) / duration; if (value < 1) { element.style.opacity = value; requestAnimationFrame((t) => animate(t)); } else element.style.opacity = 1; }
  2. document.timeline.currentTime 사용하기

    • requestAnimationFrame을 처음 호출하기 전에 document.timeline.currentTime을 기준 시간으로 설정할 수 있다.
    • document.timeline.currentTimerequestAnimationFrametimestamp 인자와 동일한 기준을 사용하기 때문에 기준 시간은 0번째 프레임의 timestamp 값과 동일하다.
    const zero = document.timeline.currentTime; requestAnimationFrame(animate); function animate(timestamp) { const value = (timestamp - zero) / duration; // animation-timing-function: linear if (value < 1) { element.style.opacity = value; requestAnimationFrame((t) => animate(t)); } else element.style.opacity = 1; }
  3. performance.now() 사용하기

    • 콜백의 인자 대신 performance.now()를 이용한다.
    • 정밀도를 약간 더 높일 수도 있다.
    • 애니메이션 콜백 간의 동기화가 완벽히 보장되지 않는다.
    const zero = performance.now(); requestAnimationFrame(animate); function animate() { const value = (performance.now() - zero) / duration; if (value < 1) { element.style.opacity = value; requestAnimationFrame((t) => animate(t)); } else element.style.opacity = 1; }

2.2.2. requestId

requestAnimationFrame의 반환값인 id는 보통 window에서 1씩 증가하는 방식으로 구현되어 있다.

즉, requestAnimationFrame이 재귀적으로 실행될 때마다 반환되는 id 값이 1씩 증가한다는 뜻이다.

그런데 이 값은 unsigned long 타입의 정수이기 때문에 아주아주 드물게 오버플로우가 발생할 수 있다.

2.2.3. cancelAnimationFrame

위에서 설명한 반환값은 추후 애니메이션 요청을 취소할 때 사용된다.

 

이렇게 cancelAnimationFrame 메소드에 id를 넘기면, 아직 실행되지 않은 콜백의 실행을 취소할 수 있다.

const requestId = requestAnimationFrame(callback); cancelAnimationFrame(requestId);

3. 렌더링 단계에서 rAF 살펴보기

rAF는 단순히 부드러운 애니메이션을 만들어주는 함수가 아니다.

setTimeout이나 setInterval보다 성능적으로 유리하기 때문에

3.1. 브라우저의 렌더링 사이클

브라우저가 화면을 렌더링하기 위해서는 아래 순서를 거친다!

1. browser render logic

(출처: https://web.dev/articles/debounce-your-input-handlers)

  1. JS 실행: JS 코드 실행, 이벤트 핸들링 등
  2. Style 계산: CSS 스타일 계산
  3. Layout (Reflow): 요소들으 위치와 크기 계산
  4. Paint: 각 요소를 픽셀 단위로 그리기
  5. Composite: 여러 레이러를 합쳐 하나의 화면으로 구성

(렌더링 과정은 다른 포스트에서 더 자세히 다루었다.)

 

requestAnimationFrame의 callback 함수는 1번과 2번 사이, 즉 style 계산 직전에 실행된다.

덕분에 콜백 안에서 DOM의 위치나 스타일을 바꾸면, 바로 해당 프레임에서 반영이 되는 것이다!

 

setTimeout이나 setInterval을 사용한다면 브라우저의 렌더링 주기와는 무관하게 실행되기 때문에

3.2. rAF 콜백 내에서 할 수 있는 일

등, 현재의 상태를 기반으로 애니메이션을 보여주기에 좋다.

 

한편으로는 rAF 콜백 안에서 가급적 피해야 하는 작업들도 있다.

참고