브라우저 Reflow와 Repaint (3): 실무에서 자주 만나는 예제로 완성하기

Frontend

이 글은 Reflow와 Repaint 시리즈의 마지막 3편입니다.

1편에서 원리를, 2편에서 세 가지 축(횟수 줄이기 · 범위 좁히기 · 싼 단계로 옮기기)을 정리했습니다. 이번 글은 그 도구들을 들고 실무에서 정말 자주 만나는 다섯 장면으로 갑니다. 모든 예제는 같은 형식입니다. 문제 코드 → 무엇이 왜 비싼가 → 개선 코드.

한눈에 보면

  • 스크롤 헤더: 이벤트마다 DOM을 만지지 말고, 상태가 바뀌는 순간에만 클래스를 토글합니다.
  • 아코디언: height: auto는 트랜지션이 안 됩니다. grid-template-rows로 풀면 깔끔합니다.
  • 대량 리스트: innerHTML += 반복은 최악의 패턴입니다. DocumentFragment로 조립하고, 그 이상은 가상화입니다.
  • 드래그 앤 드롭: 측정은 시작할 때 한 번, 이동은 transform으로, 반영은 프레임당 한 번입니다.
  • 툴팁 위치 계산: 읽기 → 계산 → 쓰기 순서만 지켜도 thrashing이 사라집니다.
  • React: 리렌더링과 reflow는 다른 층이고, 측정 코드는 useLayoutEffect에서도 thrashing을 만들 수 있습니다.
  • 의심되면 추측하지 말고 DevTools Performance 패널로 확인합니다.

예제 1: 스크롤하면 줄어드는 헤더

스크롤을 내리면 헤더가 작아지면서 그림자가 생기는, 어디에나 있는 UI입니다.

// ❌ Before
const header = document.querySelector<HTMLElement>('.header')!;
 
window.addEventListener('scroll', () => {
  const height = header.offsetHeight; // 읽기: 강제 레이아웃 후보
 
  if (window.scrollY > 80 && height !== 48) {
    header.style.height = '48px'; // 쓰기: Layout 유발
    header.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.15)';
  } else if (window.scrollY <= 80 && height !== 80) {
    header.style.height = '80px';
  }
});

이 코드의 문제는 세 겹입니다.

  1. scroll은 초당 수십 번 발생하는데 매번 DOM을 읽고 씁니다
  2. height를 바꾸므로 매번 Layout이 일어나고, 헤더가 문서 흐름 안에 있다면 아래 콘텐츠 전체가 밀립니다
  3. 읽기(offsetHeight)와 쓰기(style.height)가 붙어 있어 강제 동기 레이아웃 조건도 갖췄습니다

개선 방향을 흐름으로 먼저 보면 이렇습니다.

flowchart LR
  E["scroll 이벤트<br/>(초당 수십 회)"] --> T{이번 프레임에<br/>이미 예약했나?}
  T -- 예 --> SKIP[아무것도 안 함]
  T -- 아니오 --> R[rAF 예약]
  R --> F{80px 경계를<br/>넘나들었나?}
  F -- 아니오 --> NOOP[DOM 접근 없음]
  F -- 예 --> C[클래스 토글 1번]
 
  style SKIP fill:#f1f5f9,stroke:#94a3b8,color:#111
  style NOOP fill:#f1f5f9,stroke:#94a3b8,color:#111
  style C fill:#bbf7d0,stroke:#16a34a,color:#111
/* ✅ After: 헤더를 흐름에서 분리하고, 변화는 transform과 그림자로 */
.header {
  position: fixed; /* 축 ②: 주변 배치에 영향 없음 */
  inset: 0 0 auto 0;
  height: 80px;
  transition:
    transform 0.2s ease,
    box-shadow 0.2s ease;
}
 
.header--shrunk {
  transform: translateY(-16px) scale(0.95); /* 축 ③: Composite */
  box-shadow: 0 2px 8px rgb(0 0 0 / 0.15); /* Paint까지만 */
}
// ✅ After: 상태가 바뀌는 순간에만 DOM을 만진다
const header = document.querySelector<HTMLElement>('.header')!;
let shrunk = false;
let ticking = false;
 
