[React] 성능최적화 3편 - 애니메이션 최적화(feat. 리플로우,리페인트,CSS)

신세원·2023년 1월 1일
4

React

목록 보기
28/28
post-thumbnail

이번 시간에는 애니메이션 최적화를 알아보기로 한다.
애니메이션 최적화를 알기전에 우리는 먼저 CSS가 브라우저에 어떻게 렌더링되는지 과정을 알아야한다.

🏞브라우저 렌더링 과정

브라우저는 기본적으로 위와 같은 과정을 거쳐서 화면을 그린다.
이러한 과정을 주요 렌더링 경로(Critical Rendering Path) 또는 픽셀 파이프라인(pixel Pipeline)이라고 한다.
그럼 하나하나씩 과정이 어떻게 되는지 알아보도록 하자.

1. DOM + CSSOM

  • 가장 먼저 HTML 파일과 CSS 등 화면을 그리는데 필요한 리소스를 다운로드 한다.
  • 다운로드한 HTML을 브라우저가 이해할 수 있는 형태로 변환하는 파싱(parsing)과정을 거친다.
  • 트리(tree)구조로 표현되어 있는 DOM(Document Object Model)을 만든다.

CSS도 HTML과 비슷한 과정을 거쳐 브라우저가 이해할 수 있는 형태로 변환된다. 그 결과 CSSOM(CSS Object Model)이라는 트리 구조가 생성된다.

2. 렌더트리

이렇게 DOM과 CSSOM이 생성되고, 이 두가지의 결합으로 렌더 트리(render tree)가 생성된다.
이 렌더 트리는 화면에 표시되는 각 요소의 레이아웃을 계산하는데 사용된다.
(다르게 표현해서, display:none 으로 설정되어 화면에 표시되지않는 요소는 렌더 트리에 포함되지 않는다.)

display:none은 렌더 트리에 포함되지 않지만, opacity:0 이나 visibility:hidden인 요소는 렌더트리에 포함된다.
사용자 눈에는 보이지 않지만, 요소 자체가 없어진 것은 아니기 때문이다.

그림) 렌더 트리 구조

3. 레이아웃

렌더트리가 완성되면, 레이아웃(layout) 단계로 넘어간다. 여기서의 하는 역할은 크게 두가지다.

  • 화면 구성 요소의 위치나 크기를 계산
  • 해당 위치에 요소를 배치하는 작업

정리하자면, 화면의 레이아웃을 잡는 과정이라고 보면 된다.(이 과정에서는 color 또는 background-color가 변경되어도 레이아웃이 변경되지 않는다.)

4. 페인트

제목만 봐도 알 수 있듯이, 이 단계에서는 화면에 배치된 요소에 색을 채어 넣는 작업을 한다.
예를 들어, 배경 색을 채워 넣거나 글자 색을 결정하거나 테두리 색을 변경 하는 작업들이다.

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

5. 컴포지트

페인트 단계에서 설명한 것처럼 브라우저는 화면을 그릴 때 여러 개의 레이어로 화면을 쪼개서 그리는데, 이 단계에서는 각 레이어를 하나로 합성한다.

🤔애니메이션으로 CSS를 제어할땐 어떻게 변하나요?

이렇게 브라우저 렌더링 과정을 알아보았다. 하지만 오늘의 주제처럼 우리는 애니메이션을 다룰때 많은 CSS 요소들을 건드리게 된다.
애니메이션으로 스타일을 변경하거나, 추가, 제거하면 어떻게 될까?

이럴때는 위에서 알아본 주요 렌더링 경로에서 거친 과정을 다시 한 번 실행하면서 새로운 화면을 그리는데, 이것을 리플로우(Reflow) 또는 리페인트(Repaint)라고 한다.

1. 리플로우

가정1) 작업하다가 처음 화면이 모두 그려진 이후, 자바스크립트로 인해 어떤 요소의 너비높이가 변경되었다.

