예전에 스터디에서 어떤 분이 리렌더링을 발생시키는 CSS 속성을 최소화하여 브라우저 렌더링 성능을 향상하는 방법이 있다고 얘기해준 적이 있다.
언제 한 번 공부해서 정리해야지 하고 미루고 있던 숙제를 늦게나마 풀어본다.
클라이언트 단에서 성능 최적화에 가장 중요한 건 이미지 최적화와 리렌더링 최소화가 아닐까 싶다.
여기서 말할 리렌더링은 리액트에서 말하는 리렌더링과는 다르다.
리액트의 리렌더링은 컴포넌트 재실행되어 가상 DOM이 갱신되는 과정을 말한다.
이 역시 성능에 큰 영향을 미치고 최소화하는 것이 중요하다.
하지만 이 글에서 다룰 리렌더링은 리플로우(Reflow)와 리페인트(Repaint), 즉 브라우저가 화면을 다시 계산하고 그리는 과정을 포괄하는 용어다.
보통 이 두 단계를 묶어 리렌더링이라고 부르지만, 공식적인 렌더링 엔진 용어는 아니라고 한다.
아무튼 리렌더링을 최소화한다는 건, 리플로우와 리페인트를 최소화한다는 말과 같다.
이 중에서도 리플로우는 레이아웃 자체를 다시 계산하는 가장 무거운 작업이기 때문에 특히 신경을 써야 한다.
리렌더링이 발생하지 않는 CSS 속성으로는 transform
, opacity
등이 있다.
이 속성들은 GPU에서 처리하기 때문에 성능 부담을 낮춰준다.
리페인트를 발생시키는 속성으로는 color
, background-color
, visibility
, border-color
, outline
, box-shadow
등이 있다.
마지막으로 리플로우를 발생시키는 속성으로는 width
, height
, padding
, margin
, border-width
, position
, top
, right
, bottom
, left
, display
, flex
, grid
, inline-block
, font-size
, line-height
, overflow
, white-space
등이 있다.
속성을 성의없이 나열한 이유는 하나하나 암기하는 것보다 렌더링 이후에 어떤 속성이 변경되느냐에 따라 성능에 미치는 영향이 훨씬 크다고 느꼈기 때문이다.
처음 CSS를 적용할 때 어떤 속성이든 큰 문제는 없지만, 렌더링된 DOM이 동적으로 변화할 때 발생하는 불필요한 리플로우, 리페인트를 줄이는 게 핵심이다.
리렌더링은 결국 렌더링 이후, 화면을 다시 계산하고 그리는 과정이기 때문에 "어떤 CSS를 썼느냐"보다, "언제, 어떻게 바뀌느냐가 중요하다"는 건 어찌 보면 당연한 말이다.
처음에 이 주제에 접근할 땐 "어떤 속성을 써야 하지?"라고 접근했다가, 어떤 CSS 속성이 리렌더링을 유발하는지 아는 것도 중요하지만, 정작 중요한 건 그것들이 "렌더링 후에 동적으로 바뀔 때" 성능에 영향을 준다는 점이었다.
자식 요소는 position
이 fixed
또는 absolute
가 아니라면 부모 요소의 크기나 위치가 변경될 때마다 영향을 받는다.
즉, 상위 요소에 스타일을 변경하면 그 하위 전체가 리플로우 대상이 될 수 있다.
따라서 특정 부분만 변화가 필요한 상황이라면, 가능한 그 요소에만 직접 스타일을 주는 것이 좋다.
반면 fixed
, absolute
는 Normal Flow(문서의 기본 배치 흐름)에 벗어나기 때문에 주변 요소나 부모 레이아웃에 영향을 주지 않는다.
애니메이션이 들어가는 요소라면 position
을 fixed
나 absolute
로 주고, 위치 변경은 top
, left
대신, transform
으로 처리하는 것이 좋다.
JavaScript에서 element.style
을 이용해 직접 스타일을 변경할 때, 다음과 같이 스타일을 연달아 변경하면
el.style.width = '100px';
el.style.height = '50px';
el.style.margin = '10px';
브라우저는 이를 최적화해서 한 번에 처리할 수 있다.
하지만 스타일을 변경하는 중간에 레이아웃 정보를 읽는 작업이 들어가면 이야기가 달라진다.
offsetWidth
, offsetHeight
, clientWidth
, clientHeight
, scrollTop
, scrollLeft
, scrollHeight
, getBoundingClientRect()
, getComputedStyle(el)
과 같은 속성들은 모두 브라우저가 현재 레이아웃을 계산해야만 알 수 있다.
따라서 아래와 같이 스타일을 조작하면서 중간에 레이아웃 정보를 읽는 경우
el.style.width = '100px';
const h = el.offsetHeight;
el.style.margin = '10px';
브라우저는 offsetHeight
를 계산하기 위해 지금까지 적용된 스타일을 반영해 레이아웃을 강제로 리플로우를 발생시킨다.
이런 조작 → 읽기 → 또 조작
흐름이 반복되면 레이아웃 스레싱(layout thrashing)이 발생할 수 있다.
이를 방지하려면 읽기 작업과 쓰기 작업을 분리하거나, 스타일 변경은 모아서 한 번에 처리하는 것이 좋다.
또한 requestAnimationFrame
을 활용해 다음 프레임에 반영되도록 조정하는 것도 좋은 방법이다.
requestAnimationFrame
은 브라우저가 다음 프레임을 렌더링하기 직전에 콜백 함수를 실행시켜주는 API다.
여기서 말한 프레임은 단순한 DOM 조작을 넘어, 스타일, 애니메이션, 스크롤, 포커스 등 모든 시각적인 요소가 반영되는 단위를 말한다.
처음엔 "프레임을 렌더링한다"는 표현이 꽤 헷갈렸지만, 브라우저가 화면을 실제로 다시 그리는 시점이라고 이해하면 조금 더 감이 온다.
브라우저는 초당 60 프레임을 목표로 렌더링하고, transform
과 같은 속성이 바뀌면 브라우저는 반영하기 위해 다음 프레임을 예약한다.
즉, transform
은 DOM 자체를 바꾸진 않지만, 시각적인 변화가 생기기 때문에 프레임을 유발한다.
requestAnimationFrame
은 프레임 타이밍에 맞춰 콜백을 실행하여 부드러운 애니메이션을 제공하고, 읽기와 조작을 분리하여 레이아웃 스레싱을 방지할 수 있다는 장점이 있다.
음... 사실 적으면서도 렌더링과 프레임에 대한 생각이 분리되지 않는다.
뭔가 같이 묶어서 생각하자니 별개같고, 별개로 생각하자니 엮인 부분이 많은 것 같다.
어벤져스와 X맨의 관계랄까.
굳이 나눠서 정의를 한다면, 렌더링은 변화에 대한 계산 과정, 프레임은 그걸 사용자에게 보여주는 타이밍이라고 볼 수 있다.
대충 몽말인지 알지?
<table>
은 내부 셀 간 의존 관계가 복잡하여 작은 변화에도 전체 테이블이 리플로우된다.
단순히 레이아웃을 구성하는 목적이라면 flex
나 grid
를 사용하는 것이 좋다.
이 항목은 CSS-in-JS 또는 런타임 기반의 스타일링 방식에서 해당된다.
${}
같은 표현식은 렌더링 시점마다 다시 계산될 수 있으므로, 불필요한 재계산을 유발하지 않도록 주의해야 한다.
div ul li span {...}
처럼 구조가 깊은 선택자를 사용하면, 브라우저는 span
을 찾은 뒤, 부모가 li
, ul
, div
인지 역방향으로 계속 탐색한다.
이는 스타일 계산 성능에 영향을 줄 수 있으며, 유지보수 측면에서도 불리하다.
가능하면 선택자의 깊이를 단순하게 유지하고, 필요하다면 클래스를 명확하게 지정하는 것이 좋다.
css도 리렌더링에 영향을 미치고 , 자주사용했던 div ul li ..의 구조 또한 리플로우, 리페인트를 생각해야하는 개선사항이라는 것도 덕분에 알고 갑니다. ! 감사합니다
오 div ul li span {...} 이렇게 사용하는 구조가 스타일 계산 성능에 영향을 줄 수 있다는 건 몰랐네요! 리플로우, 리페인트에 대한 건 많이 들어봤어도 이걸 개선하기 위한 방법은 깊이 있게 다뤄보지 않았던 것 같은데 대표적인 나쁜 사례와 대체 안에 대해서도 나와있어 좋았습니다👍