window.addEventListener('scroll', () => {
  if (ticking) return;
  ticking = true;
 
  requestAnimationFrame(() => {
    const next = window.scrollY > 80;
 
    if (next !== shrunk) {
      shrunk = next;
      header.classList.toggle('header--shrunk', shrunk); // 경계를 넘을 때 1번
    }
 
    ticking = false;
  });
});

바뀐 점을 2편의 축으로 읽으면, 헤더를 fixed로 흐름에서 분리했고(②), 크기 변화를 transform으로 옮겼고(③), DOM 접근을 "상태가 실제로 바뀐 프레임"으로 제한했습니다(①). 스크롤 내내 일어나던 Layout이 경계를 넘는 순간의 클래스 토글 한 번으로 줄어듭니다.

예제 2: 아코디언 열고 닫기

FAQ, 필터 패널, 댓글 접기에서 늘 만나는 UI입니다. 그리고 늘 같은 벽에 부딪힙니다. height: auto로는 트랜지션이 되지 않습니다.

/* ❌ 동작하지 않음: auto로는 트랜지션 불가 */
.accordion__body {
  height: 0;
  overflow: hidden;
  transition: height 0.3s ease;
}
.accordion--open .accordion__body {
  height: auto;
}

그래서 흔히 두 가지 우회로가 쓰입니다.

방법 동작 방식 문제점
max-height: 9999px 트릭 충분히 큰 값으로 트랜지션 실제 높이와 무관한 값이라 열림/닫힘 속도가 어색해짐
JS로 scrollHeight 측정 후 지정 열 때마다 읽기 → 쓰기 측정 코드가 강제 동기 레이아웃 후보가 됨

요즘은 CSS만으로 풀 수 있습니다. grid-template-rows0fr → 1fr 트랜지션이 가능하다는 점을 이용합니다.

/* ✅ After: 측정 없이 CSS만으로 */
.accordion__body {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.3s ease;
}
 
.accordion--open .accordion__body {
  grid-template-rows: 1fr;
}
 
.accordion__inner {
  overflow: hidden;
  min-height: 0; /* grid 자식이 0까지 줄어들 수 있게 */
}
// JavaScript는 토글만
trigger.addEventListener('click', () => {
  accordion.classList.toggle('accordion--open');
});

여기서 정직하게 짚을 부분이 있습니다. 아코디언은 열리면서 아래 콘텐츠를 실제로 밀어내야 하는 UI입니다. 즉 트랜지션 동안의 Layout 자체는 피할 수 없고, 피하는 것이 목표도 아닙니다. 우리가 없앤 것은 JavaScript 측정 코드(강제 동기 레이아웃)와 어색한 타이밍이고, 남긴 Layout은 이 UI의 본질적인 비용입니다. 모든 reflow가 제거 대상은 아니라는 감각이 이 예제의 핵심입니다.

예제 3: 리스트에 항목 500개 추가하기

검색 결과 갱신, 채팅방 입장 시 이전 메시지 로딩, 무한 스크롤의 다음 페이지. 전부 같은 문제입니다.

// ❌ Before: 최악의 패턴
messages.forEach((msg) => {
  list.innerHTML += `<li class="message">${msg.text}</li>`;
});

innerHTML +=는 겉보기보다 훨씬 비쌉니다.

innerHTML += 가 매 순회마다 하는 일
 
1. 기존 HTML 전체를 문자열로 직렬화   (항목이 늘수록 점점 느려짐)
2. 새 문자열을 붙여서 전체를 다시 파싱
3. 기존 노드 전부 파괴 후 재생성      (이벤트 리스너도 사라짐)
 
500개 추가 = 이 작업을 500번, 매번 점점 커지는 리스트로

2편에서 본 DocumentFragment가 정확히 이 상황의 처방입니다.

// ✅ After: 문서 밖에서 조립하고 한 번에 붙인다
const fragment = document.createDocumentFragment();
 
messages.forEach((msg) => {
  const li = document.createElement('li');
  li.className = 'message';
  li.textContent = msg.text;
  fragment.appendChild(li);
});
 
list.appendChild(fragment); // 라이브 DOM 변경 1번
 
// 채팅이라면: 쓰기가 끝난 뒤에 읽기 1번은 정당한 비용
list.scrollTop = list.scrollHeight;

