Published on

Chrome CLS 급증 - 폰트·이미지·광고 시프트 해결

Authors

서론

CLS(Cumulative Layout Shift)는 “사용자가 보고 있던 레이아웃이 예고 없이 밀리는 정도”를 수치화한 Core Web Vitals 지표입니다. 문제는 CLS가 배포 직후가 아니라 광고 로딩, 폰트 교체, 이미지 지연 로딩 같은 런타임 조건에서 갑자기 치솟는 경우가 많다는 점입니다. 특히 Chrome에서 필드 데이터(실사용자) 기준으로 CLS가 급증하면, 단순히 Lighthouse 점수만 보고는 원인을 놓치기 쉽습니다.

이 글에서는 Chrome에서 CLS가 급증할 때 가장 흔한 3대 원인인 폰트(FOUT/FOIT), 이미지/비디오의 크기 미지정, 광고/동적 위젯 슬롯을 중심으로, DevTools로 원인을 특정하고 코드로 고치는 실전 패턴을 정리합니다. 운영 환경에서 “왜 갑자기?”를 빠르게 좁혀 가는 관점으로 설명하겠습니다.

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

1) DevTools에서 Layout Shift 원인 요소 잡기

Chrome DevTools에서 다음 경로로 들어가면 레이아웃 시프트 이벤트를 타임라인에서 확인할 수 있습니다.

  • Performance 탭 → 녹화(Record) → 로딩/스크롤 재현
  • 하단 Experience 섹션의 Layout Shifts 클릭
  • 어떤 노드가 얼마나 이동했는지, 원인이 무엇인지 하이라이트

여기서 중요한 건 “시프트가 발생한 시점에 무엇이 로딩/렌더링되었는가”입니다. 보통 다음 시점에 집중하면 원인이 빨리 드러납니다.

  • 웹폰트 다운로드 완료 직후(텍스트 폭 변화)
  • 이미지/iframe이 뒤늦게 크기를 확정하는 순간
  • 광고 스크립트가 슬롯 높이를 바꾸는 순간

2) 런타임에서 CLS 이벤트 수집(현업용)

실사용자에서 CLS가 튀는 경우, DevTools 재현만으로는 부족합니다. web-vitals를 이용해 CLS를 수집하고, 시프트에 기여한 요소를 샘플링해 로그로 남기면 원인 파악이 빨라집니다.

// npm i web-vitals
import { onCLS } from 'web-vitals';

onCLS((metric) => {
  // metric.value: CLS 점수
  // metric.entries: LayoutShift 엔트리들
  // (브라우저/버전에 따라 entries 상세는 다를 수 있음)
  const topEntries = (metric.entries || [])
    .filter(e => !e.hadRecentInput)
    .slice(-5)
    .map(e => ({
      value: e.value,
      startTime: e.startTime,
      sources: (e.sources || []).slice(0, 3).map(s => ({
        node: s.node ? s.node.nodeName : 'unknown',
        previousRect: s.previousRect,
        currentRect: s.currentRect,
      }))
    }));

  navigator.sendBeacon('/rum/vitals', JSON.stringify({
    name: metric.name,
    value: metric.value,
    id: metric.id,
    url: location.href,
    entries: topEntries,
  }));
});

필드에서 “특정 페이지/특정 기기/특정 광고 조합에서만” 발생하는 CLS 급증은, 이런 RUM(Real User Monitoring) 없이는 진단 시간이 폭발합니다. 운영 트러블슈팅을 체계화하는 관점은 인프라 장애를 좁혀 가는 방식과 유사합니다. 예를 들어 네트워크/로드밸런서 이슈를 로그로 30분 내 진단하는 접근처럼요: Cloudflare 520·521, Nginx·ALB 로그로 30분 진단

원인 1) 웹폰트가 텍스트 폭을 바꿔 CLS를 만든다

웹폰트는 다운로드 전에는 fallback 폰트로 렌더링되다가, 로딩 완료 후 실제 폰트로 교체됩니다. 이때 글자 폭/자간/행간이 달라지면 텍스트 블록 높이와 주변 레이아웃이 밀리며 CLS가 발생합니다.

해결 전략 A: font-display로 렌더링 전략 고정

가장 기본은 @font-facefont-display를 명시하는 것입니다.

@font-face {
  font-family: "MyWebFont";
  src: url("/fonts/myfont.woff2") format("woff2");
  font-display: swap; /* 또는 optional */
}
  • swap: 즉시 fallback으로 그리고, 폰트 로딩되면 교체(FOUC 가능). 다만 교체 시 레이아웃 변화가 생길 수 있음.
  • optional: 네트워크가 느리면 교체 자체를 포기해 레이아웃 안정성을 우선.

