◼ FrontEnd/Web & HTML, CSS

[Web] 브라우저 렌더링 파이프라인에 대하여 Part 2

SangYoonLee (SYL) 2025. 7. 14. 18:07
반응형

이번 포스트는 저번 포스트에 이어 브라우저 렌더링 파이프라인(이하 렌더링 파이프라인)의 Pre-Paint 과정부터 이어 설명해보겠습니다. 포스트 작성이 늦어진 이유는, 여럿 있지만, 우선 제가 여행을 다녀왔고, 코딩테스트 캠프 참여 및 프로그래머스 데브코스 선발 준비 등 여러 일들을 같이 하다 보니 기술 공부 및 포스트 작성이 미뤄졌기 때문입니다.

변명은 이 쯤만 하고..ㅜㅜ 시작해 보겠습니다.

 


먼저 렌더링 파이프라인의 과정을 다시 실펴봅시다.

1. Parse: HTML을 파싱하여 DOM 트리 구축
2. Style: CSS를 파싱하여 CSSOM 트리 구축 (+ JS 파싱)
3. Layout: DOM 트리와 CSSOM 트리를 결합해 Layer Tree 구축
4. Pre-Paint: 실제 칠해야 할 노드들만을 추리는 과정
5. Paint: 추려진 노드들을 그리는 과정
6. Layerize: 독립 레이어들로 쪼개서 레이어 트리를 생성
7. Commit: 메인 스레드가 만든 레이어 트리를 Compositor Thread로 복사
8. Tiling: 효율을 위해 레이어를 타일 객체로 나누는 과정
9. Raster: 타일들을 픽셀 비트맵으로 변환
10. Activate: 비트맵을 바탕으로 새 레이어를 화면에 적용하는 과정
11. Aggregate: Compositor Frame을 묶는 과정
12. Display: GPU 메인 스레드가 Compositor Frame 안의 명령들을 순서대로 실행하여 화면을 그리는 과정

 

이전 포스트에서는 파싱(Parse) 과정부터 레이아웃(Layout) 과정까지 진행하며 브라우저가 HTML, CSS, JS 등의 문서를 해석할 수 있는 구조로 바꾸는 여정을 살펴보았습니다. 이번 포스트에서는 그 다음 과정인 Pre-Paint부터 Commit 과정까지 알아보겠습니다.

 


4. Pre-Paint

레이아웃 단계가 끝나면 브라우저는 화면 속 모든 요소에 좌표표, 쉽게 말해 ‘이 상자는 x = 120px, y = 48px 지점에 폭 300px, 높이 150px로 놓인다’라는 배치 좌표 정보',를 만들어 둡니다. 화면을 그리기 위한 구체적인 설계도 같은 개념입니다. 이 좌표표는 각 요소마다 존재하며, 나중 단계를 위해 위치·크기·회전 각도 같은 기하 정보가 소수점 단위로 기록되어 있습니다. 브라우저는 곧바로 픽셀 작업에 들어가기 전 프리-페인트(Pre-Paint) 라는 짧은 절차를 먼저 실행해 “정말로 다시 칠해야 하는 곳이 어디인지”를 골라냅니다.

 

Pre-Paint가 가장 먼저 하는 일은 변화 감지입니다. 브라우저는 이번 프레임에서 갱신된 좌표표와 바로 이전 프레임 좌표표를 빠르게 대조합니다. 두 표를 비교했을 때 위치나 크기가 바뀐 요소가 있으면, 그 영역을 네모난 ‘무효화 사각형(invalidate rect)’으로 표시하고 “여기는 반드시 새로 칠해 달라”는 표식을 붙입니다. 반대로 좌표가 그대로라면 해당 영역은 건드리지 않고 넘어갑니다. 이를 통해 시간과 전력을 아낄 수 있는 것입니다.

 

두 번째로 하는 일은 칠하지 않아도 되는 변화 걸러내기입니다. 특정 CSS 속성, 예를 들어 transform(회전·이동)이나 opacity(투명도)는 실제 픽셀을 다시 칠하지 않아도 GPU 합성 단계에서 레이어를 겹치는 것만으로 화면을 업데이트할 수 있습니다. 이런 속성이 바뀌었을 경우 경우, 브라우저는 “페인트 생략, 합성 단계에서 처리”라는 별도 표식을 달아 Paint 과정을 생략하게 만듭니다. 이러한 작업 덕분에 회전·이동·투명도 같은 애니메이션은 복잡한 페이지에서도 끊김없이 부드럽게 움직일 수 있는 것입니다.

 