마지막 줄이 좋은 예시입니다. scrollHeight 읽기는 방금 쓴 변경 때문에 레이아웃 계산을 강제하지만, 일괄 쓰기가 끝난 뒤 한 번이므로 문제가 없습니다. 나쁜 것은 읽기 자체가 아니라 읽기와 쓰기의 교차 반복입니다.

항목이 수천 개를 넘어가면 DOM에 다 넣는 것 자체가 부담이 됩니다. 그때부터는 화면에 보이는 것만 렌더링하는 가상화(virtualization) 영역이고, React라면 @tanstack/react-virtual 같은 라이브러리가 이 역할을 합니다. 원리는 동일합니다. Layout 대상 노드 수 자체를 줄이는, 가장 극단적인 축 ②입니다.

예제 4: 드래그 앤 드롭

칸반 보드, 파일 업로드 영역, 이미지 크롭 핸들. 드래그는 pointermove가 초당 수백 번 발생할 수 있어서 나쁜 패턴의 비용이 즉시 드러납니다.

// ❌ Before: 이벤트마다 측정하고 Layout 속성으로 이동
element.addEventListener('pointermove', (e) => {
  const rect = element.getBoundingClientRect(); // 매번 읽기
 
  element.style.left = `${e.clientX - rect.width / 2}px`; // 매번 Layout
  element.style.top = `${e.clientY - rect.height / 2}px`;
});

개선 원칙은 세 줄로 요약됩니다.

측정은 → 드래그 시작할 때 1번
이동은 → transform으로 (Composite)
반영은 → requestAnimationFrame으로 프레임당 1번
// ✅ After
let grabX = 0;
let grabY = 0;
let nextX = 0;
let nextY = 0;
let frameId = 0;
 
element.addEventListener('pointerdown', (e) => {
  const rect = element.getBoundingClientRect(); // 읽기는 여기서 1번만
  grabX = e.clientX - rect.left;
  grabY = e.clientY - rect.top;
 
  element.setPointerCapture(e.pointerId);
});
 
element.addEventListener('pointermove', (e) => {
  if (!element.hasPointerCapture(e.pointerId)) return;
 
  nextX = e.clientX - grabX; // 계산만 하고
  nextY = e.clientY - grabY;
 
  if (frameId) return; // 이미 예약됐으면 값만 갱신
 
  frameId = requestAnimationFrame(() => {
    element.style.transform = `translate(${nextX}px, ${nextY}px)`; // 쓰기
    frameId = 0;
  });
});

드래그 대상이 position: absolute로 흐름에서 분리되어 있다면 더 좋습니다. 움직이는 동안 주변 요소가 재계산될 일 자체가 없어집니다. 드롭 위치 계산을 위해 다른 카드들의 위치가 필요하다면, 그 측정도 pointerdown 시점에 한 번 해서 배열로 캐싱해두는 것이 정석입니다.

예제 5: 툴팁 위치 계산

트리거 근처에 툴팁을 띄우되 화면 밖으로 나가면 방향을 뒤집는, 역시 흔한 요구사항입니다. 이 로직은 본질적으로 읽기(트리거 위치, 툴팁 크기, 뷰포트)와 쓰기(툴팁 좌표)가 섞일 수밖에 없어서 순서가 중요합니다.

// ❌ Before: 읽기와 쓰기가 뒤섞임
function openTooltip(trigger: HTMLElement, tip: HTMLElement) {
  tip.style.display = 'block'; // 쓰기
  const triggerRect = trigger.getBoundingClientRect(); // 읽기 💥
  tip.style.left = `${triggerRect.left}px`; // 쓰기
  const tipRect = tip.getBoundingClientRect(); // 읽기 💥
  if (triggerRect.bottom + tipRect.height > window.innerHeight) {
    tip.style.top = `${triggerRect.top - tipRect.height}px`; // 쓰기
  } else {
    tip.style.top = `${triggerRect.bottom}px`;
  }
}
// ✅ After: 읽기 → 계산 → 쓰기 3단계로 정렬
function openTooltip(trigger: HTMLElement, tip: HTMLElement) {
  // 1) 읽기: 필요한 측정을 전부 먼저
  const triggerRect = trigger.getBoundingClientRect();
  const tipRect = tip.getBoundingClientRect();
  const viewportHeight = window.innerHeight;
 
  // 2) 계산: DOM 접근 없이 순수 계산만
  const openUpward = triggerRect.bottom + tipRect.height + 8 > viewportHeight;
  const top = openUpward ? triggerRect.top - tipRect.height - 8 : triggerRect.bottom + 8;
 
  // 3) 쓰기: 마지막에 몰아서
  tip.style.transform = `translate(${triggerRect.left}px, ${top}px)`;
  tip.classList.add('tooltip--visible');
}