CLS 관점에서는 optional이 유리한 경우가 많습니다(브랜드 타이포그래피가 절대적인 서비스가 아니라면).

해결 전략 B: preload + 올바른 CORS로 폰트 지연 최소화

폰트가 늦게 도착할수록 교체 타이밍이 뒤로 밀려 시프트가 더 눈에 띕니다.

<link rel="preload"
      href="/fonts/myfont.woff2"
      as="font"
      type="font/woff2"
      crossorigin>
  • CDN 사용 시 Access-Control-Allow-Origin이 맞지 않으면 preload가 무시되거나 중복 다운로드가 날 수 있습니다.

해결 전략 C: 폰트 메트릭 오버라이드로 폭 차이를 줄이기

fallback 폰트와 웹폰트의 메트릭 차이를 줄이면 교체 시 레이아웃 변화가 크게 감소합니다.

@font-face {
  font-family: "MyWebFont";
  src: url("/fonts/myfont.woff2") format("woff2");
  font-display: swap;
  ascent-override: 92%;
  descent-override: 22%;
  line-gap-override: 0%;
  size-adjust: 102%;
}

body {
  font-family: "MyWebFont", system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", sans-serif;
}

이 방식은 “fallback으로 그린 결과”와 “웹폰트로 교체된 결과”의 높이/폭을 맞춰 CLS를 줄이는 데 효과적입니다. 다만 폰트별로 조정값을 실측해야 하므로, 핵심 폰트 1~2개에만 적용하는 것을 권장합니다.

원인 2) 이미지/비디오/iframe 크기 미지정(또는 잘못 지정)

CLS의 고전적인 원인은 이미지 높이를 모르는 상태에서 먼저 텍스트가 렌더링되고, 나중에 이미지가 로딩되며 공간을 차지해 아래 콘텐츠가 밀리는 경우입니다.

해결 전략 A: width/height 속성으로 고정 비율 확보

현대 브라우저는 imgwidth/height를 통해 aspect ratio(종횡비) 를 계산하고, 실제 픽셀과 무관하게 레이아웃 슬롯을 미리 잡습니다.

<img src="/img/hero.webp"
     width="1200"
     height="675"
     alt="hero"
     loading="lazy"
     decoding="async">
  • loading="lazy"를 쓰더라도, 크기만 지정되어 있으면 슬롯이 먼저 확보되어 CLS가 줄어듭니다.

해결 전략 B: CSS aspect-ratio로 반응형 슬롯 만들기

반응형 카드/썸네일에서는 고정 width/height를 쓰기 어렵습니다. 이때 컨테이너에 aspect-ratio를 주고 내부 이미지를 object-fit으로 채우면 안정적입니다.

<div class="thumb">
  <img src="/img/thumb.webp" alt="thumb" loading="lazy" decoding="async">
</div>
.thumb {
  aspect-ratio: 16 / 9;
  width: 100%;
  background: #f2f2f2; /* 로딩 중 스켈레톤 역할 */
  overflow: hidden;
}

.thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

해결 전략 C: 동적 콘텐츠(iframe, embed)는 “최대 높이”로 슬롯 예약

YouTube embed, 지도, 결제 위젯 등은 로딩 후 높이가 바뀌기 쉽습니다. 가능한 한 초기 높이를 넉넉히 잡고, 내부에서 스크롤되게 하거나 확장 UI를 별도 처리합니다.

.embed-slot {
  min-height: 360px;
}

.embed-slot iframe {
  width: 100%;
  height: 360px;
  border: 0;
}

“정확한 높이를 모를 때는 최소/고정 높이로 안전하게 예약”이 CLS 관점에서 가장 단순하고 강력합니다.

원인 3) 광고/추천 위젯이 슬롯 높이를 바꾼다

CLS 급증의 실전 최빈 원인은 광고입니다.

  • 광고 스크립트가 늦게 로드됨
  • 빈 슬롯(0px)으로 시작했다가 광고가 채워지며 높이가 생김
  • 리프레시/리사이즈 로직이 슬롯 크기를 바꿈

특히 모바일에서 뷰포트가 작아 “조금만 밀려도” CLS가 크게 계산됩니다.

해결 전략 A: 광고 슬롯의 고정 크기(또는 고정 범위) 예약

가장 중요한 원칙은 광고가 없더라도 공간을 먼저 확보하는 것입니다.

