브라우저 Reflow와 Repaint (1): 렌더링 파이프라인부터 이해하는 기본 원리

Frontend

프론트엔드 면접에서도, 코드 리뷰에서도 한 번쯤 이런 말을 듣게 됩니다.

  • "left로 움직이지 말고 transform을 쓰세요"
  • "그 코드는 reflow를 유발해서 느려요"
  • "이건 repaint만 일어나서 괜찮습니다"

들을 때는 고개를 끄덕이게 되는데, 막상 "reflow가 정확히 뭔가요?"라고 물으면 설명이 잘 안 나옵니다. "레이아웃을 다시 계산하는 것"까지는 말할 수 있어도 그게 왜 비싼지, 언제 일어나는지, transform은 왜 예외인지까지 이어서 설명하기는 어렵습니다.

이 시리즈는 그 지점을 채우려는 글입니다. 1편에서는 브라우저가 화면을 그리는 과정을 따라가면서 reflow와 repaint의 원리를 정리하고, 2편에서 이를 줄이는 방법, 3편에서 실무에서 자주 만나는 예제를 다룹니다.

한눈에 보면

  • 브라우저는 DOM과 CSSOM으로 렌더 트리를 만들고 Layout → Paint → Composite 순서로 화면을 그립니다.
  • Reflow는 요소의 크기와 위치를 다시 계산하는 일이고, Repaint는 계산된 자리에 픽셀을 다시 칠하는 일입니다.
  • Reflow가 일어나면 Repaint도 따라옵니다. 반대로 Repaint는 Reflow 없이 단독으로 일어날 수 있습니다.
  • transformopacity는 Layout도 Paint도 건너뛰고 Composite 단계에서 처리될 수 있어서 가장 저렴합니다.
  • 브라우저는 스타일 변경을 모아서 한 번에 처리하려고 하는데, 중간에 레이아웃 값을 읽으면 이 최적화가 깨집니다. 이것이 강제 동기 레이아웃입니다.
  • 한 프레임의 예산은 60Hz 기준 약 16.6ms입니다. 이 안에 JavaScript 실행과 렌더링 작업이 모두 끝나야 화면이 부드럽습니다.

브라우저는 화면을 어떻게 그릴까

reflow와 repaint를 이해하려면 먼저 브라우저가 HTML과 CSS를 픽셀로 바꾸는 과정을 알아야 합니다. 이 과정을 흔히 렌더링 파이프라인이라고 부릅니다.

flowchart LR
  A[HTML 파싱<br/>DOM] --> C[Render Tree]
  B[CSS 파싱<br/>CSSOM] --> C
  C --> D["Layout<br/>(Reflow)"]
  D --> E["Paint<br/>(Repaint)"]
  E --> F[Composite]

단계별로 보면 이렇습니다.

  1. DOM 생성: HTML을 파싱해서 문서 구조를 트리로 만듭니다.
  2. CSSOM 생성: CSS를 파싱해서 어떤 요소에 어떤 스타일이 적용되는지 계산합니다.
  3. Render Tree 구성: DOM과 CSSOM을 합쳐 실제로 화면에 보이는 요소만 남긴 트리를 만듭니다. display: none인 요소는 여기서 빠집니다.
  4. Layout: 각 요소가 화면 어디에 어떤 크기로 놓일지 계산합니다. 이 단계를 다시 수행하는 것이 reflow입니다.
  5. Paint: 계산된 자리에 텍스트, 색, 그림자, 테두리 같은 픽셀을 실제로 칠합니다. 이 단계를 다시 수행하는 것이 repaint입니다.
  6. Composite: 여러 레이어로 나눠 칠해진 결과물을 하나의 화면으로 합성합니다.

첫 로딩 때는 이 과정을 전부 거칩니다. 문제는 그다음입니다. 사용자가 버튼을 누르고, 스크롤하고, JavaScript가 DOM과 스타일을 바꿀 때마다 브라우저는 이 파이프라인의 어딘가부터 다시 시작해야 합니다.

여기서 성능 이야기가 시작됩니다. 어느 단계부터 다시 시작하느냐에 따라 비용이 크게 달라지기 때문입니다.

Reflow: 배치를 다시 계산하는 일