툴팁을 position: fixed + visibility: hidden으로 미리 렌더링해두면 크기 측정을 위한 display 토글도 필요 없어집니다. 툴팁 하나면 어느 쪽이든 체감 차이가 없지만, 테이블의 셀마다 툴팁을 계산하거나 차트 위 수십 개 포인트에 라벨을 배치하는 순간 이 순서가 성능을 가릅니다.

React에서는 어떻게 보일까

React를 쓰면 DOM을 직접 만질 일이 줄어서 reflow와 멀어진 느낌이 들지만, 층이 나뉘었을 뿐 원리는 그대로입니다.

flowchart TD
  subgraph REACT["React의 세계 (JavaScript)"]
    S[상태 변경] --> RR[리렌더링<br/>컴포넌트 함수 재실행] --> DIFF[이전 결과와 비교]
  end
 
  DIFF -- 바뀐 부분이 있으면 --> COMMIT[실제 DOM 변경]
 
  subgraph BROWSER["브라우저의 세계"]
    COMMIT --> RF["Layout (Reflow)"] --> RP["Paint (Repaint)"] --> CP[Composite]
  end
 
  DIFF -. 결과가 같으면 .-> NONE[DOM 변경 없음<br/>reflow도 없음]
 
  style REACT fill:#eff6ff,stroke:#3b82f6,color:#111
  style BROWSER fill:#fef3c7,stroke:#d97706,color:#111
  style NONE fill:#f1f5f9,stroke:#94a3b8,color:#111

이 그림에서 두 가지를 읽을 수 있습니다.

  • 리렌더링 ≠ reflow: 리렌더링이 일어나도 DOM 변경이 없으면 브라우저는 아무것도 다시 그리지 않습니다. 리렌더링 최적화(memo, useMemo)와 reflow 최적화는 서로 다른 층의 문제입니다.
  • React도 결국 DOM을 바꾼다: commit 이후는 이 시리즈에서 다룬 원리가 그대로 적용됩니다. React가 쓰기를 모아서 처리해주더라도, 읽기는 우리 코드의 몫입니다.

그 읽기가 주로 등장하는 곳이 측정 훅입니다.

// ❌ 리스트의 각 항목이 각자 측정하는 구조
function ListItem({ text }: { text: string }) {
  const ref = useRef<HTMLLIElement>(null);
 
  useLayoutEffect(() => {
    const height = ref.current!.offsetHeight; // 항목 수만큼 읽기
    ref.current!.style.setProperty('--h', `${height}px`); // 항목 수만큼 쓰기
  });
 
  return <li ref={ref}>{text}</li>;
}

항목이 100개면 useLayoutEffect가 100번 실행되는데, 실행 순서상 읽기와 쓰기가 교차하면 layout thrashing이 React 안에서 재현됩니다. 측정이 꼭 필요하다면 부모가 한 번에 모아서 처리하는 편이 안전합니다.

// ✅ 부모가 읽기 전부 → 쓰기 전부로 정렬
function List({ items }: { items: string[] }) {
  const listRef = useRef<HTMLUListElement>(null);
 
  useLayoutEffect(() => {
    const nodes = listRef.current!.querySelectorAll<HTMLElement>('li');
 
    const heights = Array.from(nodes).map((n) => n.offsetHeight); // 읽기 전부
 
    nodes.forEach((n, i) => {
      n.style.setProperty('--h', `${heights[i]}px`); // 쓰기 전부
    });
  }, [items]);
 
  return (
    <ul ref={listRef}>
      {items.map((text) => (
        <li key={text}>{text}</li>
      ))}
    </ul>
  );
}

크기 변화에 계속 반응해야 한다면 ResizeObserver가 더 맞는 도구입니다. 콜백이 레이아웃 계산이 끝난 시점에 묶여서 호출되므로 강제 동기 레이아웃을 만들지 않습니다.

