Reflow는 왜 느릴까?

브라우저 렌더링의 숨은 비용 찾아 최적화하기

Table Of Contents

들어가기

불필요한 Reflow를 줄이면 화면이 끊기지 않고 부드럽게 보여요. 이 글에서는 Reflow가 성능에 미치는 영향과, Reflow를 줄이는 방법을 다뤄요.

초기 웹이 정적인 문서였다면, 지금의 웹은 사용자와 계속 상호작용하는 애플리케이션이에요. 입력할 때마다, 데이터를 불러올 때마다, 스크롤할 때마다 화면이 바뀌어요. 변하는 화면을 그리려면 브라우저가 레이아웃을 다시 계산해야 해요. 이 레이아웃 재계산 과정이 Reflow예요. Reflow는 브라우저 작업 중에서도 비용이 커요. 불필요한 Reflow가 반복되면 사용자는 화면이 끊기거나 버벅거린다고 느껴요.

Reflow 이해하기

Reflow가 발생하는 시점과 이유

Reflow는 브라우저가 웹 페이지의 레이아웃을 다시 계산하는 과정이에요.

브라우저가 화면을 그릴 때는 이런 과정을 거쳐요.

  1. DOM 생성: HTML을 파싱해요.
  2. CSSOM 생성: CSS를 파싱해요.
  3. Render Tree 생성: DOM과 CSSOM을 결합해요.
  4. Layout (Reflow): 각 요소의 위치와 크기를 계산해요.
  5. Paint: 실제 픽셀로 그려요.
  6. Composite: 레이어를 합성해요.

브라우저 렌더링 과정을 더 자세히 알고 싶다면 Navigation부터 Compositing까지의 전체 과정을 참고해요.

이 중 4번째 단계가 바로 Reflow예요. 요소의 위치와 크기가 변경되면, 브라우저는 영향을 받는 모든 요소의 위치를 다시 계산해야 해요.

그래서 DOM을 수정하거나 스타일을 바꾸는 일을 할 때마다, 브라우저는 Reflow 단계를 다시 거쳐요. 구체적인 예시는 아래와 같아요.

1. DOM을 조작해요

DOM 요소를 추가하거나 제거할 때마다 Reflow가 발생해요. 반복문 안에서 요소를 하나씩 추가하면 매번 Reflow가 실행돼서 성능이 크게 떨어져요.

100개의 div를 추가하는 예시예요. 반복문으로 하나씩 추가하면 Reflow가 100번 발생하고, 브라우저는 매번 전체 레이아웃을 다시 계산해서 비효율적이에요.

// ❌ Reflow가 100번 발생해요 for (let i = 0; i < 100; i++) { const div = document.createElement("div"); div.textContent = `Item ${i}`; document.body.appendChild(div); }

Fragment를 사용하면 모든 요소를 메모리에서 조립한 뒤 DOM에 한 번만 추가하므로, Reflow가 1번만 발생해요.

// ✅ Reflow는 1번만 발생해요 const fragment = document.createDocumentFragment(); for (let i = 0; i < 100; i++) { const div = document.createElement("div"); div.textContent = `Item ${i}`; fragment.appendChild(div); } document.body.appendChild(fragment);

2. (일부) 스타일을 변경해요

레이아웃에 영향을 주는 스타일(width, height, margin, padding 등)을 변경하면 Reflow가 발생해요.

// ⚠️ 이 속성들은 Reflow를 발생시켜요 element.style.width = "100px"; element.style.height = "100px"; element.style.margin = "10px"; element.style.padding = "10px"; element.style.display = "none"; element.style.position = "absolute";

반면 색상이나 투명도 같은 시각적 속성만 변경하면 Repaint만 발생해요.

// ✅ 이 속성들은 Reflow 없이 Repaint만 발생시켜요 element.style.color = "red"; element.style.backgroundColor = "blue"; element.style.visibility = "hidden";

3. 레이아웃 정보를 읽어와요

레이아웃 정보(offsetWidth, offsetHeight 등)를 읽으면 브라우저가 최신 레이아웃을 계산하기 위해 즉시 Reflow를 실행해요. 읽기와 쓰기가 반복되면 브라우저가 매번 레이아웃을 다시 계산해서 성능이 급격히 떨어져요.

// ❌ 읽기와 쓰기가 섞여있어요 element.style.width = "100px"; console.log(element.offsetWidth); element.style.height = "100px"; console.log(element.offsetHeight);

읽기를 먼저 모두 처리하고, 쓰기를 나중에 한 번에 처리하면 강제 Reflow를 방지할 수 있어요.

