- Published on
Chrome Forced reflow 경고 원인·해결 7단계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 장애처럼 보이지 않지만, 프론트엔드 성능 문제는 사용자 이탈을 가장 빠르게 부릅니다. Chrome DevTools에서 Forced reflow(강제 리플로우) 경고가 뜨면 대개 레이아웃(Layout) 계산이 예상보다 자주, 그리고 비싼 타이밍에 실행되고 있다는 뜻입니다. 특히 스크롤/리사이즈/입력 이벤트 안에서 DOM을 읽고 쓰는 순서가 꼬이면, 브라우저는 “지금 당장 정확한 레이아웃이 필요해”라고 판단해 동기적으로 레이아웃을 다시 계산합니다.
이 글은 Forced reflow의 정확한 의미(리플로우/리페인트/컴포지팅), 대표적인 트리거, 그리고 실무에서 바로 적용 가능한 해결 7단계를 코드와 함께 정리합니다. 문제를 재현하고 측정하는 방법까지 포함해 “감”이 아니라 “데이터”로 고칠 수 있게 구성했습니다.
> 비슷한 방식으로 문제를 단계적으로 복구하는 접근이 익숙하다면, Git rebase 후 강제푸시? PR 안전복구 7단계도 함께 참고하면 좋습니다. (원인 분해 → 안전한 절차 → 검증이라는 구조가 동일합니다.)
0. Forced reflow란 무엇인가 (정확히)
브라우저 렌더링 파이프라인을 단순화하면 다음 흐름입니다.
- Style 계산: CSS 규칙 적용
- Layout(Reflow): 각 요소의 크기/위치 계산
- Paint: 픽셀로 그리기
- Composite: 레이어 합성(주로 GPU)
Forced reflow는 보통 자바스크립트 실행 중에 레이아웃 정보가 필요해져(읽기) 브라우저가 대기 중이던 레이아웃 계산을 즉시 수행하는 상황을 말합니다. 즉, “레이아웃은 원래 프레임 끝에서 한 번에 계산하려 했는데, 네가 중간에 offsetHeight 같은 걸 읽어서 지금 당장 계산해야 한다”는 경고입니다.
핵심은 **DOM 쓰기(write) 후 레이아웃 관련 DOM 읽기(read)**가 섞일 때 발생한다는 점입니다. 이 패턴을 흔히 **layout thrashing(레이아웃 스래싱)**이라고 부릅니다.
1단계: DevTools에서 문제를 ‘재현’하고 ‘증거’를 확보
해결은 먼저 측정부터입니다.
Performance 패널로 Forced reflow 위치 찾기
- DevTools → Performance
- Record(녹화) → 문제 동작(스크롤/애니메이션/입력) 수행
- Main 스레드에서 Recalculate Style / Layout이 자주 끼어드는지 확인
- 이벤트 핸들러(예:
scroll,mousemove) 안에서 Layout이 동기적으로 발생하는지 확인
Console 경고를 더 잘 보이게
Forced reflow while executing JavaScript took XXms 같은 메시지가 뜨면, 해당 시점의 콜스택을 Performance 트레이스에서 추적합니다.
> 팁: “어느 함수가 레이아웃을 강제했는지”가 중요합니다. Layout 자체는 결과이고, 레이아웃을 강제한 ‘읽기’ API 호출이 원인입니다.
2단계: 대표 트리거(읽기 API) 목록을 암기 수준으로 정리
다음은 “읽는 순간 레이아웃을 확정해야 해서” 강제 리플로우를 유발하기 쉬운 API들입니다.
offsetWidth/offsetHeight/offsetTop/offsetLeftclientWidth/clientHeightscrollWidth/scrollHeight/scrollTop(상황에 따라)getBoundingClientRect()getComputedStyle(el)(특히 layout 관련 속성)el.scrollIntoView()
그리고 이 읽기들 앞에 다음과 같은 **쓰기(write)**가 있으면 위험합니다.
style.width/height/top/left변경classList.add/remove/toggle- DOM 삽입/삭제(
appendChild,removeChild,innerHTML)
안 좋은 예: 읽기/쓰기가 번갈아 등장
const items = document.querySelectorAll('.item');
function bad() {
items.forEach((el) => {
// write
el.style.width = (Math.random() * 200 + 100) + 'px';
// read -> 여기서 브라우저는 방금 width 변경을 반영한 레이아웃이 필요
const h = el.getBoundingClientRect().height;
// write
el.style.height = (h + 10) + 'px';
});
}
이 코드는 요소마다 레이아웃 계산이 끼어들 수 있어, 리스트가 길어질수록 프레임 드랍이 급격히 악화됩니다.
3단계: DOM 읽기(read)와 쓰기(write)를 ‘배치’로 분리
가장 효과가 큰 1순위 처방은 읽기 루프 → 쓰기 루프로 재구성하는 것입니다.
개선 예: 측정은 먼저 모아서, 반영은 나중에
const items = [...document.querySelectorAll('.item')];
function better() {
// 1) read phase: 레이아웃 측정만 수행
const heights = items.map((el) => el.getBoundingClientRect().height);
// 2) write phase: 스타일 변경만 수행
items.forEach((el, i) => {
el.style.height = (heights[i] + 10) + 'px';
});
}
이렇게 하면 브라우저는 read phase에서 한 번(또는 최소화된 횟수로) 레이아웃을 계산하고, write phase에서 변경을 모아서 처리할 여지가 생깁니다.
더 안전한 패턴: DOM 변경을 fragment로 모으기
function renderList(container, data) {
const frag = document.createDocumentFragment();
for (const row of data) {
const li = document.createElement('li');
li.className = 'item';
li.textContent = row.title;
frag.appendChild(li);
}
container.appendChild(frag);
}
DOM 삽입을 여러 번 하는 대신 한 번에 붙이면 Layout/Paint 압력을 낮출 수 있습니다.
4단계: rAF(requestAnimationFrame)로 프레임 경계에 맞춰 쓰기
스크롤/마우스무브처럼 고빈도 이벤트에서 매번 DOM을 건드리면, 브라우저는 프레임 예산(16.6ms/60fps)을 쉽게 초과합니다. 이때는 이벤트에서는 상태만 저장하고, 실제 DOM 쓰기는 requestAnimationFrame에서 한 번만 처리합니다.
let latestY = 0;
let scheduled = false;
window.addEventListener('scroll', () => {
latestY = window.scrollY;
if (!scheduled) {
scheduled = true;
requestAnimationFrame(() => {
// write를 프레임 단위로 1회로 제한
document.documentElement.style.setProperty('--scrollY', latestY);
scheduled = false;
});
}
}, { passive: true });
passive: true는 스크롤 성능에 특히 중요합니다(브라우저가 스크롤을 막지 않는다고 확신).- rAF는 “다음 페인트 직전”에 실행되어, 불필요한 중간 레이아웃 강제를 줄입니다.
5단계: 레이아웃을 건드리는 애니메이션을 transform/opacity로 치환
자주 발생하는 실수는 top/left/width/height로 애니메이션을 하는 것입니다. 이는 Layout을 매 프레임 유발할 수 있습니다. 가능하면 Composite 단계에서 처리 가능한 transform, opacity로 바꿉니다.
나쁜 예: top 변경
.bad {
position: relative;
transition: top 200ms ease;
}
.bad.open {
top: 20px;
}
좋은 예: transform 변경
.good {
transform: translateY(0);
transition: transform 200ms ease;
will-change: transform;
}
.good.open {
transform: translateY(20px);
}
will-change는 남용하면 메모리/레이어가 늘어 역효과가 날 수 있어, 짧은 애니메이션 구간에만 제한적으로 사용합니다.
6단계: 강제 동기 레이아웃이 필요한 경우 ‘캐싱’과 ‘무효화’ 전략을 세우기
현실적으로는 레이아웃 값을 읽어야만 하는 UI가 있습니다(툴팁 위치, 드롭다운 충돌 처리 등). 이때는 같은 프레임에서 반복 측정하지 않도록 캐싱합니다.
let cachedRect = null;
let cacheFrame = -1;
function getRectOncePerFrame(el) {
const frame = performance.now() | 0; // 단순화(정교한 프레임 id는 rAF에서 관리 권장)
if (cacheFrame !== frame) {
cachedRect = el.getBoundingClientRect();
cacheFrame = frame;
}
return cachedRect;
}
더 실무적인 방식은 다음입니다.
- rAF 시작 시점에 필요한 요소들의 rect를 한 번에 수집(read batch)
- 이후 로직에서 그 값을 참조만 함
- DOM 변경(write)이 일어나면 “측정값 무효화” 플래그를 세움
또한 크기 변화를 감지해야 한다면 ResizeObserver를 사용해 “변화가 있을 때만” 재측정합니다.
const ro = new ResizeObserver((entries) => {
for (const e of entries) {
// e.contentRect 기반으로 필요한 계산만 수행
}
});
ro.observe(document.querySelector('.panel'));
7단계: 구조적 해결 — 가상화/컨테인먼트/레이아웃 격리
리스트/테이블처럼 DOM 자체가 큰 경우, 미세 최적화만으로는 한계가 있습니다. 이때는 구조를 바꿔야 합니다.
(1) 리스트 가상화(virtualization)
- 화면에 보이는 영역 + 버퍼만 렌더링
- 나머지는 DOM에서 제거(또는 재사용)
- React라면
react-window,react-virtualized같은 라이브러리 고려
(2) CSS Containment로 레이아웃 영향 범위 제한
contain은 요소 내부 변경이 바깥 레이아웃에 미치는 영향을 줄여, 계산 범위를 좁힙니다.
.card-list {
contain: layout paint;
}
- 적용 가능 여부는 레이아웃 요구사항에 따라 다릅니다(외부와의 크기 의존성이 있으면 제한).
(3) 스크롤 성능: content-visibility
긴 페이지에서 보이지 않는 영역의 렌더링 비용을 낮출 수 있습니다.
.section {
content-visibility: auto;
contain-intrinsic-size: 1px 800px; /* 대략적인 placeholder 크기 */
}
자주 하는 오해 5가지 (실전 체크리스트)
- Forced reflow는 무조건 나쁜가?
- “필요 이상으로 자주”가 문제입니다. 한 번의 강제 레이아웃이 아니라, 반복/중첩이 병목을 만듭니다.
- console 경고가 없으면 안전한가?
- 아닙니다. Performance 트레이스에서 Layout 시간이 커지면 사용자 체감은 이미 나쁠 수 있습니다.
- getBoundingClientRect()는 항상 금지인가?
- 금지보다 “배치와 캐싱”이 핵심입니다.
- will-change면 해결되는가?
- 레이아웃 스래싱을 해결하지 못합니다. transform 애니메이션을 돕는 도구일 뿐입니다.
- 스크롤 이벤트에서 setState(React)하면 왜 느린가?
- 상태 변경 → 렌더 → DOM 변경(write) + 측정(read)이 얽히면 강제 레이아웃이 폭발합니다. rAF로 묶고, 측정/반영 단계를 분리하세요.
마무리: 7단계 요약
- 1) 측정: Performance로 Layout 강제 지점과 콜스택 확보
- 2) 트리거 파악: offset*/getBoundingClientRect/getComputedStyle 등 read API 확인
- 3) 배치: read phase와 write phase 분리
- 4) rAF: 고빈도 이벤트는 프레임당 1회로 쓰기 제한
- 5) 애니메이션 치환: layout 기반(top/left/width/height) → transform/opacity
- 6) 캐싱/옵저버: 같은 프레임 반복 측정 제거, ResizeObserver로 변화 기반 업데이트
- 7) 구조 변경: 가상화, contain, content-visibility로 계산 범위 자체 축소
Forced reflow는 “브라우저가 게으르게 최적화하려던 계획을 JS가 깨뜨린 흔적”입니다. 읽기/쓰기 순서를 정리하고, 프레임 경계로 작업을 몰아넣고, 필요하면 구조를 바꾸는 것—이 3축만 지키면 대부분의 경고는 사라지고 체감 성능도 같이 올라갑니다.
추가로, 성능 문제는 종종 한 번에 끝내기보다 단계적 개선과 검증이 중요합니다. 진단-해결을 반복하는 운영 관점의 글로는 Cloudflare 520·521, Nginx·ALB 로그로 30분 진단도 참고할 만합니다.