Reflow는 요소의 기하학적 속성, 즉 크기와 위치를 다시 계산하는 작업입니다. 브라우저 엔진에 따라 layout이라고도 부릅니다. Chrome DevTools에서는 Layout이라는 이름으로 보입니다.

예를 들어 section 하나의 너비를 바꾸면 어떤 일이 벌어지는지 트리로 보겠습니다.

flowchart TD
  BODY[body] --> HEADER[header]
  BODY --> MAIN["main<br/>높이 다시 계산"]
  MAIN --> ASIDE["aside<br/>형제가 밀려남"]
  MAIN --> SECTION["section<br/>💥 width: 300px → 500px"]
  SECTION --> P["p<br/>줄바꿈 다시 계산"]
  SECTION --> IMG["img<br/>위치 다시 계산"]
 
  style SECTION fill:#fecaca,stroke:#dc2626,color:#111
  style MAIN fill:#fef3c7,stroke:#d97706,color:#111
  style ASIDE fill:#fef3c7,stroke:#d97706,color:#111
  style P fill:#fef3c7,stroke:#d97706,color:#111
  style IMG fill:#fef3c7,stroke:#d97706,color:#111

직접 바꾼 것은 section 하나인데 자식의 줄바꿈, 형제의 위치, 부모의 높이까지 함께 재계산됩니다.

즉, reflow가 비싼 진짜 이유는 계산 자체가 아니라 영향이 주변으로 전파된다는 데 있습니다. 문서 구조에 따라 사실상 페이지 전체가 영향권에 들어가기도 합니다.

무엇이 reflow를 일으키나

대표적인 트리거는 아래와 같습니다.

  • DOM 노드 추가, 제거, 이동
  • width, height, margin, padding, border 같은 크기 관련 속성 변경
  • top, left 같은 위치 속성 변경
  • font-size, font-family 변경이나 웹폰트 로딩 완료
  • 텍스트 내용 변경 (글자 수가 달라지면 줄바꿈이 달라집니다)
  • display: none ↔ 다른 값 전환
  • 브라우저 창 크기 변경, 방향 전환
  • 크기를 지정하지 않은 이미지의 로딩 완료

그리고 하나 더 있습니다. 스타일을 바꾸지 않아도 레이아웃 값을 읽기만 해도 reflow가 일어날 수 있습니다. offsetWidth, offsetHeight, getBoundingClientRect() 같은 API가 여기에 해당하는데, 이 부분은 뒤에서 강제 동기 레이아웃과 함께 자세히 보겠습니다.

Repaint: 픽셀을 다시 칠하는 일

Repaint는 레이아웃은 그대로 두고 픽셀만 다시 칠하는 작업입니다. 요소의 자리와 크기는 변하지 않고 겉모습만 바뀔 때 일어납니다.

  • color, background-color
  • box-shadow, outline
  • border-radius (테두리 두께가 아니라 모서리 모양만 바뀔 때)
  • visibility

visibility: hidden이 좋은 예입니다. 비슷해 보이는 두 가지 숨김 방식의 비용이 다릅니다.

/* 자리는 그대로 차지 → 배치 계산 불필요 → repaint만 */
.soft-hide {
  visibility: hidden;
}
 
/* 렌더 트리에서 제거 → 주변 배치가 전부 달라짐 → reflow */
.hard-hide {
  display: none;
}
구분 visibility: hidden display: none
자리 차지 유지 사라짐
렌더 트리 남아 있음 제거됨
발생하는 작업 Repaint Reflow + Repaint

둘의 관계를 정리하면 이렇습니다.

  • Reflow가 일어나면 repaint는 반드시 따라옵니다. 자리가 바뀌었으면 다시 칠해야 하니까요.
  • Repaint는 reflow 없이 단독으로 일어날 수 있습니다. 색만 바뀌었으면 자리 계산은 필요 없습니다.

그래서 같은 시각 효과라도 reflow를 동반하는 방식과 repaint만 일으키는 방식의 비용이 다르고, 이 차이를 아는 것이 최적화의 출발점이 됩니다.