// ✅ 읽기와 쓰기를 분리해요 const width = element.offsetWidth; const height = element.offsetHeight; element.style.width = width + 10 + "px"; element.style.height = height + 10 + "px";

이런 속성과 메서드를 읽으면 브라우저가 즉시 Reflow를 실행해요. 더 많은 목록은 이 문서를 참고해요.

- offsetWidth - offsetHeight - offsetLeft - offsetTop - scrollWidth - scrollHeight - clientWidth - clientHeight - getClientRects() - getBoundingClientRect() - getComputedStyle()

Reflow가 비용이 큰 이유

Reflow가 성능에 큰 영향을 주는 이유는 세 가지예요.

  1. 계산 범위가 넓어요.
    • 한 요소를 변경하면 연결된 부모, 형제, 자식 요소가 모두 위치와 크기에 영향을 받아요.
  2. 다른 작업을 하지 못해요.
    • Reflow는 메인 스레드를 쓰기 때문에 그동안 다른 JavaScript가 실행되지 않아요.
    • 클릭이나 스크롤 같은 사용자 입력도 Reflow가 끝날 때까지 기다렸다가 처리돼요.
  3. 다음 단계까지 실행해요.
    • Reflow가 끝나면 Repaint(픽셀 다시 그리기), Composite(레이어 다시 합성)까지 다시 실행돼요.

Reflow를 최적화해요

6가지 최적화 방법

Reflow를 최소화하는 방법은 여섯 가지예요. 각 기법은 상황에 따라 다르게 쓰면 돼요.

1. CSS 클래스로 스타일 한 번에 변경하기

개별 스타일 속성을 하나씩 바꾸면 매번 Reflow가 발생해요.

// ❌ Reflow가 여러번 일어나요 element.style.width = "100px"; element.style.height = "100px"; element.style.border = "1px solid black";

CSS 클래스로 여러 속성을 한 번에 적용하면 Reflow는 한 번만 발생해요.

// ✅ Reflow가 한 번만 일어나요 // CSS 파일 .my-style { width: 100px; height: 100px; border: 1px solid black; } // JS 파일 element.classList.add("my-style");

2. Document Fragment로 여러 요소 한 번에 추가하기

Fragment(DocumentFragment)는 화면에 그려지지 않고 메모리에만 있는 컨테이너예요. 그래서 여러 요소를 DOM에 넣을 때는 Fragment에 먼저 모아 둔 뒤, 한 번에 붙이면 Reflow를 한 번으로 줄일 수 있어요.

// ✅ DOM에 추가하기 전에 fragment로 조립해요 const fragment = document.createDocumentFragment(); items.forEach((item) => { const li = document.createElement("li"); li.textContent = item; // Fragment는 실제 DOM이 아니므로 Reflow가 일어나지 않아요 fragment.appendChild(li); }); list.appendChild(fragment);

3. 읽기/쓰기 배치 처리하기

DOM 읽기와 쓰기를 분리하면 불필요한 강제 Reflow를 방지할 수 있어요. FastDOM 같은 라이브러리를 활용하면 자동으로 배치 처리를 해줘요.

// ✅ FastDOM 라이브러리로 읽기와 쓰기를 자동으로 분리해요 import fastdom from "fastdom"; fastdom.measure(() => { // 모든 읽기 작업은 이 콜백에서 해요 const width = element.offsetWidth; fastdom.mutate(() => { // 모든 쓰기 작업을 이 콜백에서 해요 element.style.width = width * 2 + "px"; }); });

FastDOM은 내부적으로 requestAnimationFrame을 써서 읽기를 먼저 모아 처리하고, 쓰기를 나중에 모아 처리해요. 브라우저의 렌더링 사이클에 맞춰 DOM을 조작할 수 있어요.

4. position: absolute/fixed로 레이아웃 분리하기

애니메이션할 요소를 레이아웃 플로우에서 분리하면 다른 요소에 영향을 주지 않아요.

.animated-element { position: absolute; /* 또는 fixed */ /* 이제 이 요소의 크기나 위치가 변경되어도 다른 요소에 영향을 주지 않아요 */ }

position이 absolute나 fixed인 요소는 문서의 일반적인 흐름에서 벗어나요. 이런 요소의 레이아웃이 바뀌어도 다른 요소는 영향을 받지 않아서 Reflow 범위가 줄어들어요.

예를 들어 모달 다이얼로그나 드롭다운 메뉴는 fixed position을 사용하면 애니메이션 중에도 페이지의 다른 요소에 영향을 주지 않아요.

5. transform과 opacity로 애니메이션하기

left/top같은 속성으로 위치를 수정하면 요소의 레이아웃 위치가 변경되므로 Reflow가 발생해요.

