Published on

Chrome Forced Reflow 경고 원인·해결 8가지

Authors

서버 튜닝에서 CPU 100%를 보면 병목을 의심하듯, 프런트엔드 성능에서 Forced reflow 경고는 UI 스레드가 레이아웃 계산에 끌려다니고 있다는 신호입니다. 특히 스크롤, 드래그, 애니메이션, 리스트 렌더링 같은 "자주 실행되는" 코드 경로에 섞이면 체감이 바로 나빠집니다.

이 글에서는 Chrome DevTools에서 자주 보이는 Forced reflow while executing JavaScript 경고의 의미를 짚고, 실제로 가장 많이 발생하는 원인 8가지와 해결 패턴(코드 포함)을 정리합니다.

병목 진단을 체계적으로 하는 관점은 백엔드/인프라에서도 동일합니다. 예를 들어 블로킹으로 런타임이 멈추는 원인을 좁혀가는 방식은 Rust Tokio runtime 멈춤? 블로킹 I/O 진단법에서도 유사한 흐름으로 다룹니다.

Forced Reflow란 무엇인가

브라우저는 대략 다음 파이프라인으로 화면을 만듭니다.

  • JavaScript 실행
  • 스타일 계산(Style)
  • 레이아웃(Layout, Reflow)
  • 페인트(Paint)
  • 합성(Composite)

여기서 Reflow(레이아웃) 는 요소의 크기/위치가 정해지는 단계입니다. 문제는 JS가 실행되는 도중에 레이아웃 정보(예: offsetHeight, getBoundingClientRect())를 읽으면, 브라우저가 "지금까지의 변경 사항"을 반영하기 위해 레이아웃 계산을 강제로 동기 수행할 수 있다는 점입니다. 이때 DevTools가 Forced reflow 경고를 띄우곤 합니다.

핵심 요약:

  • DOM/CSS 변경(쓰기) 이후 레이아웃 관련 값 읽기(읽기)가 섞이면 위험
  • 루프/스크롤 핸들러/애니메이션 프레임에서 발생하면 프레임 드랍으로 직결

먼저: 재현과 진단 방법(DevTools)

1) Performance 패널로 원인 함수 찾기

  1. DevTools Performance 탭에서 Record
  2. 스크롤/드래그 등 문제 동작 수행
  3. Main 스레드에서 Layout 이벤트가 자주/길게 찍히는 지점 확인
  4. Call Tree 또는 Bottom-Up에서 레이아웃을 유발한 JS 함수를 역추적

2) Rendering 패널로 레이아웃 시각화

DevTools Command Menu에서 Rendering 패널을 열고:

  • Paint flashing
  • Layout Shift Regions(필요 시)

등을 켜면, 어느 영역이 자주 다시 그려지는지 감이 잡힙니다.


원인·해결 1) 읽기/쓰기 교차(레이아웃 스래싱)

가장 흔한 패턴입니다.

문제 코드

const items = document.querySelectorAll('.item');

items.forEach((el) => {
  // 쓰기
  el.style.height = (Math.random() * 100 + 20) + 'px';

  // 읽기(레이아웃 강제)
  const h = el.offsetHeight;

  // 다시 쓰기
  el.style.lineHeight = h + 'px';
});

해결: 읽기와 쓰기를 분리(배치)

const items = [...document.querySelectorAll('.item')];

// 1) 먼저 읽기(필요한 측정값 수집)
const heights = items.map((el) => el.getBoundingClientRect().height);

// 2) 그 다음 쓰기(스타일 적용)
items.forEach((el, i) => {
  el.style.lineHeight = heights[i] + 'px';
});

추가 팁:

  • 읽기 단계에서는 DOM 변경을 하지 않기
  • 쓰기 단계는 가능하면 한 번에 몰아서 처리

원인·해결 2) 루프 안에서 getBoundingClientRect() 남발

getBoundingClientRect() 자체는 유용하지만, 루프 안에서 DOM 변경과 섞이면 레이아웃 계산이 폭발합니다.

해결: 측정은 한 번, 캐시해서 재사용

const container = document.querySelector('.container');
const rect = container.getBoundingClientRect(); // 한 번만

function place(el, idx) {
  // rect를 재사용
  el.style.transform = `translate(${rect.left + idx * 10}px, ${rect.top}px)`;
}

