← Blog
최적화

Reflow와 Repaint — 렌더링 파이프라인에서 무엇이 비싼가

한 줄 요약

브라우저 렌더링 파이프라인에서 Reflow는 요소의 위치/크기를 재계산하는 작업이고, Repaint는 픽셀을 다시 그리는 작업이다. Reflow는 항상 Repaint를 동반하지만, Repaint는 단독으로 발생할 수 있다.


왜 알아야 했나

  • 드래그로 스크롤을 구현하는 코드(node.scrollLeft -= delta)를 작성하면서, 이 동작이 얼마나 비용이 큰지 궁금해짐
  • mousemove처럼 빈번하게 호출되는 이벤트 핸들러에서 성능 최적화가 필요한 상황
  • CSS 애니메이션을 만들 때 transform/opacity를 권장하는 이유를 이해하기 위해
  • Layout Thrashing(레이아웃 스래싱) 같은 안티 패턴을 피하기 위해

핵심 개념

🎨 브라우저 렌더링 파이프라인

JavaScript → Style → Layout(Reflow) → Paint(Repaint) → Composite
  1. JavaScript: DOM/스타일을 변경하는 스크립트 실행
  2. Style: CSS 규칙을 매칭해서 각 요소의 최종 스타일 계산
  3. Layout (Reflow): 각 요소의 위치와 크기를 계산
  4. Paint (Repaint): 픽셀을 채우는 작업 (색상, 텍스트, 이미지, 그림자 등)
  5. 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-image
  • border-color, outline-color

2. 가시성 관련

  • visibility (visiblehidden) — 공간은 유지되므로 Repaint만
  • outline, outline-style

3. 그림자/효과

  • box-shadow, text-shadow
  • text-decoration
  • border-style, border-radius

Composite Only 속성 (가장 빠름)

  • transform (translate, scale, rotate, skew)
  • opacity
  • filter (별도 레이어 승격 시)
  • 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: hiddendisplay: 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 최소화 팁

  1. 레이아웃 변경을 일괄 처리 (class 토글 활용)
  2. DOM에서 분리 후 작업 (display: none 또는 DocumentFragment)
  3. 레이아웃 정보 캐싱 (offsetHeight 같은 값을 변수에 저장)
  4. transform, opacity 활용 (GPU 가속)
  5. will-change로 변경 예고
  6. requestAnimationFrame으로 변경을 프레임에 맞춤
  7. 읽기는 읽기끼리, 쓰기는 쓰기끼리 묶기 (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와 스크롤 성능

🔗 참고