// ❌ Reflow가 발생해요 element.style.left = "100px";

transform은 레이아웃을 변경하지 않고 GPU의 합성 단계에서만 처리되므로 Reflow가 발생하지 않아요. 그래서 훨씬 부드러운 애니메이션을 만들 수 있어요.

// ✅ Reflow 없이 Repaint만 발생해요 element.style.transform = "translate(100px, 100px)";

transformopacityComposite 단계에서만 처리되기 때문에 Reflow도 Repaint도 발생하지 않아요. 이를 Composite Only Properties라고 불러요.

6. will-change로 브라우저에게 미리 알리기

will-change를 쓰면 브라우저에게 변경될 속성을 미리 알릴 수 있어요. 브라우저가 해당 요소를 별도 레이어로 분리해 두고 최적화해요.

.animated { /* 브라우저에게 transform과 opacity가 변경될 것임을 미리 알려줘요 */ will-change: transform, opacity; }

너무 많은 요소에 will-change를 쓰면 메모리 사용량이 늘어나니 주의해요. 애니메이션 직전에 설정하고, 끝나면 제거하는 게 좋아요.

// 애니메이션 시작 전 element.style.willChange = "transform"; // 애니메이션 실행 element.style.transform = "translateX(100px)"; // 애니메이션 완료 후 element.addEventListener("transitionend", () => { element.style.willChange = "auto"; });

실전에서 사용하기

실제 프로젝트에서 자주 마주치는 상황별 최적화 방법이에요.

무한 스크롤에서 대량의 아이템을 렌더링해요

무한 스크롤에서 여러 아이템을 추가할 때 Fragment를 사용하면 성능을 크게 개선할 수 있어요.

// ❌ 아이템을 추가할 때마다 Reflow가 발생해요 function addItems(items) { items.forEach((item) => { const div = document.createElement("div"); div.textContent = item; container.appendChild(div); }); } // Fragment로 한 번만 Reflow를 발생시켜요 function addItems(items) { const fragment = document.createDocumentFragment(); items.forEach((item) => { const div = document.createElement("div"); div.textContent = item; fragment.appendChild(div); }); requestAnimationFrame(() => { container.appendChild(fragment); }); }

테이블이나 그리드에서 대량의 데이터를 업데이트해요

테이블의 여러 셀을 업데이트할 때는 테이블을 잠시 숨겼다가 다시 표시하면 Reflow를 줄일 수 있어요.

// ✅ 테이블을 숨겼다가 업데이트 후 다시 표시해요 const table = document.getElementById("myTable"); table.style.display = "none"; // 1번 Reflow // 여러 업데이트 작업을 해요 // display: none 상태에서는 레이아웃 계산이 필요 없어요 table.rows[0].cells[0].textContent = "Updated"; table.rows[1].cells[0].textContent = "Updated"; table.style.display = ""; // 1번 Reflow // 총 2번 Reflow가 발생했어요

display: none 상태의 요소는 레이아웃 계산에서 완전히 제외되므로, 여러 셀을 업데이트해도 Reflow가 발생하지 않아요. display를 none으로 설정할 때, 그리고 모든 업데이트가 끝나고 다시 display를 복원할 때 총 2번만 Reflow가 발생해요.

React에서는 배치 업데이트로 Reflow를 줄여요

React는 기본적으로 상태 변경을 배치로 처리해서 Reflow를 최소화해요.

function MyComponent() { const [width, setWidth] = useState(100); const [height, setHeight] = useState(100); const handleClick = () => { setWidth(200); setHeight(200); // React가 자동으로 배치 처리해요 }; return <div style={{ width, height }} onClick={handleClick} />; }

React는 이벤트 핸들러 안에서 발생한 모든 상태 변경을 모아서 한 번에 처리해요. 따라서 여러 setState를 호출해도 실제 DOM 업데이트는 한 번만 일어나므로 Reflow도 최소화돼요.

React 18 이전 버전에서, setTimeout이나 Promise 콜백 안에서는 배치 처리가 되지 않았어요. React 18부터는 자동 배치(Automatic Batching)가 모든 곳에서 동작해요.

마치며

Reflow는 레이아웃이 바뀔 때마다 발생하고, 범위가 넓을수록 비용이 커져요. DOM 조작은 묶어서 한 번에 하고, 레이아웃 읽기와 쓰기 코드를 나누고, 애니메이션은 transform·opacity로 처리하면 불필요한 Reflow를 줄일 수 있어요. 실무에서는 "이 변경이 레이아웃에 영향을 주는가?", "읽기와 쓰기가 섞여 있지 않은가?"를 먼저 확인하는 게 좋아요.

참고 자료