Lighthouse Forced reflow: 의미와 해결 방법
스크립트가 페이지를 변경한 직후 크기를 조회하면 브라우저는 레이아웃을 즉시 다시 계산합니다. 이로 인해 탭 반응이 멈칫거리는 경우와 무해한 경우를 다룹니다.
Lighthouse에서 Forced reflow는 무엇을 의미합니까?
Forced reflow는 스크립트가 페이지를 변경한 직후 크기를 조회하여 브라우저가 레이아웃을 즉시 다시 계산해야 하는 현상을 말합니다. Lighthouse는 이러한 스크립트를 Forced reflow 인사이트("A forced reflow occurs when JavaScript queries geometric properties")에 표시합니다. 한 번 발생하는 것은 정상이지만, 루프 안에서 반복되면 탭 반응이 멈칫거립니다. 스크립트가 하던 작업을 CSS로 처리하거나, 스타일을 변경하기 전에 모든 크기를 먼저 읽으세요.
Forced reflow 항목에는 무엇이 표시됩니까?
Lighthouse 13과 PageSpeed Insights에서는 이를 다음과 같이 설명합니다. "A forced reflow occurs when JavaScript queries geometric properties (such as offsetWidth) after styles have been invalidated by a change to the DOM state. This can result in poor performance. Learn more about forced reflows and possible mitigations." 페이지 로드 중에 스크립트가 30 ms 이상의 레이아웃 작업을 한 번에 강제 실행하면 이 항목이 빨간색으로 표시됩니다.
아래 리포트는 WordPress 휴가용 렌탈 사이트처럼 직접 제작한 캐빈 숙소 목록 페이지의 결과입니다. 이 페이지의 테마 스크립트는 모든 설명을 세 줄로 자르고 카드 높이를 동일하게 맞춥니다.

모바일 환경의 Lighthouse 13.5, DevTools 스로틀링(Slow 4G, 4x CPU).
- Top function call: 레이아웃 작업을 시작한 외부 함수입니다. 축약된 파일 경로에 마우스를 올리면 테마, 플러그인 또는 앱 이름이 포함된 전체 경로를 확인할 수 있습니다. 다른 도메인의 파일은 세션 레코더와 같은 서드파티 태그인 경우가 많습니다(수정할 수 없는 스크립트).
- Source: 크기를 조회한 각 코드 라인입니다.
- Total reflow time: 해당 함수나 코드 라인으로 인해 발생한 레이아웃 시간입니다.
- Unscored: 이 인사이트는 Performance 점수에 직접 반영되지 않습니다. 하지만 그 원인이 되는 long task(페이지가 응답할 수 없는 50 ms 이상의 구간)가 Total Blocking Time을 통해 점수를 떨어뜨립니다.
상단 행의 수치(388 ms)가 Source 행들의 합계(743 ms)보다 작은 이유는 로드 중에 캐빈 스크립트가 두 번 실행되었고, 상단 행에는 그중 더 오래 걸린 실행만 표시되기 때문입니다.
스크립트가 브라우저에 레이아웃 재계산을 강제하는 이유는 무엇입니까?
위 캐빈 목록 페이지에서는 "All" 필터를 탭하면 48개의 카드가 나타나기 전에 페이지가 1/3초 동안 멈춥니다.
레이아웃은 브라우저가 각 요소의 위치와 크기를 계산하는 단계입니다. 일반적으로 스크립트 실행이 끝난 후 프레임당 한 번씩 수행됩니다. 하지만 스크립트가 페이지를 변경한 뒤(텍스트, 클래스, 높이 등) offsetHeight, getBoundingClientRect(), scrollTop, getComputedStyle() 같은 속성으로 크기를 읽으면, 브라우저는 값을 반환하기 위해 스크립트 실행 도중 즉시 레이아웃을 계산해야 합니다. 리플로우를 유발하는 전체 조회 목록은 Paul Irish가 정리한 목록에서 확인할 수 있습니다.
단 한 번의 forced reflow는 브라우저가 어차피 잠시 뒤에 처리했을 레이아웃 작업을 조금 앞당겨 하는 것에 불과합니다. 하지만 변경과 조회가 반복되는 루프에서는 매 반복마다 전체 레이아웃이 일어납니다. 바로 layout thrashing입니다. 캐빈 테마는 각 설명을 다음과 같이 자릅니다.
while (el.offsetHeight > max && words.length > 1) {
words.pop();
el.textContent = words.join(' ') + '…';
} 단어를 하나씩 제거할 때마다 텍스트가 변경되므로, 다음 offsetHeight 조회 시 새로운 레이아웃이 강제됩니다. 모든 카드의 단어마다 레이아웃이 한 번씩 일어나는 셈입니다.
Forced reflow는 페이지 속도를 얼마나 느리게 만듭니까?
테마 스크립트를 사용한 캐빈 목록(Before)과, CSS로 텍스트를 자르고 카드 높이를 맞춘 버전(After)의 속도를 측정했습니다. 두 버전의 화면은 완전히 동일합니다.
| Before | After | |
|---|---|---|
| Lighthouse 점수 | 84 | 92 |
| Total Blocking Time | 360 ms | 0 ms |
| Total reflow time | 743 ms | 없음 |
| "All" 탭 | 320 ms | 16 ms |

