내가 보려고 만든 개발 공부 일지

브라우저 렌더링 - 리플로우(reflow) 와 리페인트(repaint) 본문

그 외 잡다구리

브라우저 렌더링 - 리플로우(reflow) 와 리페인트(repaint)

kwangsunny 2022. 4. 5. 15:00

어느 기술 인터뷰에서 리플로우와 리페인트에 대해 설명해 달라는 질문을 받은적이 있었다.

아는대로 설명을 하긴 했지만 언제 어떻게 발생되고 성능적으로 어떻게 개선할 수 있을지에 대해서는

자세하게 대답하지 못했었다.

그래서 이번 포스팅에서는 리플로우와 리페인트가 무엇이고 개발할때 어떤 부분을 신경써야 하는지 정리해보려 한다.  

 

 

브라우저 렌더링 과정

브라우저의 역할은 사용자가 보고자 하는 웹페이지를 서버에 요청하고 서버에서 보내준 응답을 받아

브라우저 화면에 그려주는 것이다. 보통 서버는 응답으로 html 파일을 보내준다.

브라우저의 렌더링 엔진은 이 html 파일을 해석하여 DOM 트리와 CSSOM 트리를 만들고, 이 둘을 결합하여 렌더 트리를 만든다. 이렇게 만들어진 렌더 트리를 기반으로 UI가 그려지게 된다.

 

사용자가 https://kwangsunny.tistory.com/ 에 접속하려는 상황이라 가정해보자.

브라우저는 위 주소로 네트워크 요청을 보낸다. 이 요청은 먼저 DNS 서버에 들린다. 

 

여기서 DNS (domain name server) 란? 

컴퓨터, 스마트폰, 등 네트워크 통신을 하는 모든 기기들은 숫자로 이루어진 주소를 통해 다른 기기를 찾고 요청과 응답을 주고받는데 이 숫자 주소를 IP 라고 한다. IP는 192.168.0.0 뭐 이런 식으로 생겼는데 이 형태는 사람들이 인지 하기
어렵다. 그래서 위 IP대신 www.google.com 같이 사람이 읽기 쉽고 기억하기도 쉬운 단어들로 이루어진 주소를 사용하는데 이를 도메인 이라고 한다. 하지만 실제로 다른 기기의 위치를 찾기 위해선 숫자 주소인 IP가 필요하다.
그래서 도메인과 IP 를 매핑해 놓은 서버가 있는데 이 서버가 DNS 서버다.

 

브라우저 요청은 DNS 서버에서 https://kwangsunny.tistory.com/ 에 해당하는 IP 를 찾아 티스토리 서버로 가게된다.
티스토리 서버는 위 주소에 해당하는 리소스를 응답으로 보내줄 것이다.
이 응답은 개발자 도구 (단축키 F12) 의 네트워크 탭에서 확인 가능하다.

서버가 보내준 응답 (html 파일)

렌더링 엔진은 이 html 파일을 처음부터 한줄한줄 읽고 해석하여 브라우저가 이해할 수 있는 DOM 트리를 생성 하는데 이 작업을 html 파싱 이라 한다.

렌더링 엔진이 html을 파싱하는 도중 CSS파일을 로드하는 link 태그나 style 태그를 만나게 되면 CSS파일을 서버에 요청하고 이 CSS 파일을 파싱하여 CSSOM 트리를 생성한다.
CSS를 파싱하는 동안 html 파싱은 멈추지 않고 계속 진행된다. 그래서 CSS 파싱과 html 파싱은 어느쪽이 먼저 끝나게 될지 알 수 없다. CSS 파싱이 먼저 끝나고 html 파싱이 끝나게 된다면 스타일이 모두 입혀진(완성된) html 요소가 브라우저 화면에 보일것이다.  

 

그런데 CSS 파싱이 html 파싱보다 더 늦게 끝나면? 아무 스타일도 입혀지지 않은 앙상한 모습의 UI 가 화면에 보일것이다. 아마 가끔씩 어떤 사이트에 접속했을때 해당 페이지가 스타일 없이 뼈대만 보이다가 몇초 지나거나 새로고침을 하면

