Lighthouse와 PageSpeed의 Layout shift culprits 해결하기
페이지 로드 중 무엇이 움직였는지 알아보고, Lighthouse가 지목한 요소를 확인하며, 이미지, 폰트, 광고, 상단 바, 늦게 로드되는 CSS로 인한 레이아웃 이동을 해결하는 방법을 설명합니다.
PageSpeed Insights에서 Layout shift culprits가 왜 빨간색으로 표시될까요?
로드 중 발생한 레이아웃 이동의 합이 CLS 0.1 이상이면 Layout shift culprits가 빨간색으로 표시됩니다: "레이아웃 이동은 사용자 상호작용 없이 요소가 움직일 때 발생합니다." 이전 Lighthouse에서는 이 항목을 Avoid large layout shifts라고 불렀습니다. 이 항목은 이동한 요소를 나열하고 원인을 추정합니다. 늦게 로드되는 요소를 위한 공간을 미리 확보하세요. 이미지에는 width와 height를, 광고 슬롯에는 height를 지정해야 합니다.
빠른 해결책만 확인하고 싶다면 이미지, 웹 폰트, 광고 및 임베드, 배너 항목으로 바로 이동하세요.
Layout shift culprits 표는 어떻게 읽을까요?
Lighthouse는 모바일 화면 크기에서 페이지를 한 번 로드한 뒤 발생한 레이아웃 이동을 버스트(1초 이내에 연속으로 일어난 이동) 단위로 묶어 표 하나씩 나열합니다. 가장 심한 버스트의 점수가 0.1 이상이면 알림이 빨간색으로 표시되고, 그 미만이면 회색으로 표시됩니다. 회색으로 뜨더라도 0을 목표로 잡으세요. 0.1 미만의 이동도 방문자가 읽던 텍스트를 밀어내기 때문입니다.
웹 폰트 헤드라인, 뉴스레터 플러그인, 헤더 아래 광고, 레시피 카드 플러그인을 적용해 WordPress 요리 블로그처럼 만든 레시피 페이지에서는 버스트 하나가 0.241점을 기록하며 알림이 빨간색으로 표시되었습니다.

Lighthouse 13.5 모바일 환경(PageSpeed Insights와 동일한 시뮬레이션 스로틀링 적용)에서의 레시피 페이지 알림.
- Total은 해당 버스트의 점수입니다. 가장 심한 버스트의 Total이 바로 Lighthouse가 보고하는 CLS입니다.
- Element는 해당 이동에서 가장 크게 움직인 요소로,
<main>같은 대형 컨테이너인 경우가 많습니다. 이를 밀어낸 요소는 보통 그 바로 위에 있습니다. - Layout shift score는 해당 단일 이동이 Total에서 차지하는 점수입니다.
- 각 행 아래 표시된 줄은 Lighthouse가 추정한 원인입니다. 이미지 정보와 함께 "Unsized image element"로 뜨거나, 폰트 파일과 함께 "Web font"로 표시됩니다. 둘 다 표시되거나 아무것도 없을 수도 있습니다.
레시피 페이지에서 Lighthouse는 4번의 이동 중 3번이나 잘못된 이미지를 원인으로 지목했습니다. 무언가를 수정하기 전에 실제로 어떤 요소가 움직였는지 먼저 확인하세요.
로드 중에 페이지가 왜 덜컥거릴까요?
방문자가 본문을 읽기 시작하는 순간 텍스트가 밀려나거나, 링크를 누르려 했는데 그 자리에 끼어든 엉뚱한 요소를 탭하게 됩니다.
브라우저는 가지고 있는 리소스로 먼저 화면을 렌더링합니다. 미리 확보된 공간 없이 뒤늦게 도착하는 요소는 그 아래의 콘텐츠를 밀어냅니다. 크기가 지정되지 않은 이미지는 높이 0에서 시작하고, 웹 폰트는 fallback 폰트보다 헤드라인을 더 넓게 렌더링합니다. 이러한 움직임 하나하나가 레이아웃 이동이며, 화면의 얼만큼이 얼마나 멀리 이동했는지를 기준으로 점수를 매깁니다. 간격이 1초 미만인 이동들은 하나의 버스트로 묶이며, CLS는 가장 심한 버스트의 점수입니다. 따라서 짧은 간격으로 일어난 작은 이동들이 합산되어 점수가 커집니다.
레시피 페이지의 경우, 각 원인의 단독 점수는 모두 0.1 미만이었지만 네 가지가 한 버스트에 몰아서 발생했습니다:
| 레시피 페이지의 발생 원인 | 단독 CLS | 수정 후 |
|---|---|---|
| 헤드라인 웹 폰트 | 0.035 | 0 |
| width와 height가 없는 레시피 사진 | 0.036 | 0 |
| 헤더 상단 뉴스레터 바 | 0.058 | 0 |
| 빈 슬롯에 들어간 광고 | 0.054 | 0 |
| 네 가지 모두 합산 | 0.241 | 0 |
네 가지를 합친 점수가 각 행의 단순 합계보다 높은 이유는 전체 페이지에서 폰트와 사진이 화면의 더 넓은 영역을 밀어내기 때문입니다. 네 가지 원인을 모두 수정하자 FCP, LCP, Total Blocking Time은 그대로 유지되면서 모바일 Performance 점수가 87점에서 100점으로 올랐습니다. CLS는 해당 점수에서 4분의 1을 차지하지만, Google이 순위를 매길 때 반영하는 것은 실제 방문자가 겪는 CLS입니다.

