놓치기 쉬운 Reflow, Repaint

jh_leitmotif·2023년 5월 15일
1

밥 아저씨는 항상 참 쉽다고 하시는데, 쉬운 일이 하나도 없다.

새로 맡게 된 프로젝트가 1차 수준이라, 여러가지 개선이 많이 필요하여

그와 관련해 빠르게 도입할 수 있는 최적화 방법을 고민하고 있는데, 그 중 흔하게 놓치기 쉽다고 생각되는 Reflow, Repaint와 관련된 경험을 기록한다. 분명히 난 이걸 또 까먹겠지

Reflow는 뭐고 Repaint는 뭐냐

Reflow와 Repaint가 무엇인지 알기 전, 브라우저 렌더링 순서를 알아야 한다.

브라우저 렌더링 순서

  1. 브라우저는 HTML, CSS, JS, 이미지, 폰트 등등을 서버에게 요청하여 응답받는다.
  2. HTML과 CSS는 렌더링 엔진에 의해 파싱되어 각각 트리가 생성된다.
    • DOM (Document Object Model)
    • CSSOM (CSS Object Model)
  3. 생성된 HTML, CSS 트리가 결합되어 렌더 트리가 된다.
  4. 렌더 트리를 토대로 그려질 요소들의 위치와 크기를 미리 계산하여 노드화 한다. (레이아웃 단계)
  5. 각 노드를 그린다. (페인트 단계)

이 과정 중, 브라우저가 HTML을 파싱해서 DOM을 그려나가다가... 스크립트를 만나면 잠시 자바스크립트 엔진에 제어권을 넘긴다.

그러면 JS 엔진은 스크립트를 파싱하여 추상구문트리(AST)를 만들고, 이를 바이트 코드화하여 실행하게 된다.

만약 이 때 스크립트가 DOM을 변경하는 경우, 다시 렌더링 과정을 수행하게 된다.

Reflow

Reflow는 간단하게 비유하면 그림의 모양이 달라진다던가 크기가 달라진다던가... 하는 구조적인 변화가 있는 경우 수행된다고 표현하고 싶다.

위 브라우저 렌더링 순서에서 (레이아웃 단계) 에 해당된다.

위에서 언급한 것과 같이, 요소들의 '위치'와 '크기'를 미리 계산하는 단계이므로, 아하! 그러면 어떤 요소가 Reflow를 일으키는지 대충 알 수 있다.

position, width, height, margin, padding, 
border, border-width, font-size, font-weight, 
line-height, text-align, overflow, 
top, left, right, bottom 

...등등등등...

https://gist.github.com/paulirish/5d52fb081b3570c81e3a

감사하게도 위 링크에서 대충 어떤 요소가 Reflow를 일으키는지 정리가 잘 되어있다.

꼭 요소의 너비나 높이를 변경하지 않더라도, 이를테면 offset, client가 붙은 위치 값을 읽는데에도 요소 연산이 들어가기에 Reflow가 발생하며

그것은 스크롤 위치도 마찬가지이다.

심지어는 focus() 함수도 reflow를 발생시킨다고 한다. 아마도 해당 요소가 어디있는지 알아야 포커싱해줄 수 있기 때문인 듯.

Repaint

Repaint는 언뜻 보면 '다시 그리다' 처럼 느껴지지만, '다시 칠하다'가 맞다.
예를 들어 벽을 도색한다, 또는 덧칠한다 하지 않는가? Repaint는 그런 단계다.

background, color, text-decoration
border, outline, visibility

...등등등...

Reflow만 일어나고 Repaint만 일어날 수 있나요?

반은 맞고 반은 틀리다.

애초에 브라우저 렌더링 단계를 보면 Reflow는 Repaint를 선행한다.

그러므로 Repaint만 일어날 수 있지만 Reflow는 Repaint를 동반한다라고 해야한다.

잘 모르겠는데 예제를 좀 주세요 🤷

가장 쉬운 예제가 바로 로딩바 (혹은, Progress 바) 다.

나는 멋진 리액트와 스타일드 전사니깐, 멋대로 React, styled-components를 끌고 오겠다.

const [ptg, setPtg] = useState(0);

  return (
    <Container>
      <button onClick={() => setPtg((cur) => cur + 10)}>클릭!</button>
      <ProgressBarContainer>
        <ProgressBar ptg={ptg} />
      </ProgressBarContainer>
      <button onClick={() => setPtg((cur) => cur - 10)}>클릭!</button>
    </Container>
  );

여기서 ProgressBarContainer가 텅빈 하얀 공간이고, ProgressBar는 빨간색 꾸물거리는 막대기다. ProgressBar가 어떤 css 동작으로 움직이는지 살펴보자.