그제서야 페이지가 제대로 보이는 경험을 해본적이 한 번 쯤은 있을 것이다.

이 상황은 아마 link 태그에 걸려있는 외부 CSS파일의 용량이 크거나 인터넷 연결이 좋지 않아 CSS 파일을 로드하는게

늦어져서 그 동안 html은 다 파싱이 되었지만, CSS가 뒤늦게 파싱됨에따라 렌더링 트리가 다시 재구성 되면서

발생하는 상황일 것이다.

 

그래서 CSS 관련 링크나 style 태그는 꼭 html 문서의 상단에 (보통 head 태그 사이) 위치시키는것이 좋다.

렌더링 엔진은 html 을 위에서부터 내려가며 파싱하기 때문에 CSS관련 태그들을 html 하단에 위치시키면

그만큼 CSS 파싱도 늦어져서 위와같은 상황이 생길 수 있기 때문이다.  

 

어쨋거나 렌더링 엔진은 이렇게 생성된 DOM 트리와 CSSOM 트리를 결합하여 렌더 트리를 만든다.
렌더 트리에는 화면에 보이지 않는 요소들은 포함되지 않는다. 예를 들어 DOM 트리의 html, head, meta 등의 비 시각적 요소들이나 display : none 속석이 적용된 요소들은 렌더 트리에서 제외된다.
그리고 이 렌더 트리를 기반으로 HTML 요소의 위치, 크기 등 기하학적인 모든 부분을 계산하여 실제로 화면에 그리게 된다.

그런데 만약 html 파싱도중 스크립트 태그를 만나게 되면 어떻게 될까? 스크립트는 link 태그나 style 태그를 만났을때와는 달리 html 파싱을 멈추고 스크립트를 다운로드한다. 스크립트 로딩이 완료되면 자바스크립트 엔진이 스크립트를 실행한다. 이 과정이 끝나면 렌더링 엔진은 파싱이 중단됐던 곳으로 다시 돌아가 html 파싱을 계속하게 된다.

스크립트 태그를 만나면 html 파싱이 중단되기 때문에 스크립트 태그의 위치도 중요하다.
아래 예시 코드를 보자.

<html>
    <head>
        <link rel="stylesheet" href="style.css">
        <script src="big-size.js"></script>   <!-- (A) -->
    </head>
    <body>
    	<div id="root"></div>  <!-- (B) -->
    </body>
</html>

서버가 위의 html 파일을 응답으로 줬다고 해보자.

렌더링 엔진은 <html> 부터 아래로 내려가며 파싱을 진행하다가 link 태그를 만나 style.css 를 로드하고 CSSOM 트리를 구성한다. CSSOM을 구성하는 동안 파싱은 멈추지 않고 계속 되고 (A) 에서 스크립트 태그를 만나게된다.

이때, html 파싱은 중단되고 big-size.js 를 로딩한다. big-size.js 를 로딩하는 동안 html 파싱이 중단되기 때문에 

(B) 부분의 div 는 그려지지 않게 되서 화면엔 아무것도 없는 흰색 바탕만 보이게 될것이다.

또한 다른 스크립트에서 이 div 에 접근하려고 해도 아직 이 div 는 없기 때문에 에러가 발생할것이다.

 

이런 문제 때문에 로딩이 오래 걸리는 스크립트, 혹은 html 요소에 접근하는 코드가 있는 스크립트는 body 의 닫는 태그 바로 위에 위치시킨다.   

<html>
    <head>
        <link rel="stylesheet" href="style.css">
    </head>
    <body>
    	<div id="root"></div> 
        <script src="big-size.js"></script> 
    </body>
</html>

이렇게 스크립트의 위치를 바꿔주면 모든 html 요소가 화면에 다 그려지고 난 후에 스크립트를 로딩하기 때문에

텅빈화면이 보인다거나 div 에 접근할때 에러가 발생하거나 하는 문제는 생기지 않을것이다.

 

