Published on

Chrome CLS 급증? Layout Shift 원인 7가지

Authors

서론

CLS(Cumulative Layout Shift)는 “페이지가 로드/실행되는 동안 사용자가 보게 되는 레이아웃 점프”를 수치화한 지표입니다. 문제는 CLS가 어느 날 갑자기 급증하는 케이스가 많다는 점입니다. 광고 스크립트, 폰트, 이미지, SPA 라우팅, A/B 테스트, 쿠키 배너 등 “내 코드 밖”에서 발생하는 변화가 레이아웃을 흔들어 버리기 때문이죠.

이 글에서는 Chrome(특히 CrUX/PSI, Lighthouse, Web Vitals 확장, DevTools Performance/Rendering)을 기준으로 Layout Shift의 대표 원인 7가지를 짚고, 각 원인별로 “어떻게 재현·진단하고 무엇을 고치면 되는지”를 코드와 함께 정리합니다.

> 참고: 운영 환경에서 갑자기 지표가 튀는 문제는 대개 “관측 → 원인 좁히기 → 재현 → 고정”의 루프로 해결됩니다. 성능 이슈를 진단하는 접근은 인프라/앱을 막론하고 비슷합니다. 예를 들어 Jenkins 빌드가 갑자기 느려질 때 원인 7가지처럼, 갑작스러운 변화는 대부분 외부 의존성/환경/릴리즈 단위의 조합에서 발생합니다.

CLS 급증을 먼저 ‘증거’로 잡는 방법

원인 7가지를 보기 전에, DevTools에서 “무엇이 움직였는지”를 빠르게 캡처하는 방법부터 정리합니다.

1) DevTools에서 Layout Shift 이벤트 찾기

  1. Chrome DevTools → Performance
  2. Record(새로고침 포함) 후
  3. 타임라인에서 Experience 섹션 또는 Layout Shift 이벤트를 클릭
  4. 오른쪽 패널에서 Affected nodes(영향받은 DOM) 확인

추가로,

  • Rendering 탭 → Layout Shift Regions 체크: 화면에서 이동한 영역이 하이라이트됩니다.

2) 런타임에서 CLS/Shift 원인 로그 찍기(실전용)

아래 스니펫은 layout-shift 엔트리를 수집해 어떤 요소가 언제 이동했는지를 콘솔에 남깁니다. (운영에서는 샘플링/PII 주의)

<script>
(() => {
  let cls = 0;
  const po = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // 사용자 입력(클릭/키보드) 직후의 shift는 CLS 계산에서 제외됨
      if (entry.hadRecentInput) continue;
      cls += entry.value;

      const sources = (entry.sources || []).map(s => {
        const node = s.node;
        return {
          node: node ? node.tagName + (node.id ? `#${node.id}` : '') : null,
          previousRect: s.previousRect,
          currentRect: s.currentRect,
        };
      });

      console.log('[layout-shift]', {
        value: entry.value,
        cls,
        startTime: entry.startTime,
        sources,
      });
    }
  });

  po.observe({ type: 'layout-shift', buffered: true });
})();
</script>

이 로그가 있으면 “광고가 들어오면서 어떤 컨테이너가 밀렸는지”, “폰트 로딩 이후 어떤 텍스트 블록이 재배치됐는지”를 훨씬 빨리 특정할 수 있습니다.

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

가장 흔한 원인입니다. 브라우저는 이미지가 다운로드되기 전까지 intrinsic size를 확정할 수 없고, 결과적으로 레이아웃이 “일단 대충 배치 → 로드 후 재배치”되며 shift가 발생합니다.

해결

  • imgwidth/height를 지정하거나
  • CSS의 aspect-ratio로 비율을 고정하고
  • 반응형이라면 max-width: 100%와 함께 사용합니다.
<!-- BAD: 크기 미지정 -->
<img src="/hero.jpg" alt="hero">

<!-- GOOD: 고정 크기(브라우저가 공간을 미리 확보) -->
<img src="/hero.jpg" alt="hero" width="1200" height="630" style="max-width:100%;height:auto;">

<!-- GOOD: 비율 고정(동적 레이아웃에서 유용) -->
<div class="media">
  <iframe src="..." loading="lazy"></iframe>
</div>

<style>
.media {
  aspect-ratio: 16 / 9;
  width: 100%;
}
.media iframe {
  width: 100%;
  height: 100%;
  border: 0;
}
</style>

추가 팁:

  • loading="lazy"는 CLS를 줄여주기도 하지만, lazy 로딩으로 인해 늦게 삽입되는 요소가 위/아래를 밀면 오히려 shift가 커질 수 있습니다. 핵심은 “늦게 로드돼도 공간은 미리 확보”입니다.