수정 전, first paint 시점

수정 전, 2초 시점

수정 후, first paint 시점

수정 후, 2초 시점
Lighthouse의 모바일 화면 크기로 본 레시피 페이지. 점선 상자는 first paint 시점에 사진이 있던 자리입니다.
Lighthouse가 올바른 요소를 지목했을까요?
Lighthouse는 타이밍을 보고 원인을 추정합니다. 이동 직전에 다운로드가 끝난 웹 폰트나, 이동 직후에 브라우저가 렌더링한 이미지 같은 것들입니다. 따라서 다른 요소에 밀려나기만 한 이미지도 "Unsized image element"로 목록에 오를 수 있습니다. 레시피 페이지의 대표 이미지는 width="1200" height="675" 속성을 가지고 있었지만, Lighthouse는 폰트, 뉴스레터 바, 광고로 인한 이동 아래에 이 이미지를 원인으로 지목했습니다. 아래 줄에 아무런 설명이 없는 행이라면 2단계부터 시작하세요.
- 지목된 이미지에 크기가 지정되어 있나요? 동일한 보고서의 진단(Diagnostics) 섹션에 있는 "Image elements do not have explicit width and height" 항목에 크기가 없는 이미지들이 나열됩니다. DevTools에서는 공간이 예약되어 있을 경우 이미지의 계산됨(Computed) 탭에서 aspect-ratio 비율(auto 1200 / 675)이 표시되고, 예약되지 않았으면 auto만 단독으로 표시됩니다. 테마에서 width: auto를 지정한 경우(아래의 Unsized image element 참고)를 제외하면, 크기가 지정된 이미지는 이동을 일으키지 않습니다. 행 아래의 다른 원인 줄(대개 "Web font")을 확인하거나 2단계로 넘어가세요.
- 그 순간에 무엇이 나타났나요? Performance 패널에서 새로고침을 기록하세요. 각 이동은 Layout shifts 트랙에 보라색 다이아몬드로 표시됩니다. 마우스를 올리면 점수와 함께 이동한 영역이 표시된 해당 순간의 스크린샷을 볼 수 있습니다. 레시피 페이지의 0.0583점짜리 이동에서는 상단에 방금 나타난 초록색 뉴스레터 바가 확인됩니다. 스크롤 중에 발생하는 이동을 확인하려면 기록(Record)을 클릭하고 스크롤한 뒤 중지(Stop)를 누르세요.
- 원인으로 의심되는 요소를 차단하고 새로고침하세요. Network 패널에서 폰트 파일이나 광고 태그처럼 의심되는 요청을 마우스 오른쪽 버튼으로 클릭한 뒤, Block requests를 선택하고 Block request URL을 누른 다음 다시 기록하세요. 사라진 다이아몬드가 바로 그 요청 때문에 발생한 이동입니다. 레시피 페이지의 폰트를 차단하자 이동 하나가 사라지고 나머지 세 개만 남았습니다. 확인이 끝나면 Request conditions 서랍에서 차단 체크를 해제하세요.

