우리가 프론트엔드를 공부하면서 바이블마냥 익히는 과정이 있습니다.
흔히 google.com 혹은 naver.com에 접속했을 때 일어나는 과정에 대해서 설명해주세요.라는 질문에 나오는 과정입니다. (아래 이미지 참조)
처음 이 개념을 공부할 때는 렌더 트리를 만드는 과정에 집중했습니다.
DOM, CSSOM이 어떤 속성을 가지는지, 병렬로 이뤄지는지 등 개념 관련하여 공부하고, 파싱 블로킹을 막기 위해 각 파일은 어느 위치에 삽입하고 스크립트는 어떤 속성을 가져야 하는지 등 렌더 트리 과정을 최적화하기 위한 방법도 익혔습니다.
레이아웃 이후의 과정은 개념을 제외하면 리렌더링에 좀 더 유리한 스타일 속성 (e.g. transform, opacity) 만 몇 개 보고 넘어갔었는데, 얼마 전 서비스의 최적화가 불가피해지며 페인트 과정에 대해 심도 있는 공부를 하게 되었습니다 🥲
⚠️ 아래 내용은 Paint 과정에 집중되어 있습니다.
Paint 과정 분석
Paint는 크게 두 가지로 분류할 수 있습니다.
브라우저는 최초 렌더링 이후 화면이 바뀔 때 페인트 과정을 반복하게 되는데, 이를 가리키기 위해 '최초 페인트'와 '리페인트' 두 가지로 페인트 과정을 분류합니다.
MDN1에 따르면, 최초 페인트는 브라우저가 처음으로 픽셀을 화면에 렌더링해서 본문의 기본 배경색과 시각적으로 다른 모든 항목 렌더링하는 사이의 시간입니다.
요약하면 '픽셀 렌더링'이라고 볼 수 있습니다.
그럼 Paint는 실제 브라우저에 그려지는 단계이라고 볼 수 있을까요?
엄밀히 따지자면 No입니다. Paint는 몇 가지 단계가 포함되어 축약하고 있기 때문입니다.
여기에서 Paint 단계를 좀 더 세분화해볼까요?
- Paint
- Raster (new!)
- Composite (new!)
Raster와 Composite이라는 새로운 단계가 생겼습니다.
Paint와는 별개지만, 기본 브라우저 렌더링 과정을 접할 때 Paint과 합쳐서 다루기 때문에 잘 드러나지 않는 단계이기도 합니다.
그럼 각 단계의 역할을 간단하게 재정의해보겠습니다.
| 과정 | 역할 |
|---|---|
| Paint | 렌더 트리를 기반으로 어떤 색, 글자, 테두리, 그림자 등을 그릴지 벡터 정보를 생성하는 과정입니다. 이 단계까지는 실제 픽셀이 화면에 그려진 것이 아니라, "무엇을 어떻게 그릴지"가 정리된 상태입니다. |
| Raster | Paint 결과를 Raster Thread (CPU) 에서 비트맵으로 변환합니다. (GPU에 위임되기도 합니다.) 여기에서 픽셀 단위 데이터가 만들어집니다. |
| Composite | Raster에서 생성된 이미지를 GPU 메모리에 올리고, 여러 레이어를 합성합니다. 최종적으로 디스플레이에 출력하는 역할을 담당합니다. |
여기에서 중요한 점이 있습니다. 바로 각 단계의 의존성입니다.
Raster 단계는 Paint 단계에서 전달되는 벡터 정보를 픽셀로 쪼개는 후속 단계이기 때문에, Paint 없이 Raster만 발생할 수는 없습니다.
비슷한 맥락으로, Paint 단계가 트리거되면 Raster 단계는 자동으로 뒤따라 발생합니다. 즉, Paint -> Raster 발생은 필연적입니다.
다만 Composite 단계는 이전 단계에 의존하지 않고 트리거할 수 있는 몇 가지 속성이 있습니다.
보통 Composite 단계는 GPU에서 일어나기 때문에, Composite 단계를 트리거하는 몇몇 속성 (e.g. transform, opacity) 을 GPU 가속 속성이라고 말합니다. 앞선 단계를 건너뛰고 GPU 합성 단계에서만 처리되기 때문에 어느 정도 최적화가 가능하기 때문입니다.
각 단계를 좀 더 분석해보겠습니다.
Paint 단계
Paint 단계는 CPU에서 동작합니다.
DOM, CSSOM을 조합해서 만들어진 렌더 트리를 기반으로 어느 위치에 그려질 지 Layout 단계에서 결정되고, 이어지는 Paint 단계에서 각 요소가 어떤 색, 그림자 등을 가질 지 스타일 정보를 주는 과정을 수행합니다.
Paint 단계를 트리거하는 대표적인 CSS 속성은 아래와 같습니다.
배경
- background-color
- background-image
- background-position
- background-repeat
- background-size
- background-clip
- background-origin
테두리
- border (width, style, color)
- border-radius
- outline, outline-offset
그림자
- box-shadow
- text-shadow
텍스트
- color
- text-decoration, text-decoration-color, text-decoration-style
시각 효과
- visibility
- mix-blend-mode
- isolation
픽셀 연산 계열
- filter (blur, brightness, contrast 등)
- clip-path
- mask
단, Paint 단계를 트리거하는 속성 중 몇 가지에 해당하여 최신 브라우저에서는 GPU에서 바로 처리할 수 있도록 최적화했습니다.
1. filter
blur, brightness, contrast, drop-shadow, grayscale, sepia 등이 있습니다.
요소의 픽셀 데이터를 실시간으로 변환합니다.
GPU로 계산할 수 있지만, 적용 면적이 클수록 GPU 부하가 큽니다. 때문에 작은 카드나 이미지 썸네일에는 무난하지만, 페이지 전체 배경에는 위험할 수 있습니다.
2. mask, -webkit-mask
요소를 마스크 이미지 / 패턴으로 잘라냅니다.
모양 자체가 픽셀 단위로 바뀝니다.
3. clip-path
circle, polygon, inset 등이 있습니다.
mask와 마찬가지로 요소를 정해진 모양으로 잘라냅니다.
그렇다고 해서 위 속성들이 Paint 단계를 유발하지 않는 것은 아닙니다.
브라우저의 파이프라인은 "이 속성이 픽셀 단위 이미지를 바꾼다"라고 판단되면 Paint 플래그를 세웁니다.
위 속성들은 픽셀 모양 자체를 바꾸기 때문에 Paint 단계 트리거로 기록되는 것입니다.
즉, GPU에서 계산을 해도 Paint 단계가 건너뛰어지지는 않고, Raster 비용을 GPU에 오프로드하는 형태라고 이해하면 좋습니다.
CPU -> GPU 위임의 효과는 크게 두 가지로 볼 수 있습니다.
- CPU 부하 감소
- GPU의 병렬 연산 성능 활용
실제 무거운 비용은 Raster 단계에서 발생하기 때문에, 이 연산을 GPU에 위임함으로써 성능 향상을 기대하는 것입니다.
하지만 모든 경우에 GPU에서 처리되는 건 아닙니다. clip-path: path() 같은 복잡한 벡터는 여전히 CPU 연산에 의존할 수 있습니다.
정리하면, Paint 단계는 요소의 시각적 정보 (색, 그림자, 테두리 등) 를 벡터 형태로 정리하는 과정이며, 어떤 속성을 변경하느냐에 따라 GPU가 일부 연산을 도와주더라도 Paint 단계 자체는 생략되지 않고 항상 Raster 단계로 이어집니다.
Raster 단계
Raster 단계는 Paint 이후에 나온 벡터 정보를 실제 픽셀 비트맵으로 변환하는 과정입니다.
화면에 출력하기 위해서는 반드시 픽셀 단위 버퍼가 필요하기 때문에, Raster 단계를 건너뛸 수는 없습니다.
즉, Paint 단계에서는 무엇을 어떻게 그릴지 정리했다면, Raster 단계에서는 그 설계도를 토대로 실제로 그릴 픽셀 데이터를 만든다고 볼 수 있습니다.
Raster 단계는 "타일링" 이라는 개념을 도입하여, 스크롤이나 화면 일부를 효율적으로 갱신할 수 있도록 합니다.
타일링
브라우저는 문서 전체를 한 번에 래스터화하지 않고, 여러 개의 타일 단위로 쪼갭니다.
그렇게 해야 스크롤 시 보이는 영역만 우선 처리할 수 있고, 뷰포트 밖의 영역은 나중에 천천히 계산하거나 아예 생략하여 최적화할 수 있습니다.
특히 고해상도 디스플레이일수록 타일 하나에 들어가는 픽셀 수가 많아지므로, 같은 레이아웃이더라도 래스터 비용이 증가합니다.
때문에 만약 Performance 탭을 통해 리페인트 영역을 확인했을 때 의도한 부분보다 더 큰 컨테이너가 리페인트된다면, 이는 최적화할 여지가 있는 사항으로 볼 수 있습니다.
Raster 단계는 전통적으로 CPU Raster Thread에서 처리됩니다.
CPU Raster Thread
메인 스레드와 분리된 별도의 워커 스레드로 동작합니다.
문서 전체를 한 번에 처리하지 않고, 큰 레이어를 타일 단위로 잘라 병렬로 처리합니다. 또한 브라우저는 여러 개의 Raster Thread를 동시에 실행하여 멀티코어 CPU를 활용합니다.
연산량이 많을수록 CPU 부하가 커지며, 고해상도 디스플레이나 큰 면적에 무거운 필터 효과를 쓰면 프레임 드랍의 원인이 되기도 합니다.
또한, 위에서도 다루었던 일부 무거운 연산에 대해서는 Raster Thread 대신 GPU Shader로 오프로드하여 더 빠르게 처리하기도 합니다.
(이 경우에도 Raster 단계가 사라졌다고 이해하기 보다는, 단순히 연산을 CPU 대신 GPU에서 실행한다고 보는 것이 더 정확합니다.)
GPU Shader
그래픽 연산을 담당하는 GPU의 작은 프로그램 단위로, 입력된 데이터를 병렬 연산을 통해 빠르게 변환하거나 효과를 적용합니다.
ex) Vertex Shader -> 좌표, 기하 정보 반환
ex) Fragment Shader (Pixel Shader) -> 픽셀 단위 색상, 텍스처, 효과 계산
래스터 연산을 GPU로 전환함으로써 얻는 이점은 아래와 같습니다.
- 픽셀 단위 연산을 병렬로 수행하므로 필터 효과를 빠르게 처리할 수 있습니다.
- CPU 부하를 줄이고 프레임 유지에 유리합니다. Raster Thread 과부하로 인한 프레임 드랍을 최소화할 수 있습니다.
단, GPU 성능에 의존하기 때문에 큰 면적에서는 여전히 성능 병목이 발생할 수 있습니다.
또한 모든 연산이 GPU에서 가능한 것이 아니기 때문에, 복잡한 벡터 처리는 여전히 CPU에 남아 있기도 합니다.
위처럼 기본적인 최적화가 고려되어 있더라도 Raster 단계는 연산 자체가 무거운 편에 속하기 때문에, 비용이 너무 커지지 않도록 관리가 필요합니다.
Raster 단계의 비용이 커지는 경우는 크게 네 가지로 분류할 수 있습니다.
첫 번째는 넓은 면적 리페인트 입니다.
큰 배경 영역이나 이미지가 다시 그려져야 할 때 래스터 비용은 급격히 증가합니다.
만약 전체 배경에 블러를 적용했다고 가정했다면, 블러가 변경될 때 브라우저는 화면의 전체 픽셀을 다시 계산해야 하기 때문입니다.
GPU 셰이더로 연산하더라도 픽셀 자체의 수가 많기 때문에 연산량이 폭증합니다.
두 번째는 빈번한 타일 업데이트입니다.
브라우저는 문서를 타일 단위로 쪼개어 래스터를 수행합니다.
스크롤이 빠르게 일어나거나 무한 스크롤이 이어질 경우, 뷰포트 밖의 새로운 타일이 계속해서 래스터화됩니다.
이 상황에서는 타일 생성 -> 래스터화 -> GPU 업로드가 반복되며 성능 병목을 만들 수 있습니다.
세 번째는 고해상도 환경입니다.
같은 뷰포트 크기라도 픽셀 밀도가 2, 3배 높은 디스플레이에서는 타일 하나에 들어가는 픽셀 수가 훨씬 많습니다. (dpr 수치로 확인 가능. 보통 Retina에서는 2배 정도)
이 때문에 래스터 비용이 선형 이상으로 증가할 수 있습니다.
네 번째는 불필요한 레이어 증가입니다.
will-change, translateZ 같은 속성은 합성 레이어를 분리해 Composite 단계에서만 처리할 수 있도록 최적화합니다.
합성 레이어를 만들면 브라우저는 그 레이어의 비트맵을 준비해야 하기 때문에, 레이어 분리는 새로운 래스터화 대상을 추가하는 동작을 유발합니다.
원래는 하나의 큰 레이어로 묶여서 한 번만 래스터되던 영역이 분리되면서, 각각의 레이어가 별도의 비트맵 타일로 래스터화되어 총 래스터 작업량이 증가할 수 있습니다.
정리하면, Raster 단계는 Paint 단계에서 정리한 벡터 정보를 실제 픽셀 비트맵으로 생성하는 과정입니다. 브라우저는 이 과정을 타일 단위로 병렬 처리하고, 경우에 따라 GPU에 연산을 위임해 성능을 높이기도 합니다. 그러나 넓은 영역, 잦은 타일 생성, 고해상도 환경, 불필요한 레이어 증가는 여전히 큰 부담이 될 수 있기 때문에 관리가 필요합니다.
Raster 단계는 최종적으로 Composite 단계에서 합성될 비트맵을 준비하는 다리 역할을 하므로, 이 단계의 비용을 줄이지 못하면 화면 최적화 전반에 한계가 생길 수 있습니다.
Composite 단계
Composite 단계는 Raster 단계에서 생성된 비트맵을 GPU 메모리에 올리고, 여러 레이어를 합성해 최종적으로 화면에 출력하는 과정입니다.
즉, 앞선 단계에서 준비된 픽셀 데이터를 조립해 하나의 완성된 프레임을 만드는 역할을 합니다.
브라우저는 요소를 합성 레이어 단위로 나누어 GPU에서 처리합니다.
합성 레이어
브라우저가 Composite 단계에서 GPU로 따로 관리할 필요가 있다고 판단한 요소 집합입니다.
브라우저에 따라 세부 차이는 있지만, 공통적으로 아래과 같은 경우에 합성 레이어가 생성됩니다.
- Composite-only 속성
transform, opacity, will-change 등이 적용된 요소- 하드웨어 가속 강제
translateZ(0), perspective 같이 3D 변환을 일으키는 속성- 비디오 / Canvas / 플러그인 콘텐츠
<video>, <canvas> 같은 요소는 기본적으로 별도 레이어로 관리- z-index stacking context 분리
새로운 stacking context가 필요할 때 레이어로 승격될 수 있음- 브라우저 최적화 내부 판단
스크롤 컨테이너나 fixed 요소처럼 독립적으로 갱신이 자주 필요한 경우
합성 레이어는 기본적으로 DOM 요소 단위지만, 무조건 요소 하나 === 하나의 합성 레이어는 아닙니다.
브라우저는 렌더링 효율성을 위해 독립적으로 GPU에서 관리하는 것이 이득이라고 판단할 때만 레이어로 승격합니다.
Composite 단계에서는 여러 레이어를 쌓아 올리듯 조합하면서, transform, opacity, z-index 같이 GPU 친화적인 속성을 반영해 최종 이미지를 구성합니다.
또한, 합성에 GPU의 텍스처 블렌딩을 활용하기 때문에 Paint, Raster 단계보다 훨씬 가볍게 수행됩니다.
그러나 합성 레이어가 많아진다고 무조건 성능이 좋아지지는 않습니다. 앞서 다뤘듯, 레이어마다 별도의 래스터화와 GPU 메모리 할당이 필요하기 때문입니다.
실제 레이어 구성을 확인하며 자주 변경되는 요소에만 레이어 승격을 유도하는 습관을 들이면, 적절한 성능 최적화를 의도할 수 있습니다.
또한, Composite 단계만 트리거하는 속성을 사용하는 것도 리페인트 최적화에 좋습니다.
Composite 트리거 속성은 아래와 같습니다.
transform
- 요소의 좌표계를 변경하는 속성
- 기존 픽셀 비트맵을 GPU에서 직접 이동·회전·크기 변환하기 때문에 Paint나 Raster를 다시 거치지 않음
opacity
- 요소의 투명도를 제어하는 속성 (0 ~ 1)
- GPU가 기존 비트맵의 알파값을 조정하는 방식으로 처리하기 때문에 Paint나 Raster를 다시 거치지 않음
이 속성들은 Paint나 Raster를 거치지 않고 GPU 합성만 다시 하기 때문에 애니메이션 구현에 특히 유리합니다.
CSS 애니메이션을 구현할 때 transform과 opacity가 자주 권장되는 이유이기도 합니다. 보통 권장 이유로 리플로우를 생략하고 리페인트만 발생한다고 들지만, 세부적으로 들어가면 이미 그려진 레이어를 Composite 과정을 통해 재합성만 하기 때문에 많은 비용이 세이브됩니다.
또한 이와 별개로 자주 변경되는 요소를 별도의 합성 레이어로 분리하는 방법도 있습니다.
바로 will-change 속성입니다.
will-change는 쉽게 말해서 "이 속성이 곧 변경될 거야"라는 힌트를 브라우저에 주는 속성입니다.
만약 transform, opacity가 변경되는 요소라면 합성 레이어로 올려 Composite-only 처리를 할 수 있고, Composite 트리거 속성은 아니지만 빈번한 주기로 변경되는 속성을 가진 요소에 적용했을 때도 리플로우 -> 리페인트 영향의 범위를 최소화할 수 있습니다.
다만 will-change를 적용한 요소가 무조건 별도의 합성 레이어로 승격되는 것은 아니라서, 적절한 판단 후 사용이 필요합니다.
Composite 단계에서는 렌더 트리와 별개로 레이어 트리라는 구조가 만들어집니다.
Paint, Raster 단계에서는 렌더 트리를 사용하지만, Composite 단계에서는 GPU 합성을 위해 합성 레이어 단위의 별도 트리가 필요합니다. 그 트리가 레이어 트리입니다.
레이어 트리
레이어 트리는 어떤 요소가 독립적으로 GPU에서 관리되어야 하는지를 기준으로 구성됩니다.
예를 들어 transform, opacity처럼 Composite-only 속성이 적용된 요소나 fixed처럼 독립 갱신이 필요한 요소는 렌더 트리에서 분리되어 레이어 트리의 독립 노드로 승격됩니다.
이렇게 만들어진 레이어 트리는 단순한 나열이 아니라, 합성 순서까지 정의합니다.
레이어의 합성 순서는 우리가 흔히 다루는 z-index와 쌓임 맥락 규칙에 따라 결정됩니다.
- 새로운 쌓임 맥락이 생기면, 해당 요소와 자식들은 하나의 레이어 그룹으로 묶여서 합성 순서가 보장됩니다.
- z-index 값은 같은 컨텍스트 내부에서 레이어의 합성 순서를 결정합니다.
- 최종적으로 GPU는 레이어 트리를 순회하며 순서대로 레이어를 합성하고, 이 과정에서 오버드로우 같은 성능 비용이 발생할 수 있습니다.
Composite 단계는 Paint, Raster에 비해 가벼운 단계지만, 그렇다고 비용이 전혀 없는 것은 아닙니다. 단순히 레이어를 겹쳐 붙이는 것 이상으로 몇 가지 연산이 수행되기 때문입니다.
GPU 메모리 업로드: Raster 단계에서 만들어진 비트맵 타일을 GPU 텍스처로 전송합니다.블렌딩: opacity, mix-blend-mode 등에 따라 레이어를 픽셀 단위로 혼합 처리합니다.오버드로우: 여러 레이어가 같은 픽셀 영역을 덮으면 불필요하게 중복 계산이 발생합니다.
그렇기에 Composite 트리거 속성을 사용할 때는 적절한 우선순위를 따져보고 적용하는 것이 중요합니다.
정리하면, Composite 단계는 최종 출력을 위한 합성 과정이자, 부드러운 애니메이션 구현에 중요한 단계입니다. 독립적으로 트리거될 수 있고, GPU를 사용하기 때문에 이 특징을 활용한 최적화가 가능합니다. 하지만 Paint, Raster 단계에서 이미 병목이 발생한다면 Composite만으로는 성능을 개선하기 어렵기 때문에 앞선 두 단계의 중요도가 훨씬 높습니다.
브라우저 렌더링은 흐름 전체를 고려하면서, 각 단계의 트리거 속성을 전략적으로 활용하는 것이 좋습니다.