Published on

Chrome CLS 급증 원인 7가지와 실전 해결

Authors

서버/네트워크 지표가 멀쩡한데도 사용자 체감이 갑자기 나빠지고, Lighthouse나 CrUX에서 CLS가 튀기 시작하면 대개 레이아웃이 ‘밀리는’ 이벤트가 특정 조건에서 폭발적으로 발생하고 있다는 신호입니다. 특히 Chrome은 폰트 로딩, 이미지 디코딩, 스크롤바 처리, 동적 컴포넌트 렌더링 등에서 미묘한 타이밍 차이로 CLS가 쉽게 급증할 수 있습니다.

이 글은 “왜 Chrome에서만/특정 버전에서만/특정 페이지에서만” CLS가 튀는지 원인을 7가지로 분해하고, 재현 → 원인 특정 → 코드 레벨 수정 → 재측정 순서로 실전 해결 방법을 제공합니다.

> 참고로 성능 문제는 원인이 연쇄적으로 얽히는 경우가 많습니다. 예를 들어 배포 파이프라인 변경으로 리소스 해시가 바뀌거나 캐시가 무효화되면 로딩 타이밍이 달라져 CLS가 튈 수 있습니다. 캐시/빌드 관점의 흔한 함정은 Docker 빌드 캐시가 무효화되는 원인 7가지도 함께 보시면 진단에 도움이 됩니다.

CLS 급증을 ‘측정 가능’하게 만들기

원인 해결보다 먼저 해야 할 일은 현상 재현과 수치화입니다. CLS는 “페이지가 로딩되는 동안”뿐 아니라 사용 중에도 발생합니다(무한 스크롤, 배너 삽입, 토스트, 폰트 스왑 등).

1) DevTools에서 Layout Shift 원인 확인

  1. Chrome DevTools → Performance
  2. Record 후 페이지 로드/스크롤/클릭 등 재현
  3. Timings/Experience 섹션에서 Layout Shift 이벤트 클릭
  4. 오른쪽 패널에서 Affected nodes 확인

이때 “어떤 DOM이 밀렸는지”가 나오면 절반은 끝난 겁니다.

2) web-vitals로 현장(Real User) 수집

실서비스에서 “어떤 사용자/어떤 뷰포트/어떤 네트워크”에서 CLS가 튀는지 보려면 RUM이 필요합니다.

// web-vitals v3+ 예시
import { onCLS } from 'web-vitals';

onCLS((metric) => {
  // metric.value: CLS 값
  // metric.entries: LayoutShiftEntry 배열
  // metric.id: 세션 식별
  navigator.sendBeacon('/rum', JSON.stringify({
    name: metric.name,
    value: metric.value,
    id: metric.id,
    navType: performance.getEntriesByType('navigation')[0]?.type,
    url: location.href,
  }));
});

3) PerformanceObserver로 Shift entry를 직접 로깅

특정 페이지에서만 터진다면, 해당 페이지에 임시로 심어서 원인 노드를 좁힐 수 있습니다.

// Chrome에서 Layout Shift entry 관찰
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // 사용자 입력(클릭/탭) 직후의 shift는 제외하는 게 일반적
    if (!entry.hadRecentInput) {
      console.log('layout-shift', {
        value: entry.value,
        sources: entry.sources?.map(s => ({
          node: s.node,
          previousRect: s.previousRect,
          currentRect: s.currentRect,
        }))
      });
    }
  }
}).observe({ type: 'layout-shift', buffered: true });

Chrome CLS 급증 원인 7가지와 해결책

아래 7가지는 실제로 CLS가 “갑자기” 튀는 패턴에서 가장 자주 발견되는 원인들입니다. 각 원인마다 증상 → 진단 포인트 → 해결 코드 순으로 정리합니다.

1) 이미지/비디오/iframe의 고정 크기 미지정

증상

  • 로딩 중 텍스트가 아래로 밀림
  • 이미지가 뜨는 순간 레이아웃이 재배치

진단

  • Layout Shift의 affected node가 img, iframe, video 주변 컨테이너로 잡힘
  • 반응형 이미지에서 height:auto만 있고, 초기 레이아웃에 높이 정보가 없음

해결

가장 확실한 해법은 초기 레이아웃에 공간을 예약하는 것입니다.

(1) HTML 속성으로 width/height 지정

<img src="/hero.jpg" width="1200" height="675" alt="hero" />

브라우저는 이 비율을 이용해 로딩 전에도 박스 크기를 계산합니다.

(2) CSS aspect-ratio로 공간 예약

.hero {
  width: 100%;
  aspect-ratio: 16 / 9;
  background: #f2f2f2;
}
.hero > img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

(3) 광고/iframe도 “슬롯”을 고정

