레이아웃에서 페인트로
이전 글에서 렌더 트리의 각 노드에 정확한 위치와 크기가 결정되는 레이아웃 과정을 살펴봤습니다. 이제 브라우저는 이 기하학적 정보를 바탕으로 실제 픽셀을 화면에 그리는 단계로 넘어갑니다.
페인트 단계의 핵심 질문은 두 가지입니다.
- 무엇을 그릴 것인가 , 배경, 테두리, 텍스트, 이미지 등 각 요소의 시각적 속성
- 어떤 순서로 그릴 것인가 , 겹치는 요소가 있을 때 누가 위에 보이는지
이 두 질문의 답이 바로 페인트 레코드입니다.
페인트 레코드
페인트 레코드는 브라우저가 생성하는 저수준 그리기 명령의 목록 입니다. 디스플레이 리스트 (display list) 라고도 부릅니다. 각 명령은 "이 좌표에 이 색으로 사각형을 그려라", "이 위치에 이 폰트로 텍스트를 그려라" 같은 구체적인 지시입니다.
페인트 레코드 예시:
1. drawRect(#ffffff, 0,0, 1024,768) ← body 배경
2. drawRect(#f8f8f8, 102,0, 819,768) ← .container 배경
3. drawBorder(#eee, 1px, 102,33, 819,1) ← h1 하단 테두리
4. drawText("Hello", navy, 24px, 102,24) ← h1 텍스트
5. drawText("Welcome", #333, 16px, 110,60) ← p 텍스트하나의 요소라도 여러 개의 페인트 레코드를 생성할 수 있습니다. 예를 들어 div 하나가 배경색, 배경 이미지, 테두리, 박스 그림자를 모두 가지고 있다면 각각이 별도의 그리기 명령이 됩니다.
페인트 순서와 스태킹 컨텍스트
페인트 순서는 CSS 명세의 페인팅 순서 (painting order) 를 따릅니다. 간략하게 요약하면:
- 루트 요소의 배경과 테두리
- 자식 요소들을 문서 순서대로, 각각에 대해:
- 배경색과 배경 이미지
- 테두리
- 인라인 콘텐츠 (텍스트 등)
z-index가 양수인 positioned 요소
이 순서가 직관적이지 않게 느껴지는 이유는 스태킹 컨텍스트 때문입니다.
스태킹 컨텍스트가 생성되는 조건
스태킹 컨텍스트는 다음 조건에서 생성됩니다.
- 루트 요소 (
html) position이absolute또는relative이면서z-index가auto가 아닌 경우position: fixed또는position: stickyopacity가 1보다 작은 경우transform이none이 아닌 경우will-change에transform,opacity등 특정 속성이 지정된 경우filter가none이 아닌 경우isolation: isolatecontain: layout,contain: paint, 또는contain: strict
/* 모두 새로운 스태킹 컨텍스트를 생성합니다 */
.case-1 { position: relative; z-index: 1; }
.case-2 { opacity: 0.99; }
.case-3 { transform: translateZ(0); }
.case-4 { will-change: transform; }
.case-5 { filter: blur(0px); }
.case-6 { isolation: isolate; }핵심 원칙: 스태킹 컨텍스트 내부의 z-index는 외부에 영향을 줄 수 없습니다. 이것이 z-index: 9999를 줘도 원하는 대로 동작하지 않는 가장 흔한 원인입니다.
z-index가 예상대로 안 되는 이유
.parent-a { position: relative; z-index: 1; }
.parent-b { position: relative; z-index: 2; }
/* child-a의 z-index가 아무리 높아도
parent-a(z:1) 안에 있으므로 parent-b(z:2) 위로 올라갈 수 없습니다 */
.parent-a .child-a { position: relative; z-index: 9999; }
.parent-b .child-b { position: relative; z-index: 1; }브라우저는 먼저 같은 부모 스태킹 컨텍스트 내에서 형제 간 순서를 결정하고, 그 다음에 각 스태킹 컨텍스트 내부를 그립니다. .parent-a의 z-index: 1이 .parent-b의 z-index: 2보다 작으므로, .child-a가 아무리 높은 z-index를 가져도 .parent-b 아래에 그려집니다.
시각화
아래 시각화에서 레이아웃 결과부터 페인트 레코드 생성, 타일 분할, 래스터라이제이션까지의 전체 과정을 단계별로 확인하세요.
래스터라이제이션
페인트 레코드는 "무엇을 어디에 그릴지"를 기술하는 벡터 명령 입니다. 이것만으로는 화면에 표시할 수 없습니다. 실제로 모니터에 출력하려면 각 픽셀의 색상 값을 계산해야 합니다. 이 변환 과정이 래스터라이제이션입니다.
벡터 명령: 비트맵 (픽셀):
drawRect(blue, 2,1, 4,3) → ⬜⬜⬜⬜⬜⬜
⬜⬜🟦🟦🟦🟦
⬜⬜🟦🟦🟦🟦
⬜⬜🟦🟦🟦🟦
⬜⬜⬜⬜⬜⬜래스터라이제이션은 계산 비용이 큰 작업입니다. 텍스트 렌더링의 경우 글리프의 벡터 아웃라인을 서브픽셀 단위로 안티앨리어싱해야 하고, 그림자나 블러 효과는 주변 픽셀까지 고려해야 합니다. 이런 이유로 최신 브라우저는 GPU를 활용합니다.
타일 기반 래스터라이제이션
초기 브라우저는 뷰포트 전체를 한 번에 래스터라이즈했습니다. 하지만 이 방식은 두 가지 문제가 있습니다.
- 메모리 낭비 , 사용자에게 보이지 않는 영역까지 모두 비트맵으로 변환
- 초기 표시 지연 , 전체 페이지 래스터라이제이션이 끝나야 화면에 무언가 표시
현대 브라우저 (Chromium 기준) 는 타일 기반 래스터라이제이션 을 사용합니다.
- 페이지를 256×256 또는 512×512 픽셀 크기의 타일 로 분할
- 각 타일에 우선순위 부여, 현재 뷰포트에 보이는 타일이 가장 높은 우선순위
- GPU의 래스터 스레드 에서 타일별로 병렬 래스터라이즈
- 완성된 타일 비트맵은 GPU 메모리에 저장
+----------+----------+
| 타일 A | 타일 B | 뷰포트 영역: 높은 우선순위 (GPU 먼저 처리)
| (우선) | (우선) |
+----------+----------+
| 타일 C | 타일 D | 뷰포트 근처: 중간 우선순위
+----------+----------+
| 타일 E | 타일 F | 화면 밖: 낮은 우선순위 (스크롤 시 처리)
| (지연) | (지연) |
+----------+----------+스크롤이 발생하면 새로 보이는 영역의 타일이 우선 래스터라이즈되고, 화면에서 벗어난 타일의 비트맵은 필요에 따라 폐기됩니다.
실무 사례
z-index 디버깅
z-index가 기대대로 동작하지 않을 때, 먼저 스태킹 컨텍스트 트리를 파악 하세요.
- Chrome DevTools → Elements 패널에서 문제 요소 선택
- Computed 탭 에서
z-index,position,opacity,transform등 확인 - 부모 요소를 올라가며 스태킹 컨텍스트를 생성하는 속성이 있는지 확인
opacity: 0.99나transform: translateZ(0)같은 "보이지 않는" 스태킹 컨텍스트에 주의
/* 흔한 함정: 부모에 transform이 있으면 자식의 fixed가 깨집니다 */
.parent {
transform: translateX(0); /* 스태킹 컨텍스트 + 컨테이닝 블록 생성 */
}
.parent .modal {
position: fixed; /* 뷰포트가 아닌 .parent 기준으로 배치됨! */
z-index: 9999; /* parent의 스태킹 컨텍스트에 갇힘 */
}스태킹 컨텍스트 함정
will-change 속성은 브라우저에게 최적화 힌트를 주지만, 동시에 새로운 스태킹 컨텍스트를 생성 합니다.
.animated {
will-change: transform; /* 새 스태킹 컨텍스트 생성! */
}마찬가지로 CSS filter, backdrop-filter, mix-blend-mode 등도 스태킹 컨텍스트를 생성합니다. 성능 최적화를 위해 추가한 속성이 레이어 순서를 예상치 못하게 바꿀 수 있으므로 주의가 필요합니다.
리페인트 최소화
페인트는 비용이 큰 연산입니다. 불필요한 리페인트를 줄이려면:
will-change나transform: translateZ(0)를 사용해 별도 레이어로 승격시키면 해당 요소의 변경이 다른 요소의 리페인트를 유발하지 않습니다- Chrome DevTools → Rendering 패널 → "Paint flashing"을 활성화하면 리페인트가 발생하는 영역이 녹색으로 표시됩니다
contain: paint를 사용하면 해당 요소의 자손 변경이 외부 페인트에 영향을 주지 않습니다
/* 자주 변경되는 요소를 별도 레이어로 분리 */
.frequently-updated {
will-change: transform;
/* 또는 */
contain: paint;
}다음 단계
페인트 레코드가 래스터라이즈되어 타일 비트맵이 완성되었습니다. 하지만 아직 화면에 표시되지는 않았습니다. 여러 레이어의 비트맵을 올바른 순서로 합쳐 최종 프레임을 만드는 합성 (Compositing) 단계가 남아 있습니다.
다음 글에서는 합성 레이어, GPU 가속, transform과 opacity가 왜 성능에 좋은지를 살펴봅니다.