웹에서의 애니메이션 퍼포먼스 개선하기
19년도 하반기에 회사에 속한 팀에서 웹 앱 애니메이션 퍼포먼스 개선 프로젝트에 참여했다. 한정된 디바이스 리소스와 서비스 특성 상 백그라운드에 실행되고 있는 프로세스가 많을 수 밖에 없는 구조라 웹 앱에서 가용할 수 있는 리소스가 적은 상황이었다. 하지만 유저들은 UI/UX 디자인과 화면에서 발생하는 인터랙션에 민감해왔다. 기획이나 디자인, 마케팅 쪽에서 유려하고 인터랙티브한 UI는 지속적으로 요구해왔다. 우리 팀은 당시 새로 구성된 팀이라 이전에 다른 개발자분들이 짰던 legacy 코드를 리팩토링하거나 새로 짜면서 기존에 고려되지 못했던 애니메이션 성능을 위한 내용을 적용해가며 개선하게 되었다.
그때 애니메이션 퍼포먼스를 높이기 위해 개선해나가며 적용했던 몇 가지를 공유하고자 한다. 항목 별로 자세한 내용보다는 정리하는 차원으로 포인트를 짚어보겠다.
1. 브라우저 렌더링
우선 웹 앱이 올라가는 브라우저의 일반적인 렌더링에 대해 알아보자. 웹 앱은 브라우저에서 구동되기 때문에 브라우저의 렌더링 프로세스를 아는 것은 가장 우선적으로 이루어져야 할 일이다.
브라우저 렌더링은 순서대로 크게 Scripting, Rendering, Painting로 나뉜다. 이 세가지 과정은 다시 세부적으로 아래와 같이 나뉜다. 각각의 과정이 무엇이고 어떻게 애니메이션 퍼포먼스에 영향을 미치는지 알아보자.
Scripting
- Javascript: Sever API로부터 data를 요청하거나 데이터 탐색, 정렬, DOM 요소 추가 등의 일반적으로 자바스크립트로 행하는 내용이 포함된다. JS Animation이나
Console
객체를 이용한 로그도 포함된다. 의미없이 DOM 요소를 rerendering하거나 애니메이션 진행 시 발생하는 로그는 여지없이 퍼포먼스에 영향을 미친다.
Rendering
-
Style: 우리가
#id
,.class
,parent > children
등의 selector를 이용해서 적용한 styling 코드가 각각 어떤 요소에 어떤 스타일 규칙으로 적용될 지 계산한다. 여기에는 css 우선순위를 계산하는 것도 포함된다. 복잡하고 긴 selector 구조나 화면 변화에 의미없이 부여된 styling 코드는 렌더링 성능을 떨어뜨린다. 또한 하나의 요소에 여러 css 파일에서 style 규칙을 적용되어 우선순위를 계산하게 하는 것도 마찬가지다. -
Layout: 각 개별 요소에 어떤 스타일 규칙이 적용될지 정해지면 이에 따라 화면에 각 요소가 어떻게 위치할 지를 계산한다. 요소 간 의존적인 경우일 수록 이 프로세스에 소요되는 시간이 증가한다. 예를 들어, 하나의 div 내에 존재하는 두 개의 div 요소 A와 B가 모두
position: relative
를 가진다면 A에 적용되는 스타일 규칙 변화에 따라 B의 위치나 크기가 영향받게 될 수 있다. 개별 요소에 적용한 스타일 규칙에 의해 전체 혹은 일부가 영향받는 것을 reflow(브라우저 리플로우 최소화에 관한 글)라고 한다. 개별 요소가 다른 요소에 영향을 미치지 않도록 하기 위해서는 DOM 구조를 개선하거나 reflow를 방지하는 css속성으로 화면을 구현하는 것이 중요하다. reflow에 대해서는 후에 더 자세한 포스트를 진행하고자 한다.
Painting
-
Paint: 화면에 픽셀을 그리는 과정이다. 텍스트, 컬러, 이미지 등의 요소가 화면에 그리는 작업이다. css 속성 중
filter: drop-shadow()
나box-shadow
등 그림자와 같이 particle(?)적인 형태가 많은 리소스를 할애하도록 한다. 그래서 box-shadow의 경우, blur-radius와 spread-radius 값을 과하지 않게 주는 것이 필요하다.(box-shadow와 성능에 관한 글) -
Composite: Paint 과정에서 그려지는 요소들은 사실 여러 레이어로 이루어져있다. chrome에서 chrome dev tools를 열어 More tools에서 Layers 탭을 꺼낼 수 있다. 이 도구는 현재 웹페이지를 구성하는 레이어 형상을 보여준다. 레이어가 나뉘어져 있다는 것은 한 레이어가 repainting 될 때, 다른 레이어가 영향 받지 않게 된다는 의미이다. 이를 위해 의도적으로
z-index
혹은transform:translateZ()
등의 속성을 이용해 레이어를 나눌 수도 있다. 하지만 과도하게 나뉘어진 레이어는 오히려 composite 시간을 증가시킨다.
2. 애니메이션 렌더링 성능 개선
진행했던 성능 개선 지점들을 위에 설명했던 브라우저 렌더링 프로세스에 맞추어 공유해보겠다.
A. Scripting 개선
a. rerendering 최소화 적용
기존 코드에는 DOM 구조의 깊은 Depth와 하위 컴포넌트로 분리될 수 있을 요소들이 상위 컴포넌트에 묶여 render되고 있었다. 개발 프레임워크로 React가 사용되고 있었는데, 그마저도 16.2 버전에 머무른 상태로 개발되어 있었다. 처음에는 당시 react 최신 버전인 16.8로 버전업하여 진행하고자 했지만 서비스 특성 및 회사 내 히스토리로 인해 기존 버전을 유지한 채 진행하게 되었다.(사이드 이펙트가 발생할 지도 모를 이전 히스토리에 대한 파악이 어려운 상태였다…)
React는 props나 state의 변경에 따라 해당 컴포넌트의 render 함수가 호출되며 rerendering 과정을 거친다. React의 클래스형 컴포넌트는 shouldComponentUpdate() 훅과 PureComponent 객체를 지원한다.(16.2 이상 버전에서는 memo 객체를 지원하거나 Hook을 이용할 경우에는 다르게 처리하겠지만) 이를 통해 불필요하게 rerendering 되는 지점을 제거하였다. 또한 하위 컴포넌트로 분리될 수 있는 요소들을 컴포넌트화 하여 자체적으로 rerendering 여부를 판단할 수 있도록 하였다.
위 그림과 같이 shouldComponentUpdate() 훅에서 rerendering 여부를 판단하는 정도에만 리소스를 들인다. 다시 렌더링 해야하는 경우(true를 반환하는 경우)에만 렌더링에 대한 비용을 지불하므로써 기존보다 javascript 시간을 줄일 수 있었다.
b. mount/unmount 방식에서 display 속성을 on/off하는 방식으로
기존 서비스에 적용된 상하, 좌우 스크롤링 carousel UI를 개선하는데에도 많은 고민이 필요했다.
서비스의 화면 구조는 대략 위의 이미지와 같다. 우방향과 하방향으로 확장되어 있어 이동 가능한 일반적인 구조이다.
기존에 적용된 방식은 화면을 스크롤링될 때 현재 화면 위치에서 노출되어야하는 리스트는 render하고 그외의 리스트들은 render하지 않는 방식이었다. 이 방식은 물론 일반적으로 사용될 수 있는 방법이지만 DOM을 붙였다 떼어야하는 점에서 javascript 시간을 증가시킨다.
이를 해결하기 위해 스크롤 컨테이너를 새로 구현하여 상하 스크롤링 시에는 미리 초기에 DOM을 준비시킨 뒤 css의 display
속성으로 토글할 수 있도록 진행하였다. 좌우에는 적용시키지 못했는데 이는 한번에 너무 많은 DOM을 초기화하고 화면에 배치한 상태에서 스크롤링하게되면 퍼포먼스가 오히려 떨어졌기 때문이다. 물론 상하로 미리 준비된 DOM에는 image lazyload를 적용해 필요한 경우에 즉시 이미지 요청을 할 수 있도록 처리하였다.
B. Rendering 개선
a. 스크롤 컨테이너 내 아이템 배치를 상대 위치 값에서 절대 위치값으로
앞서 언급했던 새로 구현한 스크롤 컨테이너에는 다른 변화도 있었다. 기존 스크롤 컨테이너의 경우에는 컨테이너 내에 위치한 아이템들이 서로 상대적인 위치(position:relative
)를 가지며 컨테이너 내부에 위치하였다. 아이템 간의 의존성으로 인해 화면에 보여지는 아이템이 결정될 때마다 reflow가 발생하며 각 아이템의 위치값에 대한 재연산이 발생하여 rendering 시간이 길어져 있었다.
이를 개선하기 위해 새롭게 개발할 때는 아이템들에 절대 위치를 부여하고 현재 화면에서 보여지는 아이템이 DOM에 추가/제거 되거나 diplay가 토글 될 때, 서로 영향을 미치지 않도록 하면서 reflow를 방지하여 layout 에 소요되는 시간을 줄이게 되었다.
b. DOM 구조 재설계 및 CSS Selector depth 축소
기존 서비스 코드의 DOM 구조는 불필요하게 depth가 깊었다. web publishing을 담당하는 업체에서 가져온 마크업에 대한 검수가 제대로 이루어지지 않으면서 쌓인 문제였던 것 같다. 이번에 새로 컴포넌트를 개발하면서 내부에서 마크업을 진행하여 최대한 짧은 depth의 DOM 구조로 개선하였다. 자연스레 CSS Selector의 depth가 축소하면서 style 에 소요되는 시간을 줄이게 되었다.
c. css 애니메이션을 js 애니메이션으로
CSS 애니메이션은 브라우저의 fps에 맞추어 애니메이션을 동작시킨다. 일반적인 브라우저의 fps는 60으로 맞추어져있다.(초당 60프레임이 발생한다.) 내가 개발하는 서비스의 리소스로는 도저히 60fps의 애니메이션을 감당하지 못하고 버벅거리는 상황이었다. 모든 애니메이션이 CSS 애니메이션으로 구현된 것을 파악하고 우리 팀은 JS 애니메이션으로 전환하고 fps를 20~30 정도로 제한하기로 했다. 없는 리소스에 초당 60프레임을 강요해서 버벅거림을 발생시키지 말고 아예 초당 그릴 프레임 수를 줄여 부담을 줄이겠다는 것이다. 사실 과거 TV의 기본 fps도 25였다고 한다. 요즘 게임 화면은 150fps를 넘나들지만 사람 눈에 1초 당 20 ~ 30프레임으로 동작하는 애니메이션도 충분히 자연스러운 것이다.(유려해보이지는 않는다.)
초기에 프로토타이핑을 진행하면서 requestAnimationFrame()을 이용해 fps를 조절하여 적용해보았다. 구글링하니 fps를 조절하는 다양한 방법이 존재했다. 본격적으로 개발에 들어갈 때는 gsap에서 제공하는 라이브러리를 이용하기로 했다. fps를 set하고 다양한 tween 애니메이션 훅을 제공하고 있어 빠르게 리팩토링에 적용하기에 적합했다고 생각한다. gsap의 TweenLite
객체를 이용하여 모든 css]CSS 애니메이션을 JS 애니메이션으로 전환하며 fps를 제한하여 애니메이션의 버벅거림을 줄일 수 있었다.
e. duration의 단위를 ‘시간’에서 ‘frame’으로
fps를 제한했지만 여전히 버벅거리는 느낌이 남아있어 개선할 수 있는 방법을 찾았다. 라이브러리에서 제공하는 기능은 없는지 확인하다가 TweenLite
의 옵션 중 useFrames
라는 속성을 발견했다. 이 옵션을 true롤 주게 되면 기존에 애니메이션의 duration을 시간 단위가 아닌 frame 단위로 줄수 있게 된다. 즉, fps를 20으로 제한한 상황에서 애니메이션의 duration을 10 frame으로 주게 되면 20fps가 보장된다는 전제하에 0.5초동안 애니메이션이 발생하게 된다. 시간 단위가 아닌 frame 단위로 애니메이션이 진행되다보니 리소스에 따라 fps가 변경되더라도 애니메이션이 약간 빠르거나 느려질지언정 끊기거나 누락되지는 않게 되는 것이다. 이 내용은 후에 gsap 라이브러리에 대한 포스팅과 함께 더 자세하게 다뤄보고자 한다.
C. Painting 개선
a. style 코드 대신 이미지로 디자인 요구사항에 대응
개발 쪽에서 코드 리팩토링이 진행될 때, 같은 팀의 디자인 파트에서는 UX를 개선하기 위한 대대적인 디자인 개편 작업이 이루어지고 있었는데, 우리는 디자인 쪽에 새로운 디자인이 성능 개선에 도움이 되는 방향이 되었으면 좋겠다는 의견을 지속적으로 전달했다. 그중에서도 가장 간단하면서도 큰 효과를 얻었던 것이 컨텐츠 블록에 box-shadow
로 적용되었던 그림자를 이미지로 교체한 것이었다. 위에서도 설명했듯이 그림자와 같은 particle(?)적인 형태의 style 코드는 paint 과정의 소요 시간을 늘린다. 그래서 만약 이들 속성으로 애니메이션을 적용하게되면 더더욱 퍼포먼스를 떨어뜨릴 우려가 있다.
컨텐츠 타입별로 그림자를 적용하는 과정이 번거롭긴 했지만 결과적으로 paint 에 대한 비용을 줄였다.
b. layer 개수를 조절하기
화면에 layer를 분리하는 것은 rerendering되고 있는 요소와 그렇지 않은 부분이 서로 영향을 미치지 않게 하는 것과 연관있다. chrome dev tools에서 제공하는 Layers 탭을 이용하여 현재 화면에 분리된 레이어의 형상을 확인할 수 있다. z-index
, transform: translate()
속성이 layer를 분리할 수 있는 속성들인데, 불필요하게 적용되어 레이어를 너무 많이 나누게 되면 composite 비용이 증가하기 때문에 적절한 곳에 이용되도록 css 코드를 검수하는 작업을 진행하였다.
앞서 언급했던 gsap의 tween 애니메이션 객체들은 애니메이션의 타겟이되는 요소에 애니메이션이 발생할 때, 일시적으로 layer를 분리시키는 형태로 진행되는 것으로 보인다. 애니메이션의 성능을 높이기 위한 디테일이 아닌가 싶다.
마치며
지금까지 브라우저 렌더링 프로세스와 그에 맞추어 회사에서 웹앱의 애니메이션 성능 개선을 위해 적용했던 몇가지 항목에 대해 살펴보았다. 자세한 내용보다는 요점 중심으로 정리가 되어있어 막연하고 추상적인 내용이 되버린 것 같기도 하다. 이후에 항목별로 좀 더 자세하고 예시를 통해 정리해볼 기회가 있을 것이다…
개발하고 있는 서비스가 올라가는 환경이 일반적인 웹앱이 구동되는 환경에 비해 성능적으로 매우 떨어져 개발하는데 어려움이 많았다. 컨텐츠 블록에 적용되는 그림자를 css 코드로 넣지 못하고 이미지로 넣는 것도 처음에 납득이 쉽지 않았다. 하지만 지난 하반기 프로젝트를 진행하며 최적화와 효율에 대한 고민을 많이 하는 시간이었고 많은 공부를 하게했다는 점에서 즐거웠다.
Leave a comment