앞의 여러 편에서 브라우저가 문서와 스타일을 구조로 바꾸고, 그릴 것을 골라 자리를 계산하고, 색을 칠해 층으로 합치는 과정을 단계별로 살펴보았다. 그런데 이 과정은 화면을 처음 그릴 때 한 번만 일어나는 것이 아니다. 사용자가 스크롤하고 조작하고 스크립트가 화면을 건드릴 때마다, 브라우저는 이 단계들의 일부 또는 전부를 다시 밟는다. 이번 편은 이 다시 그리기가 어느 단계부터 되풀이되는지, 곧 자리를 다시 잡는 무거운 갱신과 색만 다시 칠하는 가벼운 갱신을 나누어 다룬다.
이 구분은 앞서 색칠과 합치기 편에서 세 가지 깊이로 짚었던 것을, 무엇이 각 깊이를 촉발하는지의 관점에서 다시 파고드는 것이다. 화면이 버벅이는 문제의 대부분은 가벼워도 될 갱신이 무거운 갱신으로 번지는 데서 온다. 어떤 변화가 어느 단계를 다시 부르는지를 정확히 알면, 같은 결과를 훨씬 값싸게 얻는 길이 보인다.
자리를 다시 잡는 갱신
화면에 이미 그려진 요소의 크기나 위치가 바뀌면, 브라우저는 그 요소가 화면의 어디에 얼마만 한 크기로 놓일지를 처음부터 다시 계산해야 한다. 이렇게 자리를 다시 잡는 갱신을 리플로우라 부른다. 리플로우는 렌더링 단계 중에서도 앞쪽에 있는 배치 계산을 다시 돌리는 일이므로, 그 뒤에 이어지는 색칠과 합치기까지 연달아 다시 하게 만든다.
리플로우가 무거운 근본 이유는 앞서 다룬 배치의 연쇄성 때문이다. 한 요소의 크기가 바뀌면 그 옆과 아래의 요소들이 밀려나고, 그 밀려남이 또 다음으로 퍼진다. 그래서 겉보기에 작은 변화 하나가 실제로는 화면의 넓은 영역을 다시 계산하게 만든다. 리플로우의 비용을 그 변화가 직접 건드린 요소 하나로 가늠하면 크게 과소평가하게 된다.
리플로우의 범위는 변화가 일어난 위치에 따라서도 달라진다. 문서의 위쪽, 곧 많은 요소가 그 아래에 딸려 있는 지점에서 크기가 바뀌면, 그 아래 모든 요소가 함께 밀려 재계산 범위가 넓어진다. 반대로 문서의 끝자락에서 일어난 변화는 뒤에 딸린 요소가 적어 파장이 작다. 같은 성격의 변화라도 어디에서 일어나느냐에 따라 비용이 크게 갈린다.
또한 리플로우는 변화가 일어난 요소가 어떤 배치 규칙 아래 놓여 있는지에도 영향을 받는다. 서로 크기를 나눠 갖거나 격자에 맞춰 정렬되는 배치 안에서는, 한 요소의 변화가 같은 규칙에 묶인 형제 요소들의 자리까지 다시 정하게 만든다. 배치 규칙이 요소들을 촘촘히 엮어 둘수록, 하나의 변화가 부르는 재계산의 그물도 넓어진다.
색만 다시 칠하는 갱신
모든 변화가 자리를 다시 잡게 만드는 것은 아니다. 요소의 크기와 위치는 그대로인데 겉모습만 바뀌는 변화가 있다. 글자 색이나 배경색을 바꾸거나, 테두리의 색을 바꾸는 것이 그렇다. 이런 변화는 자리를 다시 계산할 필요가 없으므로 배치 단계를 건너뛰고 색칠부터 다시 한다. 이렇게 자리 없이 겉모습만 다시 그리는 갱신을 리페인트라 부른다.
리페인트는 리플로우보다 값싸다. 무거운 배치 계산을 통째로 건너뛰기 때문이다. 그러나 리페인트도 공짜는 아니다. 색을 다시 칠할 영역이 넓거나, 그림자나 반투명한 겹침처럼 계산이 많이 드는 꾸밈이 그 영역에 걸쳐 있으면, 리페인트만으로도 제법 부담이 된다. 넓은 영역의 잦은 리페인트는 그 자체로 화면을 무겁게 만들 수 있다.
가장 값싼 갱신은 앞서 색칠과 합치기 편에서 다룬 대로, 이미 그려 둔 층을 옮기거나 그 투명도를 바꾸는 것이다. 이런 변화는 층의 내용을 다시 그릴 필요조차 없어, 배치도 색칠도 건너뛰고 합치기만 다시 한다. 요소를 옮기거나 서서히 나타나게 하는 흔한 움직임을 이 방식으로 표현하면, 아무리 자주 반복해도 화면이 부드럽게 유지된다.
그래서 화면 변화는 세 층으로 나뉜다. 자리를 다시 잡는 리플로우, 겉모습만 다시 칠하는 리페인트, 그리고 층만 다시 합치는 가장 얕은 변화다. 같은 목적을 이루는 데 여러 방법이 있다면, 되도록 얕은 층에서 해결하는 것이 성능의 기본 원칙이다. 무엇을 바꾸느냐만큼이나 어떻게 바꾸느냐가 비용을 좌우한다.
무엇이 리플로우를 부르는가
리플로우를 아끼려면 무엇이 리플로우를 부르는지를 알아야 한다. 가장 직접적인 원인은 요소의 크기나 위치, 여백을 실제로 바꾸는 것이다. 너비나 높이를 바꾸고, 안팎의 여백을 조절하고, 요소를 다른 자리로 옮기는 변화는 모두 배치를 다시 계산하게 만든다. 이런 값들은 다른 요소들의 자리와 맞물려 있어 건드리는 순간 파장이 퍼진다.
요소를 새로 붙이거나 떼어내는 것도 리플로우를 부른다. 나무에 마디를 더하거나 빼면 그 주변 요소들의 자리가 달라지므로, 브라우저는 영향받는 범위의 배치를 다시 잡는다. 그래서 목록에 항목을 하나씩 여러 번 붙이면 그때마다 리플로우가 일어나, 한꺼번에 붙일 때보다 훨씬 비싸진다. 여러 요소를 더할 때는 미리 묶어 두었다가 한 번에 붙이는 편이 낫다.
글자의 크기나 글꼴이 바뀌는 것도 리플로우의 원인이다. 글자가 커지면 그 글자가 담긴 상자의 크기가 달라지고, 그 변화가 주변으로 퍼진다. 바깥에서 내려받는 글꼴이 뒤늦게 도착해 적용되면서 글자 모양이 바뀌면, 그 순간 화면이 다시 배치되며 출렁이는 일이 생긴다. 글꼴이 도착하기 전과 후의 크기 차이를 줄여 두면 이 출렁임을 완화할 수 있다.
창의 크기가 바뀌는 것은 화면 전체의 리플로우를 부르는 가장 큰 사건이다. 화면의 너비가 달라지면 상대적인 크기로 지정된 거의 모든 요소의 자리가 다시 계산된다. 그래서 창 크기 변화에 반응해 무언가를 처리할 때는, 그 처리가 매 순간 쉴 새 없이 불리지 않도록 간격을 두어 몰아서 다루는 것이 중요하다.
무효화의 범위를 좁히기
브라우저는 변화가 생겼다고 해서 무작정 화면 전체를 다시 계산하지는 않는다. 어느 부분이 변화의 영향을 받는지를 표시해 두었다가, 실제로 다시 그릴 때가 되면 그 표시된 범위만 다시 처리한다. 이 표시를 무효화라 부르며, 무효화된 범위가 좁을수록 다시 그리기가 가벼워진다.
그래서 성능을 다루는 한 갈래는 이 무효화의 범위를 좁게 유지하는 것이다. 변화를 화면의 한 구석에 가두면 그 구석만 다시 처리하면 되지만, 변화가 넓은 영역에 걸치거나 여러 곳에 흩어져 있으면 무효화 범위가 커진다. 앞 편에서 다룬 격리, 곧 어떤 구획의 변화가 바깥으로 퍼지지 않게 울타리를 세우는 방법이 여기서 무효화 범위를 좁히는 수단으로 이어진다.
층을 나누는 것도 무효화를 다루는 방법이다. 자주 변하는 요소를 별도의 층으로 떼어 두면, 그 요소가 바뀌어도 무효화는 그 층 안에 갇혀 나머지 층은 손대지 않아도 된다. 다만 앞서 짚었듯 층은 메모리를 대가로 하므로, 자주 변하는 것에만 골라 적용해야 한다. 무효화 범위를 좁히려다 층을 남발하면 다른 비용이 커진다.
무효화를 이해하면 왜 어떤 화면은 작은 변화에도 전체가 버벅이는지가 보인다. 무효화 범위를 넓게 잡게 만드는 구조, 곧 모든 요소가 서로 촘촘히 엮여 하나가 바뀌면 전체가 흔들리는 구조에서는, 아무리 작은 변화도 큰 재계산으로 번진다. 반대로 화면을 서로 독립적인 구획으로 나누어 두면, 각 변화의 무효화가 제 구획 안에 머문다.
강제로 앞당겨지는 리플로우
브라우저는 원래 여러 변화를 모아 두었다가 다시 그릴 때가 되면 한 번에 배치를 계산한다. 변화가 생길 때마다 즉시 리플로우하지 않고 미뤄 두는 것인데, 이 미룸 덕분에 여러 변화가 하나의 리플로우로 합쳐져 효율이 오른다. 그런데 이 미룸을 깨뜨려 리플로우를 그 자리에서 강제로 앞당기는 상황이 있다.
무언가를 바꾼 직후에 그 결과로 정해질 위치나 크기 같은 값을 곧바로 읽으려 하면, 브라우저는 정확한 값을 돌려주기 위해 미뤄 두었던 배치 계산을 즉시 수행할 수밖에 없다. 아직 반영하지 않은 변화가 있는데 그 변화가 반영된 값을 묻기 때문에, 브라우저로서는 지금 계산하는 수밖에 없는 것이다. 이렇게 앞당겨진 리플로우가 성능 함정의 흔한 원인이다.
이 함정이 특히 심각해지는 것은 바꾸고 읽는 일을 반복 안에서 되풀이할 때다. 여러 요소를 돌며 하나를 바꾸고 그 결과를 읽고, 다시 다음을 바꾸고 읽으면, 그때마다 리플로우가 강제로 앞당겨져 같은 계산이 요소 수만큼 되풀이된다. 원래 한 번이면 될 리플로우가 수십 번으로 불어나는 것이다. 이 문제는 앞의 배치 편에서 다룬 것과 같은 뿌리를 가진다.
해법은 읽는 일과 쓰는 일을 시간적으로 분리하는 것이다. 필요한 값들을 먼저 한꺼번에 모두 읽어 따로 담아 둔 뒤, 그다음에 변화를 몰아서 적용한다. 읽기와 쓰기가 교차하지 않으면 강제로 앞당겨지는 리플로우가 사라지고, 브라우저는 모아 둔 변화를 한 번의 리플로우로 처리한다. 코드의 순서만 바꿀 뿐인데 리플로우의 횟수가 극적으로 줄어든다.
뒤늦게 도착한 것이 부르는 흔들림
리플로우 가운데 특히 사용자를 거슬리게 하는 것은 화면이 이미 뜬 뒤에 뒤늦게 일어나는 것이다. 첫 화면이 그려진 뒤에 어떤 자원이 뒤늦게 도착해 요소의 크기를 바꾸면, 이미 자리 잡은 요소들이 갑자기 밀려나며 화면이 출렁인다. 사용자가 읽던 자리나 누르려던 버튼이 순간 아래로 밀려, 엉뚱한 곳을 누르게 되기도 한다.
이 뒤늦은 흔들림의 대표적 원인은 크기를 미리 정해 두지 않은 이미지다. 이미지가 도착하기 전에는 브라우저가 그 자리를 얼마나 차지할지 알지 못해 자리를 비워 두지 않는다. 그러다 이미지가 도착하면 그 크기만큼 자리가 갑자기 벌어지며 아래 내용이 밀려난다. 이미지의 크기를 미리 알려 두면, 브라우저가 도착 전에 그만큼의 자리를 비워 두어 흔들림이 사라진다.
바깥에서 내려받는 글꼴도 흔한 원인이다. 글꼴이 도착하기 전에는 대체 글꼴로 글자를 그리는데, 그 대체 글꼴과 나중에 도착한 글꼴의 글자 크기가 다르면 도착하는 순간 글자가 담긴 상자의 크기가 바뀐다. 그 변화가 리플로우를 부르며 화면이 다시 배치된다. 대체 글꼴과 실제 글꼴의 크기 차이를 줄여 두면 이 흔들림을 완화할 수 있다.
뒤늦게 끼어드는 요소도 같은 문제를 일으킨다. 화면이 뜬 뒤에 어떤 알림이나 배너가 위쪽에 끼어들면, 그 아래 모든 내용이 밀려난다. 이런 요소를 넣어야 한다면 처음부터 그 자리를 비워 두거나, 이미 자리 잡은 내용을 밀어내지 않는 방식으로 겹쳐 두어야 한다. 뒤늦은 삽입이 자리를 밀어내지 않게 하는 것이 흔들림을 막는 원칙이다.
이 뒤늦은 흔들림은 그 자체로 하나의 성능 지표로 다뤄질 만큼 사용자 경험에 큰 영향을 준다. 화면이 얼마나 빨리 뜨는지 못지않게, 뜬 뒤에 얼마나 안정적으로 유지되는지가 중요하기 때문이다. 뒤늦게 도착하는 모든 것에 대해 그 자리를 미리 짐작해 비워 두는 습관이, 화면을 흔들림 없이 안정적으로 지킨다.
가볍게 유지하고 눈으로 확인하기
지금까지의 이야기를 종합하면, 다시 그리기를 가볍게 유지하는 원칙은 몇 가지로 정리된다. 첫째는 잦은 변화를 되도록 얕은 층에서 처리하는 것이다. 매 순간 이어지는 움직임은 자리를 건드리는 대신 층을 옮기거나 투명도를 바꾸는 방식으로 표현해, 리플로우와 리페인트를 아예 건너뛴다.
둘째는 리플로우를 부르는 변화를 한데 모아 처리하는 것이다. 여러 곳을 바꿔야 한다면 그것들을 잠시 모아 두었다가 한 번에 반영하고, 읽기와 쓰기를 뒤섞지 않는다. 셋째는 무효화의 범위를 좁게 유지하는 것이다. 화면을 독립적인 구획으로 나누어, 한 곳의 변화가 전체로 번지지 않게 울타리를 세운다. 넷째는 애초에 구조를 가볍게 유지해, 다시 그릴 요소 자체를 줄이는 것이다.
이 원칙들은 짐작만으로 적용하기 어렵다. 어떤 변화가 실제로 리플로우를 부르는지, 리페인트에 그치는지, 아니면 합치기만으로 끝나는지는 눈으로 확인해야 확실하다. 브라우저의 개발자 도구는 화면을 다시 그리는 과정을 시간 순서로 펼쳐, 어느 변화가 어느 단계를 다시 밟았는지를 보여 준다. 이 도구로 잦은 변화가 밟는 단계를 확인하고 나서 판단하는 것이 근거 있는 최적화다.
정리하면, 화면의 갱신은 자리를 다시 잡는 리플로우, 겉모습만 다시 칠하는 리페인트, 층만 다시 합치는 얕은 변화로 나뉘며, 무엇이 각 단계를 부르는지를 알면 같은 결과를 훨씬 값싸게 얻을 수 있다. 이 구분은 앞서 다룬 배치와 색칠의 원리를 실전의 갱신 상황에 적용한 것이다. 다음 편에서는 이 갱신 비용을 포함해, 화면이 한 프레임 안에 부드럽게 이어지기 위한 렌더링 성능 전반을 다룬다.