멋진 애니메이션 전사되기

jh_leitmotif·2023년 10월 11일
1
post-thumbnail

나는 초 멋진 웹 개발자로서, 시간이 한가하면 막 평소에 쓰던 라이브러리를 해체한다던지, 아래와 같은 한량짓을 한다.


(근데 이거 하는데 4시간 걸림 ㅡㅡ)

언젠가 회사에서 팀원이 프로덕트에 간단한 애니메이션을 적용했는데,

하필이면 그 애니메이션은 사양이 낮은 클라이언트 기기에서 음청난 프레임 드랍이 일어나는 것이었다.

문제는 애니메이션에서만 드랍이 걸리는 게 아니라 전체적으로 걸린 문제에 있었으니. 아마도 연산이 복잡했거나 혹은 layout -> paint 단계가 빈번하게 유발되지 않았을까 하는 생각이 들었다.

그런 김에, 이런 애니메이션을 만들 때에 구현 방법을 어떻게 가져가야 할까? 에 대해 고민해본 것을 써본다.


대뜸 떠오를 방식, setTimeout과 setInterval

애니메이션은 특정 동작을 '반복적으로' 실행한다. 그래서 생각나는 것이 setTimeout과 setInterval이 되겠다.

대충 이런 코드가 있을 수 있겠다.

const intervalTimer = setInterval(()=>{
	setAnimateVector((prev)=>!prev);
},100) // useEffect의 클린업에서 clearInterval 수행.

<div style={{
	width: animateVector ? 100 : 200,
    transition:width 1s ease-in-out
}}>
 ... 스타일이 적용된 뭐시기 ...
</div>

꼭 주의해야할 것. 페이지가 언마운트되더라도 interval은 남아 돌기 때문에 클린업 시점에 clear 처리를 해주어야 한다.

reflow, repaint를 주의하자.

위의 코드에서 문제가 있다면, width는 layout을 유발하는 css 속성에 포함된다.

따라서 위와 같은 이벤트가 애니메이션이 진행될 때마다 밀리초 단위로 계속, 계--------속 실행되기 때문에 추가적인 리소스가 할당된다.

그러면 이걸 어떻게 해결해야될까? 당연하게도, 같은 동작을 layout을 일으키지 않는 방법으로 해결하면 된다.

예를 들면 아래와 같다.

const intervalTimer = setInterval(()=>{
	setAnimateVector((prev)=>!prev);
},100) // useEffect의 클린업에서 clearInterval 수행.

<div style={{
	transform: scaleX(animateVector ? 100 : 200)
    transition:transform 1s ease-in-out
}}>
 ... 스타일이 적용된 뭐시기 ...
</div>

이렇게하면 레이아웃 단계가 발생하지 않는다. scale은 layout에 포함되지 않는 속성이기 때문이다.

관련해서 어떤 속성이 reflow를 일으키는지에 대해 정리된 문서 링크를 기록한다.
https://gist.github.com/paulirish/5d52fb081b3570c81e3a

근데, setInterval이 진짜 최선일까?

여기까지만 글을 쓴다면 사실은

https://velog.io/@leitmotif/%EB%86%93%EC%B9%98%EA%B8%B0-%EC%89%AC%EC%9A%B4-reflow-repaint

이 게시글과 다를바가 전혀 없다.

조금 더 로우 레벨로 들어가보자, 과연 setInterval이 최선일지에 관해 생각을 해보았다.

만약 setInterval 내부에 정의된 함수가 너무 복잡해서, 최초 Interval이 실행된 뒤 다음 Interval로 가기 전까지 애니메이션이 모두 동작하지 못하면 어떡하지?

위에 공튀기는 애니메이션을 구성하는 Interval 코드는 아래와 같다.

getBoundingClientRect()는 element의 위치 정보를 계산해서 반환해주는 함수로서, Layout 단계를 유발시키는 속성에 해당된다.

그리고 top, left 속성은 다른 element의 좌표에 영향을 미치는 속성에 해당한다. 그러므로, 다음과 같이 변경할 수 있다.

컨테이너에 닿았는지에 대해 체크해야 되기 때문에...

어쩔 수 없이 translateX,Y 값을 동기화시켜주는 로직(offset 이용)이 포함되어 Layout 자체는 일어나겠지만, 적어도 setInterval 내부에서 getBoundingClientRect 함수를 호출하는 것을 막을 수 있다.

transform 속성은 paint 이후, 좌표계를 계산해주며 이 때 계산은 사용자 기기의 GPU에 위임된다.

transform vs positioning

top, left를 이용한 케이스는 아래와 같다.

transform을 이용한 케이스는 아래와 같다.

다시 돌아가서...

Interval이 돌아가기 전에 미리 공이 튀기는 컨테이너의 위치 정보를 계산해두고,

Interval 내부에서는 공의 위치를 주기적으로 확인해서 움직이는 방향을 적절하게 바꿔줄 수 있도록 되어있다.

* 맨 밑에 닿았을 때.
 - 우측 아래 대각으로 움직이고 있었다면
 - 우측 위 대각으로 방향을 바꿔준다.
 - 좌측 아래 대각으로 움직이고 있었다면
 - 좌측 위 대각으로 방향을 바꿔준다.

* 오른쪽 끝에 닿았을 때.
 - 우측 위 대각으로 움직이고 있었다면
 - 좌측 위 대각으로 방향을 바꿔준다.
 - 우측 아래 대각으로 움직이고 있었다면
 - 좌측 아래 대각으로 방향을 바꿔준다.

..등등..

// 아래는 예외 케이스

* 맨 밑에 닿았는데, 좌/우측 끝에도 닿았다면
 - 우/좌측 위 대각으로 방향을 설정한다.

