CLS 개선하기: 레이아웃 이동 원인 분석부터 해결까지
문제해결 | | 13분 읽기
Google Search Console에서 모바일 홈 페이지의 CLS가 0.104로, 권장 기준인 0.1을 초과한 SEO 리포트를 확인했다.
원인을 확인하기 위해 상용 환경에서 스크롤을 포함한 시나리오를 측정했고, 특정 조건에서 CLS가 0.73까지 증가하는 것을 확인했다.
Search Console의 0.104는 실제 사용자 데이터의 집계값이고, 0.73은 로컬에서 특정 조건을 재현한 측정값이다.
문제를 해결하는 과정에서 CLS 측정 방식에 대해 잘못 이해하고 있던 부분도 있었고, 처음 고려했던 해결 방법보다 더 간단한 방법을 찾기도 했다.
이번 글에서는 CLS 문제의 원인을 확인하고 개선했던 과정을 정리했다.
CLS 측정 방식 이해하기
CLS(Cumulative Layout Shift)는 페이지에서 예상하지 못한 레이아웃 이동이 얼마나 발생하는지를 나타내는 지표다.
문제를 분석하기 전에 CLS가 어떤 기준으로 측정되는지부터 확인했다.
CLS는 평균이 아닌 p75를 기준으로 평가한다.
Search Console에서는 Chrome UX Report(CrUX)의 최근 28일 데이터를 바탕으로 CLS를 평가한다. 이때 사용하는 값은 평균이 아니라 75번째 백분위수(p75)다.
예를 들어 p75가 0.104라면 측정된 사용자 경험의 약 75%는 0.104 이하라는 의미다. Search Console은 유사한 URL을 그룹으로 묶어 보고하므로, 해당 값이 홈 URL만의 측정값인지 그룹의 집계값인지도 구분해서 봐야 한다.
p75는 일부 방문의 결과나 평균값만 보는 대신, 다수 사용자의 경험이 권장 기준을 충족하는지 확인하는 데 사용한다.
CLS는 모든 레이아웃 이동을 단순히 합산하지 않는다
CLS는 세션 윈도우(session window)를 기준으로 계산한다.
레이아웃 이동이 1초 미만의 간격으로 발생하면 같은 윈도우로 묶이며, 하나의 윈도우는 최대 5초까지 이어질 수 있다.
이렇게 만들어진 윈도우 중 점수가 가장 높은 구간이 최종 CLS가 된다.
따라서 페이지 전체에서 발생한 레이아웃 이동을 모두 합산하는 것은 아니다.
페이지 로딩 중에도 CLS가 측정된다
처음에는 페이지 로딩이 완료된 이후의 레이아웃 이동을 주로 확인했다.
하지만 CLS는 페이지 로딩 중에도 측정된다. 이미지나 스켈레톤이 표시되는 동안 발생한 레이아웃 이동 역시 측정 대상이다.
또한 CLS는 초기 로딩뿐 아니라 페이지에 머무르는 동안 발생한 레이아웃 이동도 포함한다.
이번 작업에서도 페이지 로딩 이후 스크롤하는 과정에서 가장 큰 문제가 발견됐다.
요소의 크기 변화가 항상 CLS로 이어지는 것은 아니다
CLS는 요소의 크기 변화 자체보다 화면에 표시된 요소의 위치 이동을 기준으로 계산된다.
예를 들어 이미지 높이가 증가하더라도, 다른 요소의 위치에 영향을 주지 않는다면 CLS가 발생하지 않을 수 있다.
반면 이미지 높이가 변경되면서 아래에 있는 요소들이 밀려난다면 해당 이동이 CLS에 반영된다.
개별 레이아웃 이동의 점수는 다음 두 값의 곱으로 계산된다. 이 점수들을 세션 윈도우별로 합산한 뒤 가장 큰 값을 CLS로 사용한다.
Layout shift score = Impact Fraction × Distance Fraction
- Impact Fraction: 레이아웃 이동의 영향을 받은 뷰포트 영역의 비율
- Distance Fraction: 영향을 받은 요소 중 가장 큰 수평 또는 수직 이동 거리를 뷰포트의 너비와 높이 중 큰 값으로 나눈 비율
또한 뷰포트 밖에서만 발생한 이동은 일반적으로 점수에 영향을 주지 않는다.
CLS 측정 방법
Chrome DevTools 활용
먼저 Chrome DevTools의 Performance 탭을 사용해 레이아웃 이동을 확인했다.
테스트 환경은 다음과 같이 설정했다.
- 모바일 뷰포트
- CPU 4x slowdown
- Fast 4G 네트워크
Performance 탭에서 Record and reload를 실행하면 페이지 로딩 과정의 성능 데이터를 확인할 수 있다.
Layout Shifts 트랙에서 레이아웃 이동이 발생한 시점을 확인하고, 각 이벤트를 선택해 영향을 받은 요소와 이동 전후 좌표를 살펴볼 수 있다.
Playwright로 측정 자동화
동일한 조건에서 반복적으로 측정할 필요가 있어 AI를 활용하여 Playwright를 사용했다.
처음에는 페이지 로딩이 완료될 때까지만 측정했다.
하지만 실제 사용자는 페이지 로딩 이후에도 스크롤하며 콘텐츠를 탐색하기에, 이를 고려해 페이지 하단까지 스크롤하는 시나리오를 추가했고, CLS가 0.73까지 증가하는 것을 확인했다.
초기 로딩만 측정했을 때는 발견하지 못했던 문제였다. 기본 Lighthouse 실행처럼 스크롤을 포함하지 않는 테스트와 실제 사용자 데이터 사이에 차이가 생길 수 있는 이유이기도 했다.
원인 분석: 롤링 배너의 높이 변경
가장 큰 레이아웃 이동은 홈 중간에 위치한 롤링 배너에서 발생했다.
띠 배너 이미지가 커졌다가 작아지면서 아래 구좌들의 위치가 변경되고 있다.
Layout Shifts 트랙을 통해 어느 구좌가 영향을 받았는지 확인을 했다.
측정한 좌표를 확인해 보니 배너가 뷰포트에 들어오는 시점에 다음과 같이 높이가 변경되고 있었다.
116px → 1003px → 116px
배너 높이가 1003px로 증가했다가 약 139ms 뒤 원래 크기로 돌아왔다.
이 과정에서 배너 아래에 있는 콘텐츠도 함께 이동했고, 각각 약 0.35의 레이아웃 이동이 발생했다.
두 이벤트가 하나의 세션 윈도우에 포함되면서 약 0.70의 CLS가 발생했다.
내 환경에서 측정한 최종 CLS이다.
원인은 잘못 지정된 이미지 비율이었다
해당 배너는 PC와 모바일에서 서로 다른 이미지를 사용하고 있었다.
| 구분 | 이미지 크기 |
|---|---|
| PC | 170 × 414 |
| 모바일 | 360 × 101 |
PC 이미지는 세로로 긴 형태이고, 모바일 이미지는 가로로 긴 형태다.
문제는 공통 이미지 컴포넌트에서 <img>의 width, height 속성에 항상 PC 이미지 크기를 전달하고 있었다는 점이다.
모바일 뷰포트 너비가 412px인 상황에서 PC 이미지 비율을 적용하면 다음과 같다.
412 × (414 / 170) ≈ 1003px
실제로 측정된 배너 높이와 일치했다.
width, height 속성은 이미지의 기본 크기를 제공하며, 로딩 전 비율을 계산하는 데에도 사용된다.
실제 렌더링 크기는 CSS와 함께 결정되므로, 두 속성을 단순히 비율만 지정하는 값으로 보기는 어렵다.
이번처럼 CSS로 너비를 정하고 높이를 자동 계산하는 경우에는 속성에서 얻은 비율로 공간을 확보한다.
이후 실제 이미지의 비율이 확인되면 자동 높이가 다시 계산될 수 있다.
모바일 이미지에 PC 비율이 지정되어 있어 이 과정에서 높이가 달라졌다.
이번 이슈의 원인은 로딩 전 확보한 이미지 비율이 실제 이미지 비율과 달랐다는 점이다.
개선 과정: 반응형 이미지의 비율 지정
첫 번째 방법: JavaScript로 이미지 크기 분기
처음에는 기존에 사용하던 useResponsive 훅을 활용했다.
모바일 환경에서는 모바일 이미지의 크기를, PC 환경에서는 PC 이미지의 크기를 width, height 속성에 전달하도록 변경했다.
수정 후 CLS는 0.73에서 0.0002로 감소했다.
하지만 코드 리뷰 과정에서 이 방식의 한계를 확인했다.
useResponsive는 User-Agent를 기준으로 환경을 판단하기 때문에 실제 뷰포트 크기와 일치하지 않을 수 있었다.
예를 들어 데스크톱 브라우저의 창 너비를 줄이거나 아이패드에서 데스크톱 모드를 사용하는 경우가 이에 해당한다.
이미지 선택은 <source>의 media 조건을 기준으로 이루어지는데, 이미지 크기는 User-Agent를 기준으로 결정하면 두 조건이 서로 달라질 수 있었다. 마운트 이후 속성이 변경될 가능성도 있었다.
두 번째 방법: <source>에 width와 height 지정
코드 리뷰에서 JavaScript 분기 대신 <picture>의 <source> 요소에 width****, height 속성을 지정하는 방법을 제안받았다.
<picture>
<source media="(min-width: 1025px)" srcSet={pcSrc} width={pcW} height={pcH} />
<source media="(max-width: 1024px)" srcSet={mobileSrc} width={mobileW} height={mobileH} />
{/* source 속성 미지원 구형 브라우저용 폴백 */}
<img src={src} width={pcW} height={pcH} />
</picture>이렇게 하면 브라우저가 미디어 쿼리에 따라 이미지를 선택하고, 해당 이미지의 비율을 기준으로 공간을 확보할 수 있다.
별도의 JavaScript 분기나 resize 이벤트 처리도 필요하지 않았다.
Chrome 90+ / Safari 15.4+ / Firefox 108+ 등의 지원 환경에서는 이 방식을 사용할 수 있다.
미지원 브라우저는 <img>의 크기 속성을 사용하므로 기존 동작을 유지하지만, 모바일 이미지의 비율 불일치가 해결되지 않을 수 있다.
최종 수정 후 다시 측정했을 때 CLS는 0.0002였고, 배너 높이도 변경되지 않는 것을 확인했다.
개선 결과
동일한 Playwright 측정 스크립트를 사용해 로컬 프로덕션 빌드에서 수정 전후 CLS를 비교했다.
| 케이스 | 수정 전 | 수정 후 |
|---|---|---|
| 모바일 (스크롤 포함) | 0.73 | 0.0001 |
| 데스크톱 UA + 412px 뷰포트 (리뷰 지적 케이스) | — | 0.0002 |
| PC (1440px) | 0.056 (기준 이하) | — |
모바일 환경에서 스크롤을 포함해 측정한 CLS는 0.73에서 0.0001로 감소했다.
배너 이미지가 로드되기 전후로 높이가 변경되지 않는 것을 확인할 수 있었다.
이미지 로딩 전 공간이 올바르게 확보되는지 확인할 때는 DevTools의 Network 탭도 활용했다.
[수정 전] 큰 영역을 차지하고 있음
[수정 후] 이미지 영역만큼만 차지하고 있음
Block request URL 기능으로 이미지 요청을 차단하면 이미지가 로드되지 않은 상태의 레이아웃을 확인할 수 있다.
정상적인 로딩 지연을 재현하는 것과는 다르지만, 로딩 전 공간이 확보되는지 살펴보는 데 도움이 된다.
이 상태에서 이미지 영역의 높이를 확인하면 실제 이미지가 로드되기 전에 공간이 적절하게 확보되어 있는지 검증할 수 있다.
마무리
이번 작업에서는 CLS 수치만 확인하는 것보다 실제로 어떤 요소가 이동하고 있는지 파악하는 것이 중요했다.
처음에는 페이지 로딩 과정만 확인해 큰 문제가 없다고 생각했지만, 스크롤 시나리오를 추가하면서 주요 원인을 발견할 수 있었다.
또한 이미지 비율 문제를 해결하는 과정에서 JavaScript로 환경을 구분하는 것보다 HTML에서 제공하는 기능을 활용하는 편이 더 적절하다는 점도 알게 됐다.
Search Console의 CLS는 최근 28일 데이터를 기준으로 평가하므로 수정 효과가 충분히 반영되기까지 수 주가 걸릴 수 있다. 배포 후에는 자체 RUM(Grafana Faro)의 CLS p75 변화를 먼저 확인하려고 한다.