스크롤/리사이즈에 따라 값이 바뀌는 경우는 이벤트마다 한 번만 측정하고, 그 이벤트 루프에서 재사용하세요.


원인·해결 3) offsetTop/scrollTop 읽기와 스타일 변경이 섞인 스크롤 핸들러

스크롤 이벤트는 고빈도입니다. 여기서 레이아웃 강제가 발생하면 매우 눈에 띄는 끊김이 생깁니다.

문제 패턴

window.addEventListener('scroll', () => {
  const y = document.documentElement.scrollTop; // 읽기
  header.style.top = (y > 100 ? '0' : '-60px'); // 쓰기
  const h = header.offsetHeight; // 다시 읽기(강제 레이아웃)
  console.log(h);
});

해결: requestAnimationFrame으로 프레임에 맞춰 배치

let scheduled = false;

window.addEventListener('scroll', () => {
  if (scheduled) return;
  scheduled = true;

  requestAnimationFrame(() => {
    scheduled = false;
    const y = window.scrollY; // 읽기
    header.style.transform = y > 100 ? 'translateY(0)' : 'translateY(-60px)'; // 쓰기
  });
});

포인트:

  • 스크롤에서 top 같은 레이아웃 속성보다 transform을 우선 고려
  • 측정이 꼭 필요하면, 프레임 내에서 읽기 단계와 쓰기 단계를 분리

원인·해결 4) 레이아웃을 유발하는 CSS 속성 애니메이션(top, left, width, height)

애니메이션이 부드럽지 않다면 JS가 아니라 CSS 속성 선택이 문제일 수 있습니다.

문제: top 애니메이션

.banner {
  position: fixed;
  top: -60px;
  transition: top 200ms ease;
}
.banner.show {
  top: 0;
}

해결: transform으로 변경(합성 단계 활용)

.banner {
  position: fixed;
  transform: translateY(-60px);
  transition: transform 200ms ease;
  will-change: transform;
}
.banner.show {
  transform: translateY(0);
}

will-change는 남발하면 메모리/리소스가 증가할 수 있으니, 애니메이션 대상에 제한적으로 사용하세요.


원인·해결 5) DOM을 한 개씩 추가하면서 매번 측정/정렬

예: 무한 스크롤 리스트에서 아이템을 하나 append하고, 바로 높이를 읽어 배치하는 방식.

해결: DocumentFragment로 묶어서 한 번에 반영

const list = document.querySelector('.list');

function appendItems(data) {
  const frag = document.createDocumentFragment();

  for (const item of data) {
    const li = document.createElement('li');
    li.className = 'row';
    li.textContent = item.title;
    frag.appendChild(li);
  }

  // DOM 반영은 한 번
  list.appendChild(frag);
}

추가로, 반영 후 측정이 필요하면:

  • 반영 직후 한 번만 측정
  • 또는 requestAnimationFrame에서 측정(브라우저가 레이아웃을 정리할 시간을 줌)

원인·해결 6) 강제 동기 레이아웃 트리거(el.offsetHeight로 리플로우 강제)

일부 코드는 의도적으로 offsetHeight를 읽어 CSS 애니메이션을 재시작시키기도 합니다. 이 패턴은 작동하지만 성능 비용이 큽니다.

문제 패턴(애니메이션 재시작)

el.classList.remove('shake');
void el.offsetHeight; // 강제 reflow 트리거
el.classList.add('shake');

해결: 가능하면 애니메이션을 상태 기반으로 설계

  • CSS animation-name을 바꾸거나
  • Web Animations API를 사용해 명시적으로 재생
el.animate(
  [
    { transform: 'translateX(0)' },
    { transform: 'translateX(-6px)' },
    { transform: 'translateX(6px)' },
    { transform: 'translateX(0)' },
  ],
  { duration: 250, iterations: 1 }
);

이 방식은 "레이아웃 강제"를 트릭으로 쓰지 않고도 애니메이션을 제어할 수 있어, 경고를 줄이는 데 도움이 됩니다.


원인·해결 7) 큰 DOM 트리 + 빈번한 클래스 토글(스타일 재계산 폭증)