만약 위와 같은 상황이 벌어졌다는 가정을 해보자.
그러면 브라우저는 해당 요소의 가로와 세로를 다시 계산하여 변경된 사이즈로 화면을 새로 그리게 된다.

그렇다면 그 과정은 아래와 같다.

  • (DOM+CSSOM) 요소의 스타일이 변했음으로 CSSOM을 새로 만든다.
  • (렌더트리) 변경된 CSSOM을 이용하여 새로운 렌더 트리를 만든다.
  • (레이아웃) 요소의 가로와 세로를 변경했으니, 레이아웃 단계에서 요소의 크기와 위치를 다시 고려해야 한다.
  • (페인트) 변경된 화면 구성에 색을 칠한다.
  • (컴포지트) 분할된 레이어를 하나로 합성한다.

우리는 이 과정을 리플로우 라고 한다.
리플로우는 주요 렌더링 경로의 모든 단계를 모두 재실행한다. 그래서 브라우저 리소스를 많이 사용한다.

그림) 리플로우 과정

리플로우와 리페인트를 발생시키는 속성

  • 리플로우 : position, width, height, left, top, right, bottom, margin, padding, border, border-width, clear, display, float,
    font-family, font-size, font-weight, line-height, min-height, overflow, text-align, vertical-align, white-space등
  • 리페인트 : background, background-image, background-position, background-repeat, background-size, border-radius, border-style,
    box-shadow, color, line-style, outline outline-color, outline-style, outline-width, text-decoration, visibility 등

2. 리페인트

가정2) 너비의 요소가 아닌, 글자 색(color) 또는 배경 색(background-color)가 변경되었다.

이번에는 조금 다른 상황이다.
처음에는 스타일 속성이 변경되었기 때문에 CSSOM이 새로 생성될 것이고, 렌더 트리도 새로 만들어진다.
하지만 위에서 레이아웃 단계에서 설명했듯, 이 과정에서는 레이아웃 단계가 실행되지 않는다.

왜냐하면 지금 변경된 내용은 요소의 위치나 크기의 영향을 주는 것이 아닌, 색상에 관련된 내용이기 때문이다.
레이아웃 단계를 스킵하고 색을 입히는 페인트 단계, 컴포지트 단계를 거치게 되는데 이것을 리페인트라고 한다.

리페인트 작업은 레이아웃 단계를 스캡해서 리플로우 작업보다는 조금 빠르지만, 이 단계 역시 모든 단계를 거치기 때문에 리소스를 많이 잡아 먹는다.

그림) 리페인트 과정

정리

  • 요소의 상태 변화가 일어나면 리플로우나 리페인트 과정을 거치게 된다.
  • 이러한 과정들은 브라우저의 리소스를 많이 잡아먹기 때문에 결국 화면을 새로 그리는 것이 느릴 수밖에 없다.

🧐 성능 저하를 감안하며 사용할 수밖에 없는 걸까?

다행히 리플로우와 리페인트를 피하는 방법이 있다! 바로 transform,opacity와 같은 속성을 사용하는 것이다.
이러한 속성들을 사용하면 해당 요소를 별도의 레이어로 분리하고 작업을 GPU에 위임하여 처리함으로써 레이아웃 단계페인트 단계를 건너뛸 수 있다!

1. 하드웨어 가속(GPU 가속)

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

GPU는 애초에 그래픽 작업을 처리하기 위해 만들어진 것이라서 화면을 그릴 때 활용하면 굉장히 빠르다.
특정 요소에 하드웨어 가속을 사용하려면 요소를 별도의 레이어로 분리하여 GPU로 보내야 하는데, transform,opacity 속성이 이 역할을 하게 된다.
분리된 레이어는 GPU에 의해 처리되어 레이아웃 단계와 페인트 단계 없이 화면상의 요소의 스타일을 변경할 수 있다.

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

2. 애니메이션 최적화하기

아래 애니메이션을 최적화하기 위한, 상황과 코드가 적혀있다.

import React, { useState } from "react";
import styled from "styled-components";

