본문 바로가기

Frontend/성능 최적화

[올림픽 통계 서비스 애니메이션 최적화] 웹 성능 최적화까지 해보자-7

애니메이션 최적화

이 서비스에서 애니메이션이 들어간 곳이 한 군데 있었는데, 설문 결과 영역이다.

설문 항목을 클릭하면 해당 응답에 대해 필터링되고 막대 그래프의 배경 색과 막대의 가로 길이가 변한다.

 

그런데, 이전에도 언급했다시피 뚝뚝 끊기는 듯하게 애니메이션이 동작하는 문제가 있었다.

이런 끊김 현상을 쟁크(jank)라고 한다.

이 현상이 왜 일어나는지 알기 위해서는 브라우저에서 애니메이션이 어떻게 동작하는지, 그리고 브라우저는 어떤 과정을 거쳐 화면을 그리는지 이해해야 한다.

 

애니메이션의 원리

애니메이션의 원리는 여러 장의 이미지를 빠르게 전환해서 우리 눈에 잔상을 남기고, 그로 인해 연속된 이미지가 움직이는 것처럼 느껴지게 하는 것이다.

하지만, 애니메이션의 구성 요소인 이미지들 중 한 장의 이미지가 빠져버리게 되면 어색하게 끊기는 느낌이 들 것이다.

 

보통은 1초에 60장의 정지된 화면을 빠르게 보여주는데, 이 서비스의 막대 그래프 애니메이션에서 쟁크 현상이 발생한 이유도 브라우저가 정상적으로 60FPS로 화면을 그리지 못했기 때문이라는 것을 유추해볼 수 있다.

그러면 브라우저는 왜 초당 60 프레임을 제대로 그리지 못하는지에 대해서 알아보면 된다.

 

이를 알기 위해서는 브라우저가 화면을 그리는 과정을 알아야 한다.

 

브라우저 렌더링 과정

DOM + CSSOM > 렌더 트리 > 레이아웃 > 페인트 > 컴포지트

브라우저는 기본적으로 위와 같은 과정을 거쳐서 화면을 그린다.

이 과정을 주요 렌더링 경로 또는 픽셀 파이프라인이라고 한다.

 

DOM + CSSOM

가장 처음에는 HTML 파일과 CSS 등 화면을 그리는데 필요한 리소스를 다운로드한다.

다운로드한 HTML은 브라우저가 이해할 수 있는 형태로 변환하는 파싱 과정을 거친다.

그렇게 해서 요소 간의 관계가 트리 구조로 표현되어 있는 DOM(Documnet Object Model)을 만든다.

 

CSS도 HTML과 비슷한 과정을 거쳐 브라우저가 이해할 수 있는 형태로 변환된다.

변환 결과 CSSOM(CSS Object Model)이라는 트리 구조가 생성된다.

CSSOM은 각 요소가 어떤 스타일을 포함하고 있는지에 대한 정보를 포함한다.

 

렌더 트리

렌더 트리는 DOM과 CSSOM의 결합으로 생성된다.

이 렌더 트리는 화면에 표시되는 각 요소의 레이아웃을 계산하는데 사용된다.

 

즉, display:none으로 설정돼서 화면에 표시되지 않는 요소는 렌더 트리에 포함되지 않는다.

다만, opacity:0이나 visibility:hidden 요소는 포함된다.

이는 사용자 눈에는 보이지 않지만 요소 자체가 없어진 것은 아니기 때문이다.

 

레이아웃

렌더 트리가 완성되면 레이아웃 단계로 넘어간다.

레이아웃 단계에서는 화면 구성 요소의 위치나 크기를 계산하고, 해당 위치에 요소를 배치하는 작업을 한다.

 

페인트

화면에 요소의 위치와 크기를 잡아 놓았으니 이제 화면에 배치된 요소에 색을 채워 넣는 작업을 한다.

배경 색을 채우거나 글자 색을 결정하거나 테두리 색을 변경하는 등의 작업이다.