DevTools로 직접 확인하기

여기까지의 모든 판단은 측정으로 확인할 수 있습니다. 추측으로 최적화하지 않기 위해 두 가지 도구만 익혀두면 됩니다.

Performance 패널: 어디서 Layout이 일어나는지

  1. DevTools → Performance 탭 → 녹화 시작
  2. 문제가 의심되는 동작(스크롤, 드래그, 목록 추가)을 수행
  3. 녹화를 멈추고 타임라인에서 보라색(Layout) 블록을 찾습니다
문제가 있을 때 보이는 모양
 
Main ─ | Event: scroll | Layout 💥 | Event: scroll | Layout 💥 | ...
        └ Layout 블록에 빨간 삼각형 + "Forced reflow is a likely
          performance bottleneck" 경고, 클릭하면 유발한 코드 위치 표시
 
개선 후 보이는 모양
 
Main ─ | Event: scroll |  ...  | rAF | Layout | Paint | Composite |
        └ 프레임당 Layout 1번 이하로 정리됨

Layout 블록을 클릭하면 어떤 JavaScript가 그것을 강제했는지 호출 스택까지 보여주므로, layout thrashing은 여기서 바로 잡힙니다.

Rendering 탭: 어디가 다시 칠해지는지

DevTools에서 Cmd/Ctrl + Shift + P → "Show Rendering"으로 열 수 있습니다.

옵션 보여주는 것 이럴 때 켠다
Paint flashing repaint되는 영역을 초록색으로 깜빡임 의도보다 넓은 영역이 다시 칠해지는지 확인
Layout Shift Regions 레이아웃이 밀린 영역을 파란색으로 표시 이미지·폰트 로딩으로 화면이 밀리는지 확인
Frame Rendering Stats 실시간 FPS 표시 개선 전후 프레임 비교

버튼 하나를 눌렀는데 화면의 절반이 초록색으로 깜빡인다면, 그 UI 어딘가에 이 시리즈에서 다룬 문제가 숨어 있다는 신호입니다.

실무 체크리스트

새 UI를 만들거나 성능 이슈를 만났을 때 순서대로 물어보면 됩니다.

  1. 이 이벤트는 얼마나 자주 발생하는가? scroll, pointermove, resize라면 rAF 정렬과 "상태 변화 시에만 DOM 접근"부터 확인합니다.
  2. 읽기와 쓰기가 교차하고 있지 않은가? 반복문과 useLayoutEffect 안을 먼저 봅니다.
  3. 이 시각 효과는 어느 단계 속성으로 표현되어 있는가? 위치·크기 변화라면 transform으로 옮길 수 있는지 봅니다.
  4. 이 요소의 변화가 주변을 밀어내야 하는가? 아니라면 fixed/absolutecontain으로 격리합니다. 밀어내야 한다면(아코디언) 그 Layout은 본질 비용으로 받아들이고 나머지를 다듬습니다.
  5. 정말 그런지 측정했는가? Performance 패널의 보라색 블록과 Paint flashing으로 확인합니다.

정리하면

시리즈 전체를 한 문단으로 압축하면 이렇습니다.

브라우저는 Layout → Paint → Composite 순서로 화면을 그리고, 앞 단계를 건드릴수록 비쌉니다(1편). 그래서 최적화는 횟수를 줄이고, 범위를 좁히고, 더 싼 단계로 옮기는 세 방향으로 정리됩니다(2편). 그리고 실무의 스크롤 헤더, 아코디언, 대량 리스트, 드래그, 툴팁은 전부 이 세 축의 조합으로 풀립니다(3편).

마지막으로 강조하고 싶은 것은 균형입니다. reflow는 버그가 아니라 브라우저가 화면을 맞추는 정상 동작이고, 아코디언처럼 Layout이 본질인 UI도 있습니다. 목표는 reflow를 0으로 만드는 것이 아니라 프레임 예산 안에서, 필요한 만큼만 일어나게 관리하는 것입니다. 그 판단의 근거가 되는 것이 1편의 원리이고, 도구가 2편의 세 축이며, 확인 수단이 DevTools입니다.

같이 보면 좋은 글