다만 repaint가 항상 싸다는 뜻은 아닙니다. 넓은 영역에 blur가 큰 box-shadow를 다시 그리는 일은 paint 단계만으로도 충분히 무거울 수 있습니다. "reflow보다 상대적으로 저렴하다" 정도로 이해하는 편이 정확합니다.

속성마다 다시 시작하는 단계가 다르다

지금까지의 내용을 표로 정리하면 이렇습니다. 어떤 속성을 바꾸느냐에 따라 파이프라인의 어느 단계부터 다시 실행되는지가 달라집니다.

바꾼 속성 다시 시작하는 단계 비용
width, height, margin, top, left, font-size, DOM 변경 Layout부터 비쌈
color, background, box-shadow, visibility Paint부터 중간
transform, opacity Composite만 가장 저렴

Layout부터 다시 시작하면 Paint와 Composite도 따라오고, Paint부터 시작하면 Composite가 따라옵니다. 즉, 파이프라인의 앞 단계를 건드릴수록 뒤에 딸려오는 작업이 많아집니다.

판단이 헷갈릴 때는 이 흐름을 따라가면 됩니다.

flowchart TD
  Q[스타일을 바꿨다] --> A{크기나 위치가<br/>바뀌는가?}
  A -- 예 --> L["Layout → Paint → Composite<br/>(reflow + repaint)"]
  A -- 아니오 --> B{색·그림자처럼<br/>겉모습이 바뀌는가?}
  B -- 예 --> P["Paint → Composite<br/>(repaint)"]
  B -- "아니오 (transform, opacity)" --> C[Composite만]
 
  style L fill:#fecaca,stroke:#dc2626,color:#111
  style P fill:#fef3c7,stroke:#d97706,color:#111
  style C fill:#bbf7d0,stroke:#16a34a,color:#111

어떤 CSS 속성이 어느 단계를 유발하는지는 브라우저 엔진마다 조금씩 다릅니다. 다만 실무 감각으로는 "크기·위치는 Layout, 색·그림자는 Paint, transform·opacity는 Composite"라는 세 묶음을 기억해두면 대부분의 판단이 가능합니다.

transform과 opacity는 왜 특별할까

표에서 transformopacity만 Composite로 분류된 이유를 조금 더 들여다보겠습니다.

브라우저는 페이지를 항상 한 장의 그림으로 관리하지 않습니다. 조건에 따라 일부 요소를 별도의 레이어(layer) 로 분리해서 관리하는데, 레이어로 분리된 요소는 이미 칠해진 그림을 갖고 있는 셈이 됩니다.

이 상태에서 transform으로 요소를 움직이면:

  • 크기와 위치 계산을 다시 할 필요가 없고 (Layout 건너뜀)
  • 픽셀을 다시 칠할 필요도 없고 (Paint 건너뜀)
  • 이미 칠해진 레이어를 합성 단계에서 옮기기만 하면 됩니다

Composite 작업은 메인 스레드가 아닌 컴포지터 스레드에서 GPU의 도움을 받아 처리됩니다. 그래서 메인 스레드가 JavaScript 실행으로 바쁜 순간에도 transform 기반 애니메이션은 비교적 부드럽게 유지될 수 있습니다.

left: 100px로 옮기는 것과 transform: translateX(100px)로 옮기는 것은 화면상 결과가 같아 보여도, 브라우저 입장에서는 "문서 전체의 배치를 다시 계산하는 일"과 "완성된 그림 한 장을 옆으로 미는 일"만큼 다릅니다.

다만 레이어가 공짜는 아닙니다. 레이어마다 픽셀 데이터를 메모리에 들고 있어야 하므로, 무엇이든 레이어로 만들면 좋다는 결론으로 이어지지는 않습니다. 이 균형은 2편에서 will-change와 함께 다루겠습니다.

브라우저도 나름대로 아껴 쓴다: 변경 사항 모아서 처리하기

여기까지 보면 "스타일을 바꿀 때마다 reflow가 일어난다"고 생각하기 쉽지만, 실제 브라우저는 그것보다 똑똑합니다.

const box = document.getElementById('box')!;
 
box.style.width = '200px';
box.style.height = '100px';
box.style.marginTop = '20px';