<div class="ad-slot" id="ad-top"></div>
/* 예: 모바일/데스크톱 각각 대표 사이즈를 예약 */
.ad-slot {
  width: 100%;
  min-height: 250px; /* 300x250 등 대표 높이 */
  background: #fafafa;
}

@media (min-width: 1024px) {
  .ad-slot {
    min-height: 280px;
  }
}

광고 fill rate가 낮아 빈 공간이 남는 것이 싫다면, “광고가 없을 때만” 접는 UX를 쓰되 사용자 입력 이후(클릭/탭)로 미루는 게 CLS에 유리합니다.

해결 전략 B: 광고 로딩 완료 후 높이 변경 금지(리사이즈 가드)

광고 SDK가 내부적으로 리사이즈를 시도하는 경우가 있습니다. 이때 컨테이너 높이를 강제로 고정하거나, 허용 범위를 제한합니다.

.ad-slot {
  contain: layout; /* 레이아웃 영향 범위를 줄이는 데 도움 */
}

contain: layout은 만능은 아니지만, 광고 영역이 주변 레이아웃 계산에 미치는 영향을 줄이는 데 도움이 될 수 있습니다(구현/브라우저 호환성은 확인 필요).

해결 전략 C: 스티키 광고/배너는 “오버레이”로 처리

하단 고정 배너를 레이아웃 흐름에 넣으면, 등장/사라짐마다 CLS를 유발합니다. 가능한 한 position: fixed로 오버레이 처리하고, 본문 레이아웃을 건드리지 않게 설계합니다.

.sticky-ad {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  height: 60px;
  z-index: 9999;
}

/* 본문이 가려지는 것이 싫다면 처음부터 padding을 예약 */
body {
  padding-bottom: 60px;
}

핵심은 “배너가 나타나면서 본문을 밀지 않게” 만드는 것입니다.

자주 놓치는 CLS 트리거 5가지

  1. 상단 공지/쿠키 배너가 늦게 렌더링되어 헤더를 밀어냄
  2. 이미지 lazy-load 라이브러리가 placeholder 없이 img를 나중에 삽입
  3. A/B 테스트 툴이 DOM을 교체하면서 상단 콘텐츠 높이가 변함
  4. 동적 에러 메시지/폼 검증 문구가 입력 중 레이아웃을 밀어냄
  5. SPA 라우팅 후 스크롤 복원과 컴포넌트 마운트 타이밍이 엇갈림

이런 문제는 “기능적으로는 정상”이라 QA에서 지나가기 쉽고, 필드 데이터에서만 점수가 망가집니다.

실전 체크리스트: 배포 전/후 빠른 검증 루틴

배포 전(로컬/스테이징)

  • DevTools Performance에서 Layout Shifts 확인
  • 주요 템플릿(홈/리스트/상세/결제)에서 상단 1~2뷰포트 구간 집중 측정
  • 폰트 preload 및 font-display 적용 여부 확인
  • 이미지/iframe/광고 슬롯에 최소 높이 또는 종횡비 예약

배포 후(운영)

  • RUM으로 CLS 상위 URL/디바이스/브라우저 분해
  • 광고가 원인이라면 광고 제공자/라인아이템별로 분리 관측(가능하면)
  • 특정 지역/네트워크에서만 발생하면 CDN 캐시, 폰트/이미지 응답 지연 확인

운영에서의 관측/진단은 “원인을 좁히는 데이터 파이프라인”이 핵심입니다. 인프라에서도 DNS 설정 하나로 체감 성능이 갈리는 것처럼, 클라이언트 성능도 작은 설정 차이가 크게 작용합니다: EKS에서 Pod DNS만 느릴 때 ndots·search 튜닝

결론: CLS는 ‘공간 예약’과 ‘교체 타이밍 제어’의 문제다

Chrome에서 CLS가 급증할 때, 대부분은 복잡한 버그가 아니라 다음 두 원칙을 어긴 결과입니다.

  • 공간 예약(Reserve space): 이미지/비디오/iframe/광고/배너의 슬롯을 먼저 확보한다.
  • 교체 타이밍 제어(Control swap timing): 웹폰트/동적 위젯이 레이아웃을 바꾸는 시점을 앞당기거나, 아예 교체를 포기(optional)해 안정성을 우선한다.

우선 DevTools로 “언제, 무엇이” 밀었는지 확인하고, 폰트/미디어/광고 슬롯을 순서대로 고치면 CLS 급증은 대개 빠르게 안정화됩니다. 이후 RUM으로 필드 데이터에서 재발 여부를 감시하면, 광고 정책 변경이나 A/B 스크립트 삽입 같은 ‘예상치 못한 변경’에도 흔들리지 않는 성능 운영이 가능합니다.