...
자바스크립트 웹 애니메이션
웹페이지의 애니메이션을 구현할때 CSS의 animatoin , transition , transform 속성을 통해 구현할 수도 있지만, 보다 사용자와의 복잡한 상호작용을 구현하게 하기 위해 Javascript와 함께 사용하여 스타일을 변화시키도 한다. 예를들어 특정 영역을 클릭하거나 웹페이지를 스크롤할때 변화무쌍한 애니메이션 작업들이 그러하다.
그래서 간단하고 규칙적인 애니메이션은 CSS로만 요소의 좌표값이나 스타일 크기를 변화시키고, 세밀한 조작이 필요한 애니메이션은 자바스크립트로 스타일 속성을 변경 시키는 편이다. 하지만 자바스크립트로 스타일 속성을 변화시키는 방법은 CSS보다 (특히 모바일에서) 성능이 좋지 않다. 따라서 어쩔수 없이 자바스크립트와의 상호 협력이 필요할 경우 이를 위한 최적화 기법이 존재한다.
이번 포스팅은 이러한 애니메이션 관련 최적화 API인 requestAnimationFrame() 에 대한 사용법과 원리를 소개해보는 시간을 가져 보겠다. 브라우저라는 소프트웨어를 전문적으로 다루는 프론트엔드 개발자일 경우 이에 관한 지식은 거의 필수라고 볼 수 있다.
브라우저 렌더링 단계
브라우저가 화면에 무언가를 그리기까지는 아래 사진과 같이 게 3 단계로 나눌 수 있다.
- Javascript - 애니메이션 및 기타 작업 스크립트를 수행 (DOM 생성)
- Style - CSS 규칙을 어떤 요소에 적용할지 계산하는 프로세스 (CSSOM 생성)
- Layout - 브라우저는 DOM과 CSSOM을 결합하여 객체들의 위치와 크기 등을 계산하는 렌더 트리(Render Tree)를 생성.
- Paint(redraw) - 브라우저는 렌더 트리를 사용하여 실제로 화면에 픽셀을 출력 (객체가 실제 화면에 그려지는 것을 의미)
- Composite - 브라우저는 화면에 출력되는 객체들을 합성하여 최종 화면을 생성
현재는 저 단계 하나하나를 여기서는 자세히 알아볼 필요는 없고, 자바스크립트로 엘리먼트를 무언가 변화를 준다면 코드를 실행하고 끝~ 이 아니라, 위와 같은 복잡한 랜더링 파이프라인 단계를 지나 화면에 그린다는 정도로만 알고 있으면 된다.
브라우저 프레임
게임 모니터를 고르다보면 60hz, 120hz 라고 모니터 주사율을 들어본 적이 있을 것이다. 이는 1초동안 모니터 화면의 출력 빈도를 나타내는 단위 이다. 그리고 보통 hz는 frame과 관련이 있다.
우리가 영화나 애니메이션을 보는 것은 사실 짧은 시간 간격에 이어지는 장면을 보는 것이다. 이 각각의 장면을 frame이라고 한다. 즉, 프레임은 한 장의 사진이라 봐도 무방하다. 그리고 특정 시간 내에 보여지는 frame 갯수를 frame rate 혹은 frame per second 줄여서 fps라고 한다. (게임을 해보면 가장 자주 듣는 단위일 것이다)
보통 인간의 눈은 1초에 60번 장면이 넘어가야 부드럽다고 느낀다고 한다. 그래서 현대 기기들은 시각적인 효과를 위해 초당 60번 화면을 다시 그리도록 기본적으로 설계된다. 이를 60fps 혹은 60hz 라고 불리우는 이유이다.
굳이 FPS를 설명하고 Hz까지 설명하는 이유는 웹 화면에 부드러운 애니메이션 움직임 효과를 주기 위해선 이 프레임 단위에 맞게 설계해야 되기 때문이다. 초당 60개의 프레임을 렌더링 한다는 말은, 16.666 밀리세컨드(1000ms / 60fps) 간격으로 프레임 생성이 필요한 셈이 된다. 따라서 자바스크립트로 사용자에게 부드러운 애니메이션을 구현하려면 16.6ms밀리초 마다 코드를 호출하는 식으로 구현해야 한다.
타이머 함수를 이용한 애니메이션 스크립팅
따라서 자바스크립트로 일정 시간마다 코드를 반복 호출하는 대표적인 방법으로는 타이머 함수인 setInterval() 와 setTimeout() 을 이용해 60프레임에 맞게 스크립팅을 한다면 아래 코드와 같이 구성할 수가 있다.
const performAnimation = () => {
/* 스타일 조정 스크립트 */
}
// 1초에 60번 무한 반복
setInterval(performAnimation, 1000 / 60)
const performAnimation = () => {
/* 스타일 조정 스크립트 */
setTimeout(performAnimation, 1000 / 60); // 함수 내부에서 다시 setTimeout을 호출하여 반복
}
setTimeout(performAnimation, 1000 / 60);
타이머 함수의 문제점
그러나 setInterval() 와 setTimeout() 의 문제점은 주어진 시간내에 동작을 할 뿐 프레임을 신경 쓰지 않고 동작한다는 점이다. 타이머 함수는 프레임 단위로 프레임 시작 시간에 맞춰 실행됨을 보장하지 못하기 때문이다.
만일 아래 그림처럼 약 16ms 간격으로 프레임 단위가 진행되어야 하는데, 브라우저가 다른 작업 수행으로 인해 지연되어 자바스크립트의 콜백 코드 부분이 단위 중간에서 호출되었다고 하자.
자바스크립트 실행에 의해 리플로우가 일어나 위에서 본 브라우저 렌더링 단계인 레이아웃 - 페인트 - 합성 과정이 다시 일어나게 되는데, 그러면 프레임이 생성되지 못하고 누락되어 버려 1 프레임이 깎여버리는 현상이 나타나게 된다.
이러한 현상이 일어날 수 있는 이유는 자바스크립트의 콜 스택(call stack)은 싱글 스레드 이기 때문이다
프레임이 깎인다는 것은 곧 프레임 드랍이 일어나 결국 화면이 버벅이게 된다. 이러한 지연(delay) 발생 문제 때문에 대안으로 탄생한 것이 바로 rAF(requestAnimationFrame) 이다.
requestAnimationFrame
requestAnimationFrame 함수는 시스템이 프레임을 그릴 준비가 되면 애니메이션 프레임을 호출하여 애니메이션 웹페이지를 보다 원활하고 효율적으로 생성할 수 있도록 해준다. 실제 화면이 갱신되어 표시되는 주기에 따라 함수를 호출해주기 때문에 자바스크립트가 프레임 시작 시 실행되도록 보장해주어 위와 같은 밀림 현상을 방지해준다.
아래 이미지는 requestAnimationFrame 를 통해 애니메이션을 구현할 때, 각 프레임이 브라우저의 프레임 주기에 맞추어 일정한 시간 간격으로 렌더링됨을 보여준다. requestAnimationFrame 함수가 실행되면 브라우저는 다음 프레임이 그려지기 전에 함수를 실행하도록 예약하기 때문에 각 프레임이 정확히 16.6ms 간격으로 렌더링되게 되게 된다.
See the Pen setInterval vs requestAnimationFrame by barzz12 (@inpaSkyrim) on CodePen.
이처럼 애니메이션 프레임을 페인팅할 준비가 됐을 때 호출한다는 부분은, 이전 setInterval 방식이 준비가 되지 않아도 페인팅을 요청하는 것과 대비되면서, 보다 웹브라우저에 최적화 되어 있다고 말할 수 있다. 또한 지연 및 블로킹 현상이 생기지 않아 보다 부드러운 애니메이션을 제공한다.
requestAnimationFrame 장점
백그라운드 동작 중지
setInterval 같은 경우 브라우저의 다른 탭 화면을 보거나 브라우저가 최소화되어 있을 때 계속 타이머가 돌아 콜백을 호출하기 때문에 시스템 리소스 낭비를 초래하고 불필요한 전력을 소모하게 만든다.
반면 requestAnimationFrame는 페이지가 비활성화 된 상태이면 페이지 화면 그리기 작업도 브라우저에 의해 일시 중지됨으로 CPU 리소스나 배터리 수명을 낭비하지 않게 된다.
디스플레이 주사율에 맞게 호출 횟수
이 특징은 최적화된 웹 애니메이션을 구현하는데 있어 setInterval 과 requestAnimationFrame 의 결정적인 차이점이라고 말할 수 있다. 지금까지 1초에 60번 호출한다고 써왔지만, 정확히 말하자면 자신의 모니터가 60hz 주사율 모니터일 경우이다. 즉, 웹브라우저는 디스플레이의 주사율을 따르며 만일 144hz 주사율 고사양 모니터일 경우 rAF 역시 1초에 144번 호출되게 된다.
정리하자면 setInterval 에서는 콜백 함수 호출 간격을 시간을 지정해주고 호출 횟수를 설정하지만, rAF에서는 모니터의 주사율을 그대로 따르게 되어 최적화 되어 있다는 것이다. 그리고 이러한 특성 때문에 rAF는 스크롤 이벤트 최적화 기법으로도 쓰이기도 한다.
Animation frames 큐에서 처리
requestAnimationFrame(rAF) 함수도 setTimeout 이나 여타 이벤트 핸들러와 같이 "애니메이션 프레임"을 그리기 위한 콜백 함수를 등록하고 비동기 task로 분류하여 처리된다. 이때 중요한 특징은 rAF는 일반적인 task queue가 아니라 animation frame이라는 별개의 queue에서 처리된다는 점이다.
Animation frames 은 브라우저의 렌더링 엔진이 다음 프레임을 그리기 전에 실행해야 하는 rAF에 등록한 콜백 함수들을 담는 큐이다. 별도의 큐에서 적재되어 이벤트 루프에 의해 꺼내지기 때문에 실행이 뒤쳐지거나 하는 현상을 감소할수가 있다. 단, requestAnimationFrame도 브라우저의 CPU나 GPU 사용량 여부 등에 따라 콜백 함수 실행이 밀릴 수도 있다.
위는 브라우저의 자바스크립트 이벤트 루프 주기의 실행을 그림으로 표현한 것이다. 그림을 처음 보면 되게 눈 아프로 복잡하게 보일 수 있는데 간단히 정리하면 다음과 같다.
- Task queue 1 - 이벤트 콜백 함수, setTimeout 및 setInterval 비동기 콜백 함수를 처리
- Task queue 2 - Task queue 1에 있는 모든 콜백 함수들이 실행된 후 실행되어야 하는 다음 콜백 함수들이 들어간다.
- Microtask queue - Promise 객체, Mutation Observer 객체를 처리 (가장 우선순위가 높음)
- Animation frames - requestAnimationFrame 와 같이 브라우저 렌더링과 관련된 태스크 처리
간단한 이벤트 루프 호출 스택만을 본 독자분들은 난이도가 수직 상승하는 느낌을 받을 수 있겠지만, 똑같은 비동기 함수라도 이렇게 처리 우선순위 큐가 별도로 있다는 정도 알고 있으면 된다.
Promise.then결과가setTimeout보다 우선 된다는 소리도 바로 이 때문이다.
requestAnimationFrame 사용법
requestAnimationFrame 사용법은 setTimeout 처럼 콜백 함수 내부에서 재귀 호출 하는 식으로 구성하면 된다. 브라우저는 애니메이션 프레임을 출력할 때마다 requestAnimationFrame 에 등록된 콜백 함수들을 비동기로 호출하여, 애니메이션을 부드럽게 출력해준다. 대신 차이점은 setTimeout 은 타이머를 지정해주어야 하지만, requestAnimationFrame 은 프레임 단위로 동작하기 때문에 별도의 반복 플래그가 필요 없다는 점이다.
const performAnimation = () => {
/* 스타일 조정 스크립트 */
requestAnimationFrame(performAnimation) // 함수 내부에서 다시 requestAnimationFrame을 호출하여 반복
}
requestAnimationFrame(performAnimation);
그리고 setTimeout 을 취소하기 위해 clearTimout 을 사용하듯이, requestAnimationFrame 를 취소하는 방법으로 cancelAnimationFrame 를 사용한다. 아래와 같이 특정한 조건에서 애니메이션을 중지하고 싶을때 이용하면 된다.
let raf; // requestAnimationFrame을 담을 변수
const performAnimation = () => {
/* 스타일 조정 스크립트 */
// 특정한 조건일 경우 raf를 중지하고 콜백 종료
if(조건) {
cancelAnimationFrame(raf);
return;
}
raf = requestAnimationFrame(performAnimation) // 함수 내부에서 다시 requestAnimationFrame을 호출하여 반복
}
requestAnimationFrame(performAnimation);
requestAnimationFrame 예제
다음은 로켓 요소가 있고, requestAnimationFrame를 통해 y좌표 값을 조정하여 위로 올리는 코드를 반복 호출하여 애니메이션을 실행하는 예제이다.
<img class="rocket" src="https://www.freeiconspng.com/thumbs/rocket-png/rocket-png-1.png" alt="로켓 애니메이션">
<count class="value">0</count>
<button>다시 시작</button>
const rocket = document.querySelector('.rocket');
const value = document.querySelector('.value');
let yPos = 0;
let rafId;
// 콜백 함수
const render = () => {
yPos += 2; // y 좌표 증가
rocket.style.transform = `translateY(${-yPos}px)`; // 로켓을 위로 올리기
value.innerHTML = yPos; // 카운터 표시
// 만약 로켓 위치가 일정 y좌표값일 경우 requestAnimationFrame 종료
if (yPos >= 500) {
cancelAnimationFrame(rafId);
return;
}
rafId = requestAnimationFrame(render); // rAF 반복 호출
}
requestAnimationFrame(render); // 애니메이션 시작
// 만일 로켓에 마우스 커서를 올려놓으면 애니메이션 중지
rocket.addEventListener('mouseover', () => {
cancelAnimationFrame(rafId);
});
// 만일 로켓에 마우스 커서를 빼면 애니메이션 재시작
rocket.addEventListener('mouseout', () => {
requestAnimationFrame(render);
});
See the Pen requestAnimationFrame by barzz12 (@inpaSkyrim) on CodePen.
애니메이션 타이머 이용하기
requestAnimationFrame 함수의 콜백 함수의 인자로 매개변수를 전달하면 requestAnimationFrame 함수가 실행되기 시작한 이후의 시간을 얻을 수 있다. 이를 이용해 애니메이션 수행 시간을 구해 특정 타이머일때 스크립트를 실행하는 식의 응용이 가능하다.
let startTime;
// requestAnimationFrame의 콜백 함수에 매개변수를 정의
const draw = (timestamp) => {
if (!start) start = timestamp; // 애니메이션 시작 시간
currentTime = timestamp - startTime;
// 애니메이션이 실행된지 2초가 지나면..
if(currentTime > 2000) {
console.log('END');
return;
}
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
# 참고자료
https://youtu.be/9XnqDSabFjM
https://dribbble.com/shots/1945400-FPS-frames-per-second
https://jbee.io/web/optimize-scroll-event/
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.