width의 경우

const ProgressBar = styled.div<{ptg:number}>`
	width:${({ptg})=>ptg}%;
    height:80%;
    background:red;
    transition: width 0.5s ease;
`

뭐... 기능 자체에 문제는 없는 것 같긴 하지만.

개발자 도구의 성능탭을 보면 이렇게 나온다.

미처 스크롤에 다 담지 못한 레이아웃 단계가 존재한다.

요즘 기기들이 다들 좋아서 별 위화감이 없긴 하지만, 지금도(!) 윈도우 XP같은 노인네를 혹사시키는 사용자가 있다는 것을 생각하면 문제가 발생할 수도 있다.
내가 예민한 것일 수도 있지만, 개인적으로 미묘한 끊김을 느끼는 편이다.

그러면 Reflow도, Repaint도 일으키지 않는 속성은 따로 없으려나?

CSS Composite

CSS에는 GPU와 통신하여 처리하는 속성이 있다!

HTML, CSS 파싱
 -> 렌더 트리 생성
  -> 레이아웃 단계 ( 요소 위치 및 크기 측정 노드화 )
   -> 페인트 단계
    -> Composite 레이어 단계

Reflow, Repaint 외에, Composite Layer라는 단계가 있댄다.

'생성한 레이어 계층을 합성하고, 화면에 나타낸다' 라고 하는데...

약간 더 풀어보면. 이 단계에서는 별도로 상위 과정을 거치지 않는다는 것 같다.

'분리 가능한 레이어를 분리하여 처리한다'. 즉, 별도의 UI Flow를 비동기적으로 수행하기 때문에 Reflow나 Repaint가 트리거되는 속성보다 성능상 좋을 수 밖에 없다.

쉽게 표현할 수 없지만... 최대한 축약해서 Composite 단계에 대해서 내 말로 풀어보면

스크롤을 하게 되면 밑에 있는 요소들이 보여지기 시작하는데, Composite Layer를 이용해 새로 보여지는 요소에 대해 Reflow 과정을 되풀이하는 것이 아니라, 사용자의 viewport를 움직이거나 혹은 각 UI 레이어를 움직이기만하는 것을 통해 최적화하는 기법이라고 할 수 있다.

음악이나 예능에서 사용하는 카메라 기법이랑 비슷한 것 같다.

Composite을 트리거하는 속성은 대표적으로

transform, opacity, cursor, orphans, perspective

가 있다고 한다. orphans, perspective가 뭔지는 모르겠다.

이제 다 차치하고, 그래서 width 대신 뭘 쓰면 되는지 보자.

transform: scaleX

transform의 scaleX는 X축으로 확대하기 위한 요소에 해당한다.

scaleX를 적용할 요소의 height를 100%로 놓으면 '길어지는' 효과를 만들 수 있다.

ProgressBar: styled.div<{ ptg: number }>`
    width: 100%;
    height: 80%;
    background: red;
    transform: scaleX(${({ ptg }) => ptg / 100});
    transform-origin: right center;
    transition: transform 0.5s ease;
`,

초기 scaleX는 0이 되어야 한다. 아예 안보여야하니깐.

그리고 transform-origin 세팅없이 scaleX만 쓰면, transform의 기준점이 자동으로 중앙으로 잡히기 때문에, 표현하고자하는 방향에 맞게 origin의 개량이 필요하다.

나의 경우는 오른쪽 -> 왼쪽 방향의 것이 필요했기에 right center로 지정했다.

이제 개발자 도구를 살펴보자.

아까는 스크롤을 넘어가던 레이아웃이 단 한 건도 나오지 않았다.


엥? 근데 Opacity가 Reflow된다고?

width 얘기 하다가 뜬금없이 opacity 얘기를 잠깐 해볼까 한다.

위에 분명히 Composite을 일으키는 속성 중에 opacity가 있었는데,

이 속성으로 인해 Reflow가 발생하는 케이스가 있다.

예를 들어, 앞선 로딩바에 마우스 커서를 가져다댔을 때 그림자가 지는 효과를 내고 싶다고 하자.

ProgressBar: styled.div<{ ptg: number }>`
    width: 100%;
    height: 80%;
    background: red;
    transform: scaleX(${({ ptg }) => ptg / 100});
    transform-origin: right center;
    transition: transform 0.5s ease;

    &::before {
      position: absolute;
      width: 100%;
      height: 100%;
      content: "";
      box-shadow: 0 3px 6px 1.5px rgba(0, 0, 0, 0.5);
      opacity: 0;
      transition: opacity 0.5s ease;
    }
    &:hover::before {
      opacity: 1;
    }
`,

