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초 = 1000ms
- 1000ms ÷ 60 ≈ 16.66ms
으로 두고 1초에 60프레임을 처리하도록, 즉 16ms마다 1프레임씩 처리하도록 두었다.
이렇게 애니메이션을 준다면 이론적으로는 애니메이션 로직이 초당 약 60번 실행되기 때문에 60fps처럼 부드러운 애니메이션을 만들 수 있다!
1.2. setInterval
의 한계
하지만 문제는 setInterval
이 정확히 16.66ms마다 실행된다고 보장할 수 없다는 것이다🤦
- JS가 다른 연산중이면 애니메이션 로직이 밀릴 수 있다.
- 브라우저는 렌더링 타이밍과 별개로 애니메이션 로직을 실행한다.
- 저사양 디바이스에서는 1초에 60프레임을 처리하지 못할 수도 있다.
- 브라우저가 백그라운드 탭으로 들어간 경우에도 계속 실행되면서 불필요하게 자원을 낭비한다.
즉, 단순하게 작동하는 것처럼 보일 수는 있어도 실제적으로 부드럽고 효율적으로 작동하지는 않는다는 뜻이다.
이런 문제를 해결하기 위해서는 requestAnimationFrame
을 이용할 수 있다.
2. requestAnimationFrame
의 등장
requestAnimationFrame
(RAF)은 다음 화면을 그리기 바로 전에 콜백(애니메이션 로직)을 실행하도록 브라우저에 요청하는 API다.
따라서 이런 장점이 생기게 된다!
- 애니메이션 로직의 실행 시점을 브라우저 렌더링 시점과 동기화
setInterval
은 일정한 시간 간격으로 코드를 실행하지만, 브라우저가 실제로 언제 화면을 그리는지 몰라 애니메이션 실행 시점이 화면 렌더링과 완벽하게 맞지 않을 수 있다.requestAnimationFrame
은 브라우저가 다음 프레임을 그릴 준비가 되었을 때 코드를 실행한다.
- 저사양 기기에서 프레임 조절
setInterval
은 어떤 기기에서든 일정 간격으로 실행되어 저사양 기기에서는 프레임이 밀릴 수 있다.requestAnimationFrame
은 기기가 감당 가능한 속도로만 실행된다.
- 불필요한 연산 제거
setInterval
은 브라우저가 백그라운드 탭에 있어도 계속 실행된다.requestAnimationFrame
은 탭이 비활성화되면 실행이 멈춘다.
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
의 함수 구조와 인자 설명
- 인자
callback
- 애니메이션을 업데이트하기 위해서 실행해야 하는 함수이다.
callback
함수에는 이전 프레임의 렌더링이 끝난 시점을 가리키는DOMHighResTimeStamp
타입의 값timestamp
라는 인자가 전달된다.
- 반환값
- 콜백 목록에서
callback
함수를 구분하기 위한unsigned long
타입의 정수를 반환한다. - 만약
callback
함수 호출 요청을 취소하고 싶다면,window.cancelAnimationFrame()
함수에 이 값을 넣어서 호출하면 된다.
- 콜백 목록에서
2.2.1. timestamp
requestAnimationFrame
의 callback
함수는 호출될 때 timestamp
라는 숫자값을 전달받는다.
- 소수점을 포함하는 밀리초 값이다.
timestamp
는DOMHighResTimeStamp
타입, 즉double
타입이다.- 이전 프레임의 렌더링이 끝난 시점을 가리킨다.
performance.now()
와 유사하지만, 완전히 같지는 않다.document.timeline.currentTime
과는 동일하다.
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);
- 우선 처음으로
step
함수가 실행될 때,start
값을 초기화해서 애니메이션이 처음 시작되는 시점을 저장한다. - 그리고
const elapsed = timestamp - start;
에서 현재 시간과 처음으로 애니메이션이 시작되는 시간을 비교해, 애니메이션 시작으로부터 시간이 얼마나 지났는지를 계산할 수 있다. const shift = Math.min(0.1 * elapsed, 200);
에서 이 값을 이용해 움직일 거리를 계산할 수 있는 것이다.
애니메이션의 시작점, 즉 위 예시에서의 start
를 어떻게 지정하는지에도 3가지 방법이 있다.
-
첫 콜백에서
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; }
-
document.timeline.currentTime
사용하기requestAnimationFrame
을 처음 호출하기 전에document.timeline.currentTime
을 기준 시간으로 설정할 수 있다.document.timeline.currentTime
은requestAnimationFrame
의timestamp
인자와 동일한 기준을 사용하기 때문에 기준 시간은 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; }
-
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
타입의 정수이기 때문에 아주아주 드물게 오버플로우가 발생할 수 있다.
- 이 경우 id가 0으로 돌아가거나, 음수, 혹은 오류가 발생하는 등 브라우저마다 동작이 다를 수 있다.
- 따라서 id가 falsy한지 여부로 유효한 아이디인지를 확인하면 안된다!
- 유저가
if (!requestId)
처럼 조건을 설정한 경우, requestId가 0인 경우를 처리할 수 없게 된다. - 대신에
null
등의 값을 사용해야 한다.
- 유저가
2.2.3. cancelAnimationFrame
위에서 설명한 반환값은 추후 애니메이션 요청을 취소할 때 사용된다.
이렇게 cancelAnimationFrame
메소드에 id를 넘기면, 아직 실행되지 않은 콜백의 실행을 취소할 수 있다.
const requestId = requestAnimationFrame(callback); cancelAnimationFrame(requestId);
3. 렌더링 단계에서 rAF 살펴보기
rAF는 단순히 부드러운 애니메이션을 만들어주는 함수가 아니다.
setTimeout
이나 setInterval
보다 성능적으로 유리하기 때문에
3.1. 브라우저의 렌더링 사이클
브라우저가 화면을 렌더링하기 위해서는 아래 순서를 거친다!
(출처: https://web.dev/articles/debounce-your-input-handlers)
- JS 실행: JS 코드 실행, 이벤트 핸들링 등
- Style 계산: CSS 스타일 계산
- Layout (Reflow): 요소들으 위치와 크기 계산
- Paint: 각 요소를 픽셀 단위로 그리기
- Composite: 여러 레이러를 합쳐 하나의 화면으로 구성
(렌더링 과정은 다른 포스트에서 더 자세히 다루었다.)
requestAnimationFrame
의 callback 함수는 1번과 2번 사이, 즉 style 계산 직전에 실행된다.
덕분에 콜백 안에서 DOM의 위치나 스타일을 바꾸면, 바로 해당 프레임에서 반영이 되는 것이다!
setTimeout
이나 setInterval
을 사용한다면 브라우저의 렌더링 주기와는 무관하게 실행되기 때문에
- 너무 일찍 실행되어 변경 사항이 지연되어 반영되거나
- 너무 늦게 실행되어 다음 프레임까지 기다려야 한다.
3.2. rAF 콜백 내에서 할 수 있는 일
- DOM의 위치 변경
- 색상 등 CSS 속성 변경
- canvas, WebGL 등 그리기
등, 현재의 상태를 기반으로 애니메이션을 보여주기에 좋다.
한편으로는 rAF 콜백 안에서 가급적 피해야 하는 작업들도 있다.
- 무거운 연산
- 렌더링이 지연되거나 프레임 드랍이 발생할 수 있기 때문이다.
- 이벤트 핸들러 등록 및 제거
- rAF 콜백은 렌더링을 위한 최종 준비 단계이기 때문에, 이 시점에서 이벤트 핸들러를 바꾸는 것은 목적에 맞지 않고, 부작용이 생길 수 있다.
- 비동기 작업
- 콜백 안에서 Promise같은 microtask를 등록하면, 실행 순서가 섞일 수 있다.
참고
- Window: requestAnimationFrame() method: https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame
- rAnimating with javascript: from setInterval to requestAnimationFrame: https://hacks.mozilla.org/2011/08/animating-with-javascript-from-setinterval-to-requestanimationframe/
- https://html.spec.whatwg.org/multipage/imagebitmap-and-animations.html#animation-frames
- https://html.spec.whatwg.org/multipage/webappapis.html#event-loop-processing-model