.ad-slot {
  width: 100%;
  min-height: 250px; /* 최소 높이 예약 */
}

2) 웹폰트 로딩(FOUT/FOIT)과 폰트 메트릭 불일치

증상

  • 텍스트가 먼저 렌더링됐다가 폰트 적용 시 줄바꿈/높이 변경
  • Chrome에서 특히 특정 폰트 조합에서 CLS가 큼

진단

  • Layout Shift가 텍스트 블록에서 발생
  • “폰트 적용 순간”과 shift 타이밍이 일치

해결

(1) font-display: swap + 프리로드

@font-face {
  font-family: "MyFont";
  src: url("/fonts/myfont.woff2") format("woff2");
  font-display: swap;
}
<link rel="preload" href="/fonts/myfont.woff2" as="font" type="font/woff2" crossorigin>

(2) size-adjust로 폴백과 메트릭 맞추기

크롬에서 폴백 폰트와 웹폰트의 폭/높이 차이가 크면 줄바꿈이 바뀌며 CLS가 커집니다.

@font-face {
  font-family: "MyFont-fallback";
  src: local("Arial");
  size-adjust: 105%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

body {
  font-family: "MyFont", "MyFont-fallback", system-ui, sans-serif;
}

(3) 폰트 로딩 중 레이아웃이 민감한 영역은 고정 line-height

.title {
  line-height: 1.2;
  min-height: 2.4em; /* 2줄까지 공간 예약 */
}

3) 동적 배너/공지/쿠키 배너가 ‘상단 삽입’으로 밀어내기

증상

  • 페이지 진입 후 0.5~2초 뒤 상단에 배너가 생기며 본문이 아래로 밀림
  • A/B 테스트, 마케팅 태그가 켜진 사용자에서만 발생

진단

  • Layout Shift의 source가 header, body > div 같은 상단 래퍼
  • 배너 DOM이 기존 콘텐츠 위에 overlay가 아니라, 레이아웃 흐름에 삽입

해결

(1) 밀어내기 대신 overlay(고정 위치)로

.top-banner {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 9999;
}
body {
  padding-top: 56px; /* 배너 높이를 미리 예약(또는 조건부) */
}

(2) 아예 “슬롯”을 먼저 렌더링

<header>
  <div id="banner-slot" style="height:56px"></div>
</header>

배너가 없으면 빈 공간이 아깝다고 느낄 수 있지만, CLS와 교환하는 비용입니다. 대안으로는 “배너 있는 사용자”에게만 서버에서 슬롯을 포함해 초기 HTML부터 일관된 레이아웃을 제공하는 방식이 좋습니다.

4) 스켈레톤/로딩 UI가 실제 콘텐츠와 크기가 다름

증상

  • 로딩 스켈레톤이 사라지고 실제 카드가 나타날 때 레이아웃이 흔들림
  • 무한 스크롤에서 다음 페이지 로딩 후 리스트가 재정렬

진단

  • Shift가 리스트 컨테이너/카드에서 반복
  • 스켈레톤은 2줄인데 실제는 3줄, 이미지 비율이 다름

해결

스켈레톤은 “대충”이 아니라 실제 컴포넌트의 최종 박스 모델을 복제해야 합니다.

.card {
  display: grid;
  grid-template-columns: 96px 1fr;
  gap: 12px;
  min-height: 96px;
}
.card__thumb {
  width: 96px;
  height: 96px;
  background: #eee;
}
.card__title {
  line-height: 20px;
  min-height: 40px; /* 2줄 예약 */
}

React라면 로딩/완료 상태 모두 같은 레이아웃을 쓰도록 컴포넌트를 쪼개는 게 효과적입니다.

function CardFrame({ thumb, title }: { thumb: React.ReactNode; title: React.ReactNode }) {
  return (
    <div className="card">
      <div className="card__thumb">{thumb}</div>
      <div className="card__title">{title}</div>
    </div>
  );
}

export function CardLoading() {
  return <CardFrame thumb={null} title={<span className="skeleton" />} />;
}

export function CardLoaded({ img, title }: { img: string; title: string }) {
  return <CardFrame thumb={<img src={img} width={96} height={96} alt="" />} title={title} />;
}

5) 늦게 로드되는 CSS/JS가 레이아웃을 뒤늦게 바꿈

증상

  • 첫 페인트는 괜찮은데, 잠시 후 스타일이 적용되며 전체 레이아웃이 변함
  • 특정 라우트에서만 발생(코드 스플리팅된 CSS)

진단

  • Network에서 CSS가 late하게 도착
  • “unstyled → styled” 전환이 보임(FOUC)

해결

(1) 핵심 CSS는 초기 렌더에 포함

  • Above-the-fold 영역의 CSS는 서버 렌더/인라인/초기 번들로

