최적화
Reflow와 Repaint — 렌더링 파이프라인에서 무엇이 비싼가
한 줄 요약
브라우저 렌더링 파이프라인에서 Reflow는 요소의 위치/크기를 재계산하는 작업이고, Repaint는 픽셀을 다시 그리는 작업이다. Reflow는 항상 Repaint를 동반하지만, Repaint는 단독으로 발생할 수 있다.
왜 알아야 했나
- 드래그로 스크롤을 구현하는 코드(
node.scrollLeft -= delta)를 작성하면서, 이 동작이 얼마나 비용이 큰지 궁금해짐 mousemove처럼 빈번하게 호출되는 이벤트 핸들러에서 성능 최적화가 필요한 상황- CSS 애니메이션을 만들 때
transform/opacity를 권장하는 이유를 이해하기 위해 - Layout Thrashing(레이아웃 스래싱) 같은 안티 패턴을 피하기 위해
핵심 개념
🎨 브라우저 렌더링 파이프라인
JavaScript → Style → Layout(Reflow) → Paint(Repaint) → Composite
- JavaScript: DOM/스타일을 변경하는 스크립트 실행
- Style: CSS 규칙을 매칭해서 각 요소의 최종 스타일 계산
- Layout (Reflow): 각 요소의 위치와 크기를 계산
- Paint (Repaint): 픽셀을 채우는 작업 (색상, 텍스트, 이미지, 그림자 등)
- Composite: 여러 레이어를 합성해서 화면에 출력
🔄 Reflow (Layout)
요소의 위치와 크기가 변경되어 다시 계산이 필요한 작업.
- 변경된 요소뿐 아니라 영향받는 자식, 부모, 형제 요소까지 모두 재계산
- 비용이 매우 큼
- Reflow가 발생하면 Repaint도 반드시 발생
🎨 Repaint
요소의 시각적 스타일만 변경되어 픽셀을 다시 그리는 작업.
- 레이아웃 계산은 건너뛰고 Paint 단계만 수행
- Reflow보다 비용이 적지만 여전히 부담은 있음
⚡ Composite Only (가장 저렴)
Reflow도 Repaint도 발생하지 않고 합성 단계만 수행. GPU 가속을 받을 수 있어 가장 빠름.
Reflow가 발생하는 경우
1. DOM 구조 변경
- 요소 추가/삭제 (
appendChild,removeChild,insertBefore) - 텍스트 콘텐츠 변경 (
innerHTML,textContent) - 요소의 순서 변경
2. 요소의 크기/위치 관련 스타일 변경
- 크기:
width,height,min/max-width,min/max-height - 여백/테두리:
margin,padding,border,border-width - 위치:
top,left,right,bottom - 포지셔닝:
position,float,clear,display - 폰트:
font-size,font-family,font-weight,line-height,text-align,vertical-align,white-space - overflow:
overflow,overflow-x,overflow-y
3. 윈도우/뷰포트 변경
- 브라우저 창 리사이즈
- 디바이스 회전
- 스크롤바 등장/사라짐
4. 콘텐츠 변경
- 이미지 로딩 완료 (크기 미지정 시)
- 웹폰트 로딩 완료 (FOUT/FOIT)
<iframe>,<video>등 외부 리소스 로드
5. JavaScript에서 레이아웃 정보 조회 (Forced Synchronous Layout)
조회만 해도 대기 중인 변경사항을 즉시 계산하기 위해 reflow 강제 발생:
- 위치/크기:
offsetTop,offsetLeft,offsetWidth,offsetHeight,offsetParent - 스크롤:
scrollTop,scrollLeft,scrollWidth,scrollHeight,scrollIntoView() - 클라이언트:
clientTop,clientLeft,clientWidth,clientHeight - 메서드:
getBoundingClientRect(),getClientRects(),getComputedStyle()
Repaint만 발생하는 경우 (레이아웃 영향 없음)
1. 색상 관련
color,background-color,background-imageborder-color,outline-color
2. 가시성 관련
visibility(visible↔hidden) — 공간은 유지되므로 Repaint만outline,outline-style
3. 그림자/효과
box-shadow,text-shadowtext-decorationborder-style,border-radius
Composite Only 속성 (가장 빠름)
transform(translate, scale, rotate, skew)opacityfilter(별도 레이어 승격 시)will-change로 미리 힌트를 준 속성
📊 비용 비교
| 단계 | 비용 | 예시 속성 |
|---|---|---|
| Reflow + Repaint + Composite | 🔴 매우 큼 | width, height, top, margin, display, font-size |
| Repaint + Composite | 🟡 중간 | color, background-color, visibility, box-shadow |
| Composite only | 🟢 작음 | transform, opacity |
예시 / 코드
❌ 나쁜 예: Reflow 발생
element.style.left = '100px';
element.style.top = '50px';
✅ 좋은 예: Composite only
element.style.transform = 'translate(100px, 50px)';
❌ 매우 나쁜 예: Layout Thrashing (강제 동기 레이아웃 반복)
function onMouseMove(e: MouseEvent) {
node.style.width = `${e.clientX}px`; // 쓰기 (레이아웃 변경 예약)
const w = node.offsetWidth; // 읽기 → 강제 reflow!
node.style.height = `${w}px`; // 쓰기
const h = node.offsetHeight; // 읽기 → 또 강제 reflow!
}
✅ 좋은 예: 읽기/쓰기 분리
function onMouseMove(e: MouseEvent) {
// 읽기 먼저 모두
const w = node.offsetWidth;
const h = node.offsetHeight;
// 쓰기 모아서
node.style.width = `${e.clientX}px`;
node.style.height = `${w}px`;
}
🔍 실제 분석한 코드: 드래그 스크롤
function onMouseMove(e: MouseEvent) {
if (!isDragging) return;
const delta = e.clientX - lastClientX;
node.scrollLeft -= delta; // 스크롤은 reflow 없음, Composite-only에 가까움
lastClientX = e.clientX;
}
- Reflow: 0회 (스크롤은 레이아웃을 안 바꿈)
- Repaint: 1회/이벤트 (스크롤 영역)
- Composite: 1회/이벤트 (대부분 GPU/컴포지터 스레드)
- → 이미 양호한 코드. 다만
requestAnimationFrame으로 한 번 더 최적화 가능.
🚀 rAF로 최적화한 버전
let rafId: number | null = null;
let pendingDelta = 0;
function onMouseMove(e: MouseEvent) {
if (!isDragging) return;
pendingDelta += e.clientX - lastClientX;
lastClientX = e.clientX;
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
node.scrollLeft -= pendingDelta;
pendingDelta = 0;
rafId = null;
});
}
헷갈렸던 점
-
scrollLeft -= delta가 reflow를 일으키는가? → 한 줄에 읽기/쓰기가 같이 있어 보이지만, 같은 핸들러에서 레이아웃 변경이 없으므로 강제 reflow는 발생하지 않음. 또한 스크롤 자체도 reflow를 일으키지 않음. -
visibility: hidden과display: none의 차이 →visibility는 공간을 차지하므로 Repaint만,display: none은 레이아웃에서 제거되므로 Reflow 발생. -
transform/opacity도 항상 공짜는 아니다 → 합성 레이어로 승격되어야 GPU 가속을 받음.will-change: transform이나translateZ(0)트릭으로 승격 유도 가능. 하지만 레이어 남발은 메모리/합성 비용 증가. -
Reflow ≠ 화면이 다시 그려지는 것 → Reflow는 "계산" 단계이고, 실제 화면 갱신은 Paint + Composite 단계에서 일어남.
실무 적용
어디에 써먹을 수 있나
- 애니메이션 구현:
top/left대신transform: translate(),display대신opacity - 드래그/스크롤 인터랙션:
requestAnimationFrame으로 이벤트 throttling - 리스트/테이블 대량 업데이트:
DocumentFragment활용,display: none으로 분리 후 일괄 변경 - 반응형 레이아웃 변경: class 토글로 여러 스타일 변경을 한 번에 적용
- 성능 측정: Chrome DevTools의 Performance 탭에서 Layout/Paint 발생 횟수 확인
Reflow 최소화 팁
- 레이아웃 변경을 일괄 처리 (class 토글 활용)
- DOM에서 분리 후 작업 (
display: none또는DocumentFragment) - 레이아웃 정보 캐싱 (
offsetHeight같은 값을 변수에 저장) transform,opacity활용 (GPU 가속)will-change로 변경 예고requestAnimationFrame으로 변경을 프레임에 맞춤- 읽기는 읽기끼리, 쓰기는 쓰기끼리 묶기 (Layout Thrashing 방지)
관련 작업
- [[브라우저 렌더링 파이프라인]]
- [[Critical Rendering Path]]
- [[requestAnimationFrame]]
- [[Layout Thrashing]]
- [[GPU 가속과 합성 레이어]]
- [[will-change]]
- [[Drag to Scroll 구현]]
더 공부할 것
- 합성 레이어(Compositor Layer) 승격 조건과 메모리 트레이드오프
- Chrome DevTools Performance 탭 활용법 (Layout/Paint 시각화)
- Paint Flashing 옵션으로 repaint 영역 확인하는 방법
- CSS Containment (
contain속성)으로 reflow 범위 제한하기 - Virtual DOM이 reflow를 어떻게 최적화하는가 (React, Vue)
- FastDOM 같은 라이브러리 — 읽기/쓰기 자동 배치
- 렌더링 스레드 vs 컴포지터 스레드 차이
- Passive Event Listener와 스크롤 성능