Before

After
모바일 환경의 Lighthouse 13.5, DevTools 스로틀링.
8점의 차이는 Total Blocking Time에서 발생합니다. 강제 레이아웃이 페이지 차단 시간을 늘리기 때문입니다. 방문자가 실제로 체감하는 것은 탭 반응이며, 이는 탭과 클릭에 페이지가 얼마나 빠르게 반응하는지 측정하는 Core Web Vitals 지표인 INP (Interaction to Next Paint)에 반영됩니다. 320 ms는 양호한 INP 기준인 200 ms를 초과하는 수치이며, 그중 298 ms가 스크립트가 강제한 레이아웃 시간이었습니다. 반면 16 ms는 단 한 프레임에 불과합니다. Lighthouse는 직접 탭 동작을 수행하지 않으므로, Chrome에서 CPU 속도를 스마트폰 수준으로 낮추고 탭 반응을 측정했습니다.
Forced reflow가 무해한 경우는 언제입니까?
- 로드 중 나타나는 수치가 작은 행. 30 ms를 살짝 넘는 행은 스크립트가 크기를 요청해 페이지의 첫 번째 레이아웃이 조금 일찍 실행된 경우가 많습니다. 모든 높이를 먼저 읽도록 수정한 캐빈 목록 테마 스크립트는 Lighthouse 4회 측정 중 1회에서 37 ms짜리 행이 하나 나타났지만, "All" 탭 반응 시간은 16 ms였습니다.
- "[unattributed]" 행. 특정 코드 라인이 지정되지 않은 경우입니다. Lighthouse를 다시 실행했을 때 해당 행이 사라졌다면 그냥 두어도 됩니다.
- 애니메이션을 시작하는 스크립트. Bootstrap의 모달이나 일부 팝업 플러그인은 CSS transition을 시작하기 위해 의도적으로 크기를 읽습니다.

모바일 환경의 Lighthouse 13.5, DevTools 스로틀링.
총 reflow 시간이 수백 밀리초에 달하거나, 한 파일에 수많은 행이 표시되거나, 필터, 메뉴, 슬라이더처럼 스크립트가 탭할 때도 실행된다면 문제를 해결해야 합니다.
탭할 때 어떤 스크립트가 forced reflow를 일으킵니까?
Lighthouse는 페이지 로드만 측정하므로, Performance 패널에서 반응이 느린 탭 동작을 직접 기록하세요:
- DevTools(F12)를 열고 Performance 탭으로 이동한 뒤, 노트북이 모바일 기기처럼 동작하도록 CPU를 4배 감속(4x slowdown)으로 설정하세요.
- Record를 클릭하고, 반응이 느린 요소를 탭한 뒤 기록을 중지하세요.
- Insights 사이드바에서 Forced reflow 항목을 여세요. "Call frames that trigger reflow"에 해당 함수와 코드 라인이 표시됩니다.

캐빈 목록에서 "All"을 한 번 탭했을 때의 결과: cabins.js의 clampText.
콘솔 로그 레벨에서 Verbose를 켜면 "[Violation] Forced reflow while executing JavaScript took 38ms"와 같이 동일한 내용이 출력됩니다.
Lighthouse에 jquery.min.js만 표시된다면 테마나 플러그인을 대신해 jQuery가 조회를 수행한 것입니다. Performance 패널에서 새로고침을 기록하고 빨간색 삼각형이 표시된 Layout 블록을 클릭하세요. Summary 탭의 Function stack 아래에서 jQuery 코드 라인 사이에 끼어 있는 파일(예: /wp-content/plugins/<name>/ 경로)이 바로 원인 플러그인입니다.
field data에는 실제 방문자의 탭과 클릭 데이터가 기록됩니다. CoreDash에서 INP, Breakdown을 열고 LOAF별로 그룹화하세요. Chrome은 속도가 느린 프레임마다 어떤 스크립트가 실행되었는지 기록하므로(Long Animation Frames), 각 행에는 스크립트와 해당 스크립트가 실행된 인터랙션의 INP가 표시됩니다. 클릭이나 탭 핸들러 내의 forced reflow는 processing(각 막대의 보라색 부분)으로 집계되므로, 보라색 부분이 긴 느린 스크립트부터 먼저 확인하세요.