(2) 비동기 CSS 로딩 시에도 레이아웃 안정성 확보

비동기 로딩 자체가 문제라기보다, 로딩 전후의 박스 모델이 바뀌는 게 문제입니다. 예를 들어 display:block/flex, 폰트 사이즈, 마진이 바뀌면 CLS가 큽니다.

<link rel="preload" href="/critical.css" as="style">
<link rel="stylesheet" href="/critical.css">

(3) 3rd-party 스크립트가 DOM 상단에 삽입하는지 확인

태그 매니저/광고/위젯이 상단에 요소를 끼워 넣는 경우가 많습니다. 가능하면 “지정된 슬롯” 내부에만 렌더되도록 설정하세요.

6) 늦게 뜨는 이미지 디코딩/리사이즈 + content-visibility 오용

증상

  • 스크롤하면서 갑자기 주변 콘텐츠가 흔들림
  • Chrome에서만(특히 저사양/모바일) 재현 빈도가 높음

진단

  • content-visibility: auto를 적용했는데, 자식 요소의 intrinsic size가 불안정
  • 이미지가 decode되는 순간 높이가 미세하게 바뀜

해결

content-visibility는 강력하지만, 반드시 contain-intrinsic-size로 대략의 크기를 알려줘야 안정적입니다.

.section {
  content-visibility: auto;
  contain-intrinsic-size: 800px; /* 대략적인 높이 예약 */
}

그리고 이미지에는 앞서 말한 width/height 또는 aspect-ratio를 반드시 지정하세요.

7) 스크롤바/뷰포트 변화(모바일 주소창, 오버레이)로 인한 리플로우

증상

  • 데스크톱에서 스크롤바가 나타났다 사라지며 가로폭이 바뀌고 레이아웃이 흔들림
  • 모바일 Chrome에서 주소창 접힘/펼침 시 CLS처럼 보이는 흔들림

진단

  • Shift가 전체 레이아웃에 광범위하게 발생
  • 100vh 사용 구간에서 특히 심함(모바일 주소창 이슈)

해결

(1) 스크롤바 공간을 항상 예약

html {
  scrollbar-gutter: stable;
}

(2) 모바일에서 100vh 대신 동적 뷰포트 단위 사용

.full {
  min-height: 100dvh; /* 동적 뷰포트 */
}

(3) 고정 헤더/바텀바는 overlay로 처리하고 본문 패딩을 고정

헤더 높이가 런타임에 바뀌지 않도록(폰트/아이콘 로딩 포함) 고정값 또는 최소 높이를 둡니다.

실전 디버깅 체크리스트(재현부터 검증까지)

CLS는 “한 번 수정하고 끝”이 아니라, 릴리즈/실험/콘텐츠 변화로 다시 튀기 쉽습니다. 아래 순서로 진행하면 재발률이 확 낮아집니다.

  1. CrUX / RUM에서 어떤 URL 템플릿이 튀는지 확인
  2. DevTools Performance에서 Layout Shift 이벤트의 Affected nodes 확보
  3. 원인이 광고/배너/폰트/이미지 중 어디인지 분류
  4. **공간 예약(크기/비율/슬롯)**을 최우선으로 적용
  5. 폰트는 preload + swap + 메트릭 튜닝(size-adjust)
  6. 3rd-party는 “삽입 위치”를 통제(슬롯화)
  7. 수정 후 Lighthouse만 보지 말고 실 사용자 RUM의 p75 CLS로 검증

마무리: CLS는 ‘타이밍’이 아니라 ‘공간’ 문제로 풀어야 한다

Chrome CLS 급증은 겉보기엔 로딩 타이밍 문제처럼 보이지만, 본질은 초기 레이아웃에서 공간을 예약하지 않았거나, 로딩 전후의 박스 모델이 달라지는 것입니다. 이미지/광고/폰트/스켈레톤/동적 CSS를 “나중에 채워도 되는 것”으로 취급하면, 결국 사용자는 화면이 밀리는 경험을 하게 됩니다.

운영 중이라면 배포/캐시 변화가 로딩 순서를 바꾸면서 CLS가 갑자기 튈 수도 있습니다. 릴리즈 이후 특정 조건에서만 문제가 커졌다면, 빌드/캐시 관점의 변화도 함께 점검해보세요: Docker 빌드 캐시가 무효화되는 원인 7가지

원하시면 사용 중인 스택(Next.js/React/Vue, SSR 여부, 광고 플랫폼, 폰트 로딩 방식)을 기준으로 체크리스트를 프로젝트 맞춤형으로 재구성하고, “가장 가능성 높은 1~2개 원인”부터 빠르게 줄이는 진단 플로우도 만들어드릴 수 있습니다.