스크립트의 위치를 바꾸는것 이외에 스크립트의 defer 와 async 속성을 사용해줄 수도 있다.

<script src="A.js" defer></script>
<script src="B.js" async></script>

defer 혹은 async 속성이 있는 스크립트를 만나면 브라우저는 html 파싱을 멈추지 않고 스크립트 로딩과 파싱을 

동시에 진행하게 된다. 즉, 스크립트 로딩이 비동기적으로 수행된다. 

이 두 속성의 차이는 스크립트의 실행 시점이다.

 

defer 는 스크립트 로딩이 완료되어도 실행되지 않고 html 문서가 모두 파싱될때까지 기다렸다가 파싱이 완료되면 그때

실행된다.

반면 async 는 html 문서의 파싱 완료 여부와 상관없이 스크립트가 로딩되면 바로 스크립트를 실행한다.

그래서 async 속성의 스크립트 내에서 어떤 html 요소에 접근하는 코드가 있을때, 그 html 요소가 아직 파싱되지 않았다면 에러가 발생할 수 있지만, defer 속성이라면 모든 html 문서가 다 그려진 후 실행되므로 이런 에러는 발생할 일이 없을 것이다.

 

이런 차이 때문에 html 요소에 접근할 일이 없고 독립적으로 수행되어야 하는 스크립트에 async 속성을 활용하고

모든 html 문서가 로딩된 후 실행되어야 하는 스크립트에는 defer 속성을 사용한다.

 

참고로 모듈 스크립트 <script type="module"></script> 는 defer 속성이 기본값이다.

그래서 모듈 스크립트는 html 문서 어디에 위치하든 모든 html 요소에 접근할 수 있게된다.

 

서버로부터 받은 html 파일은 이런 과정을 거쳐서 브라우저 화면에 그려지게 된다.  

그렇다면 이후에 스크립트를 통한 DOM 조작이 발생하면 렌더링 엔진은 어떤 작업을 하게될까?

 

  

리플로우 와 리페인트

리플로우와 리페인트는 DOM 요소가 시각적으로 변경됐을때, 이 변화를 다시 계산하고 화면에 그려주는 작업이다.

매크로 / 마이크로 태스크 포스팅에서 렌더링은 매크로태스크 하나 처리 -> 모든 마이크로태스크 처리 -> 렌더링 작업

순서로 진행된다고 했었는데, 이 렌더링 작업이 리플로우와 리페인트를 뜻한다.

 

 

리플로우 (reflow)

DOM 요소의 기하학적 속성이 변경될때, 브라우저 사이즈가 변할때, 스타일시트가 로딩되었을때 발생하는 변화들을 다시 계산 해주는 작업을 뜻하고 레이아웃(Layout) 이라고도 한다.

이런 요인에 의해 변화된 요소 주변의 모든 (부모, 자식, 형제) 요소들도 영향을 받게되는데 결국 DOM 요소의 하나의

시각적 변화가 DOM 트리 전체에 대해 다시 계산을 수행하게 된다.

 

예를 들어, 문서 중간에 위치한 div 의 font-size 값을 20px --> 100px 로 변경시켰다고 가정해보자.

이 div의 폰트 크기가 커진만큼 주변의 요소들이 밀려나거나 크기가 변하게 될것이다.

박스1 의 font-size 를 100px 로 키운 모습

위 그림을 보면 박스1 의 폰트 크기가 증가된 만큼 주변 요소들의 위치와 크기들이 달라졌다.

즉, html 요소 하나의 시각적 변화 만으로도 주변 요소들의 위치와 크기도 모두 계산해줘야 하고, 이 주변 요소들의

변화가 인접한 또다른 요소들에게도 다시 영향을 미치게 되어 결국 DOM 트리 전체에 대한 계산 작업이 발생하게 된다.

이런 계산 작업을 통해 렌더 트리가 업데이트 되고 리페인트가 실행되어 화면에 그려준다.

이렇게 리플로우는 대충 봐도 굉장이 비용이 많이 드는 작업임을 알 수 있다.   

 

