- Published on
Chrome CLS 급증 원인과 해결 체크리스트
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 중인 서비스에서 어느 날부터 Chrome의 CLS(Layout Shift)가 급증하면, 체감 품질(화면이 덜컥거림)뿐 아니라 Core Web Vitals 평가에도 직접적인 타격이 납니다. 특히 “최근 배포는 UI와 무관했는데도” 지표가 튀는 경우가 많아, 원인 규명이 더 까다롭습니다.
이 글은 CLS가 급증하는 전형적인 패턴을 분류하고, Chrome DevTools와 실측 데이터(CrUX/필드 데이터)로 범인을 좁힌 뒤, 코드 레벨에서 재발을 막는 방법을 체크리스트 형태로 정리합니다.
CLS를 빠르게 재정의: 무엇이 점수에 반영되나
CLS는 페이지 수명 동안 발생한 “예기치 않은 레이아웃 이동”을 점수화합니다. 단순히 애니메이션이 많다고 CLS가 오르는 것이 아니라, 사용자가 예상하지 못한 시점에 요소가 밀리거나 점프하는 경우가 문제입니다.
핵심은 다음 두 가지입니다.
- 공간이 미리 예약되지 않은 요소가 늦게 로드되며 주변 레이아웃을 밀어냄
- 초기 렌더 이후 JS나 스타일 변경으로 박스 크기/위치가 바뀜
따라서 “이미지/광고/폰트/동적 배너/지연 렌더”가 CLS 급증의 80%를 차지합니다.
1단계: 급증이 ‘실험실’인지 ‘실사용’인지 분리
먼저 지표가 어디에서 튀는지 분리해야 합니다.
- 실험실(Lighthouse/로컬 측정) 에서만 튄다: 네트워크/CPU 스로틀링 조건, 캐시, 로컬 폰트/확장프로그램, 테스트 시나리오 문제일 수 있습니다.
- 실사용(필드, CrUX/RUM) 에서 튄다: 특정 디바이스/브라우저/라우트/AB 실험/광고 슬롯/번역 위젯 등 “실제 사용자 조건”에서만 발생하는 경우가 많습니다.
가능하다면 RUM으로 CLS 이벤트를 수집해 “어떤 URL, 어떤 뷰포트, 어떤 리소스”에서 발생하는지부터 좁히는 것이 가장 빠릅니다.
2단계: DevTools로 ‘레이아웃 이동’ 범인 찾기
Chrome DevTools에서 CLS를 추적하는 가장 실전적인 방법은 Performance 패널입니다.
- DevTools
Performance탭 Web Vitals또는Experience관련 옵션(버전에 따라 다름)을 켠 뒤 기록- 타임라인에 표시되는
Layout Shift이벤트 클릭 Moved from/Moved to(이동한 요소)와 원인 스택을 확인
여기서 중요한 포인트는 “이동한 요소”가 범인이 아닐 수 있다는 점입니다. 진짜 원인은 대개 상단에서 늦게 나타난 이미지/배너/폰트/광고 컨테이너입니다.
대표 원인 1: 이미지/비디오 크기 미지정(공간 미예약)
가장 흔한 CLS 원인입니다. 이미지가 늦게 로드되면서 높이가 확정되고, 그 아래 콘텐츠가 밀립니다.
해결 원칙
width와height를 반드시 제공해 종횡비 기반 공간을 선예약- 반응형이라면
aspect-ratio로 공간 확보 - 스켈레톤/플레이스홀더도 “최종 높이”와 최대한 일치
코드 예시: HTML 이미지 공간 예약
<img
src="/images/hero.jpg"
width="1200"
height="630"
alt="hero"
loading="eager"
decoding="async"
/>
코드 예시: CSS aspect-ratio로 반응형 예약
.hero-media {
width: 100%;
aspect-ratio: 16 / 9;
background: #f2f2f2;
overflow: hidden;
}
.hero-media img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
Next.js를 쓴다면
next/image는 기본적으로 레이아웃 안정성을 높이기 위한 장치가 많습니다. 다만 fill 사용 시에도 부모 컨테이너에 높이/비율 예약이 없으면 CLS가 발생할 수 있으니, 부모에 position과 aspect-ratio를 부여해야 합니다.
대표 원인 2: 웹폰트 로딩으로 인한 FOIT/FOUT 및 라인 높이 변화
폰트가 늦게 적용되면 텍스트 폭/높이가 바뀌며 줄바꿈이 재계산되고 CLS가 발생합니다.
해결 원칙
font-display: swap(또는optional) 사용- 가능하면 메트릭 호환 폰트 또는
size-adjust로 폴백과 메트릭 차이를 줄임 - 주요 폰트는
preload로 로딩 타이밍 앞당김
코드 예시: @font-face 최적화
@font-face {
font-family: "MyWebFont";
src: url("/fonts/mywebfont.woff2") format("woff2");
font-display: swap;
}
body {
font-family: "MyWebFont", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
코드 예시: preload로 폰트 지연을 줄이기
<link
rel="preload"
href="/fonts/mywebfont.woff2"
as="font"
type="font/woff2"
crossorigin
/>
폰트는 “내 PC에서는 캐시로 빨라서 문제 없음”이 흔합니다. 필드에서만 CLS가 오를 때 가장 먼저 의심해야 합니다.
대표 원인 3: 상단 배너/공지/쿠키 배너를 ‘나중에’ 삽입
동의 배너, 앱 설치 유도 배너, 긴급 공지 등은 종종 초기 렌더 이후에 DOM에 삽입됩니다. 특히 헤더 바로 아래에 끼워 넣으면 아래 전체가 밀리며 CLS가 크게 튑니다.
해결 원칙
- 미리 공간을 예약하거나
- 레이아웃을 밀지 않는 방식(오버레이,
position: fixed)으로 표시 - 사용자 액션(클릭/탭) 없이 자동으로 레이아웃을 바꾸지 않기
코드 예시: 배너 공간 예약(높이 고정)
.top-banner-slot {
height: 56px; /* 최종 배너 높이만큼 고정 */
}
.top-banner {
height: 56px;
}
코드 예시: 오버레이 방식(레이아웃 비침범)
.cookie-banner {
position: fixed;
left: 16px;
right: 16px;
bottom: 16px;
z-index: 9999;
}
오버레이는 CLS를 줄이지만 콘텐츠 가림 문제가 생길 수 있으니, 안전 영역과 닫기 UX까지 함께 설계해야 합니다.
대표 원인 4: 광고/서드파티 위젯 슬롯 높이가 바뀜
광고는 로드 후 크리에이티브 크기에 따라 슬롯 높이가 바뀌거나, 실패 시 대체 콘텐츠가 삽입되면서 레이아웃을 흔듭니다. “특정 국가/특정 트래픽에서만” 터지는 이유도 여기에 있습니다.
해결 원칙
- 광고 슬롯에 최소 높이(
min-height)를 부여해 공간을 예약 - 반응형 광고라면 브레이크포인트별 슬롯 높이를 명시
- 실패/빈 광고 시에도 동일한 높이를 유지
코드 예시: 광고 슬롯 안정화
.ad-slot {
min-height: 250px;
background: #fafafa;
}
@media (max-width: 480px) {
.ad-slot {
min-height: 180px;
}
}
대표 원인 5: 스켈레톤/로딩 UI가 최종 UI와 크기가 다름
“로딩 중에는 카드 3개, 로딩 후에는 4개”처럼 레이아웃 그리드 자체가 바뀌면 CLS가 발생합니다. 스켈레톤이 예쁘게 보여도 최종 컴포넌트의 높이/라인 수와 다르면 점수가 튈 수 있습니다.
해결 원칙
- 스켈레톤은 최종 컴포넌트의 박스 모델(높이, 패딩, 타이포 라인 수)을 최대한 동일하게
- 데이터 로딩에 따라 요소 수가 바뀐다면, 초기에도 동일한 수의 “빈 슬롯”을 렌더링
코드 예시: 동일 그리드 유지
.card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.card {
min-height: 140px;
border: 1px solid #eee;
}
대표 원인 6: 늦게 적용되는 CSS(스타일시트 로딩/우선순위 역전)
CSS가 늦게 로드되거나, 특정 조건에서만 추가 CSS가 적용되면 초기 레이아웃이 바뀌며 CLS가 발생합니다.
해결 원칙
- 핵심 레이아웃 CSS는 가능한 한 초기 경로에서 로드
@import남용 금지(로드 지연)- critical CSS를 인라인으로 가져가는 전략도 고려
또한 프레임워크/번들러 업그레이드로 CSS chunk 분리가 바뀌면 “배포 후 갑자기 CLS가 증가”하는 일이 생길 수 있습니다.
대표 원인 7: JS로 측정한 뒤 높이를 재설정하는 패턴
예: 마운트 후 getBoundingClientRect()로 높이를 계산해 style.height를 적용하는 컴포넌트(캐러셀/탭/아코디언/가변 헤더)가 흔합니다. 이 경우 초기에는 높이 auto였다가 계산 후 픽셀 높이로 바뀌며 점프가 발생합니다.
해결 원칙
- 가능한 한 CSS로 해결(
aspect-ratio,flex,grid) - 측정이 필요하면 초기에도 “예상 높이”를 넣어두고, 변경 폭을 최소화
requestAnimationFrame타이밍으로 깜빡임을 줄이는 것보다, 레이아웃 변경 자체를 없애는 것이 우선
코드 예시: 캐러셀 컨테이너 높이 선예약
.carousel {
aspect-ratio: 3 / 1;
overflow: hidden;
}
3단계: 재현이 어려울 때의 실전 디버깅 팁
1) 네트워크 조건을 바꿔라
CLS는 “늦게 로드되는 리소스”가 원인인 경우가 많습니다. DevTools에서 느린 4G 수준으로 스로틀링하고 캐시를 끄면 재현 확률이 급상승합니다.
2) 뷰포트를 바꿔라
광고/폰트/줄바꿈은 뷰포트에 민감합니다. 모바일 폭에서만 터지는 CLS가 매우 흔합니다.
3) 서드파티 스크립트를 의심하라
태그 매니저, AB 테스트, 채팅 위젯, 번역 위젯은 초기 렌더 이후 DOM을 삽입합니다. “코드 변경 없이도” 지표가 튀는 대표 원인입니다.
4단계: RUM으로 CLS 원인을 로그로 남기기
필드에서만 발생하는 CLS는 RUM이 가장 빠릅니다. web-vitals 라이브러리로 CLS 값을 수집하고, 가능하면 어떤 라우트/디바이스에서 튀는지 태깅하세요.
코드 예시: web-vitals로 CLS 수집
import { onCLS } from "web-vitals";
function sendToAnalytics(metric: any) {
// 예: POST /analytics
// metric.name === "CLS"
// metric.value, metric.id, metric.navigationType 등 포함
}
onCLS(sendToAnalytics);
여기에 사용자 에이전트, 화면 크기, 라우트, 실험 플래그를 함께 보내면 “특정 조건에서만 CLS 급증”을 빠르게 좁힐 수 있습니다.
배포 후 갑자기 CLS가 튀는 흔한 시나리오
- 폰트 파일이 교체되었는데 폴백과 메트릭 차이가 커짐
next/image사용 방식이 바뀌었거나,fill컨테이너에 높이 예약이 사라짐- 쿠키 배너/공지 배너가 특정 국가에서만 노출되도록 변경됨
- 광고 공급자 변경으로 크리에이티브 크기/응답 속성이 바뀜
- CSS chunk 분리/지연 로딩으로 초기 스타일 적용 순서가 바뀜
이 중 일부는 “코드 변경 없이도” 운영 설정만으로 발생합니다.
체크리스트: CLS 급증을 막는 우선순위
- 이미지/비디오:
width·height또는aspect-ratio로 공간 예약 - 폰트:
font-display: swap, preload, 폴백 메트릭 차이 최소화 - 배너/공지/쿠키: 레이아웃을 밀지 않거나 슬롯 높이 고정
- 광고/위젯: 슬롯
min-height지정, 실패 시에도 높이 유지 - 스켈레톤: 최종 UI와 동일한 박스 모델 유지
- CSS 로딩: 핵심 레이아웃 CSS를 초기 경로에 포함
- JS 측정 레이아웃: CSS로 대체하거나 초기 높이 선예약
함께 보면 좋은 글(성능 지표 디버깅 관점)
CLS와 함께 Core Web Vitals를 운영에서 안정화하려면, 이벤트 추적과 원인 분리가 중요합니다. INP 급락을 Long Task로 추적하는 방법도 같은 결의 접근이라 같이 보면 도움이 됩니다.
마무리: CLS는 “나중에 나타나는 것”을 제거하면 내려간다
CLS 급증의 본질은 대부분 “초기 레이아웃이 확정된 뒤, 무언가가 뒤늦게 끼어들어 밀어내는 것”입니다. 이미지/폰트/배너/광고/스켈레톤을 중심으로 공간 예약과 로딩 타이밍을 정리하면, 대개 큰 폭으로 안정화됩니다.
다음 액션은 단순합니다.
- DevTools에서
Layout Shift이벤트로 이동 요소를 찾고 - 그 위쪽에 늦게 로드되는 리소스를 확인한 뒤
- 공간 예약 또는 오버레이 전환으로 “밀어내기”를 제거하세요.