원인 2) 광고/서드파티 위젯이 ‘나중에’ DOM을 밀어냄

광고 슬롯, 추천 위젯, 채팅 위젯, 소셜 임베드 등은 로드 타이밍이 불규칙하고, 종종 높이가 나중에 확정됩니다. 특히 상단(above the fold)에서 발생하면 CLS에 치명적입니다.

해결

  • 광고/위젯 영역에 최소 높이(min-height) 또는 고정 슬롯을 미리 잡습니다.
  • “없으면 제거”가 아니라 “없으면 빈 슬롯 유지”가 CLS 관점에서 유리할 때가 많습니다.
<div class="ad-slot" id="ad-top"></div>

<style>
.ad-slot {
  /* 실측 기반으로 보수적으로 확보 */
  min-height: 250px;
  background: #f6f7f9;
}
</style>

<script>
// 광고가 로드되면 내부에 삽입되더라도 바깥 레이아웃은 유지
loadAdInto(document.getElementById('ad-top'));
</script>

추가로, 서드파티 스크립트가 DOM 최상단에 배너를 “삽입”하는 형태라면, 삽입 위치를 하단 fixed로 바꾸거나(사용성 고려), 상단에 배너 컨테이너를 미리 렌더링해 그 안에서만 변화가 일어나도록 제한하세요.

원인 3) 웹폰트 로딩으로 인한 FOIT/FOUT 및 줄바꿈 변화

폰트가 늦게 로드되면 텍스트가 fallback 폰트로 렌더링되었다가 웹폰트로 바뀌며 폭/자간/행간이 달라져 레이아웃이 흔들립니다. 특히 제목, 버튼, 내비게이션처럼 정렬/폭에 민감한 텍스트가 문제를 일으킵니다.

해결

  • font-display: swap(또는 상황에 따라 optional) 적용
  • 가능한 경우 fallback 폰트를 “메트릭이 비슷한 폰트”로 설정
  • 중요한 폰트는 preload로 앞당김
@font-face {
  font-family: "MyFont";
  src: url("/fonts/myfont.woff2") format("woff2");
  font-display: swap;
}

body {
  font-family: "MyFont", system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", sans-serif;
}
<link rel="preload" href="/fonts/myfont.woff2" as="font" type="font/woff2" crossorigin>

실전 팁:

  • 타이포그래피가 빡빡한 헤더/탭 메뉴는 min-width/line-height를 안정적으로 잡아두면 폰트 교체 시 흔들림이 줄어듭니다.

원인 4) 동적 콘텐츠 삽입(쿠키 배너, 공지, 로그인 상태, A/B 테스트)

“사용자 상태에 따라” 또는 “실험군에 따라” 상단에 배너가 생기거나, 로그인 후 사용자 메뉴가 커지거나, 공지 영역이 추가되는 식의 변화는 CLS의 단골입니다.

해결

  • 상단에 등장할 가능성이 있는 UI는 초기 렌더 시점부터 자리(공간)를 확보합니다.
  • 조건부 렌더링 대신, 기본은 숨김(visibility) 또는 빈 상태 skeleton을 두고 내용만 교체합니다.
<header class="site-header">
  <div class="cookie-banner" id="cookie" aria-hidden="true"></div>
  <nav>...</nav>
</header>

<style>
.cookie-banner {
  /* 배너가 뜰 수 있는 최대 높이만큼 확보 */
  min-height: 56px;
}
.cookie-banner[aria-hidden="true"] {
  visibility: hidden; /* 공간은 유지 */
}
</style>

<script>
if (shouldShowCookieBanner()) {
  const el = document.getElementById('cookie');
  el.textContent = '쿠키 사용에 동의해 주세요...';
  el.setAttribute('aria-hidden', 'false');
}
</script>

A/B 테스트 도구가 DOM을 조작해 shift를 만든다면, 실험 스크립트를 head에서 너무 늦게 로드하지 않도록 하거나(초기 페인트 전에 결정), 실험 대상 영역의 높이를 고정해 “변화는 내부에서만” 일어나게 하세요.

원인 5) 스켈레톤/로딩 상태와 실제 콘텐츠의 크기 불일치

스켈레톤 UI를 넣어도, 실제 카드/텍스트/이미지의 높이가 스켈레톤과 다르면 로딩 완료 시점에 shift가 발생합니다. 특히 리스트/피드 UI에서 누적 CLS가 커집니다.

해결

  • 스켈레톤을 “대충” 만들지 말고, 실제 컴포넌트의 레이아웃(패딩/타이포/라인수)을 최대한 맞춥니다.
  • 텍스트는 line-clamp/고정 line-height로 높이를 예측 가능하게 만듭니다.
.card-title {
  line-height: 1.3;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  min-height: calc(1.3em * 2); /* 2줄 높이 확보 */
}