그런데 이렇게 직접적으로 요소의 시각적 속성을 바꿔주는 행위말고, 단순히 어떤 메서드를 호출하거나 프로퍼티 값을 가져오는 것만으로도 리플로우가 발생하는 경우가 있는데, 몇 가지 예를 들면 다음과 같은 것들이 있다.

 

elem.offsetLeft, elem.offsetTop, elem.offsetWidth, elem.offsetHeight, elem.offsetParent

elem.clientLeft, elem.clientTop, elem.clientWidth, elem.clientHeight

elem.getClientRects(), elem.getBoundingClientRect()

window.scrollX, window.scrollY

window.innerHeight, window.innerWidth

window.getComputedStyle() 

 

더 자세한 내용들은 아래 링크를 참조하면 된다.

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

 

What forces layout/reflow. The comprehensive list.

What forces layout/reflow. The comprehensive list. - what-forces-layout.md

gist.github.com

 

그러면 위와 같은 코드들이 리플로우를 발생시키는 이유는 무엇일까?

만약에 어떤 요소의 실제 가로 길이를 알고 싶다면 어떻게 해야 할까.

div.style.width 이렇게 하면 알 수 있을까? 만약 인라인 스타일로 width 값을 명시해 줬다면 이렇게 해도 될 것이다.

하지만 대부분의 경우 스타일은 클래스를 사용해 적용할 것이기 때문에 div.style.width 은 빈 문자열을 반환할 것이다. 혹은 인라인 스타일로 width : 50%; 이런식으로 픽셀값이 아닌 상대값을 주면, 이 요소의 실제 width의 픽셀값은 화면 사이즈에 따라 매순간 달라질것이다.

 

다시 말해, 우리가 알고싶은 값은 요소의 계산된 스타일 (computed style) 값이고, 위의 메서드 혹은 프로퍼티값을

사용하면 요소의 계산된 스타일 값을 알 수 있다.

그래서 만약 div.clientHeight 를 쓰면 이 div의 최신값을 계산하기 위해 리플로우가 발생하게 된다. 

또 이 메서드들과 프로퍼티들은 브라우저의 리플로우 최적화를 중단시키는데 자세한 내용은 글 후반에 더 알아보자.

 

 

리페인트 (repaint)

변경된 요소를 실제로 화면에 그려주는 작업을 리페인트라고 한다. 그래서 리플로우가 발생하면 필연적으로

리페인트가 실행된다.

리페인트도 굉장히 무거운 작업이긴 하지만 리플로우 처럼 모든 요소들에 대한 기하학적 정보들을 계산해주는 작업은

아니기 때문에 리플로우 보다는 상대적으로 훨씬 가벼운 작업이다.

 

그리고 배경색이나 visibility 속성이 변했을때는 리플로우는 발생하지 않고 리페인트만 발생하는데 

그 이유는 위치, 크기, 테두리 두께, 폰트 사이즈 같이 기하학적인 변화가 아닌 단순히 색상만 바뀌기 때문이다.  

여기서 css 속성중 display : none -- (A) 와 visibility : hidden -- (B) 을 구분할 필요가 있다.  

 

(A) 와 (B) 둘 다 화면에서 보이지 않게 만드는 css 속성이다. 그런데 (B) 는 화면에서 보이진 않아도 그 영역은

그대로 차지하고 있는 반면, (A)는 보이지도 않고, 영역도 없어지게 된다.

즉, display : none 이 적용된 요소는 렌더 트리에서 제외된다.

display : none 이 적용된 요소는 그 영역도 없어지기 때문에 주면 요소들이 그 공간을 밀고 들어오게 되어

위치와 크기가 변하게 될 것이고 이는 곧 리플로우와 리페인트를 발생시킬 것이다.

하지만 visibility : hidden 이 적용된 요소는 단순히 보이지 않게 될 뿐, 크기나 위치가 변하는게 아니기 때문에

리플로우는 발생하지 않고 리페인트만 발생한다.

마찬가지 이유로 background-color 로 요소의 배경색만 바꿔준 경우도 리페인트만 발생하게 된다. 

 