* 맨 위에 닿았는데, 좌/우측 끝에도 닿았다면
 - 우/좌측 아래 대각으로 방향을 설정한다.

개발자 도구의 성능탭을 확인해보면 이런 형태로 호출 트리가 구성된다.

흐음, 뭔가 특이사항이 잘 안보인다. 이번엔 대신, 프레임 단위가 어떻게 찍히는지 살펴보자.

setInterval(()=>{

... 인터벌 콜백 ...

},1000/60)

1000 / 60 즉 1초를 60ms로 나눴다.

즉, 1개의 프레임을 16.6ms로 표현하겠다 라고 지정을 한 것과 같은데, 실제 프레임 시간은 일정치 않은 것으로 포착되었다.

16.6 또는 16.7 ms로 일정하게 찍힐 것이라고 예상했는데, 중간 중간에 25ms가 포착되고 있다.

왜 이렇게 되나? 생각해보면 위에 올린 성능 탭 사진에보면

마이크로 태스크 실행

이라는 단계로 확인할 수 있듯이, 겉으로 보이는 time ( 16.6ms ) 과 콜스택이 비어있는 시간대가 합해져 사실은 정확한 타이머 주기가 보장되지 않는다.

( 그러니까, 아주 미묘한 오차가 발생한다는 것임 )

관련해서 아래 영상과 문서들을 보면 정말 좋다.
https://youtu.be/oWSNOrBbOIU?si=TjM0Z-PvGdhL2Bna&t=193
https://www.jeong-min.com/37-event-loop/

오차가 발생하는 요인은 2가지 정도를 더 들 수 있다.

먼저, 브라우저는 setTimeout과 setInterval에 대한 최소 delay가 있다.

https://developer.mozilla.org/en-US/docs/Web/API/setTimeout
https://developer.mozilla.org/en-US/docs/Web/API/setInterval

4ms 이하로 세팅된 delay에 대해 무조건 4ms로 지연 시간을 고정시킨다. 아마 콜백의 실행에 대해 throttling을 강제하여 브라우저에 대한 부하를 경감시키려는 목적 때문인 것 같다. 다만 이 케이스는 16.6ms의 지연시간이 기대되므로 오차에 영향을 끼치진 않았을 것이다.

둘째로, 사용자의 환경에 따라 다를 수 있겠다. 예를 들면 타이머를 걸어놓고 해제하지 않았다던지, 전역 변수가 너무 많다던지. 혹은 애초에 사용자 컴퓨터의 메모리 가용량이 적다던지.

맛있는 링크를 발견했다.
https://ui.toast.com/posts/ko_20210611

그래 최선이 아닌 건 알겠는데, 그럼 뭘 써?

requestAnimationFrame이라는 죽여주는 브라우저 내장 함수가 있다.

https://developer.mozilla.org/ko/docs/Web/API/window/requestAnimationFrame

requestAnimationFrame은 1초에 60회씩 콜백을 수행하며, repaint되기 전 시점에 콜백이 호출된다.

즉, 이 때 한 프레임당 16.6ms의 출력시간이 보장된다는 말과 같다.

성능 탭을 확인해보자.

일단 interval을 적용했던 방법에 비해 Activity의 수가 적다. 일단 여기서부터 타이머를 사용하는 것보다, 애니메이션 프레임을 사용하는 것이 비교적 더 적은 작업이 진행되는구나를 알 수 있었다.

또한 생각한대로 하나의 프레임당 16.6ms ( 반올림해서 16.7ms로 표기됨 )의 출력 시간을 가진다는 것을 확인할 수 있었다.

실제 코드는 아래와 같다. setInterval과 다른 점이라면, 애니메이션을 수행하는 콜백을 만든 뒤 해당 콜백을 재귀호출하는 것이다.

위의 사진에서는 클린업함수에서 애니메이션 프레임을 멈추도록 해두었지만, 경우에 따라 사용자가 브라우저를 벗어나 있을 때는 애니메이션을 멈추게 한다던지. n초 이후에 애니메이션이 중지되도록 만들다던지 하는 것 또한 가능하다.

setTimeout, setInterval이 고유의 id를 가지는 것처럼, requestAnimationFrame도 고유한 id를 가지고 있어 콜백 해제가 가능하다.

뿐만아니라, requestAnimationFrame은 전달된 콜백에 인자로서 DOMHighResTimeStamp 를 내려준다.

let rafId = null;
const callback = (timestamp) =>{
 ... 생략 ...
 
 rafId = requestAnimationFrame(callback)
}


requestAnimationFrame(callback)

DOMHighResTimeStamp는 각각의 프레임이 시작된 최초의 시간으로서, 애니메이션의 수행을 더 부드럽게 만드는 데에 유용히 쓰일 수 있다.

mdn문서의 예시로서 제시된 스크립트를 보면, 전달된 타임스탬프를 통해 내부에서 애니메이션 효과를 부여하는 것을 확인할 수 있다.

이 스택오버플로우 문서에 제시된 코드도 잘 정리된 것 같다.
https://stackoverflow.com/questions/17585434/inconsistent-speed-animating-with-linear-interpolation

메모리 차이는?

setInterval을 사용한 경우.

requestAnimationFrame을 사용한 경우

두 경우 모두 약 3초 (interval은 3090ms, raf는 3085ms) 를 두고 측정했다.

측정 방법은 url에 로컬호스트 url을 담아두고, 개발자도구를 킨 뒤 url을 이동시킨 순간 레코딩 버튼을 눌러 3초 정도 기다리는 것으로 했다.

메모리의 시점에서는 큰 차이가 없는 것 같다. 딱히 GPU를 쓰는 것도 아니고.


profile
Define the undefined.

0개의 댓글