브라우저는 효율적인 페인트 과정을 위해서 구성 요소를 여러 개의 레이어로 나눠서 작업하기도 한다.

 

컴포지트

컴포지트 단계는 각 레이어를 합성하는 작업을 한다.

브라우저는 화면을 그릴 때 여러 개의 레이어로 화면을 쪼개서 그린다.

그런 다음 마지막에 그 레이어를 하나로 합성하는데, 이 단계에서 그 작업이 이루어진다.

 

Performance 패널에서 메인 스레드 작업을 살펴보면 Parse HTML, Layout, Paint라고 되어 있는 것을 확인할 수 있는데, 이게 바로 브라우저 렌더링 과정이다.

 

회색 새로 점선은 브라우저가 화면을 갱신하는 주기이다.

화면이 전부 그려진 후 설문 결과에서의 애니메이션처럼 일부 요소의 스타일을 변경하거나 추가 제거하게 되면 주요 렌더링 경로에서 거친 과정을 다시 한 번 실행하면서 새로운 화면을 그리게 된다.

이를 리플로우 또는 리페인트라고 한다.

 

리플로우와 리페인트

처음 화면이 모두 그려진 후 자바스크립트로 인해 화면 내 어떤 요소의 너비와 높이가 변경되었다고 가정해보자.

그러면 브라우저는 해당 요소의 가로와 세로를 다시 계산해서 변경된 사이즈로 화면을 새로 그릴 것이다.

 

주요 렌더링 경로에 대입해보면 먼저 요소의 스타일이 변했으니 CSSOM을 새로 만들어야 한다.

변경된 CSSOM을 이용해 새로운 렌더 트리를 만든다.

요소의 가로와 세로도 변경되었으니 레이아웃 단계에서 다시 작업을 해야 한다.

이후 변경된 화면 구성에 맞게 페인트 작업이 이루어지고 분할된 레이어를 하나로 합성하는 컴포지트 과정이 이루어진다.

 

이를 리플로우라고 한다.

리플로우는 주요 렌더링 경로의 모든 단계를 모두 재실행한다.

그래서 브라우저 리소스를 많이 사용한다.

 

또 다른 경우를 예로 들어보자.

한 요소의 가로, 세로 같은 레이아웃 관련 속성이 아니라 글자 색이나 배경 색 등 색상 관련 속성이 변경되었다고 가정해보자.

처음에는 스타일 속성이 변경되었기 때문에 CSSOM이 새로 생성되고 렌더 트리도 새로 만들어진다.

하지만 레이아웃 단계는 실행되지 않는다.

이는 변경된 내용이 요소의 위치나 크기에 영향을 주는 내용은 아니기 때문이다.

레이아웃 단계를 건너뛰고 색을 입히는 페인트 단계, 레이어를 합성하는 컴포지트 단계를 거친다.

 

이를 리페인트 단계라고 한다.

리페인트 단계는 레이아웃 단계를 건너뛰기는 했지만 거의 모든 단계를 거치기 때문에 리소스를 꽤 잡아먹는다.

 

그런데, 리소스를 많이 잡아먹는 리플로우와 리페인트를 피하는 방법이 있다.

transform이나 opacity와 같은 속성을 사용하는 방법이다.

이런 속성을 사용하게 되면 해당 요소를 별도의 레이어로 분리하고 작업을 GPU에 위임해서 처리함으로써 레이아웃 단계와 페인트 단계를 건너뛸 수 있다.

이를 하드웨어 가속이라고 한다.

 

하드웨어 가속(GPU 가속)

하드웨어 가속은 CPU에서 처리해야 할 작업GPU에 위임해서 더욱 효율적으로 처리하는 방법을 말한다.

GPU는 그래픽 작업을 위해 만들어진 것이기 때문에 화면을 그릴 때 활용하면 굉장히 빠르다.

 

특정 요소에 하드웨어 가속을 사용하려면 요소를 별도의 레이어로 분리해서 GPU로 보내야 하는데, transform 속성opacity 속성이 이 역할을 한다.