function SurveyChart() {
  const [percent, setPercent] = useState(0);
  const isSelected = percent !== 0;

  const handleClickBar = () => {
    setPercent((prev) => {
      if (prev === 0) return 100;
      else return 0;
    });
  };

  return (
    <SurveyChartWrapper>
      <Bar
        percent={percent}
        isSelected={isSelected}
        handleClickBar={handleClickBar}
      />
    </SurveyChartWrapper>
  );
}

export default SurveyChart;


function Bar(props) {
  return (
    <BarWrapper onClick={props.handleClickBar} isSelected={props.isSelected}>
      <BarInfo>
        <Percent>{props.percent}%</Percent>
      </BarInfo>
      <BarGraph width={props.percent} isSelected={props.isSelected} />
    </BarWrapper>
  );
}

export default Bar;

const SurveyChartWrapper = styled.div`
  padding: 150px 20px 60px 20px;
  max-width: 800px;
  margin: auto;
  box-sizing: border-box;
`;


const BarWrapper = styled.div`
  position: relative;
  margin-bottom: 3px;
  padding: 8px 0;
  background: ${({ isSelected }) => (isSelected ? "#dddddd" : "#f3f3f3")};
`;
const BarInfo = styled.div`
  width: 100%;
  display: flex;
  z-index: 2;
  position: relative;
`;
const Percent = styled.span`
  text-align: right;
  min-width: 70px;
  flex: 0 0 auto;
`;

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가 변할 때마다 리플로우가 발생하고, 브라우저가 짧은 순간마다 화면을 갱신해야 하여 모든 단계를 제 시간에 처리하지 못하는 쟁크 현상이 발생된다.

이 과정을 Performanc 패널을 통해 살펴보자. (Performanc 패널을 열고 CPU를 6x slowdown으로 설정한다.)
그리고 새로고침 버튼이 아닌 왼쪽 상단의 기록 버튼을 누르고 막대 그래프를 클릭하여 애니메이션을 재생하고, 다시 기록 버튼을 눌러 기록을 마치면 애니메이션이 발생한 순간의 브라우저 작업이 기록된다.

애니메이션이 일어나는 구간을 확대해 보면 위 그림처럼 레이아웃,페인트,컴포지트(복합 레이어)가 발생하는 것이 보인다. width의 변경으로 인해 리플로우가 발생한 모습이다.
그럼 이제 문제의 원인과 해결 방법을 알았으니, width로 되어 있는 애니메이션을 transform으로 변경하여 최적화 해보도록 하자.
우리는 여기서 scale을 사용하여 구현할 것이다.


(...)
const BarGraph = styled.div`
  /* 생략 */

  width: 100%;
  transform: scaleX(${({ width }) => width / 100});
  transform-origin: center left;
  transition: transform 1.5s ease;

/* 생략 */

(...)

scaleX 안에 있는 width는 퍼센트 값이기 때문에 scaleX 함수의 인자로 쓰일수 있도록 1 이하의 실수 값으로 바꿔준다.
이렇게 하면, width가 0일때 scaleX에 의해 막대(BarGraph)의 너비가 0으로 줄어들 것이소, width 값이 100이 되면 scaleX(1)이 되므로 width가 100%인 상태가 유지된다.

그리고 또 주의해야할 점이 단순히 transform에 scaleX 값만 설정하면 막대 너비가 비율대로 표시가 되지만, 아래 그림처럼 왼쪽에 치우치지 않고 가운데 정렬이 되어버린다.

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

3. 애니메이션 최적화 전후 비교

최적화 전

위 그림은 최적화 전의 메인 스레드이다. 레이아웃과 페인트 작업을 하면서 꽤나 아슬아슬하게 작업을 마무리하였다.

최적화 후

위 그림은 최적화 후의 메인 스레드이다. 확실히 최적화 전보다 여유로운걸 알 수있다.

profile
생각하는대로 살지 않으면, 사는대로 생각하게 된다.

0개의 댓글