Forced reflow 경고는 레이아웃 자체뿐 아니라, 그 전 단계인 스타일 계산이 커져서 레이아웃까지 연쇄적으로 비용이 커질 때도 체감됩니다.

해결 체크리스트

  • 불필요하게 깊은 DOM 구조 줄이기
  • 전역에 가까운 상위 컨테이너에 클래스 토글을 자주 하지 않기
  • CSS 선택자 단순화(특히 과도한 후손 선택)
  • 애니메이션/상태 변경 범위를 "작은 서브트리"로 제한

예를 들어 테마 토글을 body에 매번 바꾸는 대신, 필요한 영역 컨테이너에만 적용하거나, CSS 변수를 사용해 변경 범위를 줄이는 식입니다.

:root {
  --panel-bg: #111;
}
.panel {
  background: var(--panel-bg);
}

원인·해결 8) 이미지/폰트 로딩으로 인한 레이아웃 변동(측정값 불안정)

JS가 레이아웃을 측정해 배치하는데, 이후 이미지나 웹폰트가 로딩되며 크기가 바뀌면:

  • 다시 레이아웃이 잡히고
  • 측정-배치 로직이 반복되며
  • Forced reflow 경고가 동반될 수 있습니다.

해결: 크기 예약 및 로딩 이벤트에 맞춘 측정

  • 이미지에는 width/height 또는 aspect-ratio로 공간 예약
  • 폰트는 font-display: swap 고려
  • 측정이 필요하면 ResizeObserver로 변경을 "관측"하고 배치 작업을 requestAnimationFrame으로 묶기
const ro = new ResizeObserver((entries) => {
  requestAnimationFrame(() => {
    for (const e of entries) {
      // 변경된 요소만 최소 작업
      e.target.classList.add('resized');
    }
  });
});

document.querySelectorAll('img, .card').forEach((el) => ro.observe(el));

실전 점검 순서(빠르게 줄이는 로드맵)

  1. Performance에서 Layout이 긴 구간의 콜스택을 찾는다
  2. 해당 함수에서 "읽기-쓰기-읽기"가 섞였는지 본다
  3. 스크롤/마우스무브/리사이즈 핸들러라면 requestAnimationFrame으로 스케줄링한다
  4. 애니메이션 속성이 top/left/width/height라면 transform/opacity로 바꾼다
  5. DOM 업데이트는 DocumentFragment 등으로 배치한다
  6. 측정값은 캐시하고, 필요한 순간에만 갱신한다

이 접근은 "증상(경고)"이 아니라 "원인(동기 레이아웃 강제)"을 제거하는 방식이라 재발을 줄입니다.

자주 묻는 질문

Forced reflow 경고가 떠도 무조건 고쳐야 하나

경고 자체는 힌트일 뿐입니다. 하지만 다음 조건이면 우선순위를 높게 두는 게 좋습니다.

  • 스크롤/드래그/애니메이션 중에 발생
  • Layout 시간이 프레임 예산(약 16ms)을 자주 초과
  • 저사양 기기에서 특히 끊김이 심함

라이브러리(React/Vue) 쓰면 사라지나

프레임워크가 DOM 변경을 추상화해도, 결국 레이아웃을 읽는 순간은 동일합니다. 특히:

  • 렌더 직후 측정(getBoundingClientRect) 기반 레이아웃
  • 포털/모달 위치 계산
  • 가상 스크롤

같은 영역에서 경고가 자주 나타납니다.

마무리

Forced reflow는 "레이아웃을 브라우저가 어쩔 수 없이 지금 당장 계산했다"는 뜻이고, 대부분은 측정(읽기)과 DOM 변경(쓰기)의 섞임에서 시작합니다. 이 글의 8가지 패턴을 기준으로 코드를 분류해 보면, 어디서 비용이 발생하는지 빠르게 좁힐 수 있습니다.

성능 문제는 원인 규명이 절반입니다. 재현 가능한 측정과 콜스택 기반 진단을 습관화하면, 프런트엔드에서도 백엔드 장애 분석처럼 일관된 방식으로 해결할 수 있습니다.

추가로 "병목을 진단하고 원인을 분해하는" 접근이 익숙하다면, 런타임이 멈추는 원인을 파고드는 글인 Rust Tokio runtime 멈춤? 블로킹 I/O 진단법도 함께 참고할 만합니다.