마지막으로 Pre-Paint는 Property Tree를 점검합니다. 여기서 Property Tree는 Blink 엔진이 각 요소마다 계산된 스타일 값을 “스크롤·클리핑·효과·컨테이너” 네 갈래로 나누어 따로 저장해 놓은 내부 자료구조로, 앞의 Style 과정에서 한 번 언급한 바 있습니다. 브라우저는 페이지에서 변화가 감지된 갈래, 예컨대 스크롤 위치가 변해 새 영역이 보이게 된 경우, 만 다시 계산하고, 나머지 갈래는 그대로 재사용합니다. 이렇게 해서 정리된 결과인 '무효화 사각형 목록'과 '합성 전용 표식'을 Paint 단계에 넘기면, 브라우저는 꼭 필요한 부분만 다시 칠하고 나머지는 그대로 둘 수 있어 작업량을 크게 줄일 수 있습니다.

 


5. Paint

Layout과 Pre-Paint 과정까지 진행되면 브라우저는 “어떤 요소가 어디에 위치하고, 그중 어느 부분을 다시 그려야 할지”까지를 결정한 상태입니다. 그렇지만 여전히 실제 픽셀은 한 점도 찍지 않았습니다. 이제 페인트(Paint) 단계가 시작되는데, 이 과정의 핵심 목표는 "화면에 무엇을 그릴지, 차례대로 정리한 지시서"를 만드는 것입니다.

 

먼저 브라우저는 요소마다 확정된 글꼴·색·굵기·그림자·배경 이미지 같은 스타일 정보를 읽습니다. 그런 다음 Skia라는 내부 그래픽 라이브러리에 “이 좌표 사각형을 빨간색으로 채우기”, “저기 텍스트 ‘로그인’을 이 폰트로 그리기”처럼 아주 구체적인 명령을 한 줄씩 적어 나갑니다. 이 명령을 모아 디스플레이 리스트(display list)라는 불변 자료의 형태로 보관합니다.

 

Pre-Paint 과정에서 무효화 영역이 작게 잡혔다면 페인트는 그 사각형 내부 요소만 대상으로 삼습니다. 그렇다면 나머지는 지난 프레임에서 이미 만든 디스플레이 리스트를 그대로 재사용하므로 시간을 절약할 수 있습니다. 반면에 큰 배경 이미지를 자주 바꾸거나 그림자 값을 프레임마다 바꾸는 상황이라면, 리스트 전체를 다시 만들어야 해서 페인트 시간이 길어집니다. 개발자 도구로 성능을 녹화해 보면, 이러한 차이가 Paint 막대 길이로 바로 드러나는 이유가 여기에 있습니다.

 

리스트를 다 만들고 나면 브라우저는 화면을 퍼즐 조각처럼 잘게 나눕니다. 이를 ‘타일’이라고 하는데, 스크롤이나 줌을 할 때 이미 그려 둔 조각을 재활용하고 새로 보이는 조각만 GPU에게 맡깁니다. 이렇게 타일 형식으로 준비해 두면 GPU가 필요한 순간에 빠르게 붙여 넣을 수 있어, 복잡한 페이지도 손가락 스크롤에 바로 반응해서 화면을 그려낼 수 있습니다.

 

결국 페인트 단계는 스타일과 위치 정보를 실제로 찍을 픽셀 명령으로 변환하는 작업입니다. 개발자가 transform·opacity처럼 페인트를 건너뛸 수 있는 속성 위주로 애니메이션을 구성하면, 이 단계가 거의 이루어지지 않아 60 FPS 이상을 꾸준히 유지하기 쉽습니다. 반대로 화면의 큰 부분을 자주 다시 칠해야 하는 설계라면, 페인트 병목 때문에 애니메이션이 끊길 수 있으니 미리 DevTools로 무효화 영역과 Paint 시간을 확인해 보는 것이 좋습니다.

 


6. Layerize

Paint 단계에서 브라우저는 “무엇을 어떻게 그릴지”를 한 줄씩 정리한 Display List를 만들었습니다. 하지만 이 지시서를 그대로 사용하면, 화면 일부만 움직여도 거기에 얽힌 모든 픽셀을 다시 칠해야 해서 비효율적입니다. 그래서 바로 이어지는 레이어라이즈(Layerize) 단계에서는 “자주 바뀌는 것”과 “거의 고정된 것”을 미리 나누어 넣습니다. 흔히 ‘레이어’라고 부르는 이 독립된 화면을 활용하면, 나중에 화면에 움직임이 생기더라도 해당 레이어만 다시 합성하면 되므로 나머지 화면은 수정하지 않아도 됩니다.

 

브라우저가 레이어로 승격할 대상을 고르는 기준은 명확합니다. 먼저 position:fixed 헤더처럼 스크롤과 무관하게 항상 같은 자리에 머무는 요소는 레이어로 분리하는 것이 좋습니다. 배경은 위아래로 움직이지만 헤더는 화면에 붙어서 가만히 있어야 하므로, 이 둘을 따로 떼어 놓지 않는다면 스크롤할 때마다 둘 모두 다시 그려야 합니다. <video> · <canvas>처럼 스스로 프레임을 바꾸는 태그도 레이어 후보입니다. 그리고 transform 애니메이션을 쓰거나 will-change: transform처럼 “곧 움직일 것”이라는 힌트를 준 요소도 미리 레이어로 만들어 두면, 애니메이션이 시작될 때 추가 작업이 줄어듭니다.

 