레시피 페이지의 2단계 테스트: Chrome 155, Slow 4G 및 4× CPU 감속.
DevTools 없이도 CoreDash의 cRUM을 사용하면 이 검사를 수행할 수 있습니다. 페이지의 Chrome 트레이스를 실제 방문자가 겪은 데이터와 나란히 배치해, 이동한 요소를 지목하고 방문자가 그 직전에 무엇을 했는지 보여줍니다. 아래 스크린샷은 다른 페이지의 사례입니다. Chrome trace와 Real users부터 확인하세요.

acme-pagespeed.com 모바일의 한 페이지에 대한 CoreDash cRUM.
실제 방문자에게는 어떤 요소가 이동할까요?
Lighthouse는 첫 방문자 기준으로 페이지를 한 번만 로드하며, 스크롤이나 탭 동작을 전혀 수행하지 않습니다. 반면 field data에는 방문자가 스크롤할 때 로드되는 본문 중간 광고나 스크롤 시 축소되는 헤더 등 모든 실제 방문 데이터가 기록됩니다. Lighthouse에서는 문제가 없거나 회색으로 표시되는데 Search Console에서 CLS가 나쁨(poor)으로 나온다면, 이동은 대개 페이지 로드 이후에 발생합니다.
CoreDash에서 기기를 모바일로 설정하고 Attribution Element (CLS)별로 CLS를 그룹화하면 어떤 요소가 이동했는지 확인할 수 있습니다. More dimensions 아래의 Last interaction (CLS)는 해당 이동이 스크롤 후에 일어났는지 알려줍니다. 이 요소들을 밀어낸 원인을 찾으려면 위의 점검 2단계처럼 Performance 패널에서 스크롤하면서 페이지를 기록하세요.