그러면 정말 그런지 예제로 확인 해보자.

좌 : 요소의 높이 변경  /  우 : 요소의 배경색 변경

위 실험은 크롬 개발자 도구의 performance 탭에서 div의 css를 수정했을때 발생하는 이벤트 로그를 캡쳐한 것이다.

먼저 좌측 그림의 노란색 동그라미 부분을 보면, 콘솔에서 요소의 높이값을 변경해주었다.

그리고 위쪽 동그라미친 Layout 이라고 된 부분이 바로 리플로우를 뜻한다.

그 아래쪽에 Paint 라고 써진 초록색 사각형은 리페인트 단계를 뜻한다.

 

이제 배경색만 red 로 변경해준 오른쪽 그림을 보자.

잘 보면 왼쪽과는 달리 Layout 단계 없이 바로 Paint 를 해주고 있는 모습이다.

기하학적인 변화 없이 단순 색상만 변경 되었을땐 리플로우 없이 리페인트만 발생한다는것을 확인할 수 있는 결과이다.

 

마지막으로 크롬 개발자 도구에는 재밌는 기능이 있는데, 바로 화면에서 리페인트가 실시간으로 발생하는 모습을 하이라이트 해주는 기능이다. 백번 설명 보다 한 번 보는게 더 도움이 되듯이 이 기능을 사용하면 리페인트가 

언제 일어나는지 더 피부에 와닿을 것이다.

 

이 기능은 개발자 도구 rendering 탭의 Paint flashing 이라는 항목을 체크하면 된다.

 

하이라이트 on
요소의 시각적 변화가 생길때 마다 리페인트 발생

 

 

리플로우, 리페인트 줄이기

리플로우와 리페인트가 아주 무거운 작업이라는걸 알았으니 이제 이 작업을 어떻게 하면 줄일 수 있을까?

리플로우는 모든 요소들을 다 계산해주는 작업이므로 리페인트보다 무거운 작업이다. 또 리플로우가 발생하면

필연적으로 리페인트도 발생한다. 그러니 우리는 리플로우가 최소한으로 발생되게 코드를 잘 작성해야 한다.

 

1. DOM 속성 변경 코드 그룹핑 하기

스크립트로 여러 DOM의 속성을 변경했다고 가정해보자.

그러면 그만큼 리플로우가 발생할 것이다. 하지만 코드를 어떻게 작성하냐에 따라 리플로우 발생 횟수를 줄일 수 있다. 

좌 : DOM 수정, 나눠서 수행  /  우 : DOM 수정 그룹화 하여 수행

두 예시 모두 div 각각에 높이를 10px 만큼 더해주는 코드이다. 다만 왼쪽은 각 div 별로 계산된 높이값을 가져와 10px 만큼 더해주고 있고, 오른쪽은 계산된 높이값을 가져오는 것과 높이를 10px만큼 더해주는 코드를 그룹화 하여 작성했다.

그 결과 왼쪽은 리플로우(Layout) 이 3번 발생한것에 반해, 오늘쪽은 리플로우가 한 번 발생했다.

코드 실행 순서만 바꿔줬는데 왜 이런 차이가 생겼을까?

 

사실 브라우저는 기본적으로 리플로우를 줄이기 위한 전략을 가지고 있다.

그 전략은 요소의 변경을 지금 당장 처리하지 않고 큐에 저장해뒀다가 일정시간이 지나거나 처리할 변경 작업이 일정량 쌓였을때 리플로우를 수행하는 것이다.

먼저 오른쪽 예시부터 보면, 각 div 들의 높이를 한번에 몰아서 수정하고 있는데, 브라우저는 이 높이 변화들을 큐에 쌓아뒀다가 일정 시간이 지나면 한 번에 처리하기 때문에 리플로우가 한 번 발생한 것이다.

 

반면 왼쪽 예시는 div의 높이 변경을 한 번에 처리하지 않고 clientHeight 한번, 높이 수정 한번 이런 식으로 해주고있다. 