이 코드에서 reflow가 세 번 일어날까요? 그렇지 않습니다. 브라우저는 스타일 변경을 바로 계산하지 않고 "레이아웃이 더러워졌다(dirty)"는 표시만 해두고 미뤄둡니다. 그리고 다음 화면을 그리기 직전에 쌓인 변경을 모아서 한 번에 계산합니다.

sequenceDiagram
  participant JS as JavaScript
  participant B as 브라우저
 
  JS->>B: style.width = '200px'
  Note over B: 계산 안 함, dirty 표시만
  JS->>B: style.height = '100px'
  Note over B: 계속 미룸
  JS->>B: style.marginTop = '20px'
  Note over B: 계속 미룸
  Note over B: 다음 프레임 직전<br/>Layout 1번으로 몰아서 계산 ✅

즉, 쓰기(write)만 연달아 하는 코드는 생각보다 저렴합니다. 브라우저의 이 지연 계산 덕분입니다.

그런데 이 최적화를 깨는 코드가 있다: 강제 동기 레이아웃

문제는 미뤄둔 계산을 지금 당장 해달라고 요구하는 코드입니다.

const box = document.getElementById('box')!;
 
box.style.width = '200px'; // 쓰기: 레이아웃이 dirty 상태가 됨
const width = box.offsetWidth; // 읽기: 정확한 값이 필요하므로 지금 계산해야 함

offsetWidth는 요소의 현재 너비를 돌려줘야 합니다. 그런데 방금 스타일이 바뀌어서 미뤄둔 계산이 남아 있는 상태입니다. 낡은 값을 돌려줄 수는 없으니 브라우저는 어쩔 수 없이 그 자리에서 동기적으로 레이아웃을 계산합니다. 이것이 강제 동기 레이아웃(forced synchronous layout)입니다.

sequenceDiagram
  participant JS as JavaScript
  participant B as 브라우저
 
  JS->>B: style.width = '200px'
  Note over B: dirty 표시만 하고 미룸
  JS->>B: offsetWidth 값 주세요
  Note over B: 🚨 낡은 값은 못 준다<br/>지금 당장 Layout 계산
  B-->>JS: 계산 끝난 width 반환

한 번이면 큰 문제가 아닐 수 있습니다. 진짜 문제는 이것이 반복될 때입니다.

const items = document.querySelectorAll<HTMLElement>('.item');
 
items.forEach((item) => {
  const height = item.offsetHeight; // 읽기
  item.style.height = `${height + 10}px`; // 쓰기 → 레이아웃이 다시 dirty
});

반복문의 매 순회마다 읽기와 쓰기가 번갈아 나옵니다. 쓰기로 레이아웃이 dirty가 되고, 다음 순회의 읽기가 다시 계산을 강제합니다.

순회 1: 읽기 → Layout 계산 💥 → 쓰기(dirty)
순회 2: 읽기 → Layout 계산 💥 → 쓰기(dirty)
순회 3: 읽기 → Layout 계산 💥 → 쓰기(dirty)
...
순회 200: 읽기 → Layout 계산 💥 → 쓰기(dirty)
 
항목 200개 = 레이아웃 계산 200번

이 패턴을 layout thrashing이라고 부릅니다.

레이아웃 값을 읽는 대표적인 API는 아래와 같습니다. 이 목록을 외울 필요는 없고, "요소의 현재 크기나 위치를 묻는 API는 미뤄둔 계산을 강제할 수 있다"는 감각이면 충분합니다.

  • offsetTop, offsetLeft, offsetWidth, offsetHeight
  • clientWidth, clientHeight, scrollTop, scrollHeight
  • getBoundingClientRect()
  • getComputedStyle() (레이아웃과 연관된 속성을 조회할 때)
  • window.innerWidth, window.scrollY

해결 방법 자체는 단순합니다. 읽기를 먼저 모아서 하고 쓰기를 나중에 모아서 하면 됩니다. 구체적인 패턴은 2편에서 다루겠습니다.

이 모든 것이 16.6ms 안에서 벌어진다

reflow와 repaint가 왜 체감 성능으로 이어지는지는 프레임 예산으로 설명할 수 있습니다.