CoreDash의 스크립트별 INP(데스크톱, p75): 녹색은 input delay, 보라색은 processing, 파란색은 presentation.
Forced reflow는 어떻게 해결합니까?
먼저 Top function call에 나타난 파일부터 확인하고, CSS로 해당 스크립트의 작업을 대체할 수 있는지 검토하세요. 세 줄로 자르는 텍스트(line-clamp), 카드 높이 일치(grid), 상단에 고정되는 헤더(position: sticky), 비율을 유지하는 이미지 박스(aspect-ratio)는 스크립트가 전혀 필요하지 않으며, 스크립트가 없으면 레이아웃이 강제될 일도 없습니다.
아래 방법으로 수정한 후 동일한 탭 동작을 다시 기록해 보세요. Insights 사이드바에서 Forced reflow 항목이 사라져야 합니다. 그런 다음 Lighthouse와 field data에서 결과를 확인하세요. Search Console에는 반영되기까지 최대 28일이 걸립니다.
WordPress 테마, 플러그인, 페이지 빌더
Top function call의 경로를 보면 테마(/wp-content/themes/)인지 플러그인(/wp-content/plugins/)인지 알 수 있습니다. 먼저 관련 설정이 있는지 찾아보세요. Elementor의 "섹션 늘이기(Stretch Section)"는 로드 및 리사이즈마다 JavaScript로 섹션 너비를 넓히지만, 전체 너비 컨테이너가 적용된 Elementor Full Width 페이지 레이아웃을 사용하면 CSS만으로 처리됩니다. Swiper 슬라이더에서 슬라이드 개수(slides per view)를 "auto"로 설정하면 모든 슬라이드의 크기를 측정하지만, 고정된 숫자를 지정하면 크기를 직접 조회하지 않고 너비를 계산합니다.
WP Rocket이나 유사한 플러그인에서 JavaScript 실행을 지연하면 알림은 사라지지만, 방문자가 처음 탭하거나 스크롤할 때 스크립트가 실행되면서 동일하게 레이아웃이 강제됩니다. 알림을 숨기기보다 스크립트를 직접 수정하거나, 플러그인 개발자에게 문제를 제보하고 개선되지 않으면 다른 플러그인으로 교체하세요.
테마가 JavaScript로 텍스트를 자르고 있다면 자식 테마(child theme)에서 CSS로 처리하세요. 테마에서 텍스트에 사용하는 클래스를 적용하면 됩니다.
.cabin .desc {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
overflow: hidden;
} 그런 다음 테마 옵션이나 핸들명을 인자로 넣은 wp_dequeue_script()를 사용해 테마의 clamp 스크립트를 비활성화하세요.
Shopify 테마와 앱
테마 에디터의 앱 임베드(App embeds)에서 앱을 하나씩 끄면서 Lighthouse를 다시 실행해 보세요. 알림이 사라지면 해당 앱의 개발자에게 문제를 제보하거나 다른 앱으로 교체하세요. Top function call에 /assets/ 경로가 있다면 테마 자체 파일이거나 앱이 코드를 추가한 테마 파일입니다. Dawn 및 Dawn 기반 테마는 대부분 크기를 한 번만 읽고 변경 사항을 일괄 처리하므로 앱부터 먼저 확인하세요.
직접 작성한 JavaScript 및 React
스크립트에서 크기 측정이 불가피하다면 모든 크기를 먼저 읽은 뒤 변경 사항을 적용하세요. 그러면 한 번의 레이아웃으로 모든 조회를 해결할 수 있습니다. 각 패널의 높이를 설정하는 아코디언 예시입니다.
const panels = [...document.querySelectorAll('.panel')];
// 패널마다 레이아웃 발생: 읽기, 쓰기, 읽기, 쓰기
panels.forEach(p => { p.style.maxHeight = p.scrollHeight + 'px'; });
// 한 번의 레이아웃: 모든 높이를 먼저 읽고 한 번에 쓰기
const heights = panels.map(p => p.scrollHeight);
panels.forEach((p, i) => { p.style.maxHeight = heights[i] + 'px'; }); - 리사이즈 및 스크롤: ResizeObserver와 IntersectionObserver는 브라우저가 방금 완료한 레이아웃의 크기 정보를 제공합니다. resize나 scroll 이벤트 핸들러에서 직접 크기를 읽지 말고 이 API들을 사용하세요.
- React:
useLayoutEffect에서 한 번만 측정하고 state를 한 번만 설정하세요. 메뉴, 팝오버, 툴팁은 열릴 때 마운트하여, 닫혀 있는 요소가 로드 중에 크기를 측정하지 않도록 만드세요.
수정할 수 없는 스크립트
세션 레코더와 히트맵 태그는 의도적으로 크기를 읽습니다. Microsoft Clarity는 시작할 때 페이지 크기 수십 개를 읽고 방문자가 클릭하는 모든 요소의 위치를 조회합니다. 태그를 제거하거나, 방문자의 일부만 샘플링하여 기록하거나, 분석이 필요한 특정 페이지에만 로드하세요.
교체할 수 없는 플러그인이나 앱에서 forced reflow가 발생합니까? 혹은 내 스마트폰에서는 괜찮은데 방문자의 기기에서만 탭 반응이 멈칫거립니까? Core Web Vitals power hour에서는 가장 느린 탭 반응의 원인이 되는 스크립트를 찾아내고, 이를 직접 수정할지, 교체할지, 삭제할지 함께 결정합니다.