.skeleton-title {
  height: calc(1.3em * 2);
}

원인 6) 상단 고정 헤더/배너의 “나중 적용” (CSS/JS 순서 문제)

초기에는 일반 흐름에 있던 헤더가 CSS 로딩 후 position: fixed로 바뀌거나, JS가 실행되며 헤더 높이를 재계산해 padding-top을 바꾸는 경우가 있습니다. 이런 “스타일 적용 타이밍” 문제는 로컬에서는 재현이 어려운데, 네트워크가 느린 환경에서 잘 터집니다.

해결

  • critical CSS(레이아웃에 영향 큰 스타일)를 가능한 한 빨리 적용
  • fixed 헤더라면 초기부터 body에 헤더 높이만큼 padding-top을 주고, 나중에 바꾸지 않도록 설계
<style>
:root { --header-h: 64px; }
body { padding-top: var(--header-h); }
.header {
  position: fixed;
  top: 0; left: 0; right: 0;
  height: var(--header-h);
}
</style>

<header class="header">...</header>
<main>...</main>

JS로 높이를 측정해 반영해야 한다면, 첫 페인트 이후에 값을 크게 바꾸지 않도록 “최대 높이” 기반으로 잡거나, 폰트 로드/뷰포트 변화 등과 경쟁하지 않게 주의하세요.

원인 7) 애니메이션/트랜지션에서 layout 속성(top/left/height 등)을 변경

애니메이션 자체가 나쁜 건 아니지만, top/left/height/margin 같은 레이아웃을 다시 계산하게 만드는 속성을 애니메이션하면 주변 요소까지 밀리며 shift처럼 느껴지거나 실제 Layout Shift로 기록될 수 있습니다.

해결

  • 가능한 한 transformopacity로 애니메이션(컴포지터 레벨)
  • 아코디언/드롭다운은 “레이아웃을 밀어내는 방식” 대신, 오버레이(absolute)로 띄우거나, 공간을 미리 확보
/* BAD: height 변화로 레이아웃 재계산 */
.panel { transition: height 200ms ease; }

/* GOOD: transform으로 시각적 이동 */
.toast {
  position: fixed;
  bottom: 16px;
  right: 16px;
  transform: translateY(20px);
  opacity: 0;
  transition: transform 200ms ease, opacity 200ms ease;
  will-change: transform, opacity;
}
.toast.show {
  transform: translateY(0);
  opacity: 1;
}

운영에서 “갑자기 CLS가 튄 날”을 추적하는 체크리스트

CLS 급증은 보통 배포/설정 변경/서드파티 변경과 맞물립니다. 아래 순서로 좁히면 빠릅니다.

  1. CrUX/PSI에서 URL 단위 vs Origin 단위로 동시에 확인(특정 페이지 문제인지 전반 문제인지)
  2. 최근 배포 내역에서
    • 광고/태그매니저/분석 스니펫 변경
    • 폰트/이미지 CDN 변경
    • 헤더/배너/로그인 영역 변경
    • 실험(A/B) 시작 여부
  3. DevTools Performance에서 Layout Shift 이벤트의 Affected nodes 확인
  4. 해당 노드가 “내 코드”인지 “서드파티 삽입”인지 분리
  5. 재현이 어렵다면 네트워크/CPU throttling으로 “느린 환경”에서 다시 측정

비슷한 맥락으로, 환경에 따라 재현이 갈리는 문제를 진단할 때는 로그/관측을 먼저 깔아두는 게 핵심입니다. 예를 들어 Linux OOM Killer 로그 추적과 메모리 누수 진단처럼 “언제/무엇이/왜”를 남기면 원인 규명이 빨라집니다.

마무리: CLS를 낮추는 핵심은 ‘공간을 예약’하는 것

7가지 원인을 관통하는 공통 해법은 단순합니다.

  • **늦게 로드/결정되는 것(이미지, 광고, 폰트, 상태 UI)**은
  • 레이아웃 공간을 미리 예약하고
  • 변화는 내부에서만 일어나게 만들 것

마지막으로, 팀 차원에서 재발을 막으려면 “컴포넌트 가이드”를 만들어 img/iframe 크기 규칙, 광고 슬롯 규칙, 폰트 로딩 규칙, 스켈레톤 규칙을 린트/리뷰 체크리스트로 고정하는 것이 가장 효과적입니다.

원하시면 사용 중인 프레임워크(Next.js/React/Vue)와 광고/태그 구성(GTM, AdSense, Prebid 등)을 알려주시면, 해당 스택 기준으로 CLS를 가장 많이 만드는 지점을 우선순위로 잡아 구체적인 개선안을 더 좁혀드릴 수 있습니다.