여담으로, before에 box-shadow를 박고 opacity를 적용한 이유라면 box-shadow가 reflow를 일으키는 속성이기 때문에 기껏 transform을 쓴 이유가 없어질 수 있기 때문에 before content에 넣고, opacity를 적용했다.

이 때 opacity:1 로 인해서 레이아웃 단계가 발생하게 된다.

그 이유는 opacity가 1보다 작을 때만 Layer를 트리거하기 때문이라고 한다.

Layer가 ... 그래서 뭘까?

앞서 서술했듯 브라우저는 HTML, CSS 트리를 결합하여 렌더 트리를 생성한다.
그리고 렌더 트리를 이용해 화면에 표현될 레이어를 생성한다고 한다.

이 때 이 레이어를 가리켜 Paint Layer 라고 하는데, 이 Layer들이 생성되어 누적되는 조건이 아래 링크에 상세히 정리되어 있다.

https://developer.mozilla.org/ko/docs/Web/CSS/CSS_Positioning/Understanding_z_index/The_stacking_context
https://ssocoit.tistory.com/259#0.1._Graphics_Layer

설명에 따르면, 페이지가 렌더링될 때 페이지를 위에서 아래로 바라본다고 했을 때.

Z축의 3차원 개념으로 페이지가 스태깅되는 구조라고 한다.

  1. Root element
  2. position 속성이 정의된 경우
  3. Flex/Grid container의 자식 중, z-index가 별개로 세팅된 경우.
  4. opacity < 1 인 경우
  5. transform, filter, perspective, clip-path, mask, mix-blend-mode가 정의된 경우.
  6. isolation이 isolate인 경우.
  7. -webkit-overflow-scrolling : touch
  8. will-change가 지정된 경우.
  9. contain이 layout, paint 또는 둘 중 하나를 포함하는 strict 또는 content등으로 설정된 경우.

위의 케이스에 대해 Paint Layer가 트리거된다고 한다.

여기서 나아가 GPU가 필요한 케이스에 대해 Graphics Layer가 트리거된다.

  1. video, canvas 태그 사용
  2. 하드웨어 가속 플러그인 사용
  3. 3D transform
  4. 하드웨어 가속이 적용된 2D canvas
  5. backface-visibility attr : hidden
  6. transition, animation 속성 사용
  7. will-change 설정시

여하튼 간에... 위와 같은 조건으로 Layer들을 만들어서 Composite 단계를 거치게 되는데

opacity:1 은 Composite에 해당되지 않기 때문에 고대로 Reflow를 탄다는 의미로 보인다.

그래서 이걸 어떻게 해결하냐고? opacity를 0.99로 바꾸면 된다.


야호! 이제 우린 최적화된 로딩 바를 사용할 수 있게 됐다. 어... 아마도.


요약

  1. 브라우저는 HTML, CSS를 파싱해 렌더 트리를 만들고, 먼저 각 요소가 어느 위치에 어느 크기로 그려질지 미리 계산해서 청사진을 만든 다음, 페이지에 실제로 그린다.
  2. 이 때 청사진을 그리는 단계를 Reflow, 실제로 그리는 단계를 Repaint라고 한다.
  3. 스크립트 같은 경우는, 브라우저 엔진이 DOM을 만들다가 스크립트를 마주쳤을 때 JS 엔진에게 잠시 제어권을 넘겨주어 AST(추상구문트리)로 만들어 실행하는데, 이 때 만약 DOM을 건드리면 2번이 재수행된다.
  4. Paint 단계가 끝난 뒤, 브라우저는 최적화 기법인 Composite Layer 단계로 들어간다.
  5. 이 때 Layer는 Stacking Context에 쌓이고, 일반적으로 Paint Layer라고 불리우나 GPU 연산이 필요한 레이어는 나아가 Graphics Layer로 구성된다.
  6. Composite Layer 단계는 렌더 스레드와 무관하게 비동기로 구성되어 UI를 처리하기 때문에 Reflow, Repaint를 반복하지 않으므로 성능상 좋다.
  7. 그러니까 우리는, 가능한 Reflow나 Repaint를 일으키지 않고 Composite으로 해결해보도록 하자.
  8. 근데? 잘 모르고 쓰면 opacity:1 처럼 Reflow가 일어나기도 하니, 공부를 좀 해둬야 겠다.

링크 모음

  1. 어떤 요소가 reflow가 되는 요소인가?
  2. 레이어에 대한 설명과 관련된 링크들
  3. 쓰는 데에 도움이 많이 된 글
profile
Define the undefined.

0개의 댓글