레이어가 많으면 화면 갱신이 가벼워지는 것 같지만, 실제로는 꼭 그렇진 않습니다. 레이어가 늘어나면 레이어 하나마다 GPU 텍스처가 필요하므로 메모리 사용량이 늘고, 텍스처를 그래픽 카드로 복사하는 시간 역시 길어집니다. 또한 너무 많은 레이어는 오히려 다른 작업을 밀어내어 전체 FPS를 떨어뜨릴 수 있습니다. 크롬 내부 알고리즘은 이러한 장단점을 고려하여 적절한 균형점을 자동으로 찾으려 하지만, 예상보다 레이어가 과하게 생성될 때도 있습니다. 이때는 개발자 도구의 Layers 패널을 열어 레이어 수와 크기를 직접 확인하고, 불필요한 will-change를 지우거나 복잡한 그림자를 단순화해 과도한 승격(레이어 생성)을 막을 수 있습니다.

 

Layerize 단계가 마무리되면, 메인 스레드는 이렇게 분류된 레이어와 디스플레이 리스트를 컴포지터 스레드로 넘깁니다. 이제 컴포지터는 각 레이어를 작은 타일로 더 쪼개고, GPU가 한 화면을 가장 효율적으로 합성할 순서를 계산합니다. 즉 Layerize 과정은 “앞으로 움직일 부분을 미리 따로 떼어, GPU가 빠르고 가볍게 합성하도록 돕는 준비 단계”라고 이해하시면 될 듯 합니다.

 


7. Commit

레이어라이즈가 끝나면 메인 스레드는 “화면에 무엇을 어떻게 그릴지”라는 청사진을 이미 다 마련해둔 상태입니다. 이제 그 청사진을 컴포지터 스레드에게 넘기는 절차가 필요합니다. 이 구간을 커밋(Commit) 단계라고 부릅니다. 커밋은 한마디로, 메인 스레드 메모리에 있는 디스플레이 리스트·레이어 정보·최신 스크롤·클리핑·효과 값을 그대로 복사해 컴포지터 스레드 쪽 메모리로 전달하는 작업입니다.

 

복사본이 완전히 넘어가기 전까지는 GPU가 새 장면을 합성할 수 없기 때문에, 커밋이 느려지면 곧바로 다음 프레임 일정이 밀려 화면이 끊기는 드롭 프레임이 생깁니다. 브라우저는 이 지연을 최소화하고자 이중 트리 전략을 사용합니다. 컴포지터 스레드 안에는 항상 두 개의 레이어 트리가 존재하는데, 화면을 실제로 그리고 있는 것이 active 트리, 막 복사해 온 최신 데이터가 담기는 것이 pending 트리입니다. 메인 스레드가 커밋을 시작하면, 컴포지터 스레드는 active 트리로 계속 이전 장면을 그리면서 동시에 데이터를 받아 pending 트리를 채웁니다. 그리고 GPU 타일 분할과 래스터 준비가 끝나는 순간, 두 트리의 포인터만 바꿔 연결하여 새 내용이 화면에 바로 적용됩니다. 두 트리가 바뀌는 데 걸리는 시간은 순간적이라 화면 전환을 전혀 체감할 수 없다고 합니다.

 

그러나 이중 트리를 사용한다 하더라도 데이터 양이 너무 많다면 커밋 복사 자체 과정에서 병목이 발생합니다. 예를 들어 무한 스크롤 사이트에서 새 글을 수백 개씩 한 번에 추가하거나, 큰 이미지를 잦은 인터랙션으로 교체하면 레이어 수와 디스플레이 리스트 크기가 급격히 불어납니다. 이때는 메인 스레드가 “copy to compositor”라는 굵은 작업을 수행하느라 수 밀리초씩 블로킹될 수 있습니다. Chrome DevTools의 Performance 패널을 열어 ‘Commit’ 구간이 길게 보인다면, 삽입하는 DOM 양을 줄이거나, 필요 없는 will-change를 제거해 레이어 수 자체를 낮추는 것이 가장 빠른 해결책입니다. 그리고 커밋 단계가 제시간에 끝나야 컴포지터 스레드는 곧바로 그 다음 과정인 타일 분할(Tiling)래스터(Raster)를 시작할 수 있습니다.

 

결과적으로 커밋은 “메인 스레드가 계산한 최신 장면을 안전하고 빠르게 컴포지터에게 넘겨, GPU 합성을 준비시키는 관문”이라고 이해하면 됩니다. 이 관문에서 막힘이 없을수록 스크롤·애니메이션이 부드럽고, 60 FPS 또는 120 FPS 같은 높은 주사율을 안정적으로 유지할 수 있습니다.

 


내용이 길어지는 관계로, 다음 과정인 Tiling부터는 다음 포스트에 이어 작성하도록 하겠습니다. 아마 다음 포스트에서 나머지 모든 과정을 다룰 수 있을 것 같습니다.

 

반응형