이전에 설명했던 것처럼 clientHeight 같은 프로퍼티는 요소의 최신값을 가져오기 위해 계산 작업이(리플로우) 발생한다고 했다. 이미 큐에 쌓여있는 작업들은 요소의 최신값 계산에 영향을 줄 수 있기 때문에 브라우저는 큐에 쌓인 작업들을

모두 비운 다음 clientHeight 값을 반환한다.

 

그래서 왼쪽 예시는 요소의 변경 작업이 큐에 쌓이기전에 clientHeight를 매번 실행하므로써 오른쪽 예시와는 달리 리플로우가 3번이나 발생한 것이다.    

 

2. 리플로우 유발 메서드는 별도 저장해 사용하기

이 방법은 위 예시와 연결되는 내용이다. 리플로우를 유발시키는 메서드, 프로퍼티는 매번 호출하지 말고 변수에

저장한 후 사용하자.

// 나쁜 예
for(let i=1; i<10; i++){
    div.style.left = (div1.getBoundingClientRect().left + i) + 'px';
}

// 좋은 예
let {left} = div1.getBoundingClientRect();  // reflow 유발 메서드는 변수에 저장해 사용 
for(let i=1; i<10; i++){
    div.style.left = (left + i) + 'px';
    left += i;
}

 

3. CSS 수정은 일괄로 변경 해주기

스크립트로 CSS 속성을 여러번 수정하는 것은 그만큼 리플로우를 여러번 유발시키게 된다.

그러니 CSS 수정이 많이 필요한 경우에는 클래스명을 수정하거나 cssText 속성을 이용해 일괄로 변경해주자.

<style>
    .my-div {
        width: 100px;
        height: 100px;
        padding: 5px;
        border: 5px solid blue;
        background-color: black;
        color: white;
    }
</style>
<body>
    <div id="div"></div>

    <script>
        // 나쁜 예
        div.style.width = "100px";   		  // reflow, repaint
        div.style.height = "100px";   		  // reflow, repaint
        div.style.padding = "5px";   		  // reflow, repaint
        div.style.border = "5px solid blue";      // reflow, repaint
        div.style.backgroundColor = "black";      // repaint
        div.style.color = "white";		  // repaint

        // 좋은 예1
        div.className = "my-div";

        // 좋은 예2
        div.style.cssText = "width: 100px; height: 100px; ...";
    </script>
</body>

 

4. display : none 이용하기

위 예시처럼 여러가지 속성을 변경해줄때, 먼저 해당 요소를 display : none 상태로 만들고나서 작업하는 방법도 있다.

display : none 이 적용되면 그 요소는 렌더 트리에서 제외되고 없는 요소 취급당하기 때문에 이 요소에 다른 기하학적

변화가 일어나도 리플로우나 리페인트는 발생하지 않게 된다.

div.style.display = "none";   // (A)

// ... 여러 스타일 속성 수정 ...

div.style.display = "block";  // (B)

이렇게 코드를 작성해 주면 (A) 와 (B) 에서 리플로우, 리페인트가 각각 한 번 씩 발생하므로, 그 사이에 아무리 많은

스타일 값을 변경해줘도 총 2번의 리플로우, 리페인트만 발생하게 될것이다.  

 

마지막으로 어떤 CSS 속성이 리플로우와 리페인트를 유발시키는지 정리해놓은 사이트가 있는데 참고하면 좋을것 같다.

https://csstriggers.com/

 

CSS Triggers

@PROPERTY_DESCRIPTION@ B G W E Change from default B G W E Subsequent updates

csstriggers.com

 

 

잘못된 내용은 댓글로 알려주세요 :)

 

 

참고 자료

https://d2.naver.com/helloworld/59361

 

https://www.phpied.com/rendering-repaint-reflowrelayout-restyle/

 

Rendering: repaint, reflow/relayout, restyle

2010 update: Lo, the Web Performance Advent Calendar hath moved Dec 17 This post is part of the 2009 performance advent calendar experiment. Stay tuned for the articles to come. UPDATE: Ukraine translation here. Nice 5 "R" words in the title, eh? Let's tal

www.phpied.com

 

 

Comments