모바일 CoreDash, p75: CLS가 가장 높은 요소부터 시작하세요.
Layout shift culprits는 어떻게 해결할까요?
위의 점검을 통해 원인이 확인되었다면, 가장 높은 Total을 기록한 표에서 점수가 가장 큰 이동부터 해결하세요.
Unsized image element
크기가 지정되지 않은 이미지는 높이 0에서 시작하여, 로드될 때 그 아래의 모든 콘텐츠를 아래로 밀어냅니다. WordPress는 미디어 라이브러리의 이미지에 width와 height를 자동으로 추가하지만, 테마, 페이지 빌더, 슬라이더 또는 플러그인이 자체적으로 출력하는 이미지(예: 레시피 카드 사진)에는 이 속성이 빠져 있는 경우가 많습니다. 테마를 직접 수정할 수 없다면 WP Rocket을 사용해 누락된 크기를 추가할 수 있습니다(Media의 Add missing image dimensions). 템플릿 코드라면 직접 작성해야 합니다:
<img src="shakshuka.jpg" width="800" height="600" alt="Shakshuka in the pan">
img { max-width: 100%; height: auto; } 브라우저는 이 두 속성을 종횡비로 변환하여 파일이 도착하기 전에 어떤 화면 너비에서든 알맞은 높이를 미리 확보합니다. 테마 CSS의 width: auto는 이 비율 계산을 무효화합니다(자동 크기 조절 이미지 참고). Shopify에서는 image_tag 필터가 크기를 추가해 줍니다. <img>를 직접 출력하는 테마나 앱이라면 width="{{ image.width }}" height="{{ image.height }}"를 지정해야 합니다. Next.js의 next/image에는 width와 height를 전달하거나, 자체 크기가 있는 부모 요소 안에서 fill을 사용해야 합니다.
Web font
헤드라인은 처음에 fallback 폰트로 표시된 뒤 더 넓거나 좁은 웹 폰트로 바뀝니다. 줄바꿈 위치가 달라지면서 그 아래의 모든 요소가 움직이게 됩니다. WordPress(추가 CSS) 및 Shopify(코드 편집의 테마 스타일시트, 단 테마 업데이트 시 초기화될 수 있음)에서는 로컬 폰트의 크기를 웹 폰트의 측정값에 맞게 조절하는 fallback font face를 추가합니다. Next.js에서는 next/font가 크기가 일치하는 fallback을 자동으로 추가합니다. 레시피 페이지의 헤드라인 폰트 설정은 다음과 같습니다:
@font-face {
font-family: "Young Serif Fallback";
src: local("Times New Roman"), local("Liberation Serif");
size-adjust: 131%;
ascent-override: 79.85%;
descent-override: 27.94%;
line-gap-override: 0%;
}
h1 { font-family: "Young Serif", "Young Serif Fallback", serif; } 계산기로 구한 size-adjust 값 122%를 적용했을 때는 여전히 0.029의 CLS가 발생했지만, 131%에서는 0이 되었습니다. Font display에서 Android용 font face, 직접 수치를 구하는 방법, 그리고 레이아웃 이동을 방지하고 첫 방문자에게 fallback을 보여주는 font-display: optional 설정을 확인할 수 있습니다.
광고, 동영상 및 기타 임베드
광고 스크립트는 경매가 끝난 후 슬롯에 iframe을 삽입합니다. 슬롯이 비어 있었다면 광고 높이만큼 그 아래의 모든 요소가 뚝 떨어집니다. State of Web Vitals에 따르면 광고 네트워크가 없는 사이트의 모바일 CLS 통과율은 89%인 반면, 2개 이상의 광고 네트워크를 사용하는 사이트는 73%에 불과합니다. 이는 상관관계입니다.
테마나 광고 플러그인에서, 가장 자주 노출되는 크기의 높이를 광고 컨테이너에 지정하고 iframe을 block으로 설정하세요:
.ad-slot { min-height: 50px; }
.ad-slot iframe { display: block; } iframe은 기본적으로 텍스트 라인 위에 배치(inline)되므로, 레시피 페이지의 예약된 슬롯도 iframe을 block으로 지정하기 전까지는 여전히 8픽셀 늘어났습니다. 광고가 없더라도 슬롯은 비워 둔 채 유지하세요. 슬롯을 닫아버리면 광고로 채워질 때만큼이나 페이지가 이동합니다. AdSense 자동 광고는 Google이 결정한 위치에 들어갑니다. 첫 화면에 해당하는 영역을 제외하고(광고 설정의 제외된 영역), 그 자리에 크기가 지정된 슬롯과 수동 광고 단위를 배치하세요.
YouTube, 지도 및 기타 임베드도 iframe입니다. iframe에서는 width와 height 속성이 종횡비를 제공하지 않으므로, height: auto를 지정하면 높이가 약 150픽셀로 줄어듭니다. iframe에 aspect-ratio를 부여하거나, 클릭 시 iframe으로 바뀌는 크기가 고정된 썸네일인 퍼사드(facade)를 사용하세요(YouTube, Google Maps 참고):
.video-embed iframe { width: 100%; height: auto; aspect-ratio: 16 / 9; } 스크립트가 삽입하는 바 및 배너
스크립트가 헤더 상단에 삽입하는 뉴스레터 바나 무료 배송 배너는 페이지 전체를 아래로 밀어냅니다.
플러그인이나 앱의 레이아웃 설정부터 확인하세요. 하단 바, 박스, 팝업 형태는 페이지 위에 떠 있으므로 레이아웃을 전혀 이동시키지 않습니다. OneTrust, CookieYes 같은 동의 도구도 동일한 레이아웃을 제공합니다. Lighthouse는 쿠키가 없는 상태에서 시작하므로 실행할 때마다 이들 배너를 보게 됩니다. 반드시 헤더 위에 위치해야 하는 바라면, 모바일 너비에서 계산됨(Computed) 탭에 표시되는 높이만큼 테마에서 공간을 미리 확보해 두어야 합니다:
<div id="promo-slot" style="min-height: 48px"></div> 일부 방문자에게만 표시되는 바의 경우 오버레이 형태로 띄우거나 서버의 HTML에서 직접 렌더링하세요. Google Tag Manager 태그로 삽입되는 시각적 콘텐츠는 항상 first paint 이후에 도착하므로 템플릿 안으로 옮겨야 합니다. Shopify에서 리뷰 별점이나 공지 바 같은 위젯을 삽입하는 앱을 쓸 때는 해당 앱 블록이 들어갈 컨테이너에 높이를 지정해야 합니다.
first paint 이후에 로드되는 CSS
media="print" 트릭으로 로드되거나 최적화 플러그인에 의해 지연 로드 또는 비동기 로드된 스타일시트는, 페이지가 화면에 표시된 후에 도착하여 기존 요소의 크기를 바꿔버립니다. 예를 들어 헤더가 커지면 그 아래의 모든 요소가 밀려납니다. 플러그인의 CSS 지연 로드 또는 비동기 로드 옵션에서 해당 스타일시트를 제외하세요. 테마에서는 head 태그 안에 일반적인 <link rel="stylesheet">로 로드하세요.
애니메이션, 슬라이더 및 JavaScript가 렌더링하는 블록
이러한 문제는 대개 테마, 플러그인, 또는 앱에서 비롯됩니다. 관련 설정이 있는지 먼저 찾아보세요. 설정이 없다면 개발자에게 보고서에 명시된 요소와 아래 코드를 전달하세요.
top, left, height, margin을 사용하는 애니메이션은 매 프레임마다 레이아웃을 이동시킵니다. Lighthouse는 이를 원인 설명 없이 여러 개의 작은 행으로 나열하며, 진단(Diagnostics)의 "Avoid non-composited animations" 항목에 해당 요소를 표시합니다. 대신 transform에 애니메이션을 적용하세요. 주변 요소에는 영향을 주지 않고 해당 요소의 픽셀만 움직이므로 브라우저가 레이아웃 이동으로 집계하지 않습니다.
.promo { position: fixed; top: 0; left: 0; right: 0;
transform: translateY(-100%); transition: transform .3s; }
.promo.is-open { transform: translateY(0); } 슬라이더는 스크립트가 실행되기 전까지 슬라이드들을 세로로 쌓아 표시합니다. 스크립트가 준비 완료 클래스를 추가할 때까지 첫 번째 슬라이드만 표시되도록 유지하세요(사용 중인 슬라이더의 클래스를 적용하세요):
.slider:not(.is-ready) > :not(:first-child) { display: none; } 후기나 관련 상품처럼 로드 후 JavaScript가 렌더링하는 블록은 최종 높이만큼의 빈 컨테이너를 미리 두거나 서버에서 렌더링해야 합니다. 스크롤 시 발생하는 애니메이션에 대해서는 스크롤 유발 애니메이션 및 CSS 전환(transitions) 문서를 참고하세요.
직접 수정할 수 없는 코드에서 발생하는 이동
광고 소재나 레이아웃 설정이 없는 앱, 외부 공급업체 스크립트가 삽입하는 내용을 직접 변경할 수는 없습니다. 평소 차지하는 높이만큼 공간을 미리 예약하거나, 첫 화면 아래에 배치하거나, 지정된 공간을 벗어나지 않는 것으로 교체하세요.
변경 후 CLS가 0이 되었을까요?
캐싱 플러그인과 CDN 캐시를 비운 뒤 모바일 Lighthouse에서 수정 결과를 확인하세요. 모든 이동이 해결되면 Layout shift culprits는 통과 항목으로 표시되고 CLS는 0이 됩니다. 새로고침 시 1회, 스크롤 시 1회 각각 Performance 패널을 기록해 보면 Layout shifts 트랙이 비어 있게 됩니다. CoreDash는 다음 방문부터 요소별 새 CLS를 즉시 보여주지만, Search Console에 반영되기까지는 최대 28일이 걸립니다.

