브라우저를 덜 일하게 만드는 법 -- CSS 렌더링 성능 심화
progress bar 버벅거림 해결에서 시작해, Layout Thrashing과 모던 CSS 최적화까지
progress bar 버벅거림 해결에서 시작해, Layout Thrashing과 모던 CSS 최적화까지
모바일에서 progress bar가 버벅거렸다. width를 transform: scaleX()로 바꾸니 바로 해결됐다.
한 줄짜리 수정이었지만, 이 경험을 파고들수록 브라우저가 화면을 그리는 구조 전체를 이해해야 한다는 걸 깨달았다.
// Before - 매 프레임마다 reflow 발생
<div style={{ width: `${progress}%` }} />
// After - GPU에서 처리, reflow 없음
<div
className="w-full origin-left will-change-transform"
style={{ transform: `scaleX(${progress / 100})` }}
/>
Chrome DevTools Performance 패널로 비교해보면 차이가 명확하다.
width 버전: Layout 이벤트가 프레임마다 반복, dropped frames 다수 발생transform 버전: Composite만 실행, 프레임 드롭 거의 없음왜 이런 차이가 나는지는 브라우저 렌더링 파이프라인에 정리해두었다. 핵심만 짚으면 이렇다:
width 변경 → Layout부터 다시 계산 → 메인 스레드 블로킹transform 변경 → Composite만 실행 → GPU가 처리모바일은 메인 스레드가 데스크톱보다 훨씬 약하기 때문에, 같은 코드라도 체감 차이가 극명하다.
progress bar 사례는 "잘못된 CSS 속성 선택"이 원인이었다. 하지만 실무에서 더 흔하고, 더 찾기 어려운 문제가 있다. Layout Thrashing이다.
브라우저는 원래 스타일 변경을 모아두었다가 한 번에 처리한다(batch). 그런데 스타일을 변경한 직후 레이아웃 속성을 읽으면, 브라우저는 "지금 당장" 레이아웃을 계산해야 한다.
element.style.width = '200px';
const height = element.offsetHeight; // 여기서 강제 Layout 발생
이게 한 번이면 괜찮다. 문제는 루프 안에서 반복될 때다.
// Bad: 매 반복마다 강제 reflow
elements.forEach(el => {
const height = el.offsetHeight; // read → 강제 Layout
el.style.height = height + 10 + 'px'; // write → 다음 read에서 또 강제 Layout
});
N개의 요소에 대해 N번의 Layout이 발생한다. 요소가 100개면 100번의 reflow다.
// Good: read를 먼저 모아두고, write는 나중에
const heights = elements.map(el => el.offsetHeight); // read 일괄
elements.forEach((el, i) => {
el.style.height = heights[i] + 10 + 'px'; // write 일괄
});
read와 write를 분리하면 Layout 횟수가 크게 줄어든다.
더 안전하게 가려면 requestAnimationFrame으로 write를 다음 프레임으로 미룰 수 있다.
const heights = elements.map(el => el.offsetHeight);
requestAnimationFrame(() => {
elements.forEach((el, i) => {
el.style.height = heights[i] + 10 + 'px';
});
});
DevTools Performance 패널에서 보라색 Layout 블록이 짧은 간격으로 반복되면 Layout Thrashing을 의심해야 한다. "Forced reflow is a likely performance bottleneck" 경고가 뜨면 확실하다.
CSS 속성 선택과 JS 패턴 개선 외에, 브라우저에게 "이 영역은 이렇게 처리해도 돼"라고 힌트를 줄 수 있는 모던 CSS 기능들이 있다.
뷰포트 밖에 있는 요소의 렌더링을 통째로 건너뛰게 한다.
.feed-item {
content-visibility: auto;
contain-intrinsic-size: auto 200px;
}
브라우저는 .feed-item이 뷰포트 밖에 있으면 Layout, Paint를 건너뛴다.
스크롤해서 뷰포트에 들어올 때 비로소 렌더링한다.
contain-intrinsic-size는 렌더링 전 요소의 예상 크기를 알려주는 역할이다.
이걸 빼면 스크롤바가 들쑥날쑥해지는 문제가 생긴다.
긴 목록이나 피드 형태의 UI에서 초기 렌더링 시간을 크게 줄일 수 있다. Chrome 팀의 벤치마크에서는 초기 렌더링 시간이 최대 7배까지 줄어든 사례도 있다.
content-visibility의 기반이 되는 속성이다.
특정 요소의 렌더링 영향 범위를 제한하여, 해당 요소 내부 변경이 외부로 전파되지 않게 한다.
.card {
contain: layout paint;
}
contain: layout → 내부 레이아웃 변경이 외부에 영향을 주지 않음contain: paint → 내부 Paint가 요소 경계 밖으로 번지지 않음contain: strict → size + layout + style + paint 전부 격리 (주의 필요)무한 스크롤 리스트, 카드 그리드, 대시보드 위젯처럼 서로 독립적인 영역이 많은 UI에서 효과적이다.
주의할 점: contain: size를 사용하면 요소가 자식의 크기를 무시하고 0x0으로 축소될 수 있다.
명시적으로 크기를 지정하지 않으면 레이아웃이 깨진다.
will-change는 브라우저에게 "이 속성이 곧 바뀔 거야"라고 알려서 미리 최적화하게 하는 속성이다.
.animating {
will-change: transform;
}
그런데 남용하면 오히려 역효과다.
will-change를 선언한 요소는 별도의 compositor layer로 승격되는데, 이건 GPU 메모리를 먹는다.
모든 요소에 will-change: transform을 걸면 레이어가 폭발적으로 늘어나서 메모리 부족이 생길 수 있다.
원칙은 간단하다:
width, height, top, left 대신 transform, opacity를 사용하고 있는가?content-visibility: auto를 적용했는가?will-change를 필요한 요소에만, 필요한 시점에만 사용하고 있는가?contain을 독립적인 UI 영역에 적용했는가?