브라우저 Reflow와 Repaint (2): 불필요한 렌더링 비용을 줄이는 방법
이 글은 Reflow와 Repaint 시리즈 2편입니다.
- 1편: 브라우저 Reflow와 Repaint (1): 렌더링 파이프라인부터 이해하는 기본 원리
- 3편: 브라우저 Reflow와 Repaint (3): 실무에서 자주 만나는 예제로 완성하기
1편에서 브라우저가 Layout → Paint → Composite 순서로 화면을 그리고, 파이프라인의 앞 단계를 건드릴수록 비용이 커진다는 것을 봤습니다. 이번 글의 질문은 하나입니다. 그래서 어떻게 줄이는가.
reflow 최적화 기법은 검색하면 수십 개가 나오지만, 사실 전부 세 가지 축 중 하나에 속합니다. 개별 기법을 외우는 것보다 이 지도를 먼저 잡는 편이 오래갑니다.
flowchart TD
GOAL[Reflow / Repaint 비용 줄이기]
GOAL --> A["① 횟수 줄이기<br/>같은 일을 덜 하게"]
GOAL --> B["② 범위 좁히기<br/>다시 계산할 영역을 작게"]
GOAL --> C["③ 싼 단계로 옮기기<br/>Layout 대신 Composite"]
A --> A1[읽기/쓰기 분리]
A --> A2[스타일 변경 묶기]
A --> A3[DOM 변경 일괄 처리]
B --> B1[문서 흐름에서 분리]
B --> B2[contain / content-visibility]
B --> B3[크기를 미리 약속]
C --> C1[transform / opacity]
C --> C2[will-change]한눈에 보면
- 최적화의 세 축은 횟수 줄이기, 범위 좁히기, 더 싼 단계로 옮기기입니다.
- layout thrashing은 읽기를 먼저 모으고 쓰기를 나중에 모으는 것으로 해결합니다.
- 스타일 여러 개를 바꿀 때는 인라인 스타일 조작보다 클래스 토글이 관리하기 좋습니다.
- DOM을 대량으로 바꿀 때는
DocumentFragment로 문서 밖에서 조립한 뒤 한 번에 붙입니다. - 애니메이션 대상이 위치·크기라면
top,left,width대신transform과opacity를 씁니다. position: absolute/fixed,contain,content-visibility로 reflow의 전파 범위를 격리할 수 있습니다.will-change는 효과가 있지만 남용하면 메모리로 되돌아옵니다.
축 1: 발생 횟수 줄이기
읽기와 쓰기를 분리한다
1편에서 본 layout thrashing부터 해결하겠습니다. 문제의 코드는 이랬습니다.
// ❌ Before: 읽기와 쓰기가 순회마다 번갈아 나온다
const items = document.querySelectorAll<HTMLElement>('.item');
items.forEach((item) => {
const height = item.offsetHeight; // 읽기 → 강제 레이아웃 계산
item.style.height = `${height + 10}px`; // 쓰기 → 다시 dirty
});해결은 순서를 재배치하는 것뿐입니다. 읽기를 전부 끝낸 다음 쓰기를 시작합니다.
// ✅ After: 읽기 전부 → 쓰기 전부
const items = document.querySelectorAll<HTMLElement>('.item');
// 1단계: 읽기만 (레이아웃은 이미 최신이므로 계산 1번 이하)
const heights = Array.from(items).map((item) => item.offsetHeight);
// 2단계: 쓰기만 (dirty 표시만 쌓이고 계산은 다음 프레임에 1번)
items.forEach((item, i) => {
item.style.height = `${heights[i] + 10}px`;
});두 코드의 차이를 타임라인으로 보면 명확합니다.
❌ Before (항목 200개 기준)
읽기→계산💥→쓰기 | 읽기→계산💥→쓰기 | ... × 200 = Layout 200번
✅ After
읽기 읽기 읽기 ... ×200 | 쓰기 쓰기 쓰기 ... ×200 = Layout 1번하는 일은 같은데 레이아웃 계산이 200번에서 1번으로 줄었습니다. reflow 최적화에서 가장 효과 대비 비용이 좋은 기법이라 이것 하나만 기억해도 절반은 갑니다.
스타일 변경은 묶어서 선언한다
인라인 스타일을 속성별로 조작하는 코드는 흩어지기 쉽고, 중간에 읽기가 끼어들 위험도 커집니다.
// ❌ Before: 변경이 흩어져 있어 사이에 읽기가 끼기 쉽다
el.style.width = '200px';
el.style.height = '100px';
el.style.borderRadius = '8px';
el.style.backgroundColor = 'tomato';// ✅ After: 최종 상태를 클래스로 선언하고 토글만 한다
el.classList.add('card--expanded');.card--expanded {
width: 200px;
height: 100px;
border-radius: 8px;
background-color: tomato;
}1편에서 봤듯 요즘 브라우저는 연속된 쓰기를 알아서 모아 처리하므로, 인라인 스타일 4줄 자체가 reflow 4번을 뜻하지는 않습니다. 그래도 클래스 토글이 나은 이유는 분명합니다.
- 스타일의 최종 상태가 CSS 한 곳에 선언되어 있어 중간에 읽기가 끼어들 구조 자체가 안 됩니다
- JavaScript는 상태 전환만 담당하고 모양은 CSS가 담당하는 분리가 생깁니다
- 나중에 트랜지션을 붙이기도 쉽습니다
DOM 대량 변경은 문서 밖에서 조립한다
리스트에 항목을 여러 개 추가하는 상황을 보겠습니다.
// ❌ Before: 문서에 이미 붙어 있는 노드에 하나씩 추가
const list = document.getElementById('list')!;
data.forEach((item) => {
const li = document.createElement('li');
li.textContent = item.name;
list.appendChild(li); // 매번 라이브 DOM 변경
});appendChild가 즉시 reflow를 일으키는 것은 아니지만, 라이브 DOM을 반복 조작하면 그 사이 어떤 코드든 레이아웃을 읽는 순간 재계산 범위가 커집니다. DocumentFragment를 쓰면 조립을 문서 밖에서 끝낼 수 있습니다.
// ✅ After: 문서 밖에서 다 만들고 한 번에 붙인다
const list = document.getElementById('list')!;
const fragment = document.createDocumentFragment();
data.forEach((item) => {
const li = document.createElement('li');
li.textContent = item.name;
fragment.appendChild(li); // 문서 밖이라 렌더링과 무관
});
list.appendChild(fragment); // 라이브 DOM 변경은 이 한 번flowchart LR
subgraph OFF["문서 밖 (렌더링 대상 아님)"]
F[DocumentFragment] --> L1[li]
F --> L2[li]
F --> L3[li ...]
end
OFF -- "appendChild 1번" --> DOM[라이브 DOM]
style OFF fill:#f1f5f9,stroke:#94a3b8,color:#111
style DOM fill:#bbf7d0,stroke:#16a34a,color:#111같은 원리로, 한 요소에 수십 개의 변경을 가해야 한다면 잠시 display: none으로 렌더 트리에서 빼놓고 작업한 뒤 되돌리는 방법도 있습니다. 다만 이 방법은 그 자체로 reflow 2번을 만들기 때문에 변경량이 정말 많을 때만 이득입니다.
잦은 이벤트는 프레임에 맞춰 정렬한다
scroll, mousemove, resize처럼 초당 수십에서 수백 번 발생하는 이벤트에서 매번 DOM을 만지면 프레임 예산이 금방 바닥납니다. requestAnimationFrame으로 실제 화면 갱신은 프레임당 한 번으로 제한합니다.
let ticking = false;
window.addEventListener('scroll', () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
updateHeader(window.scrollY); // 프레임당 1번만 실행
ticking = false;
});
});이 패턴의 원리와 주의점은 이전에 쓴 requestAnimationFrame 글에서 자세히 다뤘습니다.
축 2: 영향 범위 좁히기
발생 횟수를 줄였다면, 다음은 한 번 일어날 때의 전파 범위를 줄일 차례입니다.
문서 흐름에서 분리한다
1편에서 reflow가 비싼 이유는 부모·형제·자식으로 전파되기 때문이라고 했습니다. 뒤집어 말하면, 문서 흐름에서 빠져 있는 요소는 주변에 영향을 주지 않습니다.
/* 자주 움직이는 요소는 흐름에서 분리 */
.floating-widget {
position: fixed; /* 또는 absolute */
top: 16px;
right: 16px;
}flowchart TD
subgraph FLOW["문서 흐름 (서로 영향을 주고받음)"]
HEADER[header] --- MAIN[main] --- FOOTER[footer]
end
WIDGET["position: fixed 위젯<br/>크기가 바뀌어도 주변에 전파 없음"]
style WIDGET fill:#bbf7d0,stroke:#16a34a,color:#111
style FLOW fill:#f1f5f9,stroke:#94a3b8,color:#111채팅 위젯, 플로팅 버튼, 드래그 중인 요소, 진행률 표시처럼 자주 변하는데 주변 배치와 무관한 UI는 absolute나 fixed로 분리해두면 reflow가 일어나도 그 요소 안에서 끝납니다.
contain으로 격리를 선언한다
CSS contain 속성은 브라우저에게 "이 요소 내부의 변경은 바깥에 영향을 주지 않는다"고 약속하는 선언입니다. 브라우저는 이 약속을 믿고 재계산 범위를 요소 내부로 좁힐 수 있습니다.
.card {
/* 내부 레이아웃·페인트 변경을 카드 안에 가둔다 */
contain: layout paint;
}한 걸음 더 나아간 것이 content-visibility입니다. 화면 밖에 있는 요소는 렌더링 자체를 건너뜁니다.
.long-section {
content-visibility: auto;
contain-intrinsic-size: auto 480px; /* 건너뛴 동안 차지할 예상 높이 */
}| 속성 | 하는 일 | 잘 맞는 곳 |
|---|---|---|
contain: layout |
내부 레이아웃 변경이 바깥으로 전파되지 않음 | 위젯, 카드, 독립 컴포넌트 |
contain: paint |
내부 페인트가 요소 경계를 넘지 않음 | 오버플로가 없는 독립 영역 |
content-visibility: auto |
화면 밖이면 렌더링 자체를 생략 | 긴 피드, 문서형 페이지의 섹션 |
주의할 점은 contain이 말 그대로 약속이라는 것입니다. 내부 요소가 바깥으로 넘칠 수 있는 구조인데 contain: paint를 걸면 넘친 부분이 잘려 보이는 식으로, 선언과 실제가 다르면 화면이 깨집니다.
크기를 미리 약속해서 reflow 자체를 예방한다
콘텐츠가 로딩된 뒤 크기가 바뀌면 그 자체가 reflow입니다. 대표적인 사례가 크기 없는 이미지입니다.
<!-- ❌ 이미지가 로딩되는 순간 아래 콘텐츠 전체가 밀린다 -->
<img src="/banner.jpg" alt="배너" />
<!-- ✅ 자리를 미리 확보해서 로딩 후에도 배치가 그대로다 -->
<img src="/banner.jpg" alt="배너" width="1200" height="400" />/* 반응형이라면 비율로 자리를 약속한다 */
.thumbnail {
aspect-ratio: 3 / 1;
width: 100%;
}이 기법은 reflow를 줄이는 동시에 Core Web Vitals의 CLS(Layout Shift) 점수도 개선합니다. 스켈레톤 UI에 실제 콘텐츠와 같은 크기를 주는 것도 같은 원리입니다.
축 3: 더 싼 단계로 옮기기
마지막 축은 같은 시각 효과를 파이프라인의 더 뒤쪽 단계에서 처리하도록 바꾸는 것입니다.
위치·크기 애니메이션은 transform으로
가장 자주 나오는 사례입니다. 요소를 움직이는 두 가지 방법을 비교해보겠습니다.
/* ❌ Before: 매 프레임 Layout → Paint → Composite */
.panel {
position: relative;
left: 0;
transition: left 0.3s ease;
}
.panel.open {
left: 240px;
}/* ✅ After: 매 프레임 Composite만 */
.panel {
transform: translateX(0);
transition: transform 0.3s ease;
}
.panel.open {
transform: translateX(240px);
}| 구분 | left 애니메이션 |
transform 애니메이션 |
|---|---|---|
| 매 프레임 하는 일 | Layout + Paint + Composite | Composite |
| 실행 스레드 | 메인 스레드 | 컴포지터 스레드 (GPU 활용) |
| 메인 스레드가 바쁠 때 | 애니메이션이 같이 끊김 | 비교적 부드럽게 유지 |
크기도 마찬가지입니다. width, height 대신 transform: scale(), 투명도는 opacity로 처리하면 Layout과 Paint를 건너뜁니다. 흔히 말하는 "애니메이션은 transform과 opacity로만"이라는 규칙의 근거가 바로 이것입니다.
다만 scale은 내용물까지 함께 늘어난다는 점에서 width 변경과 결과가 다릅니다. 진짜로 박스 크기가 변해야 하는 아코디언 같은 UI는 어떻게 다루는지 3편에서 예제로 살펴보겠습니다.
will-change: 예고는 하되 남발하지 않는다
transform 애니메이션이 시작되는 순간에도 레이어 분리 같은 준비 작업이 필요합니다. will-change는 브라우저에게 "이 속성이 곧 바뀔 예정"이라고 미리 알려서 준비를 앞당기는 속성입니다.
/* 곧 애니메이션할 요소에만 제한적으로 */
.drawer {
will-change: transform;
}문제는 남용입니다. will-change가 걸린 요소는 레이어로 승격될 수 있고, 레이어는 픽셀 데이터만큼 메모리를 차지합니다.
/* ❌ 이러지 말 것: 모든 요소를 레이어 후보로 만든다 */
* {
will-change: transform, opacity;
}실무 기준은 이렇게 잡으면 됩니다.
- 실제로 애니메이션이 자주 일어나는 소수의 요소에만 건다
- 가능하면 애니메이션 직전에 JavaScript로 걸고 끝나면 제거한다
- "일단 걸어두면 빨라지겠지"는 대부분 역효과다
상황별 처방 요약
이번 글의 기법을 증상 기준으로 다시 정리하면 이렇습니다.
| 증상 | 처방 | 축 |
|---|---|---|
| 반복문에서 측정과 스타일 변경을 같이 한다 | 읽기 먼저, 쓰기 나중에 | ① |
| 인라인 스타일을 여러 줄 조작한다 | 클래스 토글로 묶기 | ① |
| 리스트에 노드를 수백 개 추가한다 | DocumentFragment로 일괄 삽입 |
① |
| scroll/mousemove마다 DOM을 만진다 | requestAnimationFrame으로 프레임당 1번 |
① |
| 자주 변하는 위젯이 주변 배치를 흔든다 | position: fixed/absolute로 흐름에서 분리 |
② |
| 독립적인 카드·섹션이 많은 긴 페이지 | contain, content-visibility |
② |
| 이미지·비동기 콘텐츠 로딩 시 화면이 밀린다 | width/height, aspect-ratio로 자리 확보 |
② |
top/left/width 트랜지션이 끊긴다 |
transform, opacity로 전환 |
③ |
| 애니메이션 시작 순간이 유독 버벅인다 | 해당 요소에만 will-change |
③ |
정리하면
기법은 많아 보여도 축은 세 개입니다.
- 횟수 줄이기: 읽기와 쓰기를 분리하고, 변경을 묶고, DOM 조작은 문서 밖에서 조립합니다.
- 범위 좁히기: 자주 변하는 요소는 문서 흐름에서 분리하고,
contain으로 격리를 선언하고, 크기를 미리 약속합니다. - 싼 단계로 옮기기: 시각 효과는 가능한 한
transform과opacity로 표현해 Composite 단계에서 처리하게 합니다.
새로운 최적화 기법을 만나면 "이건 어느 축이지?"를 먼저 물어보세요. 축이 보이면 그 기법이 우리 상황에 맞는지도 판단하기 쉬워집니다.
3편에서는 이 도구들을 들고 실무에서 자주 만나는 장면들로 갑니다. 스크롤 헤더, 아코디언, 대량 리스트, 드래그 앤 드롭, 그리고 React에서의 관점과 DevTools 측정까지 예제로 정리합니다.