분리된 레이어는 GPU에 의해 처리돼서 레이아웃 단계와 페인트 단계 없이 화면상의 요소의 스타일을 변경할 수 있다.

 

그래서 리플로우와 리페인트를 일으키는 width, height, color 등의 속성이 아닌 transform 또는 opacity 속성을 이용한 애니메이션 성능이 더 좋을 수밖에 없다.

 

이제 다시 막대 그래프 애니메이션을 확인해보자.

const BarGraph = styled.div`
    position: absolute;
    left: 0;
    top: 0;
    width: ${({width}) => width}%;
    transition: width 1.5s ease;
    height: 100%;
    background: ${({isSelected}) => isSelected ? 'rgba(126, 198, 81, 0.7)' : 'rgb(198, 198, 198)'};
    z-index: 1;
`

설문 결과의 막대 그래프에서는 width를 변경해서 애니메이션 효과를 줬다.

이렇게 하면 width가 변할 때마다 리플로우가 발생하고 브라우저가 아주 짧은 순간마다 화면을 갱신해야 하고 모든 단계를 제시간에 처리하지 못하는 쟁크 현상이 발생하게 된다.

 

이를 Performance 패널에서 살펴보자.

CPU를 6x slowdown으로 설정해두고 브라우저 작업을 기록해보았다.

기록된 내용 중 애니메이션이 일어나는 구간을 확대해보면, 레이아웃과 페인트, 컴포지트가 일어난다.

이는 width의 변경으로 인해 리플로우가 발생한 모습이다.

그런데, 리플로우 작업이 브라우저가 화면을 갱신하는 시점인 회색 선을 넘어가고 있다.

 

즉, 화면을 1/60초 안에 그려서 보여줘야 하는데, 리플로우가 발생해서 모든 단계를 다시 밟느라 필요한 화면을 제때 그려내지 못한 것이다.

그래서 GPU를 활용해서 레이아웃 단계와 페인트 단계를 건너뛸 수 있는 transform 같은 속성을 사용해야 한다.

 

애니메이션 최적화

이제 width로 되어 있는 애니메이션을 transform으로 변경해서 최적화해보자.

transform 속성에는 위치를 이동시키는 translate, 크기를 변경하는 scale, 요소를 회전시키는 rotate가 대표적이다.

 

여기서는 scale을 사용해서 구현해보자.

미리 막대의 너비를 100%로 채워 두고 scale을 이용해서 비율에 따라 줄이는 방식을 말하는 것이다.

const BarGraph = styled.div`
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  transform: scaleX(${({ width }) => width / 100});
  transform-origin: center left;
  transition: transform 1.5s ease;
  height: 100%;
  background: ${({ isSelected }) =>
    isSelected ? "rgba(126, 198, 81, 0.7)" : "rgb(198, 198, 198)"};
  z-index: 1;
`;

scaleX 안에 있는 width는 퍼센트 값이기 때문에 scaleX 함수의 인자로 쓰이도록 1 이하의 실수 값으로 바꿔준다.

이렇게 하면 width가 0일 때 scaleX에 의해 막대의 너비가 0으로 줄어들 것이고, width 값이 100이 되면 scaleX(1)이 되므로 width가 100%인 상태로 유지될 것이다.

 

그리고 단순히 transform에 scaleX 값만 설정하면 막대 너비가 비율대로 표시되긴 하는데 왼쪽에 치우치지 않고 가운데 정렬이 되어 버린다.

이는 기본적인 scale의 기준점이 중앙에 있기 때문에 중앙을 중심으로 정렬된 것인데, 왼쪽 기준으로 변경하기 위해서 transform-origin 속성을 center left로 변경해주어야 한다.

 

최적화 전후 비교

아래 사진은 최적화 전의 메인 스레드이다.

 

아래 사진은 최적화 후의 메인 스레드이다.

최적화 전보다 여유로워진 것을 볼 수 있다.