- Published on
Chrome Forced Reflow 경고 원인·해결 8가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 튜닝에서 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 패널로 원인 함수 찾기
- DevTools
Performance탭에서 Record - 스크롤/드래그 등 문제 동작 수행
- Main 스레드에서
Layout이벤트가 자주/길게 찍히는 지점 확인 Call Tree또는Bottom-Up에서 레이아웃을 유발한 JS 함수를 역추적
2) Rendering 패널로 레이아웃 시각화
DevTools Command Menu에서 Rendering 패널을 열고:
Paint flashingLayout 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));
실전 점검 순서(빠르게 줄이는 로드맵)
Performance에서Layout이 긴 구간의 콜스택을 찾는다- 해당 함수에서 "읽기-쓰기-읽기"가 섞였는지 본다
- 스크롤/마우스무브/리사이즈 핸들러라면
requestAnimationFrame으로 스케줄링한다 - 애니메이션 속성이
top/left/width/height라면transform/opacity로 바꾼다 - DOM 업데이트는
DocumentFragment등으로 배치한다 - 측정값은 캐시하고, 필요한 순간에만 갱신한다
이 접근은 "증상(경고)"이 아니라 "원인(동기 레이아웃 강제)"을 제거하는 방식이라 재발을 줄입니다.
자주 묻는 질문
Forced reflow 경고가 떠도 무조건 고쳐야 하나
경고 자체는 힌트일 뿐입니다. 하지만 다음 조건이면 우선순위를 높게 두는 게 좋습니다.
- 스크롤/드래그/애니메이션 중에 발생
Layout시간이 프레임 예산(약16ms)을 자주 초과- 저사양 기기에서 특히 끊김이 심함
라이브러리(React/Vue) 쓰면 사라지나
프레임워크가 DOM 변경을 추상화해도, 결국 레이아웃을 읽는 순간은 동일합니다. 특히:
- 렌더 직후 측정(
getBoundingClientRect) 기반 레이아웃 - 포털/모달 위치 계산
- 가상 스크롤
같은 영역에서 경고가 자주 나타납니다.
마무리
Forced reflow는 "레이아웃을 브라우저가 어쩔 수 없이 지금 당장 계산했다"는 뜻이고, 대부분은 측정(읽기)과 DOM 변경(쓰기)의 섞임에서 시작합니다. 이 글의 8가지 패턴을 기준으로 코드를 분류해 보면, 어디서 비용이 발생하는지 빠르게 좁힐 수 있습니다.
성능 문제는 원인 규명이 절반입니다. 재현 가능한 측정과 콜스택 기반 진단을 습관화하면, 프런트엔드에서도 백엔드 장애 분석처럼 일관된 방식으로 해결할 수 있습니다.
추가로 "병목을 진단하고 원인을 분해하는" 접근이 익숙하다면, 런타임이 멈추는 원인을 파고드는 글인 Rust Tokio runtime 멈춤? 블로킹 I/O 진단법도 함께 참고할 만합니다.