수정 전

수정 후
네 가지 수정 전후 비교: Lighthouse 13.5, 모바일, 시뮬레이션 스로틀링.
일부 이동은 직접 코드를 열어볼 수 없는 곳에서 발생합니다. 광고 파트너가 채우는 광고 슬롯, 자체 위젯을 주입하는 앱, 실제 방문자가 스크롤할 때만 발생하는 이동 등이 그렇습니다. Core Web Vitals 파워 아워에서는 field data와 페이지 기록을 함께 살펴보며 어떤 컨테이너에 어느 정도의 높이를 지정할지 결정합니다.

더 빠른 사이트가 필요하신가요?
저는 Arjen입니다. 17년 동안 느린 사이트를 고쳐 왔습니다. 직접 해결하기 어려운 문제가 있다면 URL을 보내 주세요. 편하게 이야기 나눠요.
URL 보내기
수정했는데 왜 효과가 없을까요?
때로는 CDN이 아직 이전 HTML을 제공하거나, 수정이 데스크톱 템플릿에만 들어가고 모바일은 다른 템플릿을 쓰는 경우가 있습니다. URL과 문제를 보내 주시면 가능한 시간대를 몇 가지 회신해 드립니다. 먼저 field data를 살펴보고, 사이트를 1시간 함께 본 뒤, 하루 안에 정리 노트를 서면으로 보내 드립니다. €350 (부가세 별도).
URL 보내기수정을 구현할 개발자가 없으신가요? 사이트 복사본에서 제가 직접 작업합니다.