60Hz 디스플레이 기준으로 브라우저는 초당 60번, 약 16.6ms마다 새 화면을 만들어야 합니다. 이 안에서 이벤트 처리, JavaScript 실행, 스타일 계산, Layout, Paint, Composite가 모두 끝나야 합니다. 브라우저 자체의 작업 시간을 빼면 실제로 우리 코드에 허락된 시간은 10ms 남짓입니다.

한 프레임 (60Hz = 16.6ms)
 
정상:  | JS | Style | Layout | Paint | Composite |      여유
       0ms ─────────────────────────────────► 16.6ms ✅ 부드러움
 
초과:  | JS........ | Style | Layout........ | Paint | Composite |
       0ms ────────────────────────────────────────► 24ms ❌ 프레임 드랍

이 예산을 넘기면 프레임이 통째로 밀립니다. 사용자 눈에는 스크롤이 뚝뚝 끊기거나 애니메이션이 버벅이는, 흔히 jank라고 부르는 현상으로 나타납니다. 120Hz 디스플레이라면 예산은 절반인 약 8.3ms로 줄어듭니다.

즉, reflow 최적화는 "계산을 아예 없애는 일"이 아니라 매 프레임의 예산 안에 렌더링 작업이 들어오도록 관리하는 일에 가깝습니다.

자주 하는 오해

기본 원리를 정리했으니 흔한 오해도 짚고 넘어가겠습니다.

오해 1: reflow는 내가 바꾼 요소에서만 일어난다

앞에서 본 것처럼 레이아웃 변경은 조상, 자손, 형제로 전파됩니다. 브라우저가 영향 범위를 좁히려는 최적화를 하긴 하지만, 문서 흐름 안에 있는 요소라면 변경 하나가 넓은 범위의 재계산으로 이어질 수 있다고 보는 편이 안전합니다.

오해 2: repaint는 저렴하니까 신경 쓰지 않아도 된다

칠해야 하는 영역이 넓거나 box-shadow, blur 필터처럼 픽셀당 계산이 많은 효과라면 paint도 프레임 예산을 충분히 넘길 수 있습니다. 단계가 뒤라는 것이지 항상 저렴하다는 뜻은 아닙니다.

오해 3: GPU 가속을 걸면 다 해결된다

transform: translateZ(0) 같은 트릭으로 요소를 레이어로 승격하는 방법이 한때 만능처럼 퍼졌지만, 레이어는 메모리를 차지하고 레이어가 많아지면 합성 비용 자체가 커집니다. 레이어 승격은 필요한 곳에 선택적으로 써야 하는 도구입니다.

오해 4: React 리렌더링이 곧 reflow다

둘은 다른 층위의 이야기입니다. React 리렌더링은 컴포넌트 함수가 다시 실행되어 가상 DOM을 비교하는 JavaScript 작업이고, reflow는 그 결과 실제 DOM이 바뀌었을 때 브라우저가 수행하는 레이아웃 계산입니다. 리렌더링이 일어나도 실제 DOM 변경이 없으면 reflow는 일어나지 않습니다. 이 구분은 3편에서 예제와 함께 다시 보겠습니다.

정리하면

이번 글의 내용을 압축하면 이렇습니다.

  • 브라우저는 Layout → Paint → Composite 순서로 화면을 그리고, 변경이 생기면 이 파이프라인의 어딘가부터 다시 시작합니다.
  • Reflow는 크기와 위치의 재계산이고 영향이 주변으로 전파되기 때문에 비쌉니다. Repaint는 픽셀만 다시 칠하는 작업으로 상대적으로 저렴합니다.
  • transformopacity는 Composite 단계에서 처리될 수 있어 Layout과 Paint를 모두 건너뜁니다.
  • 브라우저는 스타일 변경을 모아서 처리하지만, 중간에 레이아웃 값을 읽으면 강제 동기 레이아웃이 일어나고, 읽기와 쓰기가 반복되면 layout thrashing이 됩니다.
  • 이 모든 작업은 프레임당 16.6ms의 예산 안에서 끝나야 합니다.

원리를 알았으니 다음 질문은 자연스럽게 "그래서 어떻게 줄이는가"입니다. 2편에서는 reflow와 repaint를 줄이는 전략을 발생 횟수 줄이기, 영향 범위 좁히기, 더 싼 단계로 옮기기라는 세 가지 축으